天天看点

临界区和C++使用方式

一.临界资源

        临界资源是一次仅允许一个进程使用的共享资源。各进程采取互斥的方式,实现共享的资源称作临界资源。属于临界资源的硬件有,打印机,磁带机等;软件有消息队列,变量,数组,缓冲区等。诸进程间采取互斥方式,实现对这种资源的共享。

二.临界区:

        每个进程中访问临界资源的那段代码称为临界区(criticalsection),每次只允许一个进程进入临界区,进入后,不允许其他进程进入。不论是硬件临界资源还是软件临界资源,多个进程必须互斥的对它进行访问。多个进程涉及到同一个临界资源的的临界区称为相关临界区。使用临界区时,一般不允许其运行时间过长,只要运行在临界区的线程还没有离开,其他所有进入此临界区的线程都会被挂起而进入等待状态,并在一定程度上影响程序的运行性能。

三、优缺点

优点:效率高,与互斥和事件这些内核同步对象相比,临界区是用户态下的对象,即只能在同一进程中实现线程互斥。因无需在用户态和核心态之间切换,所以工作效率比较互斥来说要高很多。

缺点:资源释放容易出问题,Critical Section不是一个核心对象,无法获知进入临界区的线程是生是死,如果进入临界区的线程挂了,没有释放临界资源,系统无法获知,而且没有办法释放该临界资源。

临界区是一种轻量级的同步机制,与互斥和事件这些内核同步对象相比,临界区是用户态下的对象,

即只能在同一进程中实现线程互斥。因无需在用户态和核心态之间切换,所以工作效率比较互斥来说要高很多。

四、API介绍

1、初始化对象

InitializeCriticalSection

2、尝试进入临界区,如果成功调用,则进入临界区,如果资源被占用,不会阻塞

​​TryEnterCriticalSection​​​This function attempts to enter a critical section without blocking. If the call is successful, the calling thread takes ownership of the critical section.

A nonzero value indicates that the critical section is successfully entered or the current thread already owns the critical section. Zero (FALSE) indicates 

that another thread already owns the critical section.

3、EnterCriticalSection  进入临界资源,取到控制权之前会阻塞

 Before using a critical section, some thread of the process must call the InitializeCriticalSection to initialize the object.

 To enable mutually exclusive access to a shared resource, each thread calls the EnterCriticalSection function to request ownership of the critical section before executing any section of code that accesses the protected resource。

 EnterCriticalSection blocks until the thread can take ownership of the critical section. 

4、离开临界资源 

 When it has finished executing the protected code, the thread uses the LeaveCriticalSection function

 to relinquish ownership, enabling another thread to become owner and access the protected resource. 

 The thread must call LeaveCriticalSection once for each time that it entered the critical section.

5、删除临界对象

Any thread of the process can use the DeleteCriticalSection function to release the system resources that were allocated when the critical section object was initialized. After this function has been called, the critical section object can no longer be used for synchronization.

删除之后临界资源就无效了

五、代码示范

#include <iostream>
#include <windows.h>
#include <thread>
using namespace std;
CRITICAL_SECTION g_cs;

int g_Count = 0;
void func1()
{
  while(1)
  {
    EnterCriticalSection(&g_cs);
    g_Count++;
    cout <<"t1 g_Count = " << g_Count << endl;
    Sleep(3000);
    LeaveCriticalSection(&g_cs);
  }
}

void func2()
{
  while (1)
  {
    EnterCriticalSection(&g_cs);
    g_Count++;
    cout << "t2 g_Count = " << g_Count << endl;

    Sleep(2000);
    LeaveCriticalSection(&g_cs);
  }
}
int main()
{
  InitializeCriticalSection(&g_cs);
  std::thread t1(func1); // t1 is not a thread
  std::thread t2(func2); // t1 is not a thread
  t1.join();
  t2.join();

  
  cin.get();
  DeleteCriticalSection(&g_cs);
  return 0;
}      

六、注意

1. 临界区对象不是内核对象,因此不能继承,不能跨进程,也不能用waitfor什么的函数来限定时间等待。这个很好理解,你想想WaitFor要求传一个句柄,而临界区对象的类型都不是句柄,也不能用CloseHandle来关闭,怎么可能会能让WaitForXXX搞了。

2. 临界区对象使用前必须初始化,不初始化会崩溃,这是我的亲历。

3. 线程进入临界区之前会先自旋那么几次,所有自旋锁都失败了之后会创建一个内核对象然后等待这个内核从而进入到内核模式。

4. Enter和Leave必须匹配,每Enter一次,就得Leave一次,这又是可恶的计数机制。参见下面的​​代码​​:

typedef struct _RTL_CRITICAL_SECTION {
    PRTL_CRITICAL_SECTION_DEBUG DebugInfo;

    //
    //  The following three fields control entering and exiting the critical
    //  section for the resource
    //

    LONG LockCount;
    LONG RecursionCount;
    HANDLE OwningThread;        // from the thread's ClientId->UniqueThread
    HANDLE LockSemaphore;
    ULONG_PTR SpinCount;        // force size on 64-bit systems when packed
} RTL_CRITICAL_SECTION, *PRTL_CRITICAL_SECTION;      

