8.1原子访问:互锁的函数家族
虽然Windows是个抢占式的系统,但是有时候需求就是要在线程函数中同时操作同一个变量,这样输出的时候却不能保证操作的结果和代码的编写意愿相同。
所以Windows提供了一些函数,如果正确地使用它们,就能确保得到代码对应的结果。这就是原子操作:
LONG InterLockedExchangeAdd(
PLONG plAddend,
LONG lIncrement);
使用这个函数就能保证一个全局变量能够按循序地递增。所有的线程都应该使用这种方式来操作共享的变量,任何线程都不应该简单地调用C语句来修改共享的变量。
函数的实现方式取决于何种CPU平台,对于x86平台的CPU来说,互锁函数会对总线发出一个硬件信号,防止另一个CPU访问同一个内存地址。在Alpha平台上则是:
- 打开CPU中的一个特殊的位标志,并注明被访问的内存地址
- 将内存的值读入一个寄存器
- 修改这个寄存器
- 如果CPU的位标志是关闭的,则转入第二步。否则如果特殊位标志是打开的,则将值重新写回内存。
执行第四步的时候,CPU的特殊标志位是怎么关闭的呢?实际上只要又有另一个CPU试图修改同一个内存地址,那么它就能关闭CPU的特殊位标志,从而导致互锁函数返回第二步。
互锁函数的时钟周期很短,只有几个CPU周期(通常小于50),并且不会从用户方式转换为内核方式(通常需要1000个时钟周期)。
可以使用InterLockedExchangeAdd减去一个值(只要第二个参数传递一个负值),返回原始值。以下是其他两个互锁函数:
LONG InterLockedExchange(
PLONG plTarget,
LONG lValue
); //32和64都交换两个32位的值
VOID InterLockedExchangePointer(
PVOID ppvTarget,
PVOID pvValue
); //32位相同,64下交换两个64位的值
要格外小心,循环锁会浪费CPU时间。另外循环锁变量应该和循环锁保护的数据维护在不同的高速缓存中。如果在相同的高速缓存,那么使用该资源的CPU将与试图访问资源的任何CPU争用高速缓存。
应该避免在单CPU计算机上使用循环锁。如果一个线程正在循环运行,它会浪费前一个CPU时间,这将防止另一个线程修改这个值。
循环锁总是假定,受保护的资源被访问的较短的时间。这使它能够更加有效地循环运行,然后转换为内核方式进入内核方式并进入等待状态。如果资源被其他线程访问时拒绝,线程就转为内核方式,以不消耗CPU时间的方式等到资源可供使用为之。
循环锁是很常用的,不过也要小心,不应该让线程循环运行太长的时间,会浪费CPU时间。另外的两个互锁函数:
PVOID InterLockedCompareExchange( PLONG plDestination; LONG lExchange; LONG lComparand ); // 32 64 = 32 PVOID InterLockedCompareExchangePointer( PVOID* ppvDestination; PVOID pvExchange; PVOID pvComparand ); // 32 = 32; 64 = 64
LONG InterLockedIncrement(PLONG plAddend);
LONG InterLockedDecrement(PLONG plAddend);
8.2高速缓存行
知道高速缓存是能在多处理器计算机上创建的高性能应用程序的必备条件。当CPU从内存中取出一个字节时,它不是取出一个字节,而是取出足够的字节来填入高速缓存。高速缓存有32或64个字节组成(视CPU而定),并且始终在32个字节或者64个字节的边界上对齐。高速缓存行被设计成为提高CPU运行效率的。
但是,在多处理器环境中,高速缓存行使得内存的更新更加困难,举个例子:
- CPU1读取一个字节,使该字节和他的相邻字节被读入CPU1的高速缓存行。
- CPU2读取一个字节,使得第一步相同的各个字节读入CPU2的高速缓存行。
- CPU1修改内存中的该字节,使得该字节被写入CPU1的高速缓存行。但是信息尚未写入RAM。
- CPU2再次读入同一个字节。由于该字节已经放入CPU2的高速缓存行,因此它不必访问内存,但是CPU2将看不到内存中该字节的新值。
这种情况会产生严重的后果。当然,芯片设计者非常清楚这个为题,并且设计它们的芯片来处理这个问题。尤其是当一个CPU修改高速缓存行中的字节时,计算机中的其他CPU会被告知这个情况,他们的高速缓存行将变得无效。因此,CPU2的高速缓存在CPU1修改字节的值时变为无效。在第4步,CPU1必须把它的高速缓存行内存迅速转入内存,CPU2必须再次访问内存,重新将数据填入高速缓存行。所以说,高速缓存行能够帮助提高运行速度,却也是多处理器计算机上的一个不利因素。
这一切都意味着你应该将高速缓存行存储块中的高速缓存行边界上的应用程序数据,组合在一起。这样做的目的时保证不同的CPU能够访问至少有高速缓存行边界分开的不同的内存地址。还有应该将只读数据与读写数据分开、将同一个时间访问的数据组合在一起。
8.3高级线程同步
互锁函数家族在原子操作操作单个值时,是很有用的。然而大多数情况下要处理的是比单个32或64值复杂的多的数据结构,为了以原子操作操作更复杂的数据结构,互锁函数就派不上用场了,必须使用Windows的其他特性。
当线程想要访问共享资源,或者得到关于某个“特殊事件”的通知时,该线程必须调用的一个操作系统函数,给它指明一些参数,以说明在等待什么。如果操作系统发现资源可供使用,或者该特殊事件已经发生,那么函数就返回,同时该线程可以保持可调度状态。
如果资源不能使用,或者特殊事件尚未发生,那么系统便使该线程处于等待状态,线程无法被调度。这可以防止线程浪费CPU时间。当线程处于等待状态,系统作为一个代理,代表你的线程来执行操作。操作系统能记得你的线程需要什么,一旦资源可供使用,便自动让线程退出等待状态,当该线程与特殊事件实现同步。
同实际情况来看,大多数线程几乎总是处于等待状态。当系统发现所有线程有若干分钟都处于等待状态时,系统强大的管理功能就会发挥作用。
如果没有同步对象,并且操作系统不能发现各种特殊事件,那么线程就不得不使用下面要介绍的一种方法,使得自己与特殊事件保持同步。不过由于操作系统具有支持线程同步的内置特性,绝不行该使用这个方法。
运用这种方法时,一个线程能够自己与另一个线程中的人物完成实现同步,方法是不断查询多个线程共享或可以访问的变量的状态。
8.4关键代码段
关键代码段是指让一小段代码,在代码能够执行前,保证独占对某些共享资源的访问权。这是让若干行代码能够“以原子操作方式”来使用资源的一种方法。
指定了一个CRITICAL_SECTION数据结构g_cs,然后再对EnterCriticalSection和LeaveCriticalSection函数调用封装了要接触共享资源的任何代码。注意,在对EnterCriticalSection和LeaveCriticalSection的调用中,都传递了g_cs的地址。
当无法使用互锁函数解决同步问题时,应该试用关键代码段。它的优点在于它的使用非常容易,在内部使用互锁函数,这样就能迅速运行。
8.4.1关键代码段准确的描述
如果想知道关键代码段为何有效,首先介绍下CRITICAL_SECTION这个数据结构。它在PlatformSDK的文档中没有完整的解释,是因为Microsoft认为没有必要了解它的全部情况。也许你有办法操作它的成员,但是绝不应该这样做。
使用这个结构,可以调用一个Windows函数,给它传递结构的地址。
通常情况下,CRITICAL_SECTION可以作为全局变量来分配,这样,进程中的所有线程就能很容易地按照变量并来引用这个结构。当然它也可以作为局部变量来分配,或者在堆上动态分配。它只有两个要求:
- 访问该资源的所有线程必须知道负责保护资源的CRITICAL_SECTION结构的地址。
- CRITICAL_SECTION结构中的成员应该在任何线程试图访问被保护的资源之前初始化。
VOID InitializeCriticalSection(PCRITICAL_SECTION pcs);
这个函数不会失败,也没有返回值,在调用EnterCriticalSection前使用它就可以。
同时当知道线程不再试图访问贡献资源时,清除CRITICAL_SECTION,方法是:
VOID DeleteCriticalSection(PCRITICAL_SECTION pcs);
没有线程使用关键代码段后,就删除它。
EnterCriticalSection函数负责查看结构中的成员变量,它们用于指明当前哪个变量正在访问该资源,它做下列的测试:
- 如果没有线程访问该资源,EnterCriticalSection便更新成员变量,以指明调用线程已被赋予访问权并立即返回,该线程能够继续运行。
- 如果成员变量指明,调用线程已经被赋予对资源的访问权,那么EnterCriticalSection便更新这些变量,以指明调用线程多少次被赋予访问权并立即返回,使得该线程能够继续运行。
- 如果成员变量指明,一个除了调用线程之外的线程已被赋予对资源的访问权,那么EnterCriticalSection便更新CRITICAL_SECTION的成员变量。一旦目前访问该资源的线程调用LeaveCriticalSection函数,改线程就处于被调度状态。
从内部来讲,EnterCriticalSection函数并不十分复杂,它只是执行了一些简单的测试。之所以如此有效,是因为能够以原子操作方式执行所有的测试。即使在多处理器计算机上的两个线程在完全相同的时间调用EnterCriticalSection函数,该函数依然能正确地起作用,一个线程获得资源的访问权,一个线程进入等待状态。
EnterCriticalSection即便如此之好用,也是有风险的,它直接将一个线程置于等待状态,那么该线程在很长一段时间内都不会被调度,在写的不好的程序中甚至永远不会再被赋予CPU时间。这个线程就成为渴求CPU时间的线程。
如果一个线程被EnterCriticalSection置于等待状态后,线程就会在很长一段时间内都不会被调度,这种情况就叫做渴求CPU时间的线程。
但是在实际操作中,这样的线程绝对不会渴求cpu时间,对EnterCriticalSection的调用将会产生一个可以传递给调试器的异常以及超时的时间量,时间量由下列注册表路径下包含CriticalSectionTimeout数据决定。
HKEY_LOCAL_MACHINESystemCurrentControlSetControlSection Manager
这个值以秒为单位,默认为2592000s,大约是30天。这个值不能太小,否则对于系统中正常等待关键代码段超过3s的线程产生不利的影响。
EnterCriticalSection的替代函数TryEnterCriticalSection,它不允许线程进入等待状态,相反,它的返回值能够指明调用线程能否获得对资源的访问权。所以如果它发现资源以及被占用就返回FALSE,否则返回TRUE。如果是返回TRUE,那么效果和EnterCriticalSection相似,线程开始访问资源,结束后调用同样次数的LeaveCriticalSection。
VOID LeaveCriticalSection(PCRITICAL_SECTION pcs);
这个函数查看参数中结构体内的成员变量,该变量每次计数要递减1,以指明调用线程多少次被赋予对共享西元的访问权。如果该计数大于0,函数直接返回。
和EnterCriticalSection一样,LeaveCriticalSection也能以原子操作方式执行这些测试和更新。不过,LeaveCriticalSection不让线程进入等待状态,总是立即返回。
8.4.2关键代码段与循环锁
当线程试图进入另一个线程拥有的关键代码段时,调用线程就立即被置于等待状态。这意味着线程必须从用户方式转入内核方式(大于1000个CPU周期),这是相当大的代价。在多处理器的计算机上,当前拥有资源的线程可以在不同的处理器上运行,并且很快放弃对资源的控制。
假如拥有资源的线程在另一个线程转入内核状态前放开了对资源的控制,就会浪费很多的CPU时间。
为了提高关键代码段的性能,Microsoft将循环锁加入了这些代码段。因此,当EnterCriticalSection被调用时,它就使用循环锁进行循环,以多次设法获取这个资源。只有每次都失败,线程才转入内核方式。
如果要将循环锁用于关键代码段,就要调用下列函数初始化关键代码段:
BOOL InitialCriticalSectionAndSpinCount(
PCRITICAL_SECTION pcs,
DWORD dwSpinCount);
和初始化关键代码段相似,这个函数也需要一个CRITICAL_SECTION的地址。第二个参数是试图访问资源时,想要循环迭代的次数,可以是0到0xFFFFFFFF之间的任何数字。如果在单处理器计算机上调用这个函数,第二个参数将被忽略,它的计数始终被置为0。
也可以改变循环锁循环的次数:
DWORD SetCriticalSectionSpinCount(
PCRITICAL_SECTION pcs,
DWORD dwSpinCount
);
8.4.3关键代码段与错误处理
InitialCriticalSection函数运行失败的可能是微乎其微,以致于Microsoft在开始设计它的时候都没有想到这个问题,而将返回值设置为VOID。如果函数失败,系统可以根据它分配的内存块得到一些调试信息。如果该内存的分配失败,就会产生一个STATUS_NO_MEMORY的异常情况。使用结构化异常处理来跟踪这个异常。
使用更新的InitialCriticalSectionAndSpinCount函数就能够更方便地跟踪这个问题,如果内存块分配错误,它返回FALSE。
还有另一个问题。如果有一个以上的线程同时争用关键代码段,那么关键代码段会产生一个时间内核对象。由于争用的情况极少发生,因此在初次需要之前系统将不创建世界内核对象,这可以节省大量的系统资源,因为大多数代码从来不被争用。
当内存不足,关键代码段被争用,系统又无法创建事件内核对象,这时EnterCriticalSection将会产生一个EXCEPTION_INVALID_HANDLE异常。这是个很容易被忽略的错误,一般不会有专门的处理方法,因为这个错误太少见。
这样就只有两种办法,使用结构化异常来跟踪错误,当错误发生时,既可以不访问关键代码段保护的资源,也可以等待某些内存变成可用状态,然后再次调用EnterCriticalSection。
或者改用InitialCriticalSectionAndSpinCount创建关键代码段,设置了dwSpinCount参数的高信息位。当高信息位卑设置,它就创建事件内核对象,并在初始化的时候与关键代码段关联起来。如果事件无法被创建,函数返回FALSE,可以更加妥善地处理这个错误。
8.4.4非常有用的提示和技巧
- 一个资源使用一个CRITICAL_SECTION变量。
- 不要同时访问多个资源,容易发生死锁。 不要长时间运行关键代码段。
- 例如如果在线程中处理一个SendMessage,是无法知道需要花费多长时间的。这段时间内其他线程都不能访问,时间可能是几毫秒,也可能是几年。
- 将关键代码段用于关键的部分,例如只保护共享数据的操作部分,而不是全部。