做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前缀的特点,临界区的实现可谓是简洁高效,为开发和测试中的同步问题提供了很好的支持。