线程需要在下面两种情况下互相进行通信:
1. 当有多个线程访问共享资源而不使资源被破坏时。
2. 当一个线程需要将某个任务已经完成的情况通知另外一个或多个线程时。
线程同步问题在很大程度上与原子访问有关。来看一个例子:
线程函数产生的汇编代码如下:MOV EAX, [g_x] -> INC EAX --> MOV[g_x], EAX,如果两个线程先后调用,程序自然没有问题,但是如果像这个顺序执行:MOV EAX, [g_x] --> INC EAX --> MOV EAX, [g_x] -> INC EAX --> MOV[g_x], EAX --> MOV[g_x], EAX,那么g_x最后的结果不是2,而是1。
为了解决上面的问题,需要某种比较简单的方法。我们需要一种手段来保证值的递增能够以原子的操作进行,也就是不中断地进行。为此,我们可以利用互锁的函数,互锁的函数尽管用处很大,而且很容易理解,却有些让人望而生畏,大多数软件开发人员用得很少。所有的函数都能以原子的操作对一个值进行操作,让我们看一下InterlockedExchangeAdd函数如何实现上述代码的互锁:
在多线程的程序开发中,所有线程都应该通过调用这些函数来修改共享变量。而不是简单的g_x++。还有一点需要知道,互锁函数的开销很小,通常小于50个CPU周期,并且不会从用户方式转为内核方式(通常需要1000个CPU周期)。
另外再介绍两个互锁函数:LONG InterlockedExchange(PLONG plTarget, LONG lValue);PVOID InterlockedExchangePointer(PVOID *ppvTarget, PVOID pvValue),这两个互锁函数能以原子操作方式用第二个参数中传递的值来取代第一个参数中传递的当前值,并返回原始值。下面的例子是比较常用的用法:
使用这个方法需要格外小心,因为循环锁会浪费CPU资源。另外该代码假设使用循环锁的所有线程都以相同的优先级运行。应当避免在单个CPU计算机上使用循环锁,试想,当一个线程进入循环时另外一个线程无法修改该值,两个线程交替运行,永远不会同时访问共享资源。上面的例子中,我们用了Sleep,可以从一定程度上避免CPU的浪费,然后更好的方法是调用SwitchToThread替换Sleep函数。
循环锁假定,受保护的资源总是被访问较短的时间。这使它能更加有效的循环运行,然后转为内核方式并进入等待状态。许多编程人员循环一定的次数(比如400),如果对资源的访问仍然被拒绝,那么该线程就转为内核方式,在这种方式下,它要等待直到该资源变为可供使用为止。这就是关键部分的实现方法。
下面介绍最后两个互锁函数:PVOID InterlockedCompareExchange(PLONG plDestination, LONG lExchange, LONG lComparand); 和 PVOID InterlockedCompareExchangePointer(PVOID *ppvDestination, PVOID pvExchange, PVOID pvComparand); 这两个函数负责执行一个原子测试和设置操作。该函数对当前值(plDestination参数指向的值)与lComparand参数中传递的值进行比较,如果两个值相同,那么*plDestination改为lExchange参数的值。如果*plDestination中的值与lExchange的值不匹配,*plDestination保持不变。该函数返回*plDestination中的原始值。此外,当要对共享内存区域(比如内存映射文件)中的值的访问进行同步时,互锁函数也可以供多线程使用。具体例子见下一章。
高速缓存行
当一个CPU从内存中读取一个字节时,它不只取出一个字节,它要取出足够的字节数填入高速缓存行。高速缓存行是由32字节或者64字节(视CPU而定)组成,并且最终在第32个字节或第64个字节的边界上对齐。如果这些字节在高速缓存行中,那么CPU就不必访问内存总线,而访问内存总线需要多得多的时间。但是在多处理器的环境中,高速缓存行可能使得内存的更新更加困难。例如下面的情况:
1. CPU1读取一个字节,将临近的字节也放入CPU1的高速缓存行。
2. CPU2读取同一个字节,也把临近的字节放入CPU2的告诉缓存行。
3. CPU1修改该字节,使得该字节写入高速缓存行,但是该信息未写入RAM。
4. CPU2再次读取同一个字节,它将直接从高速缓存行中读取,该值不是新值,不是我们期望的。
当然,芯片的设计者会考虑到这个情况。当一个CPU修改高速缓存行中的内容后,其他CPU都会得到通知,将他们的高速缓存行视为无效,CPU2必须重新访问内存读取数据。所以,在多处理器的计算机上高速缓存行可能称为一个不利因素。
明白了这一点,我们在编写程序时应该竟然确保不同的CPU能够访问至少由高速缓存行边界分开的不同内存地址(具体做法参看核心编程page177),还有,应该将只读数据(或不常读的数据)与读写数据分开。同时,应该将同一时间访问的数据组合在一起。这两点比较好理解。
最好是始终让单个线程来访问数据(函数参数和局部变量是确保做到这一点的最好方法),或者始终让单个CPU访问这些数据(使用线程的亲缘性)。如果采取其中的一种方法,就能够完全避免高速缓存行的各种问题。
高级线程同步
虽然互锁函数很有用,但是在实际编程中往往要处理比单个32位或者64位更复杂的数据结构,为了以原子操作方式使用更加复杂的数据结构。我们需要用Windows提供的某些其他特性。而循环锁又会浪费CPU时间,我们应该谨慎地使用它们。因此需要一种机制,使线程在等待访问共享资源时不浪费CPU时间。
当线程想要访问共享资源,或者得到某个"特殊事件"的通知时,该线程必须调用一个操作系统函数,给它传递一些参数,以指明这个线程正在等待什么。如果操作系统发现资源可供使用,或者该特色事件已经发生,那么函数返回,同时线程保持可调度状态。这种机制就需要操作系统的同步对象来实现,将在下一章中介绍。如果没有同步对象,我们可能只有通过下面的方法来实现同步:
这种方法会有很多问题,由于主线程没有进入睡眠状态,它会占用其他线程宝贵的CPU时间。而且,如果主线程的优先级高于RecalcFunc线程,那么g_fFinishedCalculation将永远不会设置为TRUE,知道主线程进入睡眠状态。
我们发现上述代码中使用了volatile这个限定词。它告诉编译器,变量可以被应用程序本身以外的任何东西进行修改,包括操作系统,硬件或者同时执行的线程。尤其时volatile告诉编译器,不要对该变量进行优化。否则编译器可能会将该变量置于寄存器中,新的值不会马上写到内存。另外,如果使一个结构加上volatile限定,那么该结构的所有成员都具有这个性质。那么,看再前面一个例子中的g_fResourceInUse,是否需要用volatile限定?答案是不需要,因为我们将该变量的地址传给了互锁函数。当将一个变量的地址传递给一个函数时,该函数必须从内存中读取该值,优化程序不会对它产生任何影响。
关键代码段
关键代码段是指一小段代码,在代码能够执行前,它必须独占对某些共享资源的访问。先看下面的例子会有什么问题:
如果分开来看,两个线程结果相同。在理想情况下,我们系统两个线程能够同时运行,并且仍然使g_dwTimes数组能够产生递增的值。但是看一种情况:SecondThread先启动,执行到g_nIndex++换FirstThread执行g_dwTimes[g_nIndex] = GetTickCount(),那么g_dwTimes[1]设置了当前时间,此时FirstThread停止执行,SecondThread执行g_dwTimes[g_nIndex - 1] = GetTickCount(),此时g_nIndex-1是0,也就是对g_dwTimes[0]设了值,这当然不是我们期望的。现在我们用关键代码段来让程序正常运行:
代码不难理解,但是要注意一点,如果线程1和线程2访问一个资源,线程1和线程3访问另一个资源,那么需要为每一个资源创建一个CRITICAL_SECTION结构。关键代码段的缺点在于无法用它们对多个进程的线程进行同步。不过在19章,我将要自己创建自己的同步对象,称为Optex。这个对象将显示操作系统如果来实现关键代码段,它能用于多个进程中的线程。