第9章 线程与内核对象的同步
上一章介绍了如何使用允许线程保留在用户方式中的机制来实现线程同步的方法。用户方式同步的优点是它的同步速度非常快。如果强调线程的运行速度,那么首先应该确定用户方式的线程同步机制是否适合需要。
虽然用户方式的线程同步机制具有速度快的优点,但是它也有其局限性。对于许多应用程序来说,这种机制是不适用的。例如,互锁函数家族只能在单值上运行,根本无法使线程进入等待状态。可以使用关键代码段使线程进入等待状态,但是只能用这些代码段对单个进程中的线程实施同步。还有,使用关键代码段时,很容易陷入死锁状态,因为在等待进入关键代码段时无法设定超时值。
本章将要介绍如何使用内核对象来实现线程的同步。你将会看到,内核对象机制的适应性远远优于用户方式机制。实际上,内核对象机制的唯一不足之处是它的速度比较慢。当调用本章中提到的任何新函数时,调用线程必须从用户方式转为内核方式。这个转换需要很大的代价:往返一次需要占用x 8 6平台上的大约1 0 0 0个C P U周期,当然,这还不包括执行内核方式代码,即实现线程调用的函数的代码所需的时间。
本书介绍了若干种内核对象,包括进程,线程和作业。可以将所有这些内核对象用于同步目的。对于线程同步来说,这些内核对象中的每种对象都可以说是处于已通知或未通知的状态之中。这种状态的切换是由M i c r o s o f t为每个对象建立的一套规则来决定的。
进程内核对象总是在未通知状态中创建的。当进程终止运行时,操作系统自动使该进程的内核对象处于已通知状态。一旦进程内核对象得到通知,它将永远保持这种状态,它的状态永远不会改为未通知状态。进程内核对象中是个布尔值,当对象创建时,该值被初始化为FA L S E(未通知状态)。当进程终止运行时,操作系统自动将对应的对象布尔值改为T R U E,表示该对象已经得到通知。
线程内核对象也遵循同样的规则。即线程内核对象总是在未通知状态中创建。当线程终止运行时,操作系统会自动将线程对象的状态改为已通知状态。
下面的内核对象可以处于已通知状态或未通知状态:
■ 进程 ■ 文件修改通知
■ 线程 ■ 事件
■ 作业 ■ 可等待定时器
■ 文件 ■ 信标
■ 控制台输入 ■ 互斥对象
线程可以使自己进入等待状态,直到一个对象变为已通知状态。注意,用于控制每个对象的已通知/未通知状态的规则要根据对象的类型而定。前面已经提到进程和线程对象的规则及作业的规则。
本章将要介绍允许线程等待某个内核对象变为已通知状态所用的函数。然后我们将要讲述Wi n d o w s提供的专门用来帮助实现线程同步的各种内核对象、如事件、等待计数器,信标和互斥对象。
当我最初开始学习这项内容时,我设想内核对象包含了一面旗帜(在空中飘扬的旗帜,不是耷拉下来的旗帜),这对我很有帮助。当内核对象得到通知时,旗帜升起来;当对象未得到通知时,旗帜就降下来。当线程等待的对象处于未通知状态(旗帜降下)中时,这些线程不可调度。但是一旦对象变为已通知状态(旗帜升起),线程看到该标志变为可调度状态,并且很快恢复运行。
9.1 等待函数
等待函数可使线程自愿进入等待状态,直到一个特定的内核对象变为已通知状态为止。这些等待函数中最常用的是Wa i t F o r S i n g l e O b j e c t :
DWORD WaitForSingleObject (
HANDLE hObject,
DWORD dwMillisecond );
当线程调用该函数时,第一个参数h O b j e c t标识一个能够支持被通知/未通知的内核对象(前面列出的任何一种对象都适用)。第二个参数d w M i l l i s e c o n d s允许该线程指明,为了等待该对象变为已通知状态,它将等待多长时间。
通常情况下, I N F I N I T E是作为第二个参数传递给Wa i t F o r S i n g l e O b j e c t的,不过也可以传递任何一个值(以毫秒计算)。顺便说一下, I N F I N I T E已经定义为0 x F F F F F F F F(或-1)。当然,传递I N F I N I T E有些危险。如果对象永远不变为已通知状态,那么调用线程永远不会被唤醒,它将永远处于死锁状态,不过,它不会浪费宝贵的C P U时间。
注意,不能为d w M i l l i s e c o n d传递0。如果传递了0,Wa i t F o r S i n g l e O b j e c t函数将总是立即返回。
Wa i t F o r S i n g l e O b j e c t的返回值能够指明调用线程为什么再次变为可调度状态。如果线程等待的对象变为已通知状态,那么返回值是WA I T _ O B J E C T _ 0。如果设置的超时已经到期,则返回值是WA I T _ T I M E O U T。如果将一个错误的值(如一个无效句柄)传递给Wa i t F o r S i n g l eO b j e c t,那么返回值将是WA I T _ FA I L E D(若要了解详细信息,可调用G e t L a s t E r r o r)。
函数Wa i t F o r M u l t i p l e O b j e c t s与Wa i t F o r S i n g l e O b j e c t函数很相似,区别在于它允许调用线程同时查看若干个内核对象的已通知状态:
DWORD WaitForMultipleObjects (
DWORD dwCount,
CONST HANDLE* phObjects,
BOOL fWaitAll,
DWORD dwMilliseconds);
d w C o u n t参数用于指明想要让函数查看的内核对象的数量。这个值必须在1与M A X I M U M _WA I T _ O B J E C T S(在Wi n d o w s头文件中定义为6 4)之间。p h O b j e c t s参数是指向内核对象句柄的数组的指针。可以以两种不同的方式来使用Wa i t F o r M u l t i p l e O b j e c t s函数。一种方式是让线程进入等待状态,直到指定内核对象中的任何一个变为已通知状态。另一种方式是让线程进入等待状态,直到所有指定的内核对象都变为已通知状态。f Wa i tAl l参数告诉该函数,你想要让它使用何种方式。如果为该参数传递T R U E,那么在所有对象变为已通知状态之前,该函数将不允许调用线程运行。
d w M i l l i s e c o n d s参数的作用与它在Wa i t F o r S i n g l e O b j e c t中的作用完全相同。如果在等待的时候规定的时间到了,那么该函数无论如何都会返回。同样,通常为该参数传递I N F I N I T E,但是在编写代码时应该小心,以避免出现死锁情况。
Wa i t F o r M u l t i p l e O b j e c t s函数的返回值告诉调用线程,为什么它会被重新调度。可能的返回值是WA I T _ FA I L E D和WA I T _ T I M E O U T,这两个值的作用是很清楚的。如果为f Wa i tAl l参数传递T R U E,同时所有对象均变为已通知状态,那么返回值是WA I T _ O B J E C T _ 0。如果为f Wa i t A l l传递FA L S E,那么一旦任何一个对象变为已通知状态,该函数便返回。在这种情况下,你可能想要知道哪个对象变为已通知状态。返回值是WA I T _ O B J E C T _ 0与(WA I T _ O B J E C T _ 0 + d w C o u n t - 1)之间的一个值。换句话说,如果返回值不是WA I T _ T I M E O U T,也不是WA I T _ FA I L E D,那么应该从返回值中减去WA I T _ O B J E C T _ 0。产生的数字是作为第二个参数传递给Wa i t F o r M u l t i p l e O b j e c t s的句柄数组中的索引。该索引说明哪个对象变为已通知状态。
9.2 成功等待的副作用
对于有些内核对象来说,成功地调用Wa i t F o r S i n g l e O b j e c t和Wa i t F o r M u l t i p l e O b j e c t s,实际上会改变对象的状态。成功地调用是指函数发现对象已经得到通知并且返回一个相对于WA I T _ O B J E C T _ 0的值。如果函数返回WA I T _ T I M E O U T或WA I T _ FA I L E D,那么调用就没有成功。如果函数调用没有成功,对象的状态就不可能改变。
当一个对象的状态改变时,我称之为成功等待的副作用。例如,有一个线程正在等待自动清除事件对象(本章后面将要介绍)。当事件对象变为已通知状态时,函数就会发现这个情况,并将WA I T _ O B J E C T _ 0返回给调用线程。但是就在函数返回之前,该事件将被置为未通知状态,这就是成功等待的副作用。
这个副作用将用于自动清除内核对象,因为它是M i c r o s o f t为这种类型的对象定义的规则之一。其他对象拥有不同的副作用,而有些对象则根本没有任何副作用。进程和线程内核对象就根本没有任何副作用,也就是说,在这些对象之一上进行等待决不会改变对象的状态。由于本章要介绍各种不同的内核对象,因此我们将要详细说明它们的成功等待的副作用。
究竟是什么原因使得Wa i t F o r M u l t i p l e O b j e c t s函数如此有用呢,因为它能够以原子操作方式来执行它的所有操作。当一个线程调用Wa i t F o r M u l t i p l e O b j e c t s函数时,该函数能够测试所有对象的通知状态,并且能够将所有必要的副作用作为一项操作来执行。让我们观察一个例子。两个线程以完全相同的方式来调用Wa i t F o r M u l t i p l e O b j e c t s:
HANDLE h[2];
h[0] = hAutoResetEvent1;
h[1] = hAutoResetEvent2;
WaitForMultipleObject(2,h,TRUE,INFINITE);
当Wa i t F o r M u l t i p l e O b j e c t s函数被调用时,两个事件都处于未通知状态,这就迫使两个线程都进入等待状态。然后h A u t o R e s e t E v e n t 1对象变为已通知状态。两个线程都发现,该事件已经变为已通知状态,但是它们都无法被唤醒,因为h A u t o R e s e t E v e n t 2仍然处于未通知状态。由于两个线程都没有等待成功,因此没有对h A u t o R e s e t E v e n t 1对象产生任何副作用。
接着,h A u t o R e s e t E v e n t 2变为已通知状态。这时,两个线程中的一个发现,两个对象都变为已通知状态。等待取得了成功,两个事件对象均被置为未通知状态,该线程变为可调度的线程。但是另一个线程的情况如何呢?它将继续等待,直到它发现两个事件对象都处于已通知状态。尽管它原先发现h A u t o R e s e t E v e n t 1处于已通知状态,但是现在它将该对象视为未通知状态。
前面讲过,有一个重要问题必须注意,即Wa i t F o r M u l t i p l e O b j e c t s是以原子操作方式运行的。当它检查内核对象的状态时,其他任何线程都无法背着对象改变它的状态。这可以防止出现死锁情况。试想,如果一个线程看到h A u t o R e s e t E v e n t 1已经得到通知并将事件重置为未通知状态,然后,另一个线程发现h A u t o R e s e t E v e n t 2已经得到通知并将该事件重置为未通知状态,那么这两个线程均将被冻结:一个线程将等待另一个线程已经得到的对象,另一个线程将等待该线程已经得到的对象。Wa i t F o r M u l t i p l e O b j e c t s能够确保这种情况永远不会发生。
这会产生一个非常有趣的问题,即如果多个线程等待单个内核对象,那么当该对象变成已通知状态时,系统究竟决定唤醒哪个线程呢? M i c r o s o f t对这个问题的正式回答是:“算法是公平的。”M i c r o s o f t不想使用系统使用的内部算法。它只是说该算法是公平的,这意味着如果多个线程正在等待,那么每当对象变为已通知状态时,每个线程都应该得到它自己的被唤醒的机会。
这意味着线程的优先级不起任何作用,即高优先级线程不一定得到该对象。这还意味着等待时间最长的线程不一定得到该对象。同时得到对象的线程有可能反复循环,并且再次得到该对象。但是,这对于其他线程来说是不公平的,因此该算法将设法防止这种情况的出现。但是这不一定做得到。
在实际操作中, M i c r o s o f t使用的算法是常用的“先进先出”的方案。等待了最长时间的线程将得到该对象。但是系统中将会执行一些操作,以便改变这个行为特性,使它不太容易预测。这就是为什么M i c r o s o f t没有明确说明该算法如何起作用的原因。操作之一是让线程暂停运行。如果一个线程等待一个对象,然后该线程暂停运行,那么系统就会忘记该线程正在等待该对象。这是一个特性,因为没有理由为一个暂停运行的线程进行调度。当后来该线程恢复运行时,系统将认为该线程刚刚开始等待该对象。
当调试一个进程时,只要到达一个断点,该进程中的所有线程均暂停运行。因此,调试一个进程会使“先进先出”的算法很难预测其结果,因为线程常常暂停运行,然后再恢复运行。
9.3 事件内核对象
在所有的内核对象中,事件内核对象是个最基本的对象。它们包含一个使用计数(与所有内核对象一样),一个用于指明该事件是个自动重置的事件还是一个人工重置的事件的布尔值,另一个用于指明该事件处于已通知状态还是未通知状态的布尔值。
事件能够通知一个操作已经完成。有两种不同类型的事件对象。一种是人工重置的事件,另一种是自动重置的事件。当人工重置的事件得到通知时,等待该事件的所有线程均变为可调度线程。当一个自动重置的事件得到通知时,等待该事件的线程中只有一个线程变为可调度线程。
当一个线程执行初始化操作,然后通知另一个线程执行剩余的操作时,事件使用得最多。事件初始化为未通知状态,然后,当该线程完成它的初始化操作后,它就将事件设置为已通知状态。这时,一直在等待该事件的另一个线程发现该事件已经得到通知,因此它就变成可调度线程。这第二个线程知道第一个线程已经完成了它的操作。
下面是C r e a t e E v e n t函数,用于创建事件内核对象:
HANDLE CreateEvent (
PSECURITY_ATTRIBUTES psa,
BOOL fManualReset,
BOOL fInitialState,
PCTSTR pszname );
第3章已经介绍了内核对象的操作技巧,比如,如何设置它们的安全性,如何进行使用计数,如何继承它们的句柄,如何按名字共享对象等。由于现在你对所有这些对象都已经熟悉了,所以不再介绍该函数的第一个和最后一个参数。
F M a n n u a l R e s e t参数是个布尔值,它能够告诉系统是创建一个人工重置的事件( T R U E)还是创建一个自动重置的事件( FA L S E)。f I n i t i a l S t a t e参数用于指明该事件是要初始化为已通知状态(T R U E)还是未通知状态(FA L S E)。
当系统创建事件对象后, c r e a t e E v e n t就将与进程相关的句柄返回给事件对象。其他进程中的线程可以获得对该对象的访问权,方法是使用在p s z N a m e参数中传递的相同值,使用继承性,使用D u p l i c a t e H a n d l e函数等来调用C r e a t e E v e n t,或者调用O p e n E v e n t ,在p s z N a m e参数中设定一个与调用C r e a t e E v e n t时设定的名字相匹配的名字:
HANDLE OpenEvent (
DWORD fdwAccess,
BOOL fInherit,
PCTSTR pszname );
与所有情况中一样,当不再需要事件内核对象时,应该调用C l o s e H a n d l e函数。
一旦事件已经创建,就可以直接控制它的状态。当调用S e t E v e n t时,可以将事件改为已通知状态:
BOOL SetEvent(HANDLE hEvent);
当调用R e s e t E v e n t函数时,可以将该事件改为未通知状态:
BOOL ResetEvent(HANDLE hEvent);
M i c r o s o f t为自动重置的事件定义了应该成功等待的副作用规则,即当线程成功地等待到该对象时,自动重置的事件就会自动重置到未通知状态。这就是自动重置的事件如何获得它们的名字的方法。通常没有必要为自动重置的事件调用R e s e t E v e n t函数,因为系统会自动对事件进行重置。但是, M i c r o s o f t没有为人工重置的事件定义成功等待的副作用。
为了完整起见,下面再介绍一个可以用于事件的函数:
BOOL PluseEvent ( HANDLE hEvent);
P u l s e E v e n t函数使得事件变为已通知状态,然后立即又变为未通知状态,这就像在调用S e t E v e n t后又立即调用R e s e t E v e n t函数一样。P u l s e E v e n t函数并不非常有用。实际上我在自己的应用程序中从未使用它,因为根本不知道什么线程将会看到事件的发出并变成可调度线程。
9.4 等待定时器内核对象
等待定时器是在某个时间或按规定的间隔时间发出自己的信号通知的内核对象。它们通常用来在某个时间执行某个操作。
若要创建等待定时器,只需要调用C r e a t e Wa i t a b l e Ti m e r函数:
HANDLE CreateWaitableTimer (
PSECURITY_ATTRIBUTES psa,
BOOL fManualReset,
PCTSTR pszName );
p s a和p s z N a m e这两个参数在第3章中做过介绍。当然,进程可以获得它自己的与进程相关的现有等待定时器的句柄,方法是调用O p e n Wa i t a b l e Ti m e r函数:
HANDLE OpenWaitableTimer (
DWORD dwDesiredAccess,
BOOL bInheritHandle,
PCTSTR pszName );
与事件的情况一样, f M a n u a l R e s e t参数用于指明人工重置的定时器或自动重置的定时器。当发出人工重置的定时器信号通知时,等待该定时器的所有线程均变为可调度线程。当发出自动重置的定时器信号通知时,只有一个等待的线程变为可调度线程。
等待定时器对象总是在未通知状态中创建。必须调用S e t Wa i t a b l e Ti m e r函数来告诉定时器你想在何时让它成为已通知状态:
BOOL SetWaitableTimer (
HANDLE hTimer,
const LARGE_INTEGER* pDueTime,
LONG lPeriod,
PTIMERAPCROUTINE pfnCompletionRoutine,
PVOID pvArgToCompletionRoutine,
BOOL fResume );
这个函数带有若干个参数,使用时很容易搞混。显然, h Ti m e r参数用于指明你要设置的定时器。p D u e Ti m e和l P e r i o d两个参数是一道使用的。P D u e Ti m e r参数用于指明定时器何时应该第一次报时,而l P e r i o d参数则用于指明此后定时器应该间隔多长时间报时一次。S e t Wa i t a b l e Ti m e r的最后一个参数是f R e s u m e,它可以用于支持暂停和恢复的计算机。通常可以为该参数传递FA L S E。
除了上面介绍的定时器函数外,最后还有一个C a n c e l Wa i t a b l e Ti m e r函数:
BOOL CancelWaitableTimer (HANDLE hTimer);
这个简单的函数用于取出定时器的句柄并将它撤消。
9.4.1 让等待定时器给A P C项排队
9.4.2 定时器的松散特性
定时器常常用于通信协议中。例如,如果客户机向服务器发出一个请求,而服务器没有在规定的时间内作出响应,那么客户机就会认为无法使用服务器。目前,客户机通常要同时与许多服务器进行通信。如果你为每个请求创建一个定时器内核对象,那么系统的运行性能就会受到影响。可以设想,对于大多数应用程序来说,可以创建单个定时器对象,并根据需要修改定时器报时的时间。
定时器报时时间的管理方法和定时器时间的重新设定是非常麻烦的,只有很少的应用程序采用这种方法。但是在新的线程共享函数(第11 章中介绍)中有一个新函数,称为C r e a t e Ti m e r Q u e u e Ti m e r,它能够为你处理所有的操作。如果你发现自己创建和管理了若干个定时器对象,那么应该观察一下这个函数,以减少应用程序的开销。
虽然定时器能够给A P C项进行排队是很好的,但是目前编写的大多数应用程序并不使用A P C,它们使用I / O完成端口机制。过去,我自己的线程池中(由一个I / O完成端口负责管理)有一个线程,它按照特定的定时器间隔醒来。但是,等待定时器没有提供这个方法。为了做到这一点,我创建了一个线程,它的唯一工作是设置而后等待一个等待定时器。当定时器变为已通知状态时,线程就调用P o s t Q u e u e d C o m p l e t i o n S t a t u s函数,将一个事件强加给线程池中的一个线程。
最后要说明的是,凡是称职的Wi n d o w s编程员都会立即将等待定时器与用户定时器(用S e t Ti m e r函数进行设置)进行比较。它们之间的最大差别是,用户定时器需要在应用程序中设置许多附加的用户界面结构,这使定时器变得资源更加密集。另外,等待定时器属于内核对象,这意味着它们可以供多个线程共享,并且是安全的。
用户定时器能够生成W M _ T I M E R消息,这些消息将返回给调用S e t Ti m e r(用于回调定时器)的线程和创建窗口(用于基于窗口的定时器)的线程。因此,当用户定时器报时的时候,只有一个线程得到通知。另一方面,多个线程可以在等待定时器上进行等待,如果定时器是个人工重置的定时器,则可以调度若干个线程。
如果要执行与用户界面相关的事件,以便对定时器作出响应,那么使用用户定时器来组织代码结构可能更加容易些,因为使用等待定时器时,线程必须既要等待各种消息,又要等待内核对象(如果要改变代码的结构,可以使用M s g Wa i t F o r M u l t i p l e O b j e c t s函数)。最后,运用等待定时器,当到了规定时间的时候,更有可能得到通知。正如第2 7章介绍的那样,W M _ T I M E R消息始终属于最低优先级的消息,当线程的队列中没有其他消息时,才检索该消息。等待定时器的处理方法与其他内核对象没有什么差别,如果定时器发出报时信息,而你的线程正在等待之中,那么你的线程就会醒来。
9.5 信标内核对象
信标内核对象用于对资源进行计数。它们与所有内核对象一样,包含一个使用数量,但是它们也包含另外两个带符号的3 2位值,一个是最大资源数量,一个是当前资源数量。最大资源数量用于标识信标能够控制的资源的最大数量,而当前资源数量则用于标识当前可以使用的资源的数量。
为了正确地说明这个问题,让我们来看一看应用程序是如何使用信标的。比如说,我正在开发一个服务器进程,在这个进程中,我已经分配了一个能够用来存放客户机请求的缓冲区。我对缓冲区的大小进行了硬编码,这样它每次最多能够存放5个客户机请求。如果5个请求尚未处理完毕时,一个新客户机试图与服务器进行联系,那么这个新客户机的请求就会被拒绝,并出现一个错误,指明服务器现在很忙,客户机应该过些时候重新进行联系。当我的服务器进程初始化时,它创建一个线程池,里面包含5个线程,每个线程都准备在客户机请求到来时对它进行处理。
开始时,没有客户机提出任何请求,因此我的服务器不允许线程池中的任何线程成为可调度线程。但是,如果3个客户机请求同时到来,那么线程池中应该有3个线程处于可调度状态。使用信标,就能够很好地处理对资源的监控和对线程的调度,最大资源数量设置为5,因为这是我进行硬编码的缓冲区的大小。当前资源数量最初设置为0,因为没有客户机提出任何请求。当客户机的请求被接受时,当前资源数量就递增,当客户机的请求被提交给服务器的线程池时,当前资源数量就递减。
信标的使用规则如下:
? 如果当前资源的数量大于0,则发出信标信号。
? 如果当前资源数量是0,则不发出信标信号。
? 系统决不允许当前资源的数量为负值。
? 当前资源数量决不能大于最大资源数量。
当使用信标时,不要将信标对象的使用数量与它的当前资源数量混为一谈。
下面的函数用于创建信标内核对象:
HANDLE CreateSemaphore (
PSECURITY_ATTRIBUTE psa,
LONG lInitialCount,
LONG lMaximumCount,
PCTSTR pszName);
p s a和p s z N a m e两个参数在第3章中作过介绍。当然,通过调用O p e n S e m a p h o r e函数,另一个进程可以获得它自己的进程与现有信标相关的句柄:
HANDLE OpenSemaphore (
DWORD fdwAccess,
BOOL bInheritHandle,
PCTSTR pszName);
l M a x i m u m C o u n t参数用于告诉系统,应用程序处理的最大资源数量是多少。由于这是个带符号的3 2位值,因此最多可以拥有2 147 483 647个资源。l I n i t i a l C o u n t参数用于指明开始时(当前)这些资源中有多少可供使用。当我的服务器进程初始化时,没有任何客户机请求,因此我调用下面这个C r e a t e S e m a p h o r e函数:
HANDLE hsem = CreateSemaphore(NULL,0,5,NULL);
该函数创建了一个信标,其最大资源数量为5,但是开始时可以使用的资源为0(由于偶然的原因,该内核对象的使用数量是1,因为我刚刚创建了这个内核对象,请不要与计数器搞混)。由于当前资源数量被初始化为0,因此不发出信标信号。等待信标的所有线程均进入等待状态。
通过调用等待函数,传递负责保护资源的信标的句柄,线程就能够获得对该资源的访问权。从内部来说,该等待函数要检查信标的当前资源数量,如果它的值大于0(信标已经发出信号),那么计数器递减1,调用线程保持可调度状态。信标的出色之处在于它们能够以原子操作方式来执行测试和设置操作,这就是说,当向信标申请一个资源时,操作系统就要检查是否有这个资源可供使用,同时将可用资源的数量递减,而不让另一个线程加以干扰。只有当资源数量递减后,系统才允许另一个线程申请对资源的访问权。
如果该等待函数确定信标的当前资源数量是0(信标没有发出通知信号),那么系统就调用函数进入等待状态。当另一个线程将对信标的当前资源数量进行递增时,系统会记住该等待线程(或多个线程),并允许它变为可调度状态(相应地递减它的当前资源数量)。
通过调用R e l e a s e S e m a p h o r e函数,线程就能够对信标的当前资源数量进行递增:
BOOL ReleaseSemaphore (
HANDLE hsem,
LONG lReleaseCount,
PLONG plPreviousCount);
该函数只是将l R e l e a s e C o u n t 中的值添加给信标的当前资源数量。通常情况下,为l R e l e a s e C o u n t参数传递1,但是,不一定非要传递这个值。我常常传递2或更大的值。该函数也能够在它的* p l P r e v i o u s C o u n t中返回当前资源数量的原始值。实际上几乎没有应用程序关心这个值,因此可以传递N U L L,将它忽略。
有时,有必要知道信标的当前资源数量而不修改这个数量,但是没有一个函数可以用来查询信标的当前资源数量的值。起先我认为调用R e l e a s e S e m a p h o r e并为l R e l e a s e C o u n t参数传递0,也许会在* p l P r e v i o u s C o u n t中返回资源的实际数量。但是这样做是不行的, R e l e a s e S e m a p h o r e用0填入这个长变量。接着,我试图传递一个非常大的数字,作为第二个参数,希望它不会影响当前资源数量,因为它将取代最大值。同样, R e l e a s e S e m a p h o r e用0填入* p l P r e v i o u s。可惜,如果不对它进行修改,就没有办法得到信标的当前资源数量。
9.6 互斥对象内核对象
互斥对象(m u t e x)内核对象能够确保线程拥有对单个资源的互斥访问权。实际上互斥对象是因此而得名的。互斥对象包含一个使用数量,一个线程I D和一个递归计数器。互斥对象的行为特性与关键代码段相同,但是互斥对象属于内核对象,而关键代码段则属于用户方式对象。这意味着互斥对象的运行速度比关键代码段要慢。但是这也意味着不同进程中的多个线程能够访问单个互斥对象,并且这意味着线程在等待访问资源时可以设定一个超时值。
I D用于标识系统中的哪个线程当前拥有互斥对象,递归计数器用于指明该线程拥有互斥对象的次数。互斥对象有许多用途,属于最常用的内核对象之一。通常来说,它们用于保护由多个线程访问的内存块。如果多个线程要同时访问内存块,内存块中的数据就可能遭到破坏。互斥对象能够保证访问内存块的任何线程拥有对该内存块的独占访问权,这样就能够保证数据的完整性。
互斥对象的使用规则如下:
? 如果线程I D是0(这是个无效I D),互斥对象不被任何线程所拥有,并且发出该互斥对象的通知信号。
? 如果I D是个非0数字,那么一个线程就拥有互斥对象,并且不发出该互斥对象的通知信号。
? 与所有其他内核对象不同, 互斥对象在操作系统中拥有特殊的代码,允许它们违反正常的规则(后面将要介绍这个异常情况)。
若要使用互斥对象,必须有一个进程首先调用C r e a t e M u t e x,以便创建互斥对象:
HANDLE CreateMutex (
PSECURITY_ATTRIBUTES psa,
BOOL fInitialOwner,
PCTSTR pszName);
p s a和p s z N a m e参数在第3章中做过介绍。当然,通过调用O p e n M u t e x,另一个进程可以获得它自己进程与现有互斥对象相关的句柄:
HANDLE OpenMutex (
DWORD fdwAcess,
BOOL bInheritHandle,
PCTSTR pszName);
f I n i t i a l O w n e r参数用于控制互斥对象的初始状态。如果传递FA L S E(这是通常情况下传递的值),那么互斥对象的I D和递归计数器均被设置为0。这意味着该互斥对象没有被任何线程所拥有,因此要发出它的通知信号。
如果为f I n i t i a l O w n e r参数传递T R U E,那么该对象的线程I D被设置为调用线程的I D,递归计数器被设置为1。由于I D是个非0数字,因此该互斥对象开始时不发出通知信号。
通过调用一个等待函数,并传递负责保护资源的互斥对象的句柄,线程就能够获得对共享资源的访问权。在内部,等待函数要检查线程的I D,以了解它是否是0(互斥对象发出通知信号)。如果线程I D是0,那么该线程I D被设置为调用线程的I D,递归计数器被设置为1,同时,调用线程保持可调度状态。
如果等待函数发现I D不是0(不发出互斥对象的通知信号),那么调用线程便进入等待状态。系统将记住这个情况,并且在互斥对象的I D重新设置为0时,将线程I D设置为等待线程的I D,将递归计数器设置为1,并且允许等待线程再次成为可调度线程。与所有情况一样,对互斥内核对象进行的检查和修改都是以原子操作方式进行的。
对于互斥对象来说,正常的内核对象的已通知和未通知规则存在一个特殊的异常情况。比如说,一个线程试图等待一个未通知的互斥对象。在这种情况下,该线程通常被置于等待状态。然而,系统要查看试图获取互斥对象的线程的I D是否与互斥对象中记录的线程I D相同。如果两个线程I D相同,即使互斥对象处于未通知状态,系统也允许该线程保持可调度状态。我们不认为该“异常”行为特性适用于系统中的任何地方的其他内核对象。每当线程成功地等待互斥对象时,该对象的递归计数器就递增。若要使递归计数器的值大于1,唯一的方法是线程多次等待相同的互斥对象,以便利用这个异常规则。
一旦线程成功地等待到一个互斥对象,该线程就知道它已经拥有对受保护资源的独占访问权。试图访问该资源的任何其他线程(通过等待相同的互斥对象)均被置于等待状态中。当目前拥有对资源的访问权的线程不再需要它的访问权时,它必须调用R e l e a s e M u t e x函数来释放该互斥对象:
BOOL ReleaseMutex(HANDLE hmutex);
该函数将对象的递归计数器递减1。如果线程多次成功地等待一个互斥对象,在互斥对象的递归计数器变成0之前,该线程必须以同样的次数调用R e l e a s e M u t e x函数。当递归计数器到达0时,该线程I D也被置为0,同时该对象变为已通知状态。
当该对象变为已通知状态时,系统要查看是否有任何线程正在等待互斥对象。如果有,系统将“按公平原则”选定等待线程中的一个,为它赋予互斥对象的所有权。当然,这意味着线程I D被设置为选定的线程的I D,并且递归计数器被置为1。如果没有其他线程正在等待互斥对象,那么该互斥对象将保持已通知状态,这样,等待互斥对象的下一个线程就立即可以得到互斥对象。
9.6.1 释放问题
互斥对象不同于所有其他内核对象,因为互斥对象有一个“线程所有权”的概念。本章介绍的其他内核对象中,没有一种对象能够记住哪个线程成功地等待到该对象,只有互斥对象能够对此保持跟踪。互斥对象的线程所有权概念是互斥对象为什么会拥有特殊异常规则的原因,这个异常规则使得线程能够获取该互斥对象,尽管它没有发出通知。
这个异常规则不仅适用于试图获取互斥对象的线程,而且适用于试图释放互斥对象的线程。当一个线程调用R e l e a s e M u t e x函数时,该函数要查看调用线程的I D是否与互斥对象中的线程I D相匹配。如果两个I D相匹配,递归计数器就会像前面介绍的那样递减。如果两个线程的I D不匹配,那么R e l e a s e M u t e x函数将不进行任何操作,而是将FA L S E(表示失败)返回给调用者。此时调用G e t L a s t E r r o r,将返回E R R O R _ N O T _ O W N E R(试图释放不是调用者拥有的互斥对象)
因此,如果在释放互斥对象之前,拥有互斥对象的线程终止运行(使用E x i t T h r e a d、Te r m i n a t e T h r e a d、E x i t P r o c e s s或Te r m i n a t e P r o c e s s函数),那么互斥对象和正在等待互斥对象的
其他线程将会发生什么情况呢?答案是,系统将把该互斥对象视为已经被放弃——拥有互斥对象的线程决不会释放它,因为该线程已经终止运行。
由于系统保持对所有互斥对象和线程内核对象的跟踪,因此它能准确的知道互斥对象何时被放弃。当一个互斥对象被放弃时,系统将自动把互斥对象的I D复置为0,并将它的递归计数器复置为0。然后,系统要查看目前是否有任何线程正在等待该互斥对象。如果有,系统将“公平地”选定一个等待线程,将I D设置为选定的线程的I D,并将递归计数器设置为1,同时,选定的线程变为可调度线程。
这与前面的情况相同,差别在于等待函数并不将通常的WA I T _ O B J E C T _ 0值返回给线程。相反,等待函数返回的是特殊的WA I T _ A B A N D O N E D值。这个特殊的返回值(它只适用于互斥对象)用于指明线程正在等待的互斥对象是由另一个线程拥有的,而这另一个线程已经在它完成对共享资源的使用前终止运行。这不是可以进入的最佳情况。新调度的线程不知道目前资源处于何种状态,也许该资源已经完全被破坏了。在这种情况下必须自己决定应用程序应该怎么办。
在实际运行环境中,大多数应用程序从不明确检查WA I T _ A B A N D O N E D返回值,因为线程很少是刚刚终止运行(上面介绍的情况提供了另一个例子,说明为什么决不应该调用Te r m i n a t e T h r e a d函数)。
9.6.2 互斥对象与关键代码段的比较
特性 互斥对象 关键代码段
运行速度 慢 快
是否能够跨进程边界来使用 是 否
声明 HANDLE hmtx; CRITICAL_SECTION cs;
初始化 hmtx=CreateMutex(NULL,FALSE,NULL) InitializeCriticalSection(&es)
清除 CloseHandle(hmtx); DeleteCriticalSection(&cs);
无限等待 WaitForSingleObject(hmtx,INFINITE) EnterCriticalSection(&cs);
0等待 WaitForSingleObjectTry(hmtx,0); EnterCriticalSection(&cs);
任意等待 WaitForSingleObject(hmtx,dwMilliseconds); 不能
释放 ReleaseMutex(hmtx); LeaveCriticalSection(&cs);
是否能够等待 是 否
其他内核对象 (使用WaitForMultipleObjects或类似的函数)
9.7 线程同步对象速查表
9.8 其他的线程同步函数
Wa i t F o r S i n g l e O b j e c t和Wa i t F o r M u l t i p l e O b j e c t s是进行线程同步时使用得最多的函数。但是,Wi n d o w s还提供了另外几个稍有不同的函数。如果理解了Wa i t F o r S i n g l e O b j e c t和Wa i t F o r M u l t i p l eO b j e c t s函数,那么要理解其他函数如何运行,就不会遇到什么困难。本节简单地介绍一些这样的函数。
9.8.1 异步设备I / O
异步设备I / O使得线程能够启动一个读操作或写操作,但是不必等待读操作或写操作完成。例如,如果线程需要将一个大文件装入内存,那么该线程可以告诉系统将文件装入内存。然后,当系统加载该文件时,该线程可以忙于执行其他任务,如创建窗口、对内部数据结构进行初始化等等。当初始化操作完成时,该线程可以终止自己的运行,等待系统通知它文件已经读取。
设备对象是可以同步的内核对象,这意味着可以调用Wa i t F o r S i n g l e O b j e c t函数,传递文件、套接字和通信端口的句柄。当系统执行异步I / O时,设备对象处于未通知状态。一旦操作完成,系统就将对象的状态改为已通知状态,这样,该线程就知道操作已经完成。此时,该线程就可以继续运行。
9.8.2 Wa i t F o r I n p u t I d l e
9.8.3 MsgWa i t F o r M u l t i p l e O b j e c t s ( E x )
9.8.4 Wa i t F o r D e b u g E v e n t
9.8.5 SingleObjectAndWa i t