playerken

博客园 首页 新随笔 联系 订阅 管理

线程需要在下面两种情况下互相进行通信:
• 当有多个线程访问共享资源而不使资源被破坏时。
• 当一个线程需要将某个任务已经完成的情况通知另外一个或多个线程时。

 

原子访问:互锁的函数家族

所谓原子访问,是指线程在访问资源时能够确保所有其他线程都不在同一时间内访问相同的资源。编译器生成代码的方法,哪个CPU在执行这个代码,以及主计算机中安装了多少个CPU等因素,决定了产生的结果可能是不同的。

InterlockedExchangeAdd函数能够保证值的递增以原子操作方式来完成。所有线程都应该设法通过调用这些函数来修改共享的长变量,任何线程都不应该通过调用简单的C语句来修改共享的变量。还必须保证传递给这些函数的变量地址正确地对齐,否则这些函数就会运行失败。

 

高速缓存行

当一个CPU从内存读取一个字节时,它不只是取出一个字节,它要取出足够的字节来填入高速缓存行。高速缓存行由32或64个字节组成(视CPU而定),并且始终在第32个字节或第64个字节的边界上对齐。

但是,在多处理器环境中,高速缓存行使得内存的更新更加困难,下面这个例子就说明了这一点:
1) CPU1读取一个字节,使该字节和它的相邻字节被读入CPU1的高速缓存行。
2) CPU2读取同一个字节,使得第一步中的相同的各个字节读入CPU2的高速缓存行。
3) CPU1修改内存中的该字节,使得该字节被写入CPU1的高速缓存行。但是该信息尚未写入RAM。
4) CPU2再次读取同一个字节。由于该字节已经放入CPU2的高速缓存行,因此它不必访问内存。但是CPU2将看不到内存中该字节的新值。

这种情况会造成严重的后果。当然,芯片设计者非常清楚这个问题,并且设计它们的芯片来处理这个问题。尤其是,当一个CPU修改高速缓存行中的字节时,计算机中的其他CPU会被告知这个情况,它们的高速缓存行将变为无效。因此,在上面的情况下,CPU2的高速缓存在CPU1修改字节的值时变为无效。在第4步中,CPU1必须将它的高速缓存内容迅速转入内存,CPU2必须再次访问内存,重新将数据填入它的高速缓存行。如你所见,高速缓存行能够帮助提高运行的速度,但是它们也可能是多处理器计算机上的一个不利因素。

这一切意味着你应该将高速缓存行存储块中的和高速缓存行边界上的应用程序数据组合在一起。这样做的目的是确保不同的CPU能够访问至少由高速缓存行边界分开的不同的内存地址。还有,应该将只读数据(或不常读的数据)与读写数据分开。同时,应该将同一时间访问的数据组合在一起。

最好是始终都让单个线程来访问数据(函数参数和局部变量是确保做到这一点的最好方法),或者始终让单个CPU访问这些数据(使用线程亲缘性)。如果采取其中
的一种方法,就能够完全避免高速缓存行的各种问题。

 

高级线程同步

volatile限定词会告诉编译器,不要对该变量进行任何优化,并且总是重新加载来自该变量的内存单元的值。

当将一个变量地址传递给一个函数时,该函数必须从内存读取该值。优化程序不会对它产生任何影响。

 

关键代码段

关键代码段是指一个小代码段,在代码能够执行前,它必须独占对某些共享资源的访问权。这是让若干行代码能够“以原子操作方式”来使用资源的一种方法。

关键代码段的优点在于它们的使用非常容易,它们在内部使用互锁函数,这样它们就能够迅速运行。关键代码的主要缺点是无法用它们对多个进程中的各个线程进行同步。

它只有两个要求,第一个要求是,需要访问该资源的所有线程都必须知道负责保护资源的CRITICAL_SECTION结构的地址,你可以使用你喜欢的任何机制来获得这些线程的这个地址;第二个要求是,CRITICAL_SECTION结构中的成员应该在任何线程试图访问被保护的资源之前初始化。

EnterCriticalSection负责进行下列测试:

  • 如果没有线程访问该资源,EnterCriticalSection便更新成员变量,以指明调用线程已被赋予访问权并立即返回,使该线程能够继续运行(访问该资源)。
  • 如果成员变量指明,调用线程已经被赋予对资源的访问权,那么EnterCriticalSection便更新这些变量,以指明调用线程多少次被赋予访问权并立即返回,使该线程能够继续运行。这种情况很少出现,并且只有当线程在一行中两次调用EnterCriticalSection而不影响对LeaveCriticalSection的调用时,才会出现这种情况。
  • 如果成员变量指明,一个线程(除了调用线程之外)已被赋予对资源的访问权,那么EnterCriticalSection将调用线程置于等待状态。

