Win32多线程程序设计
声明:本文根据 http://www.blogjava.net/tinysun/archive/2008/11/30/243521.html 整理并改编,纠正了原文中的错误并重新排版。请在转载或者引用时标明本文链接网址。
线程之间通信的两个基本问题:互斥与同步。
同步是指线程之间具有制约关系,一个线程的执行依赖另一个线程,当未得到另一个线程的消息时而处于等待。
线程1 ------执行--->---------执行-------->
|
协同 | 线程1在某一时刻发出信号允许线程2执行
|
线程2 ------等待--->---------执行-------->
互斥是指在使用共享资源时,各线程访问的排他性。当多个线程请求使用同一共享资源时,任意时刻最多只允许一个线程去使用,其他需要使用该资源的线程必须要等待,直到占用资源的线程释放该资源。
线程1 ------执行--->---------执行-------->
|
互斥 | 线程1在某一时刻申请到线程2同样需要的资源
|
线程2 ------执行--->------等待----------->
根据示意图可以看出:线程互斥是一种特殊的线程同步。实际上,线程互斥和同步对应着线程通信中的两种情况:
- 当多个线程访问共享资源而不破坏资源时;
- 当两个线程需要进行信号通信时。
Win32中,同步机制主要有以下几种:
- 事件(Event)
- 信号量(Semaphore)
- 临界区(Cirtical Section)
- 互斥量(Mutex)
全局变量
进程中所有线程均可以访问所有的全局变量,因而全局变量成为Win32多线程通信的最简单方式。
一个简单的线程锁定变量叠加的示例:
1 int globalParam; // 全局变量 2 UINT threadFunction(LPVOID pParam) 3 { 4 globalParam = 0; 5 while (globalParam < MaxValue) 6 { 7 // 线程处理 8 ::InterlockedIncrement((long*)&globalParam); 9 } 10 return 0; 11 }
接下来的程序:
1 int globalFlag = false; 2 DWORD WINAPI threadFunction(LPVOID param) 3 { 4 Sleep(2000); 5 globalFlag = true; 6 return 0; 7 } 8 9 int main() 10 { 11 DWORD threadId; 12 HANDLE handle = CreateThread(NULL, 0, threadFunction, NULL, 0, &threadId); 13 if (handle) { 14 printf("Thread launched! "); 15 CloseHandle(handle); 16 } 17 18 while (!globalFlag) 19 ; 20 21 printf("exit "); 22 return 0; 23 }
以上程序中使用全局变量和while循环查询进行线程同步。实际上,我们应该避免使用这种方法,理由如下:
- 当主线程必须使自己与threadFunction函数的完成运行实现同步时,它并未使自己进入睡眠状态。由于主线程未进入睡眠状态,因此操作系统继续为它调度CPU时间,这就要其他线程宝贵的时间周期。
- 当主线程的优先级高于执行threadFunction函数的线程时,会放生globalFlag永远不被赋值为true的情况。因为在这种情况下,系统不会再将时间片分配给要执行threadFunction函数的线程。
事件(Event)
事件是Win32提供的最灵活的线程同步方式,事件可以处于激发状态(signaled or true)或者未激发状态(unsignal or false)。根据状态变迁方式的不同,事件可以分为两类:
- 手动设置:这种对象只可能用程序手动设置,在需要该事件或者事件发生时,采用SetEvent和ResetEvent来设置。
- 自动恢复:一旦事件发生并被处理后,自动恢复到没有事件状态,不需要再次设置。
创建事件的函数原型:
HANDLE CreateEvent( LPSECURITY_ATTRIBUTES lpEventAttributes, // 可为NULL BOOL bManualReset, // TRUE(手动),在WaitForSingleObject后必须手动调用ResetEvent清除信号 // FALSE(自动),在WaitForSingleObject后,系统自动清除事件信号 BOOL bInitialState, // 初始状态 LPCSTR lpName); // 事件名称
使用事件机制应该注意事项:
- 若跨进程访问事件,必须对事件命名。命名时,不要与命名空间中其他全局对象命名冲突;
- 事件是否要自动恢复;
- 事件的初始状态设置。
由于事件属于内核对象,故进程B通过对象名称可以调用OpenEvent函数获得进程A中事件对象的句柄,然后根据该句柄可用于ResetEvent、SetEvent和WaitForMultipleObject等函数。这样就可以实现一个进程的线程控制另一个进程的线程的运行。
HANDLE handle = OpenEvent(EVENT_ALL_ACCESS, true, "MyEvent"); ResetEvent(handle);
信号量(Semaphore)
信号量是维护 O 到指定最大值之间的同步对象。信号量状态在其计数大于 O 时是有信号的,而其计数是 O 时是无信号的。信号量对象在控制上可以支持有限数量共享资源的访问。
- 如果当前资源的数量大于 O,则信号量有效;
- 如果当前资源的数量等于 O,则信号量无效;
- 系统决不允许当前资源的数量为负数;
- 系统决不允许当前资源的数量大于最大资源数量。
创建信号量函数原型:
HANDLE CreateSemaphore( LPSECURITY_ATTRIBUTES lpSemaphoreAttributes, // 可为NULL LONG lInitialCount, // 开始时间可供使用的资源数 LONG lMaximumCount, // 最大资源数 LPCSTR lpName); // 信号量名称
释放信号量,通过调用该函数,线程能对当前资源数进行递增
BOOL WINAPI ReleaseSemaphore( HANDLE hSemaphore, LONG lReleaseCount, // 当前资源数增加lReleaseCount LPLONG lpPreviousCount);
打开信号量,与其他内核对象一样,信号量可以通过名字跨进程访问
HANDLE OpenSemaphore( DWORD fdwAccess, BOOL bInherithandle, LPCSTR lpName); // 信号量名称
临界区(Cirtical Section)
临界区变量定义:
CRITICAL_SECTION gCriticalSection;
可以将其定义为全局变量,这样进程中所有线程可使用此变量。
初始化临界区函数原型:
VOID WINAPI InitializeCriticalSection (LPCRITICAL_SECTION lpCriticalSection);
该函数运行一般不会失败,所以采用VOID作为返回值。任何线程调用EnterCriticalSection函数前,必须先调用该函数;否则,结果将难以预料。
删除临界区
VOID WINAPI DeleteCriticalSection (LPCRITICAL_SECTION lpCriticalSection);
进入临界区
VOID WINAPI EnterCriticalSection (LPCRITICAL_SECTION lpCriticalSection);
离开临界区
VOID WINAPI LeaveCriticalSection (LPCRITICAL_SECTION lpCriticalSection);
使用临界区编程的一般方法是:
void UpdateData() { EnterCriticalSection(&gCriticalSection); // do something LeaveCriticalSection(&gCriticalSection); }
使用临界区需要注意事项
- 每个共享资源使用一个CRITICAL_SECTION变量;
- 不要长时间运行关键代码段。当一个关键代码段长时间运行时,其他线程就会进入等待状态,这样会降低程序运行性能;
- 若需要同时访问多个资源,则可能连续调用EnterCriticalSection;
- 临界区不是操作系统内核对象,若进入临界区的线程崩溃了,将无法释放临界区资源。这个缺点在互斥量中得到了弥补。
互斥量(Mutex)
互斥量的作用是保证每次只能有一个线程获得互斥量而得以继续执行
创建互斥量对象的函数原型:
HANDLE CreateMutex( LPSECURITY_ATTRIBUTES lpMutexAttributes, // 可为NULL BOOL bInitialOwner, // 是否占有该互斥量。占有(TRUE),不占有(FALSE) LPCSTR lpName); // 信号量名称
释放互斥量函数原型:
BOOL WINAPI ReleaseMutex(HANDLE hMutex);
互斥量是内核对象,可以跨进程访问,下面的代码给出从另一进程访问命名为“MyMutex”的互斥量:
HANDLE handle = OpenMutex(MUTEX_ALL_ACCESS, "MyMutex"); if (handle) { // ... } else { // ... }
使用互斥量编程的一般方法是:
void UpdateResource() { WaitForSingleObject(handle, ...); // do something ReleaseMutex(handle); }
互斥对象能够保证线程拥有对单个资源的互斥访问权。互斥对象的行为特征与临界区相同,但互斥对象属于内核对象,而临界区则属于用户定义方式对象,因此导致互斥量与临界区之间的不同:
- 互斥对象的运行速度比临界区在关键代码段上要慢;
- 不同进程中的多个线程能够访问单个互斥对象;
- 线程在等待访问资源时可以设定一个超时值。
下面列出更详细的互斥量和临界区的不同:
————————————————————————————————————————————————
特征 互斥量 临界区
————————————————————————————————————————————————
运行速度 慢 快
能否跨进程访问 是 否
声明方式 HANDLE hMutex; CRITICAL_SECTION cs;
初始化 hMutex = CreateMutex( InitializeCriticalSection(&cs);
NULL, FALSE, NULL);
清除 CloseHandle(hMutex); DeleteCriticalSection(&cs);
无限等待 WaitForSingleObject( EnterCriticalSection(&cs);
hMutex, INFINITE);
0等待 WaitForSingleObject( TryEnterCricalSection(&cs);
hMutex, 0);
任意等待 WaitForSingleObject( 不能
hMutex, dwMilliseconds);
释放 RemoveMutex(hMutex); LeaveCriticalSection(&cs);
能否等待 是 否
————————————————————————————————————————————————
互锁访问
当必须以原子操作方式修改单个值时,互锁访问函数是相当有用的。
所谓原子访问,是指线程在访问资源时能够确保所有其他线程都不在同一时间内访问相同的资源。
请看下列代码:
1 int globalValue = 0; 2 DWORD WINAPI threadFunction1(LPVOID param) 3 { 4 globalValue++; 5 return 0; 6 } 7 8 DWORD WINAPI threadFunction2(LPVOID param) 9 { 10 globalValue++; 11 return 0; 12 }
运行ThreadFunction1和ThreadFunction2线程,结果是不可预料的。因为globalValue++并不对应一条机器指令(执行这一步C代码,需要执行三步汇编指令),看一下globalValue++的反汇编代码:
00401038 mov eax, [globalValue(0042d3f0)] # 0
0040103D add eax, 1 # 1
00401040 mov [globalValue(0042d3f0)], eax # 2
在[#0]指令与[#1]指令之间,[#1]指令与[#2]指令之间,都可能发生线程切换,使得程序执行后globalValue的值不能确定。我们可以使用InterlockedExchangeAdd函数解决这个问题:
1 int globalValue = 0; 2 DWORD WINAPI threadFunction1(LPVOID param) 3 { 4 InterlockedExchangeAdd(&globalValue, 1); 5 return 0; 6 } 7 8 DWORD WINAPI threadFunction2(LPVOID param) 9 { 10 InterlockedExchangeAdd(&globalValue, 1); 11 return 0; 12 }
InterlockedExchangeAdd函数保证变量globalValue的访问具有“原子性”。互锁访问的控制速度非常快,调用一个互锁函数的CPU通常小于50,不需要进行用户方式与内核方式的切换,而这种切换通常需要运行1000个CPU周期。
互锁访问函数的缺点是只能对单一变量进行原子访问。如果要访问的资源比较复杂,仍需要使用临界区或者互斥量。
可等待定时器
可等待定时器是在某个时间或者按规定的时间间隔发出信号通知的内核对象。它们通常用来在某个时间执行某个操作。
创建可等待定时器的函数原型:
HANDLE CreateWaitableTimer ( LPSECURITY_ATTRIBUTES lpTimerAttributes, // 可为NULL BOOL fManualReset; // 人工重置或者自动重置定时器 );
设置可等待计时器
BOOL WINAPI SetWaitableTimer ( HANDLE hTimer, // 要设置的定时器 const LARGE_INTEGER * lpDueTime, // 指明定时器第一次激活的时间 LONG lPeriod, // 指明此后定时器应该间隔多长时间激活一次
PTIMERAPCROUTINE pfnCompletionRoutine, LPVOID lpArgToCompletionRoutine, BOOL fResume);
取消可等待定时器:
BOOL CancelWaitableTimer(HANDLE hTimer);
作为内核对象,可等待计时器也支持跨进程访问(通过名字)。
打开可等待定时器函数原型:
HANDLE OpenWaitableTimer(
DWORD fdwAccess,
BOOL hInherithandle,
PCTSTR pszName);
下面的一个程序可能发生死锁:
1 #include <windows.h> 2 #include <stdio.h> 3 4 CRITICAL_SECTION cs1, cs2; 5 long WINAPI threadFunction(long); 6 7 main() 8 { 9 long threadId; 10 InitializeCriticalSection(&cs1); 11 InitializeCriticalSection(&cs2); 12 HANDLE handle = CreateThread(NULL, 0, 13 (LPTHREAD_START_ROUTINE)threadFunction, NULL, 0, &threadId); 14 CloseHandle(handle); 15 16 while(TRUE) 17 { 18 EnterCriticalSection(&cs1); 19 printf("线程1占用临界区1. "); 20 EnterCriticalSection(&cs2); 21 printf("线程1占用临界区2. "); 22 printf("线程1占用了两个临界区. "); 23 LeaveCriticalSection(&cs2); 24 LeaveCriticalSection(&cs1); 25 printf("线程1释放了两个临界区. "); 26 Sleep(20); 27 } 28 return 0; 29 } 30 31 long WINAPI threadFunction(long param) 32 { 33 EnterCriticalSection(&cs2); 34 printf("线程2占用临界区2. "); 35 EnterCriticalSection(&cs1); 36 printf("线程2占用临界区1. "); 37 printf("线程2占用了两个临界区. "); 38 LeaveCriticalSection(&cs1); 39 LeaveCriticalSection(&cs2); 40 printf("线程2释放了两个临界区. "); 41 Sleep(20); 42 }
运行这个程序,中途一旦发生这样的输入,程序就进入了“死锁”:
线程1占用临界区1
线程2占用临界区2
or
线程2占用临界区2
线程1占用临界区1
or
线程1占用临界区2
线程2占用临界区1
or
线程2占用临界区1
线程1占用临界区2
因为这样的输出意味着两个线程互相等待着对方释放临界区。
正确的做法是应该这样修改线程2的控制函数:
1 long WINAPI threadFunction(long param) 2 { 3 EnterCriticalSection(&cs1); 4 printf("线程2占用临界区1. "); 5 EnterCriticalSection(&cs2); 6 printf("线程2占用临界区2. "); 7 printf("线程2占用了两个临界区. "); 8 LeaveCriticalSection(&cs1); 9 LeaveCriticalSection(&cs2); 10 printf("线程2释放了两个临界区. "); 11 Sleep(20); 12 }
我们改变了线程2中获得临界区1、2的顺序,消除了线程1、2相互等待资源的可能性,这样程序就可以正常运行。
所以,在使用线程同步机制时,要留心死锁的发生!!!