这是临界区对象的定义,看见

RecursionCount      

这个对象了吧,你觉得它能干点啥?同时在这里你还能看到一个信号量的内核对象,还有一个自旋数量。这些玩意印证了上面的话。如果你同一个线程Leave之前Enter了两次,必须调用两个Leave,不然这个临界区对象依然会阻塞别的线程。再不明白就去看我前面有关挂起线程的那个博文。

5. 由于进入临界去是无限等待的,因此你有时间肯定希望有种方法能够查看一下临界区是否可用,不可用则希望线程立刻去做其它的事情。这时候,你就需要一个TryEnterCriticalSectionAPI,这玩意很好理解,你踹一脚临界区,如果能进去就进去,不能进去这个API立刻以False返回,你就可以安排线程去做其它的事情。注意:你一脚踹进去了之后完事了记得要离开(LeaveCriticalSection)。

5. 由于进入临界去是无限等待的,因此你有时间肯定希望有种方法能够查看一下临界区是否可用,不可用则希望线程立刻去做其它的事情。这时候,你就需要一个TryEnterCriticalSectionAPI,这玩意很好理解,你踹一脚临界区,如果能进去就进去,不能进去这个API立刻以False返回,你就可以安排线程去做其它的事情。注意:你一脚踹进去了之后完事了记得要离开(LeaveCriticalSection)。

6. 前面说了,临界区真正用内核对象挂起线程之前会自旋好几次,因此你看对象里就有一个自旋锁的计数。你可以改这个自旋锁的数量。当然我不是说让你直接修改对象的成员变量!你可以在初始化的时候指定自旋锁的数量,用这个API:InitializeCriticalSectionAndSpinCount。在这里小说一下临界区为什么会自旋。因为​​程序​​从用户态转到内核模式需要昂贵的开销(大概数百个CPU周期),很多情况下,A线程还没完成从用户态转到内核态的操作呢,B线程就已经释放资源了。于是临界区就先隔一段时间自旋一次,直到所有自旋次数都耗尽,就创建个内核对象然后挂起线程。但是,如果您的机器只有一个CPU,那么这个自旋次数就没用了,操作系统直接会无视它。原因如下:你自旋着呢,操作B线程释放不了资源,于是你还不如直接切入等待状态让B来释放资源。动态更改自旋数量请使用SetCriticalSectionSpinCount,别做直接更改对象成员变量的二事!

7. 最后,初始化临界区和进入临界区的时候都有可能会遇到异常状况,比如初始化的时候需要申请一些内存来保存DEBUG的信息(参见上面代码的第一个成员变量),如果内存不够,初始化就崩了。进入临界区的时候可能需要新建一个内核对象,如果内存不够,也崩了。解决这个问题的方法有两种

  1. 结构化异常处理
  2. 初始化的时候使用InitializeCriticalSectionAndSpinCount。这个API有返回值,如果它申请DEBUG信息失败,返回FALSE,另外,刚才提到了这个API可以指定自旋锁的自旋次数。这个自旋次数的范围并非是用0到0xFFFF

    FFFF而是0--->0x00FF FFFF,因此你可以设定高位为1指示初始化的时候直接建立内核对象。如果建立失败,这个函数也会调用失败。当然了,一上来就搞一个内核对象似乎有点浪费内存,但是这样能够保证进入临界区不会失败!但是吧,你需要注意,设置高位来保证内核对象的创建只能在2000上玩。MSDN上有说明,不信你看:

  3. Windows  2000:  If the high-order bit is set, the function pre-allocates the event used by the​​EnterCriticalSection​​​ function. Pre-allocation guarantees that entering or leaving the critical section will not raise an exception in low memory conditions. Do not set this bit if you are creating a large number of critical section objects, because it consumes a significant amount

    of nonpaged pool. Note that this event is allocated on demand starting with Windows XP and the high-order bit is ignored.

最后是一些实验:

我们看看用InitializeCriticalSection初始化一个临界区对象后,这些成员变量(除去DEBUG外)都是什么样子。

临界区和C++使用方式

我们Enter一下,看看会变成什么样子

临界区和C++使用方式

我们再让其它线程也Enter一下看看

临界区和C++使用方式

可见,新建了一个内核对象。

我们现在让主线程退出临界区

临界区和C++使用方式

对照线程句柄我们可以看出第二个线程获得了临界区对象。

我们再让第二个线程退出临界区。

临界区和C++使用方式

临界区除去内核对象外回到了原始状态。

实验2:我们让临界区对象在同一线程内相继被进入两次

::EnterCriticalSection(&g_cs);
::EnterCriticalSection(&g_cs);      
临界区和C++使用方式

可见,计数增加了一个,变成了,因此你得leave两次才能开锁

RTL_CRITICAL_SECTION 结构。为方便起见,将此结构列出如下:

struct RTL_CRITICAL_SECTION
{
PRTL_CRITICAL_SECTION_DEBUG DebugInfo;
LONG LockCount;
LONG RecursionCount;
HANDLE OwningThread;
HANDLE LockSemaphore;
ULONG_PTR SpinCount;
};      

以下各段对每个字段进行说明。

DebugInfo 此字段包含一个指针,指向系统分配的伴随结构,该结构的类型为 

RTL_CRITICAL_SECTION_DEBUG。这一结构中包含更多极有价值的信息,也定义于 WINNT.H 中。我们稍后将对其进行更深入地研究。

LockCount 这是临界区中最重要的一个字段。它被初始化为数值 -1;此数值小于-1 

时,表示此临界区被占用。当其不等于 -1 时,OwningThread 字段(此字段被错误地定义于 WINNT.H 中 — 应当是 DWORD 而不是 

HANDLE)包含了拥有此临界区的线程 ID。

RecursionCount 

此字段包含当前所有者线程已经获得该临界区的次数。如果该数值为零,下一个尝试获取该临界区的线程将会成功。

OwningThread 此字段包含当前占用此临界区的线程的线程标识符。此线程 ID 与 

GetCurrentThreadId 之类的 API 所返回的 ID 相同。

LockSemaphore 

此字段的命名不恰当,它实际上是一个自复位事件,而不是一个信号。它是一个内核对象句柄,用于通知操作系统:该临界区现在空闲。操作系统在一个线程第一次尝试获得该临界区,但被另一个已经拥有该临界区的线程所阻止时,自动创建这样一个句柄。应当调用 

DeleteCriticalSection(它将发出一个调用该事件的 CloseHandle 调用,并在必要时释放该调试结构),否则将会发生资源泄漏。

SpinCount 旋转次数。仅用于多处理器系统。MSDN 

文档对此字段进行如下说明:“在多处理器系统中,如果该临界区不可用,调用线程将在对与该临界区相关的信号执行等待操作之前,旋转 dwSpinCount 

次。如果该临界区在旋转操作期间变为可用,该调用线程就避免了等待操作。”旋转计数可以在多处理器计算机上提供更佳性能,其原因在于在一个循环中旋转通常要快于进入内核模式等待状态。此字段默认值为零,但可以用 

InitializeCriticalSectionAndSpinCount API 将其设置为一个不同值。

InitializeCriticalSectionAndSpinCount作用

The InitializeCriticalSectionAndSpinCount function initializes a critical section object and sets the spin count for the critical section.

BOOL InitializeCriticalSectionAndSpinCount(

  LPCRITICAL_SECTION lpCriticalSection,

                      // pointer to critical section

  DWORD dwSpinCount   // spin count for critical section

);SetCriticalSectionSpinCountThe SetCriticalSectionSpinCount function sets the spin count for the specified critical section. DWORD SetCriticalSectionSpinCount(

  LPCRITICAL_SECTION lpCriticalSection, 

                      // pointer to critical section

  DWORD dwSpinCount   // spin count for critical section

);

当线程试图进入另一个线程拥有的关键代码段时,调用线程就立即被置于等待状态。这意

味着该线程必须从用户方式转入内核方式(大约1 0 0 0个C P U周期)。这种转换是要付出很大代价的。

因此, InitializeCriticalSectionAndSpinCount 的作用不同于InitializeCriticalSection 之处就在于设置了一个循环锁,不至于使线程立刻被置于等待状态而耗费大量的CPU周期,而在dwSpinCount后才转为内核方式进入等待状态。通常dwSpinCount设为4000较为合适 。 

实际上对 CRITICAL_SECTION 的操作非常轻量,为什么还要加上旋转锁的动作呢?其实这个函数在单cpu的电脑上是不起作用的,只有当电脑上存在不止一个cpu,或者一个cpu但多核的时候,才管用。

如果临界区用来保护的操作耗时非常短暂,比如就是保护一个reference counter,或者某一个flag,那么几个时钟周期以后就会离开临界区。可是当这个thread还没有离开临界区之前,另外一个thread试图进入 此临界区——这种情况只会发生在多核或者smp的系统上——发现无法进入,于是这个thread会进入睡眠,然后会发生一次上下文切换。我们知道context switch是一个比较耗时的操作,据说需要数千个时钟周期,那么其实我们只要再等多几个时钟周期就能够进入临界区,现在却多了数千个时钟周期的开销,真 是是可忍孰不可忍。

所以就引入了InitializeCriticalSectionAndSpinCount函数,它的第一个参数是指向cs的指针,第二个参数 是旋转的次数。我的理解就是一个循环次数,比如说N,那么就是说此时EnterCriticalSection()函数会内部循环判断此临界区是否可以进 入,直到可以进入或者N次满。我们增加的开销是最多N次循环,我们可能获得的红利是数千个时钟周期。对于临界区内很短的操作来讲,这样做的好处是大大的。

MSDN上说,他们对于堆管理器使用了N=4000的旋转锁,然后“This gives great performance and scalability in almost all worst-case scenarios.” 可见还是很有用的:-)

8、多次调用 LeaveCriticalSection 导致死锁

继续阅读