windows核心编程8

来源:互联网 发布:打印机控制软件app31 编辑:程序博客网 时间:2024/05/14 17:46
 
第8章用户方式中线程的同步
当所有的线程在互相之间不需要进行通信的情况下就能够顺利地运行时,Wi n d o w s的运行性能最好。但是,线程很少能够在所有的时间都独立地进行操作。通常情况下,要生成一些线程来处理某个任务。当这个任务完成时,另一个线程必须了解这个情况。
 
系统中的所有线程都必须拥有对各种系统资源的访问权,这些资源包括内存堆栈,串口,文件,窗口和许多其他资源。
 
线程需要在下面两种情况下互相进行通信:
? 当有多个线程访问共享资源而不使资源被破坏时。
? 当一个线程需要将某个任务已经完成的情况通知另外一个或多个线程时。
 
8.1 原子访问:互锁的函数家族
线程同步问题在很大程度上与原子访问有关,所谓原子访问,是指线程在访问资源时能够确保所有其他线程都不在同一时间内访问相同的资源。
 
互锁的函数尽管用处很大,而且很容易理解,却有些让人望而生畏,大多数软件开发人员用得很少。所有的函数都能以原子操作方式对一个值进行操作。
I n t e r l o c k e dE x c h a n g e A d d函数
对于互锁函数,需要了解的另一个重要问题是,它们运行的速度极快。调用一个互锁函数通常会导致执行几个C P U周期(通常小于5 0),并且不会从用户方式转换为内核方式(通常这需要执行1 0 0 0个C P U周期)。
 
8.2 高速缓存行
如果想创建一个能够在多处理器计算机上运行的高性能应用程序,必须懂得C P U的高速缓存行。当一个C P U从内存读取一个字节时,它不只是取出一个字节,它要取出足够的字节来填入高速缓存行。高速缓存行由32或64个字节组成(视C P U而定),并且始终在第32个字节或第64个字节的边界上对齐。高速缓存行的作用是为了提高C P U运行的性能。通常情况下,应用程序只能对一组相邻的字节进行处理。如果这些字节在高速缓存中,那么C P U就不必访问内存总线,而访问内存总线需要多得多的时间。
 
但是,在多处理器环境中,高速缓存行使得内存的更新更加困难,下面这个例子就说明了这一点:
1) CPU1读取一个字节,使该字节和它的相邻字节被读入C P U 1的高速缓存行。
2) CPU2读取同一个字节,使得第一步中的相同的各个字节读入C P U 2的高速缓存行。
3) CPU1修改内存中的该字节,使得该字节被写入C P U 1的高速缓存行。但是该信息尚未写入R A M。
4) CPU2再次读取同一个字节。由于该字节已经放入C P U 2的高速缓存行,因此它不必访问内存。但是C P U 2将看不到内存中该字节的新值。
 
这种情况会造成严重的后果。当然,芯片设计者非常清楚这个问题,并且设计它们的芯片来处理这个问题。尤其是,当一个C P U修改高速缓存行中的字节时,计算机中的其他C P U会被告知这个情况,它们的高速缓存行将变为无效。因此,在上面的情况下, C P U 2的高速缓存在C P U 1修改字节的值时变为无效。在第4步中, C P U 1必须将它的高速缓存内容迅速转入内存,C P U 2必须再次访问内存,重新将数据填入它的高速缓存行。如你所见,高速缓存行能够帮助提高运行的速度,但是它们也可能是多处理器计算机上的一个不利因素。
 
这一切意味着你应该将高速缓存行存储块中的和高速缓存行边界上的应用程序数据组合在一起。这样做的目的是确保不同的C P U能够访问至少由高速缓存行边界分开的不同的内存地址。还有,应该将只读数据(或不常读的数据)与读写数据分开。同时,应该将同一时间访问的数据组合在一起。
 
注意:最好是始终都让单个线程来访问数据(函数参数和局部变量是确保做到这一点的最好方法),或者始终让单个C P U访问这些数据(使用线程亲缘性)。如果采取其中的一种方法,就能够完全避免高速缓存行的各种问题。
 
8.3 高级线程同步
当必须以原子操作方式来修改单个值时,互锁函数家族是相当有用的。但是大多数实际工作中的编程问题要解决的是比单个3 2位或6 4位值复杂得多的数据结构。为了以原子操作方式使用更加复杂的数据结构,必须将互锁函数放在一边,使用Wi n d o w s提供的其他某些特性。
 
前面强调了不应该在单处理器计算机上使用循环锁,甚至在多处理器计算机上,也应该小心地使用它们。原因是C P U时间非常宝贵,决不应该浪费。因此需要一种机制,使线程在等待访问共享资源时不浪费C P U时间。
 
当线程想要访问共享资源,或者得到关于某个“特殊事件”的通知时,该线程必须调用一个操作系统函数,给它传递一些参数,以指明该线程正在等待什么。如果操作系统发现资源可供使用,或者该特殊事件已经发生,那么函数就返回,同时该线程保持可调度状态(该线程可以不必立即执行,它处于可调度状态,可以使用前一章介绍的原则将它分配给一个C P U)。
 
