8.4 关键代码段
关键代码段是指一个小代码段,在代码能够执行前,它必须独占对某些共享资源的访问权。这是让若干行代码能够“以原子操作方式”来使用资源的一种方法。所谓原子操作方式,是指该代码知道没有别的线程要访问该资源。当然,系统仍然能够抑制你的线程的运行,而抢先安排其他线程的运行。不过,在线程退出关键代码段之前,系统将不给想要访问相同资源的其他任何线程进行调度。
下面是个有问题的代码,它显示了不使用关键代码段会发生什么情况:
如果分开来看,这两个线程函数将会产生相同的结果,不过每个函数的编码略有不同。如果F i r s t T h r e a d函数自行运行,它将用递增的值填入 g _ d w Ti m e s数组。如果S e c o n d T h r e a d函数也是自行运行,那么情况也一样。在理想的情况下,我们希望两个线程能够同时运行,并且仍然使g _ d w Ti m e s数组能够产生递增的值。但是,上面的代码存在一个问题,那就是 g _ d w Ti m e s不会被正确地填入数据,因为两个线程函数要同时访问相同的全局变量。
下面是如何出现这种情况的一个例子。比如说,我们刚刚在只有一个 C P U的系统上启动执行两个线程。操作系统首先启动运行 S e c o n d T h r e a d(这种情况很可能出现) ,当S e c o n d T h r e a d将g _ n I n d e x递增为1之后,系统就停止该线程的运行,而让F i r s t T h r e a d运行。这时F i r s t T h r e a d将g _ d w Ti m e s [ 1 ]设置为系统时间,然后系统停止该线程的运行,将 C P U时间重新赋予S e c o n d T h r e a d线程。然后S e c o n d T h r e a d将g _ d w Times[1 -1 ]设置为新的系统时间。由于这个操作发生在较晚的时间,因此新系统时间的值大于放入F i r s t T h r e a d数组中的时间值,另外要注意,g _ d w Ti m e s的索引1填在索引0的前面。数组中的数据被破坏了。
应该说明的是,这个例子的设计带有一定的故意性,因为要设计一个实际工作中的例子而不使用好几页的源代码是很难的。不过,通过这个例子,能够看到这个问题在实际工作中有些什么表现。考虑一下管理一个链接对象列表的情况。如果对该链接列表的访问没有取得同步,那么一个线程可以将一个项目添加给这个列表, 而另一个线程则试图搜索该列表中的一个项目。如果两个线程同时给这个列表添加项目,那么这种情况会变得更加复杂。通过运用关键代码段,就能够确保在各个线程之间协调对数据结构的访问。
既然已经了解了存在的所有问题,那么下面让我们用关键代码段来修正这个代码:
这里指定了一个C R I T I C A L _ S E C T I O N数据结构g _ c s,然后在对E n t e r C r i t i c a l S e c t i o n和L e a v e C r i t i c a l e c t i o n函数调用中封装了要接触共享资源(在这个例子中为g _ n I n d e x和g _ d w Ti m e s)的任何代码。注意,在对E n t e r C r i t i c a l S e c t i o n和L e a v e C r i t i c a l S e c t i o n的所有调用中,我传递了g _ c s的地址。
注意 最难记住的一件事情是,编写的需要使用共享资源的任何代码都必须封装在E n t e r C r i t i c a l S e c t i o n和L e a v e C r i t i c a l S e c t i o n函数中。如果忘记将代码封装在一个位置,共享资源就可能遭到破坏。例如,如果我删除了F r i s t T h r e a d线程对E n t e r C r i t i c a l S e c t i o n和L e a v e C r i t i c a l S e c t i o n的调用, g _ n I n d e x和g _ d w Ti m e s变量就会遭到破坏。即使S e c o n d T h r e a d线程仍然正确地调用E n t e r C r i t i c a l S e c t i o n和L e a v e C r i t i c a l S e c t i o n,也会出现这种情况。
当无法用互锁函数来解决同步问题时,你应该试用关键代码段。关键代码段的优点在于它们的使用非常容易,它们在内部使用互锁函数,这样它们就能够迅速运行。关键代码的主要缺点是无法用它们对多个进程中的各个线程进行同步。不过在第 1 9章中,我将要创建我自己的同步对象,称为O p t e x。这个对象将显示操作系统如何来实现关键代码段,它也能用于多个进程中的各个线程。
8.4.1 关键代码段准确的描述
现在你已经从理论上对关键代码段有了一定的了解。已经知道为什么它们非常有用,以及它们是如何实现“以原子操作方式”对共享资源进行访问的。下面让我们更加深入地看一看关键代码段是如何运行的。首先介绍一下 C R I T I C A L _ S E C T I O N数据结构。如果想查看一下Platform SDK文档中关于该结构的说明,也许你会感到无从下手。那么问题究竟何在呢?
并不是C R I T I C A L _ S E C T I O N结构没有完整的文档,而是 M i c r o s o f t认为没有必要了解该结构的全部情况,这是对的。对于我们来说,这个结构是透明的,该结构有文档可查,但是该结构中的成员变量没有文档。当然,由于这只是个数据结构,可以在 Wi n d o w s头文件中查找这些信息,可以看到这些数据成员( C R I T I C A L _ S E C T I O N在Wi n N T. h中定义为RT L _ C R I T I C A L _S E C T I O N;RT L _ C R I T I C A L _ S E C T I O N结构在Wi n B a s e . h中作了定义) 。但是决不应该编写引用这些成员的代码。
若要使用C R I T I C A L _ S E C T I O N结构,可以调用一个Wi n d o w s函数,给它传递该结构的地址。该函数知道如何对该结构的成员进行操作,并保证该结构的状态始终一致。因此下面让我们将注意力转到这些函数上去。
通常情况下,C R I T I C A L _ S E C T I O N结构可以作为全局变量来分配,这样,进程中的所有线程就能够很容易地按照变量名来引用该结构。但是, C R I T I C A L _ S E C T I O N结构也可以作为局部变量来分配,或者从堆栈动态地进行分配。它只有两个要求,第一个要求是,需要访问该资源的所有线程都必须知道负责保护资源的 C R I T I C A L _ S E C T I O N结构的地址,你可以使用你喜欢的任何机制来获得这些线程的这个地址;第二个要求是, C R I T I C A L _ S E C T I O N结构中的成员应该在任何线程试图访问被保护的资源之前初始化。该结构通过调用下面的函数来进行初始化:
VOID InitializeCriticalSection(PCRITICAL_SECTION pcs);
该函数用于对(p c s指向的)C R I T I C A L _ S E C T I O N结构的各个成员进行初始化。由于该函数只是设置了某些成员变量。因此它的运行不会失败,并且它的原型采用了 V O I D的返回值。该函数必须在任何线程调用 E n t e r C r i t i c a l S e c t i o n函数之前被调用。Platform SDK的文档清楚地说明,如果一个线程试图进入一个未初始化的 C RT I C A L _ S E C T I O N,那么结果将是很难预计的。
当知道进程的线程不再试图访问共享资源时,应该通过调用下面的函数来清除该C R I T I C A L _ S E C T I O N结构:
VOID DeleteCriticalSection(PCRITICAL_SECTION pcs);
如果E n t e r C r i t i c a l S e c t i o n将一个线程置于等待状态,那么该线程在很长时间内就不能再次被调度。实际上,在编写得不好的应用程序中,该线程永远不会再次被赋予 C P U时间。如果出现这种情况,该线程就称为渴求C P U时间的线程。
在实际操作中,等待关键代码段的线程绝对不会渴求 C P U时间。对E n t e r C r i t i c a l S e c t i o m的调用最终将会超时,导致产生一个异常条件。这时可以将一个调试程序附加给应用程序,以确定究竟出了什么问题。超时的时间量是由下面的注册表子关键字中包含的C r i t i c a l S e c t i o n Ti m e o u t数据值来决定的。
HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\Session Manager
我的win7 64是这样
这个值以秒为单位,默认为 2 592 000s,即大约3 0天。不要将这个值设置得太小(比如小于3 s) ,否则就会对系统中正常等待关键代码段超过 3 s的线程和其他应用程序产生不利的影响。
可以使用下面这个函数来代替E n t e r C r i t i c a l S e c t i o n:
函数决不允许调用线程进入等待状态。相反,它的返回值能够指明调用线程是否能够获得对资源的访问权。因此,如果 Tr y E n t e r C r i t i c a l S e c t i o n发现该资源已经被另一个线程访问,它就返回FA L S E。在其他所有情况下,它均返回T R U E。
运用这个函数,线程能够迅速查看它是否可以访问某个共享资源,如果不能访问,那么它可以继续执行某些其他操作,而不必进行等待。如果 Tr y E n t e r C r i t i c a l S e c t i o n函数确实返回了T R U E,那么C R I T I C A L _ S E C T I O N的成员变量已经更新,以便反映出该线程正在访问该资源。因此,对返回T R U E的Tr y E n t e r C r i t i c a l S e c t i o n函数的每次调用都必须与对L e a v e C r i t i c a l S e c t i o n函数的调用相匹配。
8.4.2 关键代码段与循环锁
当线程试图进入另一个线程拥有的关键代码段时,调用线程就立即被置于等待状态。这意味着该线程必须从用户方式转入内核方式(大约 1 0 0 0个C P U周期) 。这种转换是要付出很大代价的。在多处理器计算机上,当前拥有资源的线程可以在不同的处理器上运行,并且能够很快放弃对资源的控制。 实际上拥有资源的线程可以在另一个线程完成转入内核方式之前释放资源。如果出现这种情况,就会浪费许多C P U时间。
为了提高关键代码段的运行性能, M i c r o s o f t将循环锁纳入了这些代码段。因此,当E n t e r C r i t i c a l S e c t i o n函数被调用时,它就使用循环锁进行循环,以便设法多次取得该资源。只有当为了取得该资源的每次试图都失败时,该线程才转入内核方式,以便进入等待状态。
若要将循环锁用于关键代码段,应该调用下面的函数,以便对关键代码段进行初始化:
与I n i t i a l i z e C r i t i c a l S e c t i o n中的情况一样,I n i t i a l i z e C r i t i c a l S e c t i o n A n d S p i n C o u n t的第一个参数是关键代码段结构的地址。但是在第二个参数 d w S p i n C o u n t中,传递的是在使线程等待之前它试图获得资源时想要循环锁循环迭代的次数。 这个值可以是0至0 x 0 0 F F F F F F之间的任何数字。如果在单处理器计算机上运行时调用该函数, d w S p i n C o u n t参数将被忽略,它的计数始终被置为0。这是对的,因为在单处理器计算机上设置循环次数是毫无用处的,如果另一个线程正在循环运行,那么拥有资源的线程就不能放弃它。
通过调用下面的函数,就能改变关键代码段的循环次数:
同样,如果主计算机只有一个处理器,那么 d w S p i n C o u n t的值将被忽略。我认为,始终都应该将循环锁用于关键代码段,因为这样做有百利而无一害。难就难在确定为 d w S p i n C o u n t参数传递什么值。为了实现最佳的性能,只需要调整这些数字,直到对性能结果满意为止。作为一个指导原则,保护对进程的堆栈进行访问的关键代码段使用的循环次数是 4 0 0 0次。
8 .4.3 关键代码段与错误处理
函数的运行可能失败(尽管可能性很小) 。M i c r o s o f t在最初设计该函数时并没有真正想到这个问题,正因为这个原因,该函数的原型才设计为返回 V O I D。该函数的运行可能失败,因为它分配了一个内存块以便系统得到一些内部调试信息。如果该内存的分配失败,就会出现一个S TAT U S _ N O _ M E M O RY异常情况。可以使用结构化异常处理(第2 3、2 4和2 5章介绍)来跟踪代码中的这种异常情况。
使用更新的I n i t i a l i z e C r i t i c a l S e c t i o n A n d S p i n C o u n t函数,就能够更加容易地跟踪这个问题。该函数也为调试信息分配了内存块,如果内存无法分配,那么它就返回 FA L S E。
当使用关键代码段时还会出现另一个问题。从内部来说,如果两个或多个线程同时争用关键代码段,那么关键代码段将使用一个事件内核对象(第1 0章介绍Coptex C++类时,我将要说明如何使用该内核对象) 。由于争用的情况很少发生,因此,在初次需要之前,系统将不创建事件内核对象。这可以节省大量的系统资源,因为大多数关键代码段从来不被争用。
在内存不足的情况下,关键代码段可能被争用,同时系统可能无法创建必要的事件内核对象。这时E n t e r C r i t i c a l S e c t i o n函数将会产生一个E X C E P T I O N _ I N VA L I D _ H A N D L E异常。大多数编程人员忽略了这个潜在的错误,在他们的代码中没有专门的处理方法,因为这个错误非常少见。但是,如果想对这种情况有所准备,可以有两种选择。
可以使用结构化异常处理方法来跟踪错误。当错误发生时,既可以不访问关键代码段保护的资源,也可以等待某些内存变成可用状态,然后再次调用 E n t e r C r i t i c a l S e c t i o n函数。
另一种选择是使用I n i t i a l i z e C r i t i c a l S e c t i o n A n d S p i n C o u n t函数创建关键代码段,确保设置了d w S p i n C o u n t参数的高信息位。当该函数发现高信息位已经设置时,它就创建该事件内核对象,并在初始化时将它与关键代码段关联起来。如果事件无法创建,该函数返回 FA L S E。可以更加妥善地处理代码中的这个事件。如果事件创建成功,你知道 E n t e r C r i t i c a l S e c t i o n将始终都能运行,并且决不会产生异常情况(如果总是预先分配事件内核对象,就会浪费系统资源。只有当你的代码不能容许E n t e r C r i t i c a l S e c t i o n运行失败,或者你有把握会出现争用现象,或者你预计进程将在内存非常短缺的环境中运行时,你才能预先分配事件内核对象) 。