临界区的内部结构与实现

        做windows开发的人都知道临界区是应用层同步对象,相对于其它内核同步对象来说,等待临界区的开销比较小,其原因在于临界区采用忙等(自旋)的方式来避免线程切换。这些原理性知识大家都比较清楚,但对于它的实现细节就不是每个人都了解了。我在这方面也曾经存在困惑,后来通过分析它的结构以及相关几个API的实现,总算有了比较清楚的认识。

       在windows系统内部, 临界区的结构体叫做_RTL_CRITICAL_SECTION,在windbg中可以用dt命令打印出它的结构:

:006> dt_RTL_CRITICAL_SECTION

ntdll!_RTL_CRITICAL_SECTION

   +0x000 DebugInfo        : Ptr32 _RTL_CRITICAL_SECTION_DEBUG

   +0x004 LockCount        : Int4B

   +0x008 RecursionCount    :Int4B

   +0x00cOwningThread     : Ptr32 Void

   +0x010 LockSemaphore    : Ptr32 Void

   +0x014 SpinCount         :Uint4B

        DebugInfo,此字段包含一个指针,指向系统分配的伴随结构,该结构的类型为RTL_CRITICAL_SECTION_DEBUG。这一结构中包含更多极有价值的信息,也定义于 WINNT.H 中。

        LockCount,这是临界区中最重要的一个字段。它被初始化为数值 -1;此数值等于或大于 0 时,表示此临界区被占用。当其不等于 -1 时,OwningThread 字段(此字段被错误地定义于 WINNT.H 中 — 应当是 DWORD 而不是 HANDLE)包含了拥有此临界区的线程 ID。此字段与 (RecursionCount -1) 数值之间的差值表示有多少个其他线程在等待获得该临界区。

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

        OwningThread,此字段包含当前占用此临界区的线程的线程标识符。此线程 ID 与 GetCurrentThreadId 之类的 API 所返回的 ID 相同。

        LockSemaphore,此字段的命名不恰当,它实际上是一个自复位事件,而不是一个信号。它是一个内核对象句柄,用于通知操作系统:该临界区现在空闲。操作系统在一个线程第一次尝试获得该临界区,但被另一个已经拥有该临界区的线程所阻止时,自动创建这样一个句柄。应当调用 DeleteCriticalSection(它将发出一个调用该事件的 CloseHandle 调用,并在必要时释放该调试结构),否则将会发生资源泄漏。

        SpinCount,仅用于多处理器系统。MSDN® 文档对此字段进行如下说明:“在多处理器系统中,如果该临界区不可用,调用线程将在对与该临界区相关的信号执行等待操作之前,旋转 dwSpinCount 次。如果该临界区在旋转操作期间变为可用,该调用线程就避免了等待操作。”旋转计数可以在多处理器计算机上提供更佳性能,其原因在于在一个循环中旋转通常要快于进入内核模式等待状态。此字段默认值为零,但可以用 InitializeCriticalSectionAndSpinCount API 将其设置为一个不同值。

        结合临界区的结构,再看看相关的几个API的具体实现,临界区对象就算是了解清楚了,下面是几个API的代码分析(代码有省略):

ntdll!RtlInitializeCriticalSectionAndSpinCount:

7c93151a     mov    edi,edi

7c93151c     push    ebp

7c93151d     mov     ebp,esp

7c93151f     sub     esp,20h

7c931522     push    ebx

7c931523     xor     ebx,ebx

7c931525     push    edi

7c931526     mov    edi,dword ptr [ebp+8]

7c931529     or     dword ptr [edi+4],0FFFFFFFFh   //LockCount

7c93152d     mov     word ptr [edi+8],ebx          //RecursionCount

7c931530     mov    dword ptr [edi+0Ch],ebx        //OwningThread

7c931533     mov    dword ptr [edi+10h],ebx        //LockSemaphore 

7c931536     mov    eax,dword ptr fs:[00000018h]

7c93153c     mov    eax,dword ptr [eax+30h]        //PEB

7c93153f     cmp   dword ptr [eax+64h],1          //比较cpu个数

7c931543     jbe    ntdll!RtlInitializeCriticalSectionAndSpinCount+0x38 (7c931637)

………….

7c931637    mov     dword ptr [edi+14h],ebx        //单cpu, 设置SpinCount = 0

        当调用InitializeCriticalSection 初始化临界区对象时,InitializeCriticalSection最终会调上面的函数进行初始化。从上面可以看出,在设置SpinCount之前,函数会先查询cpu的个数,如果个数为1的话则会设置SpinCount为0,在单 cpu情况下没有其它cpu来执行对象释放, 忙等没有意义。

 

ntdll!RtlEnterCriticalSection:

7c921000    mov  ecx,dwordptr fs:[18h]     //ecx指向当前线程TEB

7c921007    mov  edx,dwordptr [esp+4]     //edx为临界区对象指针

7c92100b    cmp dword ptr [edx+14h],0     //SpinCount= 0 ?

7c92100f    jne    ntdll!RtlEnterCriticalSection+0x60(7c921060)

7c921011    lock inc dword ptr [edx+4]      //lock内存,LockCount加1

7c921015    jne   ntdll!RtlEnterCriticalSection+0x30 (7c921030)

7c921017    mov  eax,dword ptr [ecx+24h]    //获得控制权,获线程ID,此LockCount为0

7c92101a    mov  dword ptr [edx+0Ch],eax    //设置临界区对象OwningThread

7c92101d    mov  word ptr [edx+8],1         //设置RecursionCount