如果资源不能使用,或者特殊事件还没有发生,那么系统便使该线程处于等待状态,使该线程无法调度。这可以防止线程浪费C P U时间。当线程处于等待状态时,系统作为一个代理,代表你的线程来执行操作。系统能够记住你的线程需要什么,当资源可供使用的时候,便自动使该线程退出等待状态,该线程的运行将与特殊事件实现同步。
 
从实际情况来看,大多数线程几乎总是处于等待状态。当系统发现所有线程有若干分钟均处于等待状态时,系统的强大的管理功能就会发挥作用。
 
8.4 关键代码段
关键代码段是指一个小代码段,在代码能够执行前,它必须独占对某些共享资源的访问权。这是让若干行代码能够“以原子操作方式”来使用资源的一种方法。所谓原子操作方式,是指该代码知道没有别的线程要访问该资源。当然,系统仍然能够抑制你的线程的运行,而抢先安排其他线程的运行。不过,在线程退出关键代码段之前,系统将不给想要访问相同资源的其他任何线程进行调度。
 
指定了一个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 lS 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的地址。
 
有一个关键问题必须记住:当拥有一项可供多个线程访问的资源时,应该创建一个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结构来保护所有的资源。如果有多个不是一道使用的资源,比如线程1和线程2访问一个资源,而线程1和线程3访问另一个资源,那么应该为每个资源创建一个独立的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函数,为它传递用于标识该资源的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结构用于标识线程想要进入哪个关键代码段,而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函数。
 
注意:最难记住的一件事情是,编写的需要使用共享资源的任何代码都必须封装在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,也会出现这种情况。
忘记调用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认为没有必要了解该结构的全部情况,这是对的。对于我们来说,这个结构是透明的,该结构有文档可查,但是该结构中的成员变量没有文档。
 
若要使用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);
D e l e t e C r i t i c a l S e c t i o n函数用于对该结构中的成员变量进行删除。当然,如果有任何线程仍然使用关键代码段,那么不应该删除该代码段。同样, Platform SDK文档清楚地说明如果删除了关键代码段,其结果就无法知道。当编写要使用共享资源的代码时,必须在该代码的前面放置对下面的函数的调用:
void EnterCriticalSection(PCRITICAL_SECTION pcs);
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负责进行下列测试:
? 如果没有线程访问该资源,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便更新这些变量,以指明调用线程多少次被赋予访问权并立即返回,使该线程能够继续运行。这种情况很少出现,并且只有当线程在一行中两次调用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的调用时,才会出现这种情况。
? 如果成员变量指明,一个线程(除了调用线程之外)已被赋予对资源的访问权,那么E n e r C r i t i c a l S e c t i o n将调用线程置于等待状态。这种情况是极好的,因为等待的线程不会浪费任何C P U 时间。系统能够记住该线程想要访问该资源并且自动更新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函数,该线程就处于可调度状态。
 
从内部来讲, 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函数,该函数仍然能够正确地起作用,一个线程被赋予对资源的访问权,而另一个线程则进入等待状态。
如果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时间的线程。
 
可以使用下面这个函数来代替E n t e r C r i t i c a l S e c t i o n:
bool TryEnterCriticalSection(PCRITICAL_SECTION pcs);
Tr y 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函数的调用相匹配。
 
L e a v e C r i t i c a l S e c t i o n要查看该结构中的成员变量。该函数每次计数时要递减1,以指明调用线程多少次被赋予对共享资源的访问权。如果该计数大于0,那么L e a v e C r i t i c a l S e c t i o n不做其他任何操作,只是返回而已。如果该计数变为0,它就要查看在调用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函数就更新成员变量,以指明没有线程正在访问该资源。
与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函数也能以原子操作方式执行所有这些测试和更新。不过,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 0C 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函数被调用时,它就使用循环锁进行循环,以便设法多次取得该资源。只有当为了取得该资源的每次试图都失败时,该线程才转入内核方式,以便进入等待状态。
单处理器计算机上设置循环次数是毫无用处的,如果另一个线程正在循环运行,那么拥有资源的线程就不能放弃它。
 
8 .4.3 关键代码段与错误处理
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函数的运行可能失败(尽管可能性很小)。M i c r o s o f t在最初设计该函数时并没有真正想到这个问题,正因为这个原因,该函数的原型才设计为返回V O I D。该函数的运行可能失败,因为它分配了一个内存块以便系统得到一些内部调试信息。如果该内存的分配失败,就会出现一个S TAT U S _ N O _ M E M O RY异常情况。
 
当使用关键代码段时还会出现另一个问题。从内部来说,如果两个或多个线程同时争用关键代码段,那么关键代码段将使用一个事件内核对象。由于争用的情况很少发生,因此,在初次需要之前,系统将不创建事件内核对象。这可以节省大量的系统资源,因为大多数关键代码段从来不被争用。
 
8.4.4 非常有用的提示和技巧
1. 每个共享资源使用一个C R I T I C A L _ S E C T I O N变量。
2. 同时访问多个资源。
3. 不要长时间运行关键代码段。
当一个关键代码段长时间运行时,其他线程就会进入等待状态,这会降低应用程序的运行性能。