上一章我们介绍了用户方式的线程同步,它的优点是速度非常快。但是它也有其局限性,比如互锁函数只能在单值上运行,根本无法使线程进入等待状态。使用关键代码段可以使线程进入等待状态,但是只能用这些代码段对单个进行中的线程进行同步。另外,使用关键代码段容易导致死锁,因为在等待进入关键代码段时无法设定超时值。
这一章中我们要介绍的是内核方式的线程同步,它唯一的确定就是速度比较慢。当调用本章提到的任何函数时,调用线程必须从用户方式转为内核方式,而这个过程通常需要代价,大概需要1000个CPU周期。前面我们已经介绍了若干种内核对象,进行、线程和作业等,这些内核对象都可以用于线程同步。对于线程的同步来说,这些内核对象总是处于未通知或者已通知状态。以进程为例:进程刚创建是始终处于未通知状态,当进程终止时,操作系统自动使该进程内核对象处于已通知状态。线程内核对象也如此。下面的内核对象可以处于未通知或者已通知状态:进程、线程、作业、文件修改通知、事件、可等待定时器、文件、控制台输入、信标、互斥对象。Microsoft为每一种内核对象定义了一种规则来控制未通知/已通知状态。
等待函数:
等待函数可使线程自愿进入等待状态,直到一个特定的内核对象变成已通知状态为止。最常用的函数是WaitForSingleObject:
DWORD WaitForSingleObject( HANDLE hObject, DWORD dwMilliseconds);
第一个参数是能支持未通知/已通知状态的内核对象的句柄,第二个参数表示等待的时间。我们可以传入INFINITE作为第二个参数表示无限等待,直到等待内核对象变为已通知状态。但是这样有些危险,如果该内核对象永远处于未通知状态那么调用线程永远不能被调用,不过,它不会浪费CPU时间。我们可以为第二个参数传递一个普通的值,例如5000。这个时候可以用一个switch/case语句来检查WaitForSingleObject的返回状态。
DWORD dw = WaitForSingleObject(hProcess, 5000); switch(dw){ case WAIT_OBJECT_0: // The process terminated break; case WAIT_TIMEOUT: // The process not terminated in 5000 ms. break; case WAIT_FAILED: // Bad call to function(invalid handle?) break; }
如果返回值是WAIT_FAILED,我们可以调用GetLastError了解详细信息。下面这个函数与WaitForSingleObject类似:
DWORD WaitForMultipleObjects( DWORD dwCount, CONST HANDLE* phObjects, BOOL fWaitALL, DWORD dwMilliseconds);
第一个参数指定等待的内核对象的数量。第三个参数如果是TRUE,表明只有当等待的所有内核对象都变为已通知状态时该线程才可调用,如果是FALSE,只需其中任意一个内核对象变为已通知状态即可。如果fWaitALL是TRUE,那么返回值WAIT_OBJECT_0表示等待的所有内核对象都变为已通知状态。如果fWaitALL是FALSE,我们怎么判断返回的哪个对象变为已通知状态呢?如果返回WAIT_OBJECT_0+0表示phObjects中的第一个内核对象变为已通知状态,返回WAIT_OBJEC_0+1表示第二个,以此类推。
成功等待的副作用:
对于有些内核对象来说,成功调用WaitForSingleObject和WaiForMultipleObjects,实际上会改变对象的状态。成功地调用是指函数发现对象已经得到通知并且返回一个相对于WAIT_OBJECT_0的值。当这样的对象的状态改变时,我们就称之为成功等待的副作用。不同的内核对象有不同的副作用,而有些则没有副作用。这些副作用将在具体讲到哪个内核对象时一一介绍。
如果多个线程等待单个内核对象,那么当该内核对象变为已通知状态时,系统究竟决定唤醒哪个进程呢?Microsoft对这个问题的正式回答是:算法是公平的。这意味这线程的优先级将不起任何作用,还意味着等待时间最长的线程不一定得到该对象。然而在实际操作中,Microsoft使用的算法是常用的“先进先出”的方案。等待了最长时间的线程得到该对象。但是系统中将会执行一些操作,以便改变这个行为特性,使它不太容易预测,这也是Microsoft没有明确说明算法如何起作用的原因。
事件内核对象:
事件内核对象是最基本的对象,它包含一个使用计数(与所有内核对象相同),一个用于指明该事件是个自动重置的事件还是一个人工重置的布尔值,另一个用于指明该事件处于未通知还是已通知状态的布尔值。当人工重置的事件得到通知时,等待该事件的所有线程均变为可调度线程,当自动重置的事件得到通知时,等待该事件的线程中只有一个线程变为可调度。
当一个线程执行初始化操作,然后通知另一个线程执行剩余的操作时,事件对象使用最多。创建事件内核对象使用如下:
CreateEvent( PSECURITY_ATTRIBUTES pas, BOOL fManualReset, // 人工重置or自动重置 BOOL fInitialState, // 初始化状态,未通知or已通知 PCTSTR pszName);
一旦事件已经创建,我们就可以调用SetEvent(HANDLE ...)将事件变为已通知状态,也可以调用ResetEvent(HANDLE ...)将事件设为未通知状态。Microsoft为自动重置的事件定义了成功等待的副作用规则,即当线程成功等待到该对象时,自动重置的事件就会自动重置为未通知状态,通常没有必要为自动重置的事件调用ResetEvent函数,因为系统会自动对事件进行重置。但是,Microsoft没有为人工重置的事件定义成功等待的副作用。
另外还有一个函数:BOOL PulseEvent(HANDLE hEvent),该函数使得事件变为已通知状态,然后又立即变为未通知状态,就像调用了SetEvent之后立即调用了ResetEvent一样。由于在调用PulseEvent时无法知道任何线程的状态,因此该函数并不那么有用。
等待定时器内核对象:
等待定时器是在某个时间或按规定的间隔发出自己的信号通知的内核对象。要创建等待定义器只需调用如下函数:
HANDLE CreateWaitableTimer{ PSECURITY_ATTRIBUTES psa, BOOL fManualReset, PCTSRT pszName);
于事件的情况一样,fManualReset参数用于指明人工重置定时器或自动重置定时器。当发出人工重置的定时器信号通知时,等待该定时器的所有线程均变为可调度线程。当发出自动重置的定时器信号通知时,只有一个等待的线程变为可调度线程。
等待定时器对象总是在未通知状态中创建。必须调用SetWaitableTimer函数来告诉定时器你想在何时让它变为已通知状态:
BOOL SetWaitableTimer( HANDLE hTimer, const LARGE_INTEGER *pDueTime, LONG lPeriod, PTIMRAPCROUTINE pfnCompletionRoutine, PVOID pvArgTomCompletionRoutine, BOOL fResume);
pDueTime和lPeriod两个参数分别表示定时器何时应该第一次报时及报时间隔。具体使用方法参看核心编程page206. fResume参数用于支持暂停和恢复的计算机,参看核心编程page207. 每次调用SetWaitableTimer时都会设置新的报时条件并撤销定时器原来的条件。我们还可以使用CancelWaitableTimer函数来撤销定时器。
现在我们来比较一下用户定时器(用SetTimer进行设置)和等待定时器。用户定时器能够生产WM_TIMER消息,这些消息将返回给调用SetTimer的线程和创建窗口的线程。因此,当用户定时器报时的时候只有一个线程得到通知。另一方面,等待定时器可以供多个线程共享。如果要执行与用户界面相关的事件,以便对定时器作出响应,那么使用用户定时器来组织代码结构可能更加容易些,因为使用等待定时器时,线程必须既要等待各种消息又要等待内核对象(如果要改变代码结构,可以使用MsgWaitForMultileObjects函数)。最后,运用等待定时器,当到了规定时间的时候,更有可能得到通知。因为WM_TIMER消息始终属于最低优先级的消息,当线程的队列中没有其他消息时才检索该消息。
信标内核对象:
创建信标内核对象的方法如下:
HANDLE CreateSemaphore( PSECURITY_ATTRIBUTE psa, LONG lInitialCount, LONG lMaximumCount, PCTSTR pszName
);
lMaximumCount参数告诉系统,应用程序最大资源数量是多少,lInitialCount告诉系统开始时这些资源中有多少可供使用。通过调用等待函数,传递负责保护资源的信标的句柄,线程就能获得对该资源的访问权。从内部来说,该等待函数要检查信标的当前资源数量,如果它的值大于0(信标已经发出信号),那么计数器减1,调用线程保持可调度状态。这个过程中其他线程不得干扰。通过调用ReleaseSemaphore函数,线程就能够对信标的当前资源数量进行递增。其中有个lReleaseCount表示用于递增的数量。
互斥对象内核对象:
互斥对象拥有一个使用数量(同其他内核对象),一个线程ID用于记录当前哪个线程拥有内核对象,一个递归计数器用于指定该线程拥有互斥对象的次数。
互斥对象的使用规则如下:
1. 如果线程ID是0,互斥对象不被任何线程所拥有,并且发出该互斥对象的通知信号。
2. 如果ID个是非0数字,那么一个线程就拥有这个互斥对象,不发出通知信号。
3. 与所有其他内核对象不同,互斥对象在操作系统中拥有特殊的代码,允许它们违反正常的规则。
互斥对象的创建方法如下:
HANDLE CreateMutex( DWORD fdwAccess, BOOL fInitialOwner, PCTSTR pszName);
如果fInitialOwner是FALSE,那么互斥对象的ID和计数器都是0,因此发出通知信号。如果是TRUE,那么线程ID被设置为调用线程的ID,递归计数器被设置为1,不发出通知信号。对于互斥对象来说,正常的内核的已通知和未通知规则存在一个特殊的异常情况。比如说,一个线程试图等待一个未通知的互斥对象,通常情况下,这个线程会被置于等待状态。然而,系统要查看试图获取互斥对象的线程的ID是否与互斥对象中记录的ID相同,如果两个ID相同,即使互斥对象处于未通知状态,系统也允许该线程进入可调度状态,同时递归计数器递增,这也是递归计数器>1的唯一情况。
当线程不再需要访问权时,必须调用ReleaseMutex函数来释放该互斥对象。该函数使递归计数器减1,值得注意的是,如果一个线程多次成功的等待一个互斥对象,那么必须调用相同次数的ReleaseMutex函数来释放,只有当递归计数器变为0时,该线程ID也设置为0,同时对象变为已通知状态。
现在我们来看一下互斥内核对象的释放问题,当我们调用ReleaseMutex时,系统将检查调用线程的ID和互斥对象记录的线程ID是否相同,如果不同ReleaseMutex不进行任何操作并返回FALSE。这里会遇到一个问题,如果一个线程拥有该互斥对象后意外终止会发生什么情况?这时,系统自动将递归计数器和线程ID设置为0,如果有线程正在等待这个互斥对象,那么系统会“公平地”选取一个线程变为可调度状态。注意:这个时候WaitForSingleObject这样的函数会返回WAIT_ABANDONED值,这表示线程正在等待的互斥对象是由另外一个线程拥有的,而这个线程已经在它完成对共享资源的使用前终止了,这通常不是一个最近进入状态,因为新调度的线程不知道目前资源处于何种状态。
下面我们来比较一下互斥对象和关键代码段的区别,互斥对象运行速度较慢,但是可以跨进程使用,而且可以设定任意的等待时间,而关键代码段则正好相反。
线程同步对象速查表:
对象 | 何时处于未通知状态 | 何时处于统治状态 | 成功等待的副作用 |
进程 | 当进程仍然活动时 | 当进程终止时 | 无 |
线程 | 当线程仍然活动时 | 当现程终止时 | 无 |
作业 | 当作业的时间尚未结束时 | 当作业的时间已经结束时 | 无 |
文件 | 当I/O请求正在处理时 | 当I/O请求处理完毕时 | 无 |
控制台输入 | 不存在任何输入时 | 当存在输入时 | 无 |
文件修改通知 | 没有任何文件被修改 | 当文件系统发现被修改时 | 重置通知 |
自动重置事件 | ResetEvent、PulseEvent或等待成功 | 调用SetEvent、PulseEvent时 | 重置事件 |
人工重置事件 | ResetEvent或PulseEvent | 调用SetEvent、PulseEvent时 | 无 |
自动重置的定时器 | CancelWaitableTimer或等待成功 | 当时间到 | 重置定时器 |
人工重置的定时器 | CancelWaitableTimer | 当时间到 | 无 |
信标 | 等待成功 | 当数量>0时 ReleaseSemaphore |
数量递减1 |
互斥对象 | 等待成功 | 当未被线程拥有时 ReleaseMutex |
将所有权赋予线程 |
关键代码段 (用户方式) |
等待成功 | 当未被线程拥有时 LeaveCriticalSection |
将所有权赋予线程 |
... | ... | ... | ... |
... | ... | ... | ... |
其他线程同步函数:
WaitForInputIdel: 这个函数一直处于等待状态,直到hProcess标识的进程在创建应用程序的第一个窗口的线程中已经没有尚未处理的输入为止。
MsgWaitForMultipleObjects: 这个函数与WaitForMultipleObjects相似,差别在于它们允许线程在内核对象变为已通知状态或窗口消息需要调度到调用线程创建的窗口中时被调度。
WaitForDebugEvent: 当调试程序调用该函数时,调试程序终止运行,系统将调试事件已经发生的情况通知调试程序,方法是允许调用的WaitForDebugEvent返回,并由系统填入相关参数。
SingleObjectAndWait: 这个函数以原子的操作发出关于内核对象的通知并等待另一个内核对象。这个函数有两个功能:第一减少了系统的运行时间,如果按照普通的方式调用需要2次内核方式的转换,而这个函数只有一次。第二,如果没有这个函数,那么我们需要先Release再WaitForSingleObject完成相应功能,但是在调用完Release后可能会立即切换到其他线程运行(前提是该线程正在等待Release的内核对象),这个时候Release和WaitForSingleObject就不能紧接着执行,可能会有非预期的结果产生,具体参看核心编程page226.