7c921024    xor   eax,eax                  //返回0

7c921026    ret    4

7c921029    lea    esp,[esp]

7c921030    mov   eax,dword ptr [ecx+24h]

7c921033    cmp   dword ptr [edx+0Ch],eax     //临界区已经被占用,测试是不是当前线程占用了

7c921036    jne    ntdll!RtlEnterCriticalSection+0x40 (7c921040)

7c921038    inc     dword ptr [edx+8]           //自己占用了,增加RecursionCount

7c92103b    xor     eax,eax                   //返回

7c92103d    ret     4

7c921040    push    edx          //临界区被其他线程占用,对象指针压栈

7c921041    call    ntdll!RtlpWaitForCriticalSection (7c939a97)  //进入等待,线程切换

7c921046    mov    ecx,dword ptr fs:[18h]      //获得控制

7c92104d    mov     edx,dword ptr [esp+4]

7c921051    jmp     ntdll!RtlEnterCriticalSection+0x17 (7c921017)

7c921053    lea     esp,[esp]

7c92105a    lea     ebx,[ebx]

7c921060    mov     eax,dword ptr [ecx+24h]     //SpinCount不等于0的情况

7c921063    cmp     dword ptr [edx+0Ch],eax

7c921066     jne     ntdll!RtlEnterCriticalSection+0x80 (7c921080) //非本线程占有,跳到自旋代码处

7c921068    lock inc dword ptr [edx+4]    //本线程占有,增加LockCount并返回

7c92106c    inc    dword ptr [edx+8]   //RecursionCount+1

7c92106f    xor     eax,eax

7c921071    ret    4

7c921074    lea     esp,[esp]

7c92107b    add     eax,0

7c921080    push    dwordptr [edx+14h]     //SpinCount压栈,开始自旋

7c921083    mov     eax,0FFFFFFFFh

7c921088    mov     ecx,0

7c92108d    lock cmpxchg dwordptr [edx+4],ecx  //测试对象是否释放(LockCount 等于0xFFFFFFFF),释放则取得控制,设置LockCount = 0,这是一个原子型test and set操作

7c921092    jne    ntdll!RtlEnterCriticalSection+0xb0(7c9210b0)  //未获得控制,跳转

7c921094    add     esp,4                      //获得控制

7c921097    mov    ecx,dword ptr fs:[18h]

7c92109e    mov     eax,dword ptr [ecx+p24h]

7c9210a1    mov     dword ptr [edx+0Ch],eax

7c9210a4    mov    dword ptr [edx+8],1

7c9210ab    xor     eax,eax

7c9210ad    ret     4

7c9210b0    cmp    dword ptr [edx+4],1   

7c9210b4    jge     ntdll!RtlEnterCriticalSection+0xc3 (7c9210c3)//Lock Count >= 1

7c9210b6    pause                     //延迟一下,希望别的线程释放临界区

7c9210b8    cmp     dword ptr [edx+4],0FFFFFFFFh                 

7c9210bc    je      ntdll!RtlEnterCriticalSection+0x83 (7c921083)   //临界区空闲

7c9210be    dec     dword ptr [esp]                 //SpinCount减1

7c9210c1    jne     ntdll!RtlEnterCriticalSection+0xb6 (7c9210b6)   // SpinCount>0,继续忙等

7c9210c3    add     esp,4                          //SpinCount等于0

7c9210c6    mov     ecx,dword ptr fs:[18h]

7c9210cd    jmp     ntdll!RtlEnterCriticalSection+0x11 (7c921011) //返回前面,结束忙等

7c9210d2    lea     esp,[esp]

7c9210d9    lea     esp,[esp]

 

        RtlEnterCriticalSection函数通过SpinCount来控制忙等的次数,在SpinCount等于0而仍没有获得临界区对象的情况下,函数将会通过临界区对象内部的事件对象进行等待。忙等是通过对LockCount进行原子读写实现,汇编语言中用lock前缀达到原子访问。


ntdll!RtlLeaveCriticalSection:

7c9210e0    mov   edx,dwordptr [esp+4]    //临界区对象指针

7c9210e4    xor    eax,eax

7c9210e6    dec    dword ptr [edx+8]       //RecursionCount - 1

7c9210e9    jne    ntdll!RtlLeaveCriticalSection+0x30 (7c921110)

7c9210eb    mov   dword ptr [edx+0Ch],eax    //本线程不再占用临界区对象,设置OwningThread = 0

7c9210ee    lock dec dword ptr[edx+4]         //LockCount - 1

7c9210f2    jge     ntdll!RtlLeaveCriticalSection+0x17(7c9210f7)

7c9210f4    ret     4

7c9210f7    push    edx

7c9210f8    call    ntdll!RtlpUnWaitCriticalSection (7c939b5f)    //LockCount>= 0, 说明还有其他线程等待进入临界区,此函数创建事件对象(如果还没有创建)并设为有信号状态。

7c9210fd    xor     eax,eax

7c9210ff     ret     4

7c921102    ea     esp,[esp]

7c921109    lea     esp,[esp]

7c921110    lock dec dword ptr[edx+4]  //本线程仍然占用临界区,LockCount - 1

7c921114    ret     4

释放对象的过程比较简单,这里不再做解释,具体见上面的代码标注。

 

        小结:考虑了运行环境(CPU个数)并结合汇编语言的lock前缀的特点,临界区的实现可谓是简洁高效,为开发和测试中的同步问题提供了很好的支持。

你可能感兴趣的:(windows开发)