线程的同步
由于同一进程的所有线程共享进程的虚拟地址空间,并且线程的中断是汇编语言级的,所以可能会发生两个线程同时访问同一个对象(包括全局变量、共享资源、API函数和MFC对象等)的情况,这有可能导致程序错误。属于不同进程的线程在同时访问同一内存区域或共享资源时,也会存在同样的问题。因此,在多线程应用程序中,常常需要采取一些措施来同步线程的执行。
需要同步的情况包括以下几种:
在多个线程同时访问同一对象时,可能产生错误。例如,如果当一个线程正在读取一个至关重要的共享缓冲区时,另一个线程向该缓冲区写入数据,那么程序的运行结果就可能出错。程序应该尽量避免多个线程同时访问同一个缓冲区或系统资源。
在
Windows 95环境下编写多线程应用程序还需要考虑重入问题。Windows NT是真正的32位操作系统,它解决了系统重入问题。而Windows 95由于继承了Windows 3.x的部分16位代码,没能够解决重入问题。这意味着在Windows 95中两个线程不能同时执行某个系统功能,否则有可能造成程序错误,甚至会造成系统崩溃。应用程序应该尽量避免发生两个以上的线程同时调用同一个Windows API函数的情况。
由于大小和性能方面的原因,
MFC对象在对象级不是线程安全的,只有在类级才是。也就是说,两个线程可以安全地使用两个不同的CString对象,但同时使用同一个CString对象就可能产生问题。如果必须使用同一个对象,那么应该采取适当的同步措施。
多个线程之间需要协调运行。例如,如果第二个线程需要等待第一个线程完成到某一步时才能运行,那么该线程应该暂时挂起以减少对
CPU的占用时间,提高程序的执行效率。当第一个线程完成了相应的步骤后,应该发出某种信号来激活第二个线程。
关键节和互锁变量访问
关键节 (Critical Seciton) 与 mutex 的功能类似,但它只能由同一进程中的线程使用。关键节可以防止共享资源被同时访问。
进程负责为关键节分配内存空间,关键节实际上是一个CRITICAL_SECTION型的变量,它一次只能被一个线程拥有。在线程使用关键节之前,必须调用InitializeCriticalSection函数将其初始化。如果线程中有一段关键的代码不希望被别的线程中断,那么可以调用EnterCriticalSection函数来申请关键节的所有权,在运行完关键代码后再用LeaveCriticalSection函数来释放所有权。如果在调用EnterCriticalSection时关键节对象已被另一个线程拥有,那么该函数将无限期等待所有权。
利用互锁变量可以建立简单有效的同步机制。使用函数InterlockedIncrement和InterlockedDecrement可以增加或减少多个线程共享的一个32位变量的值,并且可以检查结果是否为0。线程不必担心会被其它线程中断而导致错误。如果变量位于共享内存中,那么不同进程中的线程也可以使用这种机制。
原子访问 所谓原子访问,是指线程在访问资源时能够确保所有其他线程都不在同一时间内访问相同的资源。互锁的函数家族:
LONG InterlockedExchangeAdd(
PLONG plAddend,
LONG Increment);
这是个最简单的函数了。只需调用这个函数,传递一个长变量地址,并指明将这个值递增多少即可。但是这个函数能够保证值的递增以原子操作方式来完成。
LONG InterlockedExchange(PLONG plTarget,
LONG lValue);
PVOID InterlockedExchangePointer(PVOID* ppvTarget,
PVOID pvValue);
I n t e r l o c k e d E x c h a n g e和I n t e r l o c k e d E x c h a n g e P o i n t e r能够以原子操作方式用第二个参数中传递的值来取代第一个参数中传递的当前值。
------------------------------以上为用户方式同步,以下为内核方式同步---------------------------------
等待函数
等待函数可使线程自愿进入等待状态,直到一个特定的内核对象变为已通知状态为止。
DWORD WaitForSingleObject(HANDLE hObject,
DWORD dwMilliseconds);
函数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);
同步对象
同步对象用来协调多线程的执行,它可以被多个线程共享。线程的等待函数用同步对象的句柄作为参数,同步对象应该是所有要使用的线程都能访问到的。同步对象的状态要么是有信号的,要么是无信号的。同步对象主要有三种:事件、 mutex 和信号灯。
事件对象 (Event) 是最简单的同步对象,它包括有信号和无信号两种状态。在线程访问某一资源之前,也许需要等待某一事件的发生,这时用事件对象最合适。例如,只有在通信端口缓冲区收到数据后,监视线程才被激活。
事件对象是用 CreateEvent 函数建立的。该函数可以指定事件对象的种类和事件的初始状态。如果是手工重置事件,那么它总是保持有信号状态,直到用 ResetEvent 函数重置成无信号的事件。如果是自动重置事件,那么它的状态在单个等待线程释放后会自动变为无信号的。用 SetEvent 可以把事件对象设置成有信号状态。在建立事件时,可以为对象起个名字,这样其它进程中的线程可以用 OpenEvent 函数打开指定名字的事件对象句柄。
mutex对象的状态在它不被任何线程拥有时是有信号的,而当它被拥有时则是无信号的。mutex对象很适合用来协调多个线程对共享资源的互斥访问(mutually exclusive)。
线程用 CreateMutex 函数来建立 mutex 对象,在建立 mutex 时,可以为对象起个名字,这样其它进程中的线程可以用 OpenMutex 函数打开指定名字的 mutex 对象句柄。在完成对共享资源的访问后,线程可以调用 ReleaseMutex 来释放 mutex ,以便让别的线程能访问共享资源。如果线程终止而不释放 mutex ,则认为该 mutex 被废弃。
信号灯对象维护一个从 0 开始的计数,在计数值大于 0 时对象是有信号的,而在计数值为 0 时则是无信号的。信号灯对象可用来限制对共享资源进行访问的线程数量。线程用 CreateSemaphore 函数来建立信号灯对象,在调用该函数时,可以指定对象的初始计数和最大计数。在建立信号灯时也可以为对象起个名字,别的进程中的线程可以用 OpenSemaphore 函数打开指定名字的信号灯句柄。
一般把信号灯的初始计数设置成最大值。每次当信号灯有信号使等待函数返回时,信号灯计数就会减 1 ,而调用 ReleaseSemaphore 可以增加信号灯的计数。计数值越小就表明访问共享资源的程序越多。
可用于同步的对象
对象
|
描述
|
变化通知
|
由
FindFirstChangeNotification 函数建立,当在指定目录中发生指定类型的变化时对象变成有信号的。 |
控制台输入
|
在控制台建立是被创建。它是用
CONIN$ 调用 CreateFile 函数返回的句柄,或是 GetStdHandle 函数的返回句柄。如果控制台输入缓冲区中有数据,那么对象是有信号的,如果缓冲区为空,则对象是无信号的。 |
进程
|
当调用
CreateProcess 建立进程时被创建。进程在运行时对象是无信号的,当进程终止时对象是有信号的。 |
线程
|
当调用
Createprocess 、 CreateThread 或 CreateRemoteThread 函数创建新线程时被创建。在线程运行是对象是无信号的,在线程终止时则是有信号的。 |
另外,有时可以用文件或通信设备作为同步对象使用。
事件内核对象
让我们观察一个简单的例子,以便说明如何使用事件内核对象对线程进行同步。下面就是这个代码:
// Create a global handle to a manual-reset, nonsignaled event.
HANDLE g_hEvent;
int WINAPI WinMain(...)
{
//Create the manual-reset, nonsignaled event.
g_hEvent = CreateEvent(NULL, TRUE, FALSE, NULL);
//Spawn 3 new threads.
HANDLE hThread[3];
DWORD dwThreadID;
hThread[0] = _beginthreadex(NULL, 0, WordCount, NULL, 0, &dwThreadID);
hThread[1] = _beginthreadex(NULL, 0, SpellCheck, NULL, 0, &dwThreadID);
hThread[2] = _beginthreadex(NULL, 0, GrammarCheck, NULL, 0, &dwThreadID);
OpenFileAndReadContentsIntoMemory(...);
//Allow all 3 threads to access the memory.
SetEvent(g_hEvent);
...
}
DWORD WINAPI WordCount(PVOID pvParam)
{
//Wait until the file's data is in memory.
WaitForSingleObject(g_hEvent, INFINITE);
//Access the memory block.
...
return(0);
}
DWORD WINAPI SpellCheck(PVOID pvParam)
{
//Wait until the file's data is in memory.
WaitForSingleObject(g_hEvent, INFINITE);
//Access the memory block.
...
return(0);
}
DWORD WINAPI GrammarCheck(PVOID pvParam)
{
//Wait until the file's data is in memory.
WaitForSingleObject(g_hEvent, INFINITE);
//Access the memory block.
...
return(0);
}
当这个进程启动时,它创建一个人工重置的未通知状态的事件,并且将句柄保存在一个全局变量中。这使得该进程中的其他线程能够非常容易地访问同一个事件对象。现在3个线程已经产生。这些线程要等待文件的内容读入内存,然后每个线程都要访问它的数据。一个线程进行单词计数,另一个线程运行拼写检查器,第三个线程运行语法检查器。这3个线程函数的代码的开始部分都相同,每个函数都调用Wa i t F o r S i n g l e O b j e c t,这将使线程暂停运行,直到文件的内容由主线程读入内存为止。
一旦主线程将数据准备好,它就调用S e t E v e n t,给事件发出通知信号。这时,系统就使所有这3个辅助线程进入可调度状态,它们都获得了C P U时间,并且可以访问内存块。注意,这3个线程都以只读方式访问内存。这就是所有3个线程能够同时运行的唯一原因。还要注意,如何计算机上配有多个C P U,那么所有3个线程都能够真正地同时运行,从而可以在很短的时间内完成大量的操作。
如果你使用自动重置的事件而不是人工重置的事件,那么应用程序的行为特性就有很大的差别。当主线程调用S e t E v e n t之后,系统只允许一个辅助线程变成可调度状态。同样,也无法保证系统将使哪个线程变为可调度状态。其余两个辅助线程将继续等待。
已经变为可调度状态的线程拥有对内存块的独占访问权。让我们重新编写线程的函数,使得每个函数在返回前调用S e t E v e n t函数(就像Wi n M a i n函数所做的那样)。这些线程函数现在变成下面的形式:
DWORD WINAPI WordCount(PVOID pvParam)
{
//Wait until the file's data is in memory.
WaitForSingleObject(g_hEvent, INFINITE);
//Access the memory block.
...
SetEvent(g_hEvent);
return(0);
}
DWORD WINAPI SpellCheck(PVOID pvParam)
{
//Wait until the file's data is in memory.
WaitForSingleObject(g_hEvent, INFINITE);
//Access the memory block.
...
SetEvent(g_hEvent);
return(0);
}
DWORD WINAPI GrammarCheck(PVOID pvParam)
{
//Wait until the file's data is in memory.
WaitForSingleObject(g_hEvent, INFINITE);
//Access the memory block.
...
SetEvent(g_hEvent);
return(0);
}
当线程完成它对数据的专门传递时,它就调用S e t E v e n t函数,该函数允许系统使得两个正在等待的线程中的一个成为可调度线程。同样,我们不知道系统将选择哪个线程作为可调度线程,但是该线程将进行它自己的对内存块的专门传递。当该线程完成操作时,它也将调用S e t E v e n t函数,使第三个即最后一个线程进行它自己的对内存块的传递。注意,当使用自动重置事件时,如果每个辅助线程均以读/写方式访问内存块,那么就不会产生任何问题,这些线程将不再被要求将数据视为只读数据。等待定时器内核对象
等待定时器是在某个时间或按规定的间隔时间发出自己的信号通知的内核对象。它们通常用来在某个时间执行某个操作。
若要创建等待定时器,只需要调用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);
进程可以获得它自己的与进程相关的现有等待定时器的句柄,方法是调用O p e n Wa i t a b l e Ti m e r函数:
HANDLE OpenWaitableTimer(
DWORD dwDesiredAccess,
BOOL bInheritHandle,
PCTSTR pszName);
当发出人工重置的定时器信号通知时,等待该定时器的所有线程均变为可调度线程。当发出自动重置的定时器信号通知时,只有一个等待的线程变为可调度线程。
等待定时器对象总是在未通知状态中创建。必须调用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);
定时器函数外,最后还有一个C a n c e l Wa i t a b l e Ti m e r函数:
BOOL CancelWaitableTimer(HANDLE hTimer);
这个简单的函数用于取出定时器的句柄并将它撤消,这样,除非接着调用S e t Wa i t a b l e Ti m e r函数以便重新设置定时器,否则定时器决不会进行报时。
信标内核对象
信标内核对象用于对资源进行计数。它们与所有内核对象一样,包含一个使用数量,但是它们也包含另外两个带符号的3 2位值,一个是最大资源数量,一个是当前资源数量。最大资源数量用于标识信标能够控制的资源的最大数量,而当前资源数量则用于标识当前可以使用的资源的数量。
信标的使用规则如下:
• 如果当前资源的数量大于0,则发出信标信号。
• 如果当前资源数量是0,则不发出信标信号。
• 系统决不允许当前资源的数量为负值。
• 当前资源数量决不能大于最大资源数量。
下面的函数用于创建信标内核对象:
HANDLE CreateSemaphore(
PSECURITY_ATTRIBUTE psa,
LONG lInitialCount,
LONG lMaximumCount,
PCTSTR pszName);
通过调用O p e n S e m a p h o r e函数,另一个进程可以获得它自己的进程与现有信标相关的句柄:
HANDLE OpenSemaphore(
DWORD fdwAccess,
BOOL bInheritHandle,
PCTSTR pszName);
通过调用R e l e a s e S e m a p h o r e函数,线程就能够对信标的当前资源数量进行递增:
BOOL ReleaseSemaphore(
HANDLE hsem,
LONG lReleaseCount,
PLONG plPreviousCount);
互斥对象内核对象
互斥对象(m u t e x)内核对象能够确保线程拥有对单个资源的互斥访问权。
互斥对象有许多用途,属于最常用的内核对象之一。通常来说,它们用于保护由多个线程访问的内存块。如果多个线程要同时访问内存块,内存块中的数据就可能遭到破坏。互斥对象能够保证访问内存块的任何线程拥有对该内存块的独占访问权,这样就能够保证数据的完整性。
互斥对象的使用规则如下:
• 如果线程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);
通过调用O p e n M u t e x,另一个进程可以获得它自己进程与现有互斥对象相关的句柄:
HANDLE OpenMutex(
DWORD fdwAccess,
BOOL bInheritHandle,
PCTSTR pszName);
一旦线程成功地等待到一个互斥对象,该线程就知道它已经拥有对受保护资源的独占访问权。试图访问该资源的任何其他线程(通过等待相同的互斥对象)均被置于等待状态中。当目前拥有对资源的访问权的线程不再需要它的访问权时,它必须调用R e l e a s e M u t e x函数来释放该互斥对象:
BOOL ReleaseMutex(HANDLE hMutex);
该函数将对象的递归计数器递减1。
互斥对象与关键代码段的比较
就等待线程的调度而言,互斥对象与关键代码段之间有着相同的特性。但是它们在其他属性方面却各不相同。表9 - 1对它们进行了各方面的比较。
表9-1 互斥对象与关键代码段的比较
特性 |
互斥对象 |
关键代码段 |
运行速度 |
慢 |
快 |
是否能够跨进程边界来使用 |
是 |
否 |
声明 |
HANDLE hmtx; |
CRITICAL_SECTION cs; |
初始化 |
h m t x = C r e a t e M u t e x(N U L L,FA L S E,N U L L); |
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 ( & e s ); |
清除 |
C l o s e H a n d l e(h m t x); |
D e l e t e C r i t i c a l S e c t i o n(& c s); |
无限等待 |
Wa i t F o r S i n g l e O b j e c t(h m t x , I N F I N I T E); |
E n t e r C r i t i c a l S e c t i o n(& c s); |
0等待 |
Wa i t F o r S i n g l e O b j e c t Tr y(h m t x , 0); |
E n t e r C r i t i c a l S e c t i o n(& c s); |
任意等待 |
Wa i t F o r S i n g l e O b j e c t(h m t x , d w M i l l i s e c o n d s); |
不能 |
释放 |
R e l e a s e M u t e x(h m t x); |
L e a v e C r i t i c a l S e c t i o n(& c s); |
是否能够等待其他内核对象 |
是(使用Wa i t F o r M u l t i p l e O b j e c t s或类似的函数) |
否 |
线程同步对象速查表
内核对象与线程同步之间的相互关系
对象 |
何时处于未通知状态 |
何时处于已通知状态 |
成功等待的副作用 |
进程 |
当进程仍然活动时 |
当进程终止运行时(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) |
无 |
线程 |
当线程仍然活动时 |
当线程终止运行时(E x i t T h r e a d,Te r m i n a t e T h r e a d) |
无 |
作业 |
当作业的时间尚未结束时 |
当作业的时间已经结束时 |
无 |
文件 |
当I / O请求正在处理时 |
当I / O请求处理完毕时 |
无 |
控制台输入 |
不存在任何输入 |
当存在输入时 |
无 |
文件修改通知 |
没有任何文件被修改 |
当文件系统发现修改时 |
重置通知 |
自动重置事件 |
R e s e t E v e n t , P u l s e - E v e n t或等待成功 |
当调用S e t E v e n t / P u l s e 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 |
当调用S e t E v e n t / P u l s e E v e n t时 |
无 |
自动重置等待定时器 |
C a n c e l Wa i t a b l e Ti m e r或等待成功 |
当时间到时(S e t Wa i t a b l e Ti m e r) |
重置定时器 |
人工重置等待定时器 |
C a n c e l Wa i t a b l e Ti m e r |
当时间到时(S e t Wa i t a b l e Ti m e r) |
无 |
信标 |
等待成功 |
当数量> 0时(R e l e a s e S e m a p h o r e) |
数量递减1 |
互斥对象 |
等待成功 |
当未被线程拥有时(R e l e a s 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) |
将所有权赋予线程 |
其他的线程同步函数
1 异步设备I / O使得线程能够启动一个读操作或写操作,但是不必等待读操作或写操作完成。例如,如果线程需要将一个大文件装入内存,那么该线程可以告诉系统将文件装入内存。然后,当系统加载该文件时,该线程可以忙于执行其他任务,如创建窗口、对内部数据结构进行初始化等等。当初始化操作完成时,该线程可以终止自己的运行,等待系统通知它文件已经读取。
2 线程也可以调用Wa i t F o r I n p u t I d l e来终止自己的运行:
DWORD WaitForInputIdle(
HANDLE hProcess,
DWORD dwMilliseconds);
该函数将一直处于等待状态,直到h P r o c e s s标识的进程在创建应用程序的第一个窗口的线程中已经没有尚未处理的输入为止。这个函数可以用于父进程。父进程产生子进程,以便执行某些操作。
3 线程可以调用M s g Wa i t F o r M u l t i p l e O b j e c t s或M s g Wa i t F o r M u l t i p l e O b j e c t s E x函数,让线程等待它自己的消息:
DWORD MsgWaitForMultipleObjects(
DWORD dwCount,
PHANDLE phObjects,
BOOL fWaitAll,
DWORD dwMilliseconds,
DWORD dwWakeMask);
DWORD MsgWaitForMultipleObjectsEx(
DWORD dwCount,
PHANDLE phObjects,
DWORD dwMilliseconds,
DWORD dwWakeMask,
DWORD dwFlags);
这些函数与Wa i t F o r M u l t i p l e O b j e c t s函数十分相似。差别在于它们允许线程在内核对象变成已通知状态或窗口消息需要调度到调用线程创建的窗口中时被调度。
4 Wi n d o w s将非常出色的调试支持特性内置于操作系统之中。当调试程序启动运行时,它将自己附加给一个被调试程序。该调试程序只需闲置着,等待操作系统将与被调试程序相关的调试事件通知它。调试程序通过调用Wa i t F o r D e b u g E v e n t函数来等待这些事件的发生:
BOOL WaitForDebugEvent(
PDEBUG_EVENT pde,
DWORD dwMilliseconds);
当调试程序调用该函数时,调试程序的线程终止运行,系统将调试事件已经发生的情况通知调试程序,方法是允许调用的Wa i t F o r D e b u g E v e n t函数返回。
5 S i n g l e O b j e c t A n d Wa i t函数用于在单个原子方式的操作中发出关于内核对象的通知并等待另一个内核对象:
DWORD SingleObjectAndWait(
HANDLE hObjectToSignal,
HANDLE hObjectToWaitOn,
DWORD dwMilliseconds,
BOOL fAlertable);