来自Windows核心编程的第八章。
讲述了用户模式下的线程同步
首先来看看第八章大纲: 第八章 – 用户模式下的线程同步
8.1 原子访问:Interlocked 系列函数
8.2 高速缓存行
8.3 高级线程同步
8.4 关键段 – 关键代码段
8.5 Slim 读/写 锁 – SWRLock
8.6 条件变量
在两种基本情况下,线程之间需要相互通信:
- 需要让多个线程访问同一资源,同时不能破坏资源完整性
- 一个线程需要通知其他线程某任务已完成
8.1 原子访问 – InterLocked系列函数
原子访问的意义是指:一个线程在访问某个资源的同时能够保证没有其他线程会在同一时刻访问同一资源。
产生破坏的情况这里就不叙述了,主要是一些函数总结。
InterlockedExchangeAdd 和对 LongLong 类型操作的 InterlockedExchangeAdd64:
1 2 3 4 5 6 7 |
LONG InterlockedExchangeAdd( PLONG volatile plAddend, LONG lIncrement); LONGLONG InterlockedExchangeAdd64( PLONGLONG volatile pllAddend, LONGLONG llIncrement); |
我们也可以用这个参数做减法,只要把第二个参数传入一个负值即可。
还有以下三个Interlocked函数
它们会把第一个参数所指向的内存地址以原子方式替换为第二个参数指定的值
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
//赋值 LONG InterlockedExchange( PLONG volatile plTarget, LONG lValue); //赋值 LONGLONG InterlockedExchange64( PLONGLONG volatile plTarget, LONGLONG lValue); //指针值替换 PVOID InterlockedExchangePointer( PVOID* volatile ppvTarget, PVOID pvValue); |
最后的比较交换函数
将 Destination 的值与 Comparand 的值比较
如果不等于,就不变,如果相等就换成 Comparand的值
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
LONG InterlockedCompareExchange( PLONG plDestination, LONG lExchange, LONG lComparand); PLONG InterlockedCompareExchangePointer( PVOID* ppvDestination, PVOID pvExchange, PVOID pvComparand); LONGLONG InterlockedCompareExchange64( PLONGLONG pllDestination, LONGLONG llExchange, LONGLONG llComparand); |
还有一个单向链表的函数,实现了栈的操作:
函数 | 描述 |
InitializeSListHead | 创建一个单向链表(空栈) |
InterlockedPushEntrySList | 向单向链表表头(栈顶)添加一个元素 |
InterlockedPopEntrySList | 从单向链表表头(栈顶)移除一个元素 |
InterlockedFlushSList | 清空单向链表 – 清空栈 |
QueryDepthSList | 返回栈中元素的数量 |
大致就是这样 – 单向链表的具体实现可以看STL 的 SList
8.2 高速缓存行
CPU从内存读取字节的时候,并不是一个字节一个字节的取,而是取回一个高速缓存行。
高速缓存行的大小可能有32byte(老CPU),64Byte 甚至 128byte 。这是取决于CPU的。
但是多线程情况下,高速缓存行的存在可能会导致对内存的更新变得更加困难。
例如:
- CPU1读取一个字节,这使得该字节以及它相邻字节被读到CPU1的高速缓存行中。
- CPU2读取同一个字节,这使得该字节被读到CPU2的高速缓存行中。
- CPU1对内存中的这个字节进行修改,使得该字节被写入到CPU1的高速缓存行中,但是还暂时没有写入到内存中。
- CPU2再次读取同一字节。由于该字节已经在CPU2的高速缓存行中,因此CPU2不需要再访问内存。但CPU2将无法看到该字节在内存的新值。
所以对于这种情况,有一个专门的设计来进行处理:
当一个CPU修改了高速缓存行的一个字节时,机器中的其他CPU会收到通知,并使自己的高速缓存行作废。
说实话,暂时不知道有啥用,不过书上给出了这样一个建议:
调用 GetLogicalProcessorInformation 函数确定高速缓存行的大小,然后对类的字段加以控制。
让数据与高速缓存行对齐,只读数据与读写数据分别存放,把差不多会同一时间访问的数据存放在一起。
8.3 高级线程同步
没看懂这一节重点是啥…
不过有一个关键字 volatile 。
volatile 告诉编译器不要对找个变量进行任何形式的优化,始终从变量在内存中的位置读取变量的值。
8.4 关键段
关键段(Critical Section)是一小段代码,它在执行之前需要独占一些共享资源的访问权。
主要函数是以下两个:
1 2 3 |
CRTITICAL_SECTION g_cs;//定义关键代码段的结构体 EnterCriticalSection(&g_cs);//进入关键代码段 LeaveCriticalSection(&g_cs);//离开关键代码段 |
需要访问共享资源的代码放在 进入 和 离开 之间即可。
关键代码段要注意的就一点:任何要访问共享资源的代码,都必须在 进入 和 离开 之间。
关键代码段最大的缺点就是不能在多个进程之间对线程进行同步。
一般情况下,我们会将CRITICAL_SECTION结构作为全局变量来分配,这样进程中的所有线程就能够非常方便地通过变量名来访问这些结构。
CRITICAL_SECTION也可以作为局部变量来分配,或者从堆中动态分配,将它作为类的私有字段来分配也是可以的。
在使用这个结构的时候,要注意的是访问资源的线程必须知道用来保护资源的CRITICAL_SECTION结构的地址,并且就是线程试图试图访问被保护的资源之前,必须对CRITICAL_SECTION结构的内部成员进行初始化。
1 |
VOID InitializeCriticalSection(PCRITICAL_SECTION pcs); |
因为只是简单的设置一些成员,不会失败,所以返回VOID
同样的可以调用下面的函数来清理CRITICAL_SECTION这个结构
1 |
VOID DeleteCriticalSection(PCRITICAL_SECTION pcs); |
EnterCriticalSection 会检查结构中的成员变量,这些变量表示是否有线程正在访问资源,以及哪个线程正在访问资源。调用这个函数时,会进行下面的测试:
- 如果没有线程正在访问资源,那么会更新成员变量,表示调用线程已获得对资源的访问,并立即返回,这样线程就可以继续执行,访问资源。
- 如果成员变量表示调用的线程已经获得过访问资源了,那么函数会更新变量,然后立即返回。这种情况会出现在LeaveCriticalSection之前连续调用 EnterCriticalSection 两次以上才会发生。
- 如果成员变量表示已经有一个非当前调用线程获得了访问资源的权限,那么EnterCriticalSection 会使用一个事件内核对象来把调用线程切换到等待状态。(这样不会浪费CPU时间,并且一旦当前访问的该资源的线程调用了LeaveCriticalSection,那么系统会自动更新 关键段数据结构 的成员变量并且将等待的线程切换回到调度状态。)
EnterCriticalSection 的内部实现并不复杂,只是一些简单的测试,但是它的价值在于所有的操作都是以原子方式执行。
我们也可以尝试使用下面的函数来代替 EnterCriticalSection 函数:
1 |
BOOL TryEnterCriticalSection(PCRITICAL_SECTION pcs); |
线程可以快速地检查它是否能够访问某个共享资源,如果返回TRUE,那么表示可以访问,同时 CRITICAL_SECTION结构体已经被更新过了,此时可以直接访问。如果返回FALSE,则表示不能访问了。
在访问完共享资源之后,我们要离开关键代码段:
1 |
VOID LeaveCriticalSection(PCRITICAL_SECTION pcs); |
内部实现:检查结构内部成员变量并将计数器减一。如果计数器本身就等于0,则直接返回。
关键段 和 旋转锁
旋转锁的概念这里可能没有提到过,先简单讲一下:
它的实现是用 InterlockedExchange :
1 2 3 4 5 6 7 8 9 10 11 12 13 |
BOOL g_fResourceInUse = FALSE; void Func1() { //等待(玩具)资源权限 while( InterlockedExchange (&g_fResourceInUse, TRUE) == TRUE) Sleep(0); //得到了权限 //开始玩~ //不要了,你们拿去玩~ InterlockedExchange(&g_fResourceInUse, FALSE); } |
线程试图进入一个关键段的时候,如果这时候关键段在被另一个线程占用,函数会把调用的线程切换到等待状态,这时候意味着要从用户模式切换到内核模式(大约1000个CPU周期),这个切换开销很大,所以可能还没有切换到等待状态(内核模式),占用资源的线程就已经结束了访问。
如果发生这样的情况,会占用大量的CPU资源,所以将旋转锁合并到关键段上面,增加了性能:当调用EnterCriticalSection 的时候,它会用一个旋转锁不断地旋转,尝试在一段时间内获得对资源的访问权。如果尝试失败,那么就切换到内核模式并进入等待。
那么在使用这个函数之前,我们必须调用以下函数来初始化关键段:
1 2 3 |
BOOL InitializeCriticalSectionAndSpinCount( PCRITICAL_SECTION pcs, DWORD dwSpinCount); |
第一个参数是关键段的结构地址,第二个参数是旋转锁的循环次数。
可以调用下面的函数来改变关键段的旋转次数:
1 2 3 |
DWORD SetCriticalSectionSpinCount( PCRITICAL_SECTION pcs, DWORD dwSpinCount); |
推荐的旋转锁旋转次数为4000次…因为保护进程堆的关键段的次数大约就是4000…
关键段 和 错误处理:
有一种情况下 InitializeCriticalSection 函数会失败,不过这种可能性很小。
失败的原因是它会分配一块内存,如果内存分配失败,那么会抛出 STATUS_NO_MEMORY 异常。
可以用 InitializeCriticalSectionAndSpinCount 来更容易地发现这个问题,如果分配不成功,会返回FALSE。
还有另一个问题是,如果两个或以上的线程在同一时刻争夺同一个关键段,那么关键段会在内部使用一个事件内核对象。但是由于争夺的情况很少发生,所以一般用到的时候才会创建它。
内存不足的情况下,可能会发生争夺关键段,但是这时候又无法创建所需内核对象,所以会抛出 EXCEPTION_INVALID_HANDLE 异常。这种错误时很少见的。
另一种选择是使用 InitializeCriticalSectionAndSpinCount 来初始化关键段,将dwSpinCount的最高位设为1,这样在初始化的时候就会直接创建一个与关键段相关的事件内核对象。如果无法创建,就会返回False…
Slim 读/写 锁
SWRLock 的目的和关键代码段是一样的:对一个资源进行保护,不让其他线程访问它。
与关键段不同的是,SRWLock允许我们区分那些想要读取资源的值的线程(读者) 和 想要写入(更新)资源值的线程(写者)。让所有的读者在同一时刻访问资源是可行的,当写者要求更新资源时,才需要进行同步。
这种情况下:写者应该独占此时对资源的访问权,任何其他线程都不允许进行访问。
还是一样的,分配一个 SRWLock结构并 初始化:
1 |
VOID InitializeSRWLock(PSWLOCK SRWLock); |
初始化完成后,写入者线程调用 AcquireSRWLockExclusive ,然后将数据结构地址作为参数传入。
1 |
VOID AcquireSRWLockExclusive(PSWLOCK SRWLock); |
然后接下来就可以对资源进行更新了,更新完毕之后调用以下函数解除对资源的锁定:
1 |
VOID ReleaseSRWLockExclusive(PSWLOCK SRWLock); |
对于读者线程来说,同样是锁定和解锁:
1 2 |
VOID AcquireSRWLockShared(PSWLOCK SRWLock); VOID ReleaseSRWLockShared(PSWLOCK SRWLock); |
最后注意:不用销毁数据结构,操作系统会自动清理。
相比关键段,读写锁缺乏下面两个特性:
- 不存在Try之类的函数,如果锁已经被占用,那么线程就会阻塞。
- 不能递归获得 SRWLock,即一个线程不能为了多次写入资源而多次锁定然后多次释放。
然后要知道的是,相比关键段,读写锁的速度更快,而且运行多个线程同时读取。
8.6 条件变量
有时候我们可能会让线程以原子方式把锁释放并将自己阻塞,直到某个条件成立为止(比如读者,读取数据区为空,要等待写者入)。Windows通过 SleepConditionVariableCS 或 SleepConditionVariableSRW 函数提供一种条件变量,来帮助我们简化这种情形下所需的工作。
1 2 3 4 5 6 7 8 9 10 |
BOOL SleepConditionVariableCS( PCONDITION_VARIABLE pConditionVariable,//指向一个已初始化的条件变量 PCRITICAL_SECTION pCriticalSection,//指向关键段 DWORD dwMilliseconds);//时间 BOOL SleepConditionVariableSRW( PCONDITION_VARIABLE pConditionVariable, PSRWLOCK pSRWLock,//指向读写锁 DWORD dwMilliseconds,//时间 ULONG Flags);//以何种方式得到锁 |
对于Flags 这个参数:
- 写入者线程传入 0
- 读者线程 传入 CONDITION_VARIABLE_LOCKMODE_SHARED
当指定的实际用完的时候,如果条件变量尚未触发,函数返回False,否则会返回TRUE。
当另一个线程 检查到条件变量已经满足的时候,可以调用 WakeConditionVariable 或者 WakeAllConditionVariable,这样阻塞在前面 Sleep… 函数里面的线程就会被唤醒。两个触发函数如下:
1 2 3 4 5 |
VOID WakeConditionVariable( PCONDITION_VARIABLE ConditionVariable); VOID WakeAllConditionVariable( PCONDITION_VARIABLE ConditionVariable); |
调用 WakeConditionVariable 的时候,会使一个在 Sleep… 函数里面的线程被唤醒。
当调用 WakeAll…这个函数的时候,会使 一个或多个 等待这个条件变量的线程得到访问权并返回。
PS.因为可以唤醒多个读者线程进行读取。
大致就这样了。然后给出一些建议:
- 以原子方式操作一组对象时,使用一个锁。
- 同时访问多个逻辑资源要以相同的顺序加锁
- 不要长时间占用锁