线程同步机制
常用的线程同步机制有很多种,主要分为用户模式和内核对象两类;其中
用户模式包括:原子操作、关键代码段
内核对象包括:时间内核对象(Event)、等待定时器内核对象(WaitableTimer)、信标内核对象(Semaphore)、互斥内核对象(Mutex)、
一、用户模式:
1.1、原子操作
原子操作,就是该操作绝不会在执行完毕前被任何其他任务或事件打断,也就说,它是最小的执行单位,不可能有比它更小的执行单位,因此这里的原子实际是使用了物理学里的物质微粒的概念。
原子操作需要硬件的支持,因此是架构相关的,其API和原子类型的定义都定义在内核源码中,因为C语言并不能实现这样的操作。
这个技术主要用于实现资源计数。原子类型定义类似下面结构:
typedef struct
{
volatile long long counter;
}
atomic_t;
核心就是通过volatile修饰变量,告诉编译器不要随该变量数据做优化处理,对它的访问每次都是对内存的访问,而不是对寄存器的访问。
读、写、加法、减法、自增、自减、比较等,常用封装如下:
//************************************
// Method: atomicRead// Access: public
// Returns: LONG
// Qualifier:
// Parameter: __IN_ volatile LONG * value
// Description: 以原子的方式读取变量。volatile修饰符确保读取的是当前的值,而不是缓存在寄存器的旧值
//************************************
static __CUR_VALUE_ MYWORD atomicRead(__IN_ MYWORD volatile *value)
{
return (*value);
} //************************************
// Method: atomicInc// Access: public
// Returns: LONG // 运算后的结果
// Qualifier: 参数必须是32位对齐的变量(即long类型的),否则可能会有意想不到的问题
// Parameter: __INOUT_ volatile LONG * value // 参数所指变量将自增1
// Description: 自增1的原子操作
//************************************
static __CUR_VALUE_ MYWORD atomicInc(__INOUT_ MYWORD volatile* value)
{
#ifdef _X86_
return InterlockedIncrement(value);
#else
return InterlockedIncrement64(value);
#endif
} //************************************
// Method: atomicdec 自减1的原子操作// Access: public
// Returns: LONG // 运算后的结果
// Qualifier: 参数必须是32位对齐的变量(即long类型的),否则可能会有意想不到的问题
// Parameter: __INOUT_ volatile LONG * value // 参数所指变量将自减1
//************************************
static __CUR_VALUE_ MYWORD atomicDec(__INOUT_ MYWORD volatile* value)
{
#ifdef _X86_
return InterlockedDecrement(value);
#else
return InterlockedDecrement64(value);
#endif
} //************************************
// Method: atomicSet// Access: public
// Returns: void
// Qualifier: 参数必须是32位对齐的变量(即long类型的),否则可能会有意想不到的问题
// Parameter: __INOUT_ LONG volatile * target // 参数所指变量将与第二参数相同
// Parameter: __IN_ LONG value
// Description: long类型数值赋值,以value值取代target值
//************************************
static void atomicSet(__INOUT_ MYWORD volatile* target,__IN_ MYWORD value)
{
#ifdef _X86_
InterlockedExchange(target,value);
#else
InterlockedExchange64(target,value);
#endif
} //************************************
// Method: atomicAdd// Access: public
// Returns: void
// Qualifier: 参数必须是32位对齐的变量(即long类型的),否则可能会有意想不到的问题
// Parameter: __INOUT_ LONG volatile * target // 参数所指变量将与第二参数相同
// Parameter: __IN_ LONG value
// Description: long类型数值赋值
//************************************
static void atomicAdd(__INOUT_ MYWORD volatile* target,__IN_ MYWORD value)
{
#ifdef _X86_
InterlockedExchangeAdd(target,value);
#else
InterlockedExchangeAdd64(target,value);
#endif
}
原子操作的缺陷在于,只能对long long数据进行,其他的复杂数据结构就无能为力。
1.2、临界区(关键代码段)
1.2.1关键代码段
关键代码段CRITICAL_SECTION的结构具体定义在WinBase.h文件中,其结构重命名为RTL_CRITICAL_SECTION;
使用它有两个要求:
- 需要该资源的所有线程都必须知道负责保护资源的CRITICAL_SECTION结构的地址
- CRITICAL_SECTION结构必须在任何线程试图访问被保护资源之前初始化
初始化函数InitializeCriticalSection:
WINBASEAPI VOID WINAPI InitializeCriticalSection(
_Out_ LPCRITICAL_SECTION lpCriticalSection );
销毁函数DeleteCriticalSection
WINBASEAPI VOID WINAPI DeleteCriticalSection(
_Inout_ LPCRITICAL_SECTION lpCriticalSection );
在所有共享资源之前调用EnterCriticalSection函数
WINBASEAPI VOID WINAPI EnterCriticalSection(
_Inout_ LPCRITICAL_SECTION lpCriticalSection );
其功能如下:
- 如果当前没有线程访问该资源,EnterCriticalSection函数便更新CRITICAL_SECTION中的相关变量,标识调用线程已取得共享资源访问权限,立即返回使调用线程继续执行
- 如果CRITICAL_SECTION成员变量指明调用线程已经被赋予访问权限,那么EnterCriticalSection函数便更新赋予权限计数并返回
- 如果CRITICAL_SECTION成员变量指明共享资源已经被其他线程占用,那么EnterCriticalSection函数便使调用线程设置为等待状态
EnterCriticalSection函数实际上只做了一些测试,但是这些测试工作都是原子的。
尝试获取权限函数TryEnterCriticalSection:
WINBASEAPI BOOL WINAPI TryEnterCriticalSection(
_Inout_ LPCRITICAL_SECTION lpCriticalSection );
TryEnterCriticalSection区别于EnterCriticalSection函数的地方在于,它会立即返回共享资源当前是否被占用,根据返回值来决定调用线程是继续等待尝试还是做其他事务。
访问共享资源结束后必须调用LeaveCriticalSection:
WINBASEAPI VOID WINAPI LeaveCriticalSection(
_Inout_ LPCRITICAL_SECTION lpCriticalSection );
1.2.2自旋锁相关的代码段
BOOL
WINAPI
InitializeCriticalSectionAndSpinCount(
_Out_ LPCRITICAL_SECTION lpCriticalSection,
_In_ DWORD dwSpinCount
);
该函数作用是初始化阶段为一个关键段设置一个自旋锁,期以提高关键段的性能。这样的关键段在后续尝试进入EnterCriticalSection的时候会用一个自旋锁循环尝试获取权限。
函数中参数dwSpinCount为自旋次数。目前路人说法是进程关键段性能最佳自旋次数为4000(该数值引用自《windows核心编程》8.4.2 关键代码段与循环锁)。
dwSpinCount的高信息位有数据时,它会创建对应的事件内核对象,初始化时与该代码段关联起来。这样可以避免InitializeCriticalSection在内存不足的情况可能初始化失败的问题,如果确认程序后续会争用关键代码段或进程在内存短缺的环境中运行的话。
DWORD
WINAPI
SetCriticalSectionSpinCount(
_Inout_ LPCRITICAL_SECTION lpCriticalSection,
_In_ DWORD dwSpinCount
);
这个函数是对上一个的补充,设置自旋次数。
二、内核模式
线程同步机制在内核模式下,等待函数是非常常用的一类,用于使调用线程主动进入等待状态。
WINBASEAPI DWORD WINAPI WaitForSingleObject(
_In_ HANDLE hHandle,
_In_ DWORD dwMilliseconds );
WaitForSingleObject函数相对使用频率比较高,针对单一内核对象的进入等待函数,参数分别为内核对象句柄及等待时长(ms)。
相应的有等待多个内核对象信号的等待函数:
WINBASEAPI DWORD WINAPI WaitForMultipleObjects(
_In_ DWORD nCount,
_In_reads_(nCount) CONST HANDLE *lpHandles,
_In_ BOOL bWaitAll,
_In_ DWORD dwMilliseconds);
参数说明:
- nCount,等待内核对象句柄数量,windows定义不超过MAXIMUM_WAIT_OBJECTS,即64个
- lpHandles,句柄数组首地址
- bWaitAll,TRUE需要所有对象有信号后返回,FALSE任何一个对象有信号即可返回
- dwMilliseconds,等待时长(ms)
通常用法是在等待时长处传入INFINITE,该宏定义为永远等待,WaitForSingleObject函数返回值只有3种可能:
- WAIT_OBJECT_0 等待到了对应信号,在WaitForMultipleObjects情况下,WAIT_OBJECT_0为数组首元素下标,以此+1取后续句柄对象
- WAIT_TIMEOUT 等待超时,通常只有设置一个具体等待时长才会触发该返回值
- WAIT_FAILED 调用失败,通常为句柄无效
2.1、事件内核对象(Event)
事件内核对象是最基本的对象。它包含一个使用计数,一个用于指明该事件是个自动重置的事件还是一个手动重置的事件的布尔值,一个用于指明该事件处于已通知状态还是未通知状态的布尔值。
WINBASEAPI _Ret_maybenull_ HANDLE WINAPI CreateEvent(
_In_opt_ LPSECURITY_ATTRIBUTES lpEventAttributes,
_In_ BOOL bManualReset,
_In_ BOOL bInitialState,
_In_opt_ LPCSTR lpName );
- lpEventAttributes,内核对象安全性描述结构,同其他所有内核对象函数参数
- bManualReset,TRUE-手动重置;FALSE-自动重置
- bInitialState,初始化状态:TRUE-已通知状态;FALSE-未通知状态
- lpName,内核对象在操作系统同一名字空间中的唯一名称
通过CreateEvent函数创建事件内核对象,调用成功返回与进程相关的对象句柄,该进程中的其他线程可以取得对该对象的访问权,可以使用继承性,由DuplicateHandle函数等来调用CreateEvent,或者调用OpenEvent,在lpName参数中设定一个与调用CreateEvent时设定的匹配名字:
WINBASEAPI _Ret_maybenull_ HANDLE WINAPI OpenEvent(
_In_ DWORD dwDesiredAccess,
_In_ BOOL bInheritHandle,
_In_ LPCSTR lpName);
- dwDesiredAccess,需要请求的事件对象的访问权限,DELETE|READ_CONTROL|SYNCHRONIZE|WRITE_DAC|WRITE_OWNER
- bInheritHandle,TRUE被此进程创建的进程继承该对象的访问权限,否则不继承
- lpName,内核对象在操作系统同一名字空间中的唯一名称
OpenEvent函数可以在全局范围内调用,其他进程只要知道lpName一样可以获取该对象句柄。
其他关于事件对象使用基本上就只有置状态的两个函数SetEvent和ResetEvent,功能分别将事件置为已通知和未通知状态。可以重复置位。
WINBASEAPI BOOL WINAPI SetEvent(
_In_ HANDLE hEvent ); WINBASEAPI BOOL WINAPI ResetEvent
( _In_ HANDLE hEvent )
2.2、等待定时器内核对象(WaitableTimer)
等待定时器是在某个时刻或按规定的时间间隔发出自己的信号通知的内核对象,通常用于在某个时刻执行某个操作。
WINBASEAPI
_Ret_maybenull_
HANDLE
WINAPI
CreateWaitableTimer(
_In_opt_ LPSECURITY_ATTRIBUTES lpTimerAttributes,
_In_ BOOL bManualReset,
_In_opt_ LPCSTR lpTimerName );
- lpTimerAttributes,同其他内核对象一致的安全性描述符,通常无特殊设定传NULL
- bManualReset,同事件内核对象,说明其是自动置位还是手动置位;TRUE-手动重置;FALSE-自动重置
- lpTimerNam, 内核对象在操作系统同一名字空间中的唯一名称
通过CreateWaitableTimer函数来创建一个等待定时器,类似可以使用OpenWaitableTimer函数来取得一个已创建的等待定时器句柄。
等待定时器对象总是在未通知状态中创建,创建完成后必须使用SetWaitableTimer函数来告诉定时器何时称为已通知状态。
WINBASEAPI
BOOL
WINAPI
SetWaitableTimer(
_In_ HANDLE hTimer,
_In_ const LARGE_INTEGER * lpDueTime,
_In_ LONG lPeriod,
_In_opt_ PTIMERAPCROUTINE pfnCompletionRoutine,
_In_opt_ LPVOID lpArgToCompletionRoutine,
_In_ BOOL fResume );
- hTimer,等待定时器句柄
- lpDueTime,指明定时器何时开始第一次报时时刻。
- lPeriod,指明定时器第一次报时后应多长时间间隔报时,单位为ms
- pfnCompletionRoutine,
- lpArgToCompletionRoutine,
- fResume,TRUE-触发时若操作系统处于休眠状态会被唤醒;FALSE触发时若操作系统处于休眠状态则不会唤醒
lpDueTime参数的些转换方式:SystemTimetoFileTime将SYSTIME结构转换为FILETIME格式,LocalFileTimeToFileTime将FILETIME结构转换为UTC时间后,再赋值给LARGE_INTEGER结构传入。
常见用法:
1.给定初始时刻,后续按周期报时。
2.不给定初始时刻,让定时器以SetWaitableTimer时刻作为初始时刻,后续按周期报时。此时初始时刻需要传入一个负值,且以100ns为时间间隔。
3.仅一次报时。lPeriod传0即可。
同一个等待定时器可以重复调用SetWaitableTimer来改变其报时时间及间隔。
最后,线程不应该等待定时器的句柄,也不应该以待命的方式等待定时器
2.3、信标内核对象(Semaphore)
信标内核对象用于对资源进行计数。与其他内核对象一样,拥有一个使用计数,同时它还有另外两个有符号的32位值,一个是最大资源数量,一个是当前资源数量。最大资源数量标识信标能够控制的资源最大数量,而当前资源数量则用于标识当前可使用资源数量。
WINBASEAPI
_Ret_maybenull_
HANDLE
WINAPI
CreateSemaphore(
_In_opt_ LPSECURITY_ATTRIBUTES lpSemaphoreAttributes,
_In_ LONG lInitialCount,
_In_ LONG lMaximumCount,
_In_opt_ LPCSTR lpName);
- lpSemaphoreAttributes,同其他内核对象一致的安全性描述符,通常无特殊设定传NULL
- lInitialCount,当前可用资源数
- lMaximumCount,最大资源数
- lpName,内核对象在操作系统同一名字空间中的唯一名称
WINBASEAPI
_Ret_maybenull_
HANDLE
WINAPI
OpenSemaphore(
_In_ DWORD dwDesiredAccess,
_In_ BOOL bInheritHandle,
_In_ LPCSTR lpName );
- dwDesiredAccess,访问权限
- bInheritHandle,是否继承
- lpName,内核对象在操作系统同一名字空间中的唯一名称
CreateSemaphore函数用于创建信标内核对象,同样还有对应的OpenSemaphore函数获取已存在的信标句柄。
线程获取到信标内核对象句柄后,通过等待函数确认信标当前是否可用,等待函数在这里会检测信标的当前可用资源数是否大于0,大于0则使可用资源数减1,调用线程保持其可调度状态。
信标的特点在于它能够以原子操作的方式来执行测试和设置操作,这意味着当使用等待函数申请一个信标保护的资源时,操作系统会检测这个资源是否可用,同时负责对该资源可用计数递减,而不让其他线程在这个过程中产生干扰。当可用资源计数递减后,系统才会允许另一个线程申请对资源的访问权。如果等待函数确认信标的可用资源数为0时,那么调用线程就会进入等待状态,当其他某个线程对信标保护资源释放后,信标可用资源计数递增了,系统会重新使等待线程进入可调度状态(相应递减它当前资源数量)。
WINBASEAPI
BOOL
WINAPI
ReleaseSemaphore(
_In_ HANDLE hSemaphore,
_In_ LONG lReleaseCount,
_Out_opt_ LPLONG lpPreviousCount);
- hSemaphore,信标句柄
- lReleaseCount,可用资源计数需要增加的数量,传入值+当前值>创建信标时最大资源计数时,函数返回FALSE
- lpPreviousCount,返回当前资源可用计数的原始值
信标保护资源使用完毕后,需要调用ReleaseSemaphore函数来递增信标可用资源计数以便操作系统调度其他可能存在的等待访问资源的线程。
目前没有方法可以获取信标当前可用计数。必须通过等待函数获取到信标访问权限后再ReleaseSemaphore函数取得可用计数的原始值加以计算。
2.4、互斥对象内核对象(Mutex)
互斥对象内核对象能够确保线程(可以是不同进程的线程)拥有对单个资源的互斥访问权限。
互斥对象包含一个使用计数,一个线程ID,一个递归计数器。ID用于标识系统当前哪个线程拥有互斥对象,递归计数器用于指明线程拥有互斥对象的次数。
互斥对象的行为特性与关键代码段相同,但是互斥对象属于内核对象,而关键代码段则属于用户方式的对象。这意味着互斥对象在运行速度上比关键段要慢,同时意味着不同进程重的多个线程能够访问单个互斥对象,并且线程在等待访问资源时可以设定一个超时值。
通常意义下,互斥对象用于保护多个线程使用的内存块的访问权限,以保证数据的完整性。其使用规则如下:
- 如果线程ID为0,互斥对象不被任何线程拥有,并且发出该互斥对象的通知信号
- 如果线程ID非0,那么意味着该ID的线程用有互斥对象,并且不发出该互斥对象的通知信号
- 与其他内核对象不同,互斥对象在操作系统中拥有特殊的代码,语序它们违反正常的规则
WINBASEAPI
_Ret_maybenull_
HANDLE
WINAPI
CreateMutex(
_In_opt_ LPSECURITY_ATTRIBUTES lpMutexAttributes,
_In_ BOOL bInitialOwner,
_In_opt_ LPCSTR lpName);
- lpMutexAttributes,同其他内核对象一致的安全性描述符,通常无特殊设定传NULL
- bInitialOwner,初始化时是否被拥有。TRUE则其线程ID为调用线程ID,递归计数器置为1,;FALSE则其线程ID为0,递归计数器置为0
- lpName,内核对象在操作系统同一名字空间中的唯一名称
WINBASEAPI
_Ret_maybenull_
HANDLE
WINAPI
OpenMutex(
_In_ DWORD dwDesiredAccess,
_In_ BOOL bInheritHandle,
_In_ LPCWSTR lpName);
- dwDesiredAccess,要请求的访问权限
- bInheritHandle,是否继承
- lpName,内核对象在操作系统同一名字空间中的唯一名称
必须通过CreateMutex函数创建一个互斥对象,或者OpenMutex函数请求一个已创建的互斥对象。其线程ID和递归计数等都是原子操作方式。
异常规则:互斥对象有一个比较特殊的情况,互斥对象当前状态为未通知,线程ID为123,而123的线程当前执行到了等待函数等待获取互斥对象的访问权限时,操作系统会允许该线程进入可调度状态且互斥对象进入到正常流程,状态为已通知,递归计数+1。若要使递归计数大于1,唯一的方法是让线程多次等待相同的互斥对象,以便利用这个异常规则。
一旦线程使用完互斥资源时,必须调用函数ReleaseMutex释放该互斥对象以便操作系统调度其他等待访问互斥资源的线程。
WINBASEAPI BOOL WINAPI ReleaseMutex(
_In_ HANDLE hMutex);
该函数将互斥对象递归计数器减1,如果线程多次成功等待一个互斥对象,在互斥对象的递归计数器变成0之前,该线程必须调用同样次数的ReleaseMutex函数直到递归计数为0,此时该线程ID被置为0且互斥对象变为已通知状态。
异常规则也适用于释放互斥对象的线程,当调用ReleaseMutex函数的线程ID与互斥对象的线程ID不匹配时,ReleaseMutex函数不进行任何操作,直接返回FALSE,此时调用GetLastError,将返回ERROR_NOT_OWNER(企图释放不是当前线程所有的对象)。
因此,当之前持有互斥对象的线程在释放之前意外终止时,操作系统会认为该互斥对象已经被释放,主动将其线程ID置为0,递归计数器归0,通知状态置为已通知状态,然后查看当前是否有其他线程等待互斥对象,如果有,将按照调度算法选择一个线程获取该互斥对象访问权限,后续流程正常进行。
此时的一个差异在于,等待函数等待到的通知信号,返回的并不是常规意义上的WAIT_OBJECT_0值,而是返回的WAIT_ABANDONED值,仅适用于互斥对象,用于指明该互斥对象之前是由另一个线程持有,而该线程在使用共享资源释放互斥对象之前就终止运行,这不是可以进入共享资源的最佳情况。新调度的线程并不知道共享资源当前的状态是否被破坏,取决于应用程序的处理。
特性 | 互斥对象 | 关键代码段 |
运行速度 | 慢 | 快 |
是否跨进程 | 是 | 否 |
声明 | HANDLE hMutex; | CRITICAL_SECTION cs; |
初始化 | hMutex = CreateMutex(NULL,FALSE,NULL); | InitializeCriticalSection(&cs); |
清除 | CloseHanle(hMutex); | DeleteCriticalSection(&ms); |
无限等待 | WaitForSingleObject(hMutex,INFINITE); | EnterCriticalSection(&ms); |
0等待 | WaitForSingleObject(hMutex,0); | TryEnterCriticalSection(&ms); |
任意等待 | WaitForSingleObject(hMutex,dwMilliseconds); | 否 |
释放 | ReleaseMutex(hMutex); | LeaveCriticalSection(&ms); |
是否能够等待其他内核对象 | 是 |
三、其他的一些
如果希望在应用程序中得到最佳性能
那么首先应该尝试不要共享数据,然后依次使用volatile读取,volatile写入,Interlocked API,SRWLock以及关键段。
当且仅当所有这些都不能满足要求的时候,再使用内核对象。
因为每次等待和释放内核对象都需要在用户模式和内核模式之间切换,这种切换的CPU开销相对更大。