文章目录
- Windows编程——线程篇(二):线程同步
-
- 为什么需要同步
-
- 资源竞争
- 高速缓存行
- 用户空间线程同步
-
-
- 原子访问
- 临界区线程同步
-
- 临界区
- 临界区与循环锁
- 临界区的错误处理
- 临界区技巧
-
- 内核对象线程同步
-
- 等待内核对象
-
- 等待函数
- 等待线程
- 等待多个进程
- 等待成功的副作用
- 事件内核对象线程同步
- 可等待的定时器内核对象线程同步
-
- 等待定时器给APC(异步过程调用)进行排队
- 定时器的松散特性
- 信号量线程同步
- 互斥体线程同步
-
- 互斥体与临界区比较
Windows编程——线程篇(二):线程同步
为什么需要同步
资源竞争
假如存在2个线程A、B,和资源T,A需要对int T进行读取操作,B需要进行写,并假设读写操作不是原子操作。那么,当A读取T的值得时候,如果B进行写,则A读取到的值讲是不确定的。而所谓原子操作就是某一操作在完成之前这个资源不会被其他线程所访问。
高速缓存行
所谓高速缓存行,就是指CPU在从内存读取一个字节的时候,并不是读取一个字节,而是取出足够的字节来填入高速缓存行。一般高速缓存行的大小为32或者64字节,当所需要访问的字节存在于高速缓存行的时候,CPU就不必去访问内存总线,从而提高存取效率。但这也会造成资源同步的问题,同时 这也会为什么存在register关键字的原因。
用户空间线程同步
原子访问
线程同步问题在很大程度上与原子访问有关,所谓原子访问,是指线程在访问资源时能够
确保所有其他线程都不在同一时间内访问相同的资源。
unsigned InterlockedExchange(unsigned volatile *Target,_In_ unsigned Value);
unsigned InterlockedExchangeAdd(PLONG plAdded,LONG lIncrement);
PVOID IntergerlockedCompareExchange(PLONG plDestination,LONG lExchange,LONG lComparand);
PVOID IntergerlockedCompareExchangePointer(PVOID *ppvDestination,PVOID pvExchange,PVOID pvComparand);
临界区线程同步
临界区
临界区是指一个小代码段,在代码能够执行前,它必须独占对某些共享资源的访问权。在线程退出关键代码段之前,系统将不给想要访问相同资源的其他任何线程进行调度。
// 进入临界区
// 1.如果没有其他线程访问该资源,则进入进阶区,以指明调用线程已被赋予访问权限并立即返回
// 2.如果已经有其他线程被赋予访问权限,那么将进行等待,等待不会浪费CPU资源,一旦目前访问该资源的线程调用leave函数,系统将通知所有等待中的线程对该资源进行竞争
VOID EnterCriticalSection(LPCRITICAL_SECTION lpCriticalSection);
// 离开临界区
VOID LeaveCriticalSection(LPCRITICAL_SECTION lpCriticalSection);
// 在任何企图进入临界区的线程之前调用初始化函数,否则后果是不可预料的
VOID InitializeCriticalSection(PCRITICAL_SECTION pcs);
// 删除临界区资源
VOID DeleteCriticalSection(PCRITICAL_SECION &pcs);
// 测试是否可以进入临界区,可以返回TRUE,否则返回FALSE
BOOL TryEnterCriticalSection(PCRITICAL_SECTION &pcs);
typedef struct _RTL_CRITICAL_SECTION {
PRTL_CRITICAL_SECTION_DEBUG DebugInfo;
//
// The following three fields control entering and exiting the critical
// section for the resource
//
LONG LockCount;
LONG RecursionCount;
HANDLE OwningThread; // from the thread's ClientId->UniqueThread
HANDLE LockSemaphore;
ULONG_PTR SpinCount; // force size on 64-bit systems when packed
} RTL_CRITICAL_SECTION, *PRTL_CRITICAL_SECTION;
typedef RTL_CRITICAL_SECTION CRITICAL_SECTION;
在使用enter和leave函数之前,先创建一个CRITICAL_SECTION对象(一般是全局变量),并且在使用这个对象之前必须调用初始化函数,然后在临界区前后分别调用enter和leave即可。
临界区与循环锁
当用户阻塞在entercriticalsection的时候,该线程将从用户态切换到内核态,这个时间大概花费1000个CPU周期,这种转换是要付出很大代价的。同时,如果是多核CPU的情况,一个线程请求进入临界区时另一个线程正在该临界区执行代码,那么请求线程将转换到内核态,但是另一线程很可能在转换完成前就已经释放了该临界区,因此这种情况也是非常浪费CPU资源的。这时候可以使用临界区循环锁,如下:
BOOL InititializeCriticalSectionAndSpinCount(
PCRITICAL_SECTION &pcs,
DWORD dwSpinCount
);
dwSpinCount的作用是告知线程在等待之前它湿度获得资源时想要循环所循环迭代的次数。当超过这个次数之后才会切换到内核态,否则将获得临界区并返回。使用以下函数可以在运行时修改循环的次数。
临界区的错误处理
由于临界区初始化的时候可能失败但是InitializeCriticalSection并不会返回false,而是产生STATUS_NO_MEMORY异常。如果这个时候调用enter函数将会导EXCEPTION_INVALID_HANDLE,如果不希望处理异常,则应该调用InititializeCriticalSectionAndSpinCount。
临界区技巧
- 为每个需要共享的资源分配一个单独的CRITICAL_SECTION
- 如果 有多个临界区需要同时访问,需要确保enter的顺序!!!
- 不要长时间在临界区运行,因为当一个临界区运行时,其他线程将进入等待状态
EnterCriticalSection(&g_cs);
SOMERESTRUCT sTemp = g_s;
LeaveCriticalSection(&g_cs);
// 先取出竞争的数据,不要在临界区中做长时间的操作
SendMessage(hWndSomeWnd,WM_SOMEMSG,&sTemp,0);
内核对象线程同步
所谓线程与内核对象的同步,就是同步的时候需要从用户态切换到内核态,而不是像前面的临界区那样可以不切换到内核态就进行同步。线程可以使自己进入等待状态,直到一个对象变为已通知状态。所谓已通知状态,对于线程和进程而言就是线程或进行结束运行。当线程和进行开始运行的时候是未通知状态,当他们结束后就变为已通知状态。
等待内核对象
等待函数
// 让函数自愿进入等待状态,直到一个特定的内核对象变为已通知状态或者超时
// WAIT_OBJECT_0:被等待的内核对象状态变为已通知
// WAIT_TIMEOUT:等待超时
// WAIT_FAILED:等待失败
DWORD WaitForSingleObject(HANDLE hHandle,DWORD dwMilliseconds);
// nCount:等待的内核对象个数
// lpHandles:内核对象数组
// bWaitAll:是否等待所有内核对象,FALSE只要有任何一个变为已通知就返回,否则需要等待所有的内核对象都变为已通知wait函数才会返回
// dwMilliseconds:毫秒数
DWORD WaitForMultipleObjects( DWORD nCount,CONST HANDLE *lpHandles,BOOL bWaitAll, DWORD dwMilliseconds
等待线程
DWORD dw = WaitForSingleObject(hThread,5000);
switch(dw){
case WAIT_OBJECT_0:
// 线程结束
break;
case WAIT_TIMEOUT:
// 等待超时,线程未结束
break;
case WAIT_FAILED:
// 等待失败,可能是因为非法HANDLE
break;
}
等待多个进程
HANDLE hProcess[3];
hProcess[0 ] = hProcess1;
hProcess[1 ] = hProcess2;
hProcess[2 ] = hProcess3;
DWORD dw = WaitForMultiObject(3,hProcess,FALSE,5000);
switch(dw)
{
case WAIT_FALIED:
break;
case WAIT_OBJECT_0 + 0:
// PROCESS 1
break;
case WAIT_OBJECT_0 + 1:
// PROCESS 2
break;
case WAIT_OBJECT_0 + 2:
// PROCESS 3
break;
case WAIT_TIMEOUT:
// 超时
break;
}
等待成功的副作用
所谓副作用,就是当调用waitobject函数成功返回的时候,有一些被等待的内核对象在这个时候状态会发生改变,但是有些就不会。线程和进程内核对象状态就不会发生改变。有些对象在wait成功返回后他们的状态会发生改变,比如自动事件被设置为通知状态后wait返回,但是wait返回的时候事件又被设置为未通知状态,这就是所谓的副作用(相当于wait函数在返回前调用了resetevent)~~(这里关于副作用可以参考windows核心编程,第九章,9.2节。)
事件内核对象线程同步
事件内核对象是个最基本的对象。事件能够通知一个操作已经完成。有手动和自动重置两种事件。当手动重置的事件得到通知时,等待该事件的所有线程都变为可调度线程。当一个手动重置的事件得到通知时,等待该事件的线程中只有一个线程变为可调度线程。当一个线程执行初始化操作,然后通知另一个线程执行剩余的操作时,事件使用得最多。事件开始时一般初始化为未通知状态,当完成特定的工作后可以选择将事件设置为已通知状态,此时等待该事件的其他线程将得到通知变得可调度。
// 2个自动重置的EVENT
HANDLE h[] = {hAutoResetEvent1,hAutoResetEvent2};
WaitForMultiObject(2,h,TRUE,INFINITE);
这里当wait函数被调用时,2个event都处于未通知状态,这就迫使调用者进入等待状态。直到2个event都变为已通知,否则调用线程会一直等待下去。
// psa:事件的安全属性
// bManualReset:是否手动重置,TRUE手动重置,否则为自动
// fInitialState:初始状态,TRUE为有信号,FALSE为无信号,一般初始化为FALSE
HANDLE CreateEvent(PSECURITY_ATTRIBUTES psa,
BOOL bManualReset,
BOOL fInitialState,
PCTSTR pszName);
// 打开pszName名字的事件,成功返回handle失败返回NULL
HANDLE OpenEvent(DWORD fdwAccess,BOOL bInherit,PCTSTR pszName);
// 设置事件为通知状态
HANDLE SetEvent(HANDLE hEvent);
// 将事件重置为未通知状态
BOOL ResetEvent(HANDLE hEvent);
// PulseEvent函数使得事件变为已通知状态,然后立即又变为未通知状态,这就像在调用SetEvent后又立即调用ResetEvent函数一样。
// 并不非常有用。实际上我在自己的应用程序中从未使用它,因为根本不知道什么线程将会看到事件的发出并变成可调度线程。
BOOL PulseEvent(HANDLE hEvent);
可等待的定时器内核对象线程同步
// 创建一个可等待的定时器内核对象,并返回其句柄
HANDLE CreateWaitableTimer(PSECURITY_ATTRIBUTES psa,BOOL bManual,PCTSTR pszName);
// 打开指定名称的可等待定时器
HANDLE OpenWaitableTimer(DWORD dwDesiredAccess,BOOL bInheritHandle,PCTSTR pszName);
// 设置可等待定时器
// pDueTime:指明定时器合适第一次报时,
// lPeriod:第一次报时之后间隔多长时间报一次时,单位为秒,如果为0则只报时一次
// pfnCompltetionRoutine:回调函数
// pvargToCompletionRoutine:回调参数
// fResume:暂停或恢复,一般为FALSE
BOOL SetWaitableTimer(HANDLE hTimer,const LARGE_INTERGER *pDueTime,LONG lPeriod,PTIMERAPCROUTINE pfnCompltetionRoutine,PVOID pvargToCompletionRoutine,BOOL fResume);
// 取出定时器的句柄并撤销定时器
BOOL CancelWaitableTimer(HANDLE hTimer);
等待定时器给APC(异步过程调用)进行排队
一般而言pfnCompltetionRoutine和其参数都为null,当规定的时间到来时就想定时器发送通知信号。但是如果setwaitabletimer的pfnCompltetionRoutine参数不为null,则当定时器报时的时候,由调用setwaitabletimer函数的同一线程来调用,但只有在调用线程处于等待状态(sleep、waitforsingle…、waitformulti、msgwaitfor等函数)的时候才会被调用,否则不会被调用。
当定时器报时的时候,如果你的线程处于等待状态,则系统使你的线程调用回调函数。
定时器的松散特性
定时器通常用于通信协议中。比如,如果可以寄向服务器发出一个请求,而服务器没有在规定的事件内做出响应,那么客户机就认为无法使用服务器。如果发现自己需要创建和管理多个定时器对象,可以使用createtimerqueuetimer,但是IO完成端口才是最完美的选择。
信号量线程同步
信号量用于对资源进行计数,他可以很好的管理资源和调度线程。
// lInitialCount:初始资源数
// lMaxCount:最大资源数
HANDLE CreateSemophere(PSECURITY_ATTRIBUTE psa,LONG lInitialCount,LONG lMaxCount,PCTSTR pszName);
HANDLE OpenSemophere(DWORD fdwAccess,BOOL bInheritHandle,PCTSTR pszName);
// 释放信号量,也就是将资源数进行递增lReleaseCount个个数
// plPrevCount:返回当前资源数的原始值,一般传递NULL即可
BOOL ReleaseSemaphore(HANDLE hSem,LONG lReleaseCount,PLONG plPrevCount);
// 创建一个信号量,其最大资源数为5,开始时可以使用的资源为0
HANDLE hSem = CreateSemophere(NULL,0,5,NULL);
如果资源数量初始化为0,因此不发出信号量的信号,等待信号量的所有线程都进入等待。信号量的出色之处在于它们能够以原子操作方式来执行测试和设置操作,这就是说,当向信号量申请一个资源时,操作系统就要检查是否有这个资源可供使用,同时将可用资源的数量递减,而不让另一个线程加以干扰。只有当资源数量递
减后,系统才允许另一个线程申请对资源的访问权。
如果一个线程调用waitfor函数对semaphore进行等待,并且信号量的当前资源数为0(信号量没有发出信号),那么系统就进入等待状态。当另一个线程将该信号量的当前资源数量进行递增的时候,系统就会通知等待在该信号量上的线程。
互斥体线程同步
互斥体一般用于保护多线程中对共享资源的访问。
// fInitialOwner:指明互斥体的初始状态,一般初始为FALSE
HANDLE CreateMutex(PSECURITY_ATTRIBUTE psa,BOOL fInitialOwner,PCTSTR pszName);
HANDLE OpenMutex(DWORD fdwAccess,BOOL bInherit,PCTSTR pszName);
// 当一个线程成功等待到一个互斥体,然后执行完必要的操作后,需要调用release函数释放对互斥体的占有
BOOL RealseMutex(HANDLE hMutex);
如果当前占有互斥体的线程异常结束,则系统自动对该线程的占用进行release,然后其他wait的线程将返回WAIT_ABANDONED值,表示上个拥有者放弃了拥有权,一般是由于该线程在完成对共享资源的使用前终止运行。
互斥体与临界区比较
互斥体和临界区都可以保护资源在多线程中的访问,他们存在以下主要区别:
特性 | 互斥体 | 临界区 |
---|---|---|
运行速度 | 慢 | 快 |
是否可以跨进程 | 是 | 否 |
声明 | HANDLE hMutex | CRITICAL_SECTION cs; |
初始化 | hMutex = CreateMutex() | InitializeCriticalSection(&cs) |
清除 | ReleaseMutex(hMutex) | DeleteCriticalSection(&cs) |
等待资源 | WaitForSingleObject | EnterCriticalSection |
释放 | ReleaseMutex(hMutex) | LeaveCriticalSection(&cs) |