TryEnterCriticalSection函数决不允许调用线程进入等待状态。相反,它的返回值能够指明调用线程是否能够获得对资源的访问权。因此,如果TryEnterCriticalSection发现该资源已经被另一个线程访问,它就返回FALSE。在其他所有情况下,它均返回TRUE。

LeaveCriticalSection要查看该结构中的成员变量。该函数每次计数时要递减1,以指明调用线程多少次被赋予对共享资源的访问权。如果该计数大于0,那么LeaveCriticalSection不做其他任何操作,只是返回而已。如果该计数变为0,它就要查看在调用EnterCriticalSection中是否有别的线程正在等待。如果至少有一个线程正在等待,它就更新成员变量,并使等待线程中的一个线程(“公正地”选定)再次处于可调度状态。如果没有线程正在等待,LeaveCriticalSection函数就更新成员变量,以指明没有线程正在访问该资源。

 

关键代码段与循环锁

当线程试图进入另一个线程拥有的关键代码段时,调用线程就立即被置于等待状态。这意味着该线程必须从用户方式转入内核方式(大约1000个CPU周期)。为了提高关键代码段的运行性能,Microsoft将循环锁纳入了这些代码段。因此,当EnterCriticalSection函数被调用时,它就使用循环锁进行循环,以便设法多次取得该资源。只有当为了取得该资源的每次试图都失败时,该线程才转入内核方式,以便进入等待状态。

若要将循环锁用于关键代码段,应该调用下面的函数,以便对关键代码段进行初始化:

InitializeCriticalSectionAndSpinCount的第一个参数是关键代码段结构的地址。但是在第二个参数dwSpinCount中,传递的是在使线程等待之前它试图获得资源时想要循环锁循环迭代的次数。这个值可以是0至0x00FFFFFF之间的任何数字。如果在单处理器计算机上运行时调用该函数,dwSpinCount参数将被忽略,它的计数始终被置为0。

我认为,始终都应该将循环锁用于关键代码段,因为这样做有百利而无一害。难就难在确定为dwSpinCount参数传递什么值。为了实现最佳的性能,只需要调整这些数字,直到对性能结果满意为止。作为一个指导原则,保护对进程的堆栈进行访问的关键代码段使用的循环次数是4000次。

 

关键代码段与错误处理

InitializeCriticalSection函数的运行可能失败。如果该内存的分配失败,就会出现一个STATUS_NO_MEMORY异常情况。可以使用结构化异常处理来跟踪代码中的这种异常情况。使用更新的InitializeCriticalSectionAndSpinCount函数,就能够更加容易地跟踪这个问题。该函数也为调试信息分配了内存块,如果内存无法分配,那么它就返回FALSE。

当使用关键代码段时还会出现另一个问题。从内部来说,如果两个或多个线程同时争用关键代码段,那么关键代码段将使用一个事件内核对象。由于争用的情况很少发生,因此,在初次需要之前,系统将不创建事件内核对象。这可以节省大量的系统资源,因为大多数关键代码段从来不被争用。在内存不足的情况下,关键代码段可能被争用,同时系统可能无法创建必要的事件内核对象。这时EnterCriticalSection函数将会产生一个EXCEPTION_INVALID_HANDLE异常。可以使用结构化异常处理方法来跟踪错误。另一种选择是使用InitializeCriticalSectionAndSpinCount函数创建关键代码段,确保设置了dwSpinCount参数的高信息位。当该函数发现高信息位已经设置时,它就创建该事件内核对象,并在初始化时将它与关键代码段关联起来。如果事件无法创建,该函数返回FALSE。

如果总是预先分配事件内核对象,就会浪费系统资源。只有当你的代码不能容许EnterCriticalSection运行失败,或者你有把握会出现争用现象,或者你预计进程将在内存非常短缺的环境中运行时,你才能预先分配事件内核对象。

 

非常有用的提示和技巧

  1. 每个共享资源使用一个CRITICAL_SECTION变量。如果应用程序中拥有若干个互不相干的数据结构,应该为每个数据结构创建一个CRITICAL_SECTION变量。这比只有单个CRITICAL_SECTION结构来保护对所有共享资源的访问要好。
  2. 同时访问多个资源时,必须始终按照完全相同的顺序请求对资源的访问,否则会产生死锁。当调用LeaveCriticalSection函数时,按照什么顺序访问资源是没有关系的,因为该函数决不会使线程进入等待状态。
  3. 不要长时间运行关键代码段。当一个关键代码段长时间运行时,其他线程就会进入等待状态,这会降低应用程序的运行性能。
posted on 2011-10-26 22:03  playerken  阅读(291)  评论(0编辑  收藏  举报