更多的锁介绍可以先看看这篇文章:多线程锁详解之【序章】
正文:
条件变量在windows-vista系统中才出现的锁,而ReactOS 系统是模仿 XP 的代码,所以里面并没有条件变量的源码。但是我在锁详解系列文章也说过,只要你熟悉锁封装的基本原理,没有的锁可以自己封装出来。
或许我们写的代码比不上微软的那么好,但是我写的这个条件变量亲测可用的,其使用方法也是跟系统的一样,大家可以通过我实现的封装代码,来大致窥探条件变量的内部实现。
源码:
这里先给大家贴一下代码,后面再进行逐一讲解。代码摘自我实现的一个线程池的部分代码,完整代码下载地址:
https://gitee.com/dai-jiapei/iocp/blob/master/Thread/
函数说明:
pthread_cond_init 初始化条件变量
pthread_cond_destroy 销毁条件变量
pthread_cond_signal 唤醒一个正在等待的线程 (awake-one)
pthread_cond_broadcast 唤醒所有正在等待的线程 (awake-all)
pthread_cond_timedwait 使当前线程陷入等待
注(这个知识点代码讲解时会用到):
SetEvent 可以简称为 awake 函数
WaitForSingleObejct 可以简称为 wait 函数
pthread_cond_signal 可以简称为 awake 函数 或者 awake_one 函数
pthread_cond_broadcast 可以简称为 awake 函数 或者 awake_all 函数
pthread_cond_timedwait 可以简称为 wait 函数
主要实现代码如下:
typedef struct __thread_cond
{
//条件变量内部的区间代码需要互斥,所以需要一个临界区
CRITICAL_SECTION hLock;
//目前有多少条线程陷入了等待,条件变量陷入等待会递增uTotalWait
//条件变量被唤醒会递减 uTotalWait
LONG nTotalWait;
//uWaitCount 表示应该剩余多少条线程进行等待,
//调用唤醒函数时,此值会减少。调用等待函数时,此值会增加
//uWaitCount 与 uTotalWait 之间的差,代表需要唤醒的线程条数
//通过hWaitEvent唤醒线程,使uTotalWait递减到与uWaitCount数量一致
LONG nWaitCount;
//可能会等待的数量,这个是用来加速的,如果没有这个变量,那么每次调用
//唤醒函数时,即时没有线程在等待,也会陷入临界区抢锁的开销,有了这个
//原子变量,那么我们可以在唤醒前判断一下是否有线程调用了 wait 函数,
//有的话,再进行唤醒,没有的话直接返回就可以了
//这个变量的运算,是与 nWaitCount 同步的
LONG nMaybeCount;
//负责等待与唤醒线程
HANDLE hWaitEvent;
}thread_cond_t;
//初始化函数,负责进行简单的赋值
BOOL pthread_cond_init(thread_cond_t * cond_t)
{
memset(cond_t, 0, sizeof(thread_cond_t));
//无信号,且自动重置信号,也就是每次只能唤醒一条线程
cond_t->hWaitEvent = CreateEvent(NULL, FALSE, FALSE, NULL);
if (cond_t->hWaitEvent)
{
InitializeCriticalSection(&cond_t->hLock);
}
return !!cond_t->hWaitEvent;
}
//销毁条件变量对象
void pthread_cond_destroy(thread_cond_t * cond_t)
{
if (cond_t->hWaitEvent)
{
DeleteCriticalSection(&cond_t->hLock);
CloseHandle(cond_t->hWaitEvent);
}
memset(cond_t, 0, sizeof(thread_cond_t));
}
//唤醒一个正在等待的条件变量
int pthread_cond_signal(thread_cond_t * cond_t)
{
int nRet = -1;
//if(0 == cond_t->nMaybeCount)
if (NULL == cond_t->hWaitEvent)
{
SetLastError(ERROR_INVALID_HANDLE);
return -1;
}
if (0 == InterlockedCompareExchange(&cond_t->nMaybeCount, 0, 0))
{
//没有线程进入等待函数
return 0;
}
EnterCriticalSection(&cond_t->hLock);
nRet = cond_t->nWaitCount;
if (cond_t->nWaitCount > 0)
{
if (TRUE == SetEvent(cond_t->hWaitEvent))
{
InterlockedDecrement(&cond_t->nMaybeCount);
cond_t->nWaitCount--;
}
else
{
nRet = -1;
}
}
LeaveCriticalSection(&cond_t->hLock);
return nRet;
}
//唤醒所有的正在等待的条件变量
int pthread_cond_broadcast(thread_cond_t * cond_t)
{
int nRet = -1;
//if(0 == cond_t->nMaybeCount)
if (NULL == cond_t->hWaitEvent)
{
SetLastError(ERROR_INVALID_HANDLE);
return -1;
}
if (0 == InterlockedCompareExchange(&cond_t->nMaybeCount, 0, 0))
{
//没有线程进入等待函数
return 0;
}
EnterCriticalSection(&cond_t->hLock);
nRet = cond_t->nWaitCount;
if (cond_t->nWaitCount > 0)
{
if (TRUE == SetEvent(cond_t->hWaitEvent))
{
InterlockedExchange(&cond_t->nMaybeCount, 0);
cond_t->nWaitCount = 0;
}
else
{
nRet = -1;
}
}
LeaveCriticalSection(&cond_t->hLock);
return nRet;
}
//让条件变量陷入等待,条件变量陷入等待会释放原来的互斥量,
DWORD pthread_cond_timedwait(CRITICAL_SECTION * lock, thread_cond_t * cond_t, DWORD tvwait)
{
DWORD dwError = 0;
BOOL bWake = FALSE;
DWORD dwTimeout = tvwait;
DWORD dwEnterTime = GetTickCount();
if (NULL == cond_t->hWaitEvent)
{
return ERROR_INVALID_HANDLE;
}
if (0 == tvwait)
{
//放弃一次时间片:
//Sleep(0);
//放弃一次锁权限 :
//LeaveCriticalSection(&cond_t->hLock);
//EnterCriticalSection(&cond_t->hLock);
return ERROR_TIMEOUT;
}
EnterCriticalSection(&cond_t->hLock);
LeaveCriticalSection(lock);
if (LONG_MAX == cond_t->nTotalWait)
{
dwError = ERROR_IS_JOIN_PATH;//资源不足
goto FINISH;
}
cond_t->nTotalWait++;
cond_t->nWaitCount++;
InterlockedIncrement(&cond_t->nMaybeCount);
do
{
LeaveCriticalSection(&cond_t->hLock);
if (-1 != tvwait)
{
dwTimeout = GetTickCount() - dwEnterTime; //先获取已经消耗掉的时间
//如果超时时间大于已经消耗的时间,那么计算时间差继续等待,
//否则超时时间为 0
dwTimeout = tvwait > dwTimeout ? tvwait - dwTimeout : 0;
}
dwError = WaitForSingleObjectEx(cond_t->hWaitEvent, dwTimeout, FALSE);
EnterCriticalSection(&cond_t->hLock);
switch (dwError)
{
case WAIT_OBJECT_0:
{
dwError = 0;//必须确保错误码就是0
#if 1 /*允许虚假唤醒*/
bWake = TRUE; //允许虚假唤醒
//其实最多只有 nWaitCount 等于 nTotalWait的情况
//nWaitCount 是不可能大于 nTotalWait,
//assert(cond_t->nTotalWait >= cond_t->nWaitCount)
if (cond_t->nWaitCount == cond_t->nTotalWait)
{
dwError = ERROR_BAD_ENVIRONMENT;//环境错误
//因为nTotalWait此时还没递减,所以nTotalWait必然大于等于 1
//间接地证明了 nWaitCount 也必然大于等于 1
InterlockedDecrement(&cond_t->nMaybeCount);
cond_t->nWaitCount--;
}
#else /*不允许虚假唤醒*/
if (cond_t->nTotalWait > cond_t->nWaitCount)//判断一下是否符合唤醒的条件
{
bWake = TRUE;
}
/*else
* {
* bWake = FALSE; //虚假唤醒
* }
*/
#endif
}break;
case WAIT_TIMEOUT:
{
dwError = ERROR_TIMEOUT;
}break;
default:
{
dwError = GetLastError();
if (0 == dwError)
{
//必须强行给他一个错误吗,错误码为空回令接下来的判断不正确
//按道理是一定不会进入这个判断的
dwError = ERROR_UNKNOWN_PROPERTY;//未知错误
}
}break;
}
} while (FALSE == bWake && 0 == dwError);
//dwError 不为 0,可能不是被唤醒函数唤醒的
//不是调用唤醒函数唤醒的情况,要主动将 nWaitCount 减 1
//因为本次函数的醒来,不属于唤醒函数的功劳,
if (FALSE == bWake && dwError && cond_t->nWaitCount > 0)
{
InterlockedDecrement(&cond_t->nMaybeCount);
cond_t->nWaitCount--;
}
cond_t->nTotalWait--;
//如果nTotalWait大于nWaitCount,说明还有其他线程需要被唤醒
if (cond_t->nTotalWait > cond_t->nWaitCount)
{
if (FALSE == SetEvent(cond_t->hWaitEvent))
{
dwError = GetLastError();
}
}
FINISH:
//先把锁释放了,不然会产生死锁
LeaveCriticalSection(&cond_t->hLock);
EnterCriticalSection(lock);
return dwError;
}
awake 函数讲解:
pthread_cond_init 和 pthread_cond_destroy 函数都十分简单,我们就直接略过了。而pthread_cond_signal 和 pthread_cond_broadcast 函数的作用,分别是唤醒一条沉睡的线程,和唤醒所有沉睡的线程。这里大家要注意,必须是处于沉睡状态的线程才能被唤醒,如果判断出没有线程正在沉睡,本质上 pthread_cond_signal 和 pthread_cond_broadcast 是什么都不用做的。
这里特意提醒大家是因为有些同学容易对条件变量的唤醒函数产生误解。一些对条件变量不够了解的同学可能会觉得条件变量跟事件对象(Event)一样。SetEvent(awake函数)跟 WaitForSingleObejct (wait 函数)的先后调用并不会产生影响,假设A线程负责调用 SetEvent(awake函数),而B线程负责调用 WaitForSingleObejct (wait 函数),如果A先调用 awake 函数的话,B再调用 wait 函数,此时B函数还是会被唤醒的,如果B先调用 wait 函数,A再调用 awake 此时B线程照样可以唤醒。可见 awake 函数调用的先后顺序,对事件对象是否能被唤醒并没有影响。
但条件变量的特征不同,如果A线程先调用 awake 函数,B线程后调用 wait 函数,这个时候B线程是不会被唤醒的,必须B线程陷入沉睡后,再调用 awake 函数,此时B线程才能被唤醒。
再回到 pthread_cond_signal 和 pthread_cond_broadcast 的代码中。它们之间的差异仅在于对 nWaitCount 成员的处理。而 nWaitCount 在整个条件变量的实现中,它主要是跟 nTotalWait 变量一起配合完成的。nTotalWait 负责记录当前有多少线程陷入了等待,而 nWaitCount 则负责记录应该剩余多少线程在等待。也就是说 nTotalWait 与 nWaitCount 的关系,好比一个追逐的过程,当nTotalWait 与 nWaitCount 不一致时,等待的线程会被逐一唤醒,而线程唤醒后,nTotalWait 会不断递减,直到nTotalWait 与 nWaitCount 相差为零,这个时候逐一唤醒的动作才会被停下。
当nTotalWait 与 nWaitCount 相差为零后,剩余的线程继续处于沉睡状态,直到被其他条件触发唤醒,比如再次调用了唤醒函数或者发生等待超时。
条件变量内部的事件对象(Event),它是使用自动重置信号的方法创建出来的,也就是说,SetEvent 函数,只能唤醒一条线程,这个对 pthread_cond_signal 函数是没影响的,但是 pthread_cond_broadcast 函数也是调用的 SetEvent,那它是怎么唤醒一条以上的线程的呢?
其实大于一条以上的线程,都是 wait 函数亲自唤醒的。只要其中一个 wait 函数被 SetEvent 函数唤醒以后,它判断到自己确实符合唤醒的条件,便会进行返回,而返回之前,wait 函数会判断一下 nTotalWait 是否大于 nWaitCount ,如果是,则说明还有其他的沉睡线程被唤醒,那么wait 函数会再调用一次 SetEvent 函数,将另外一条线程的 wait 函数唤醒,如此反复,直到 nTotalWait 与 nWaitCount 相等。
那可以考虑把事件对象改为手动重置,在wait 函数里面判断到条件合适后自行调用 ResetEvent 来关闭信号吗?答案是没必要。因为 wait 函数内部有临界区锁,被唤醒后也是串行执行的,手动重置信号会造成一次唤醒一大堆线程,最后这些唤醒的线程还是要抢夺条件变量内部的临界区而陷入等待,所以一次唤醒一堆线程只会浪费性能,把线程逐一唤醒性能反而更好。
条件变量的 awake 函数中,还有个 nMaybeCount 变量判断,这个变量都是使用原子操作访问的,只要它为 0,就意味着没有线程处于调用 wait 函数的状态,那么此时 awake 函数还要需要进行唤醒吗?不需要了,所以判断 nMaybeCount 的原始值为0,直接返回,这样减少了进入临界区的开销。
awake 函数在没有线程沉睡的情况下,尽可能减少调用开销,正是设计这个函数的难度所在,而至于利用 nMaybeCount 变量来减少开销是否是唯一的方法,我们讨论完 pthread_cond_timedwait 函数,或许你会有一番新的见解。
wait 函数讲解:
先看 pthread_cond_timedwait 函数中 tvwait 参数(等待超时的时间)的处理:
if (0 == tvwait)
{
//放弃一次时间片的处理:
//Sleep(0);
//放弃一次锁权限的处理 :
//LeaveCriticalSection(&cond_t->hLock);
//EnterCriticalSection(&cond_t->hLock);
return ERROR_TIMEOUT;
}
先忽略上面的代码,我们来思考一下,当超时时间是 0 时,为什么用户不是使用判断语句,来跳过一次 wait 函数的执行,而把 0 作为参数传了过来?用户传递的 tvwait 函数为 0 到底希望我们做些什么?放弃一次时间片?放弃一次锁权限?还是说用户懒的对超时判断空值,所以传了 0 值过来?
很明显我选择了最后一种情况:用户只是懒的判空。其实这样处理也符合开发原理:如果对条件有疑问,一切以效率为先。我最终的处理方案,明显也是三种情况中效率最高的。
而 tvwait 变量另一个被应用的地方,就是 WaitForSingleObjectEx 函数调用前。在每次在调用 WaitForSingleObjectEx 函数之前,都会先计算一下时间差。这个时间差的计算,一是为了确保 WaitForSingleObjectEx 被多次调用时,减少超时的误差性,二是即时 WaitForSingleObjectEx 只被调用一次 pthread_cond_timedwait 就符合返回的条件,但由于程序运行到 WaitForSingleObjectEx 函数时,曾经进行过一系列的临界区加解锁操作,这是非常耗时的,所以即时 WaitForSingleObjectEx 只被调用一次的情况,我还是会重新计算超时时间,减少超时误差。
有些同学可能会想,能不能把计算时间差代码的位置调整成下面这样呢?先计算了剩余的超时时间,再判断是否需要调用
WaitForSingleObjectEx 等函数,这样可以大大减少性能开销。
do
{
if (-1 != tvwait)
{
dwTimeout = GetTickCount() - dwEnterTime; //先获取已经消耗掉的时间
dwTimeout = tvwait > dwTimeout ? tvwait - dwTimeout : 0;
}
dwError = ERROR_TIMEOUT;
if(dwTimeout != 0) //等于 0 直接超时,减少加解锁的时间消耗
{
LeaveCriticalSection(&cond_t->hLock);
dwError = WaitForSingleObjectEx(cond_t->hWaitEvent, dwTimeout, FALSE);
EnterCriticalSection(&cond_t->hLock);
}
...... //do some thing
}while();
嗯,这样也不是不可以。只是系统对有信号的判断优先于超时判断,也就是说,如果 WaitForSingleObjectEx 的超时参数为0,
且事件对象是处于有信号状态,那么WaitForSingleObjectEx 的返回值就是表明有信号,并不是返回等待超时。另外在很多其他的开源库工具库中,超时事件的处理都是被排队到最后。 我封装的条件变量之所以不管 dwTimeout 是否为 0 ,都要调用一次 WaitForSingleObjectEx ,是因为我觉得这样更贴近系统的特性,也符合常用的超时处理原则。如果你更关心性能方面的消耗,改成上面的处理方式,也是可以的。处理方案是为了解决问题的,任何处理方案,只要你给出一个合理的解释,就可以去执行。或许你的,甚至我的设计方式跟系统的不一样,但不代表我们的设计方案就是错的。
此外 linux 系统中,对超时参数的检查跟windows也不一样。因为即时在第一次进入 WaitForSingleObjectEx 时 (linux 也有类似于 WaitForSingleObjectEx 的函数),是需要经历一系列加解锁操作的,假设我们的超时值为 1 毫秒,当多个线程同时调用 pthread_cond_timedwait ,很可能,以大部分计算机的速度,还没跑到 WaitForSingleObjectEx 函数,这 1 毫秒就已经消耗完了,这种情况要怎么处理额?你说直接超时吧可能信号早已到达;你说检测信号吧,可是抢锁可能已经消耗很长时间了,我连 WaitForSingleObjectEx 函数都还是没跑到呢,你抢锁就已经花费了500毫秒,这种情况实在是有些尴尬。
看来怎么处理貌似都不太理想。于是 linux 使用第三种方法解决:如果超时时间值小于 1000 毫秒,那么返回 EINVAL 错误。而 windows 中是不会对超时时间参数进行过多的检查的,也就是允许超时时间是任何值。
在判断完 tvwait 是否等于零之后,紧接着是 pthread_cond_timedwait 函数内部加锁的代码,如下:
EnterCriticalSection(&cond_t->hLock);
LeaveCriticalSection(lock);
...... //do some thing
FINISH:
LeaveCriticalSection(&cond_t->hLock);
EnterCriticalSection(lock);
return dwError;
上面贴出了加锁与解锁部分,加锁的顺序是先进入条件变量的锁,再释放外部传进来的锁。为什么要这样做呢?回答前我们先回顾一下条件变量的常规用法:
int a = 0;
while(TRUE)
{
EnterCriticalSection(lock);
...... //do some thing
pthread_cond_timedwait(lock, cond_t, -1);
...... //do some thing
LeaveCriticalSection(lock);
}
可以看出,把 cond_t->hLock 的其中一个加锁动作,实际是放在 lock 锁内部。 这是因为我们希望,谁先调用 pthread_cond_timedwait 函数,谁就能优先进入条件变量的内部锁,然而 pthread_cond_timedwait 函数结束时,它是先释放条件变量的内部锁 (cond_t->hLock),再获取外部锁的控制权限,因为这样才能够避免产生死锁。
当 pthread_cond_timedwait 抢到锁权限后,会把 nTotalWait 和 nWaitCount 累加,而 nMaybeCount 使用的则是原子累加,之前也说过,nMaybeCount 是用来记录有的多少线程调用了 pthread_cond_timedwait 函数的,它意味着将有多少线程可能会陷入沉睡,注意是可能,它不是一个准确值,即时线程被唤醒了,它也不会第一时间将自己减 1 。假设最后一条沉睡的线程刚被唤醒,pthread_cond_timedwait 内部还没来的及调用【InterlockedDecrement(&cond_t->nMaybeCount)】(原子减 1),
而此时另外一条线程调用了唤醒函数,由于此时 nMaybeCount 并不等于 0,所以唤醒函数最终会陷入抢锁动作。
再如果,唤醒函数的内部,比某个非 SetEvent 唤醒的 WaitForSingleObjectEx 上下文更早地抢到 cond_t->hLock 锁,那么唤醒函数肯定还判断到 nWaitCount 变量大于 0 ,进而执行 SetEvent 函数。其实很明显,这个时候唤醒函数已经没必要再去调用SetEvent,也没必要抢锁,可是由于信息不能及时同步的原因,最后导致唤醒函数误操作,进行了不必要的唤醒(术语叫虚假唤醒)。
关于信息不同步造成的虚假唤醒,业界目前并没有完善的处理方法。我们能够做的,是尽可能减少唤醒函数的误判断误操作,具体办法就是尽可能提供一个精确的判断条件,让唤醒函数更有效地判断出是否需要执行唤醒动作。我封装的条件变量,也是利用 nMaybeCount 变量来提供判断条件,如果没有 nMaybeCount 这个变量,明显唤醒函数将会每次都进行抢锁,其开销是十分恐怖的。
另外 nMaybeCount 的动作跟 nWaitCount 的计算方法是一致的,为什么我不直接用 nWaitCount 进行原子操作呢,其实确实可以用 nWaitCount 代替 nMaybeCount ,只是一个变量一下子是原子操作,一下子是非原子操作,我感觉代码阅读性不好,所以就增加了 nMaybeCount 这个变量。
而 nMaybeCount 往后的代码就简单了,先进入 do-while 循环,每一次 WaitForSingleObjectEx 被唤醒都判断一下线程是否真正醒来,没醒来就继续沉睡。而判断到线程的确需要醒来后,就会退出 do-while 循环。退出 do-while 循环的情况大致可分为两种,一种是 WaitForSingleObjectEx 产生了错误码的(返回值为 0),一种是没产生错误码的。没错误码的情况,一定是被正常唤醒,这个时候 nWaitCount 已经被唤醒函数修改成正确的值。nWaitCount 与 nTotalWait 之间的差,代表着被唤醒函数正确唤醒的线程条数,而产生错误码的唤醒,我们不认可它是唤醒函数的功劳,所以产生错误码的情况下,我们会将 nWaitCount 减 1,使 nWaitCount 与 nTotalWait 保持一个正确的唤醒差。
最后是准备退出的代码,退出前先判断 nTotalWait 与 nWaitCount 是否还有差值,有则调用 SetEvent 继续唤醒下一条,最后解锁条件变量,等待外部锁权限抢夺成功,函数返回。
再谈虚假唤醒
网上对虚假唤醒的一种解释的大意是,你调用了唤醒函数,唤醒了一条沉睡的线程,而新唤醒的线程什么任务都拿不到,只能重新陷入沉睡。就是假设任务队列里有3个任务,而你却唤醒了4个线程去处理它,结果导致其中一个线程什么任务都拿不到。
其实这种的说法并不对,因为线程醒来后拿不到任务,是你的业务逻辑问题,是外部的问题,不是条件变量内部的问题,你让我(条件变量)醒来,我醒了,我就没有错。而真正的虚假唤醒是:你没让我醒,我却醒过来了。
假设现在有一条调用 pthread_cond_timedwait 的线程,其内部的 WaitForSingleObjectEx 在没有调用 SetEvent 的情况下醒来,正准备抢夺 cond_t->hLock 锁。而另外一条线程恰好调用了唤醒函数,并抢到了 cond_t->hLock 锁,那么此时唤醒函数内部肯定判断到 cond_t->nWaitCount 大于 0,于是调用 SetEvent ,然后唤醒函数释放锁。
再然后肯定是 pthread_cond_timedwait 内部抢到 cond_t->hLock 锁并返回上层函数。那么问题来了,此时的 hWaitEvent 对象,它是否处于有信号状态?下一个调用 pthread_cond_timedwait 的线程会怎样?
是的, hWaitEvent 处于有信号状态,而且这个信号是先于 pthread_cond_timedwait 函数发出的,我们前面也明确提到过,条件变量的一大特性是:先有线程沉睡,唤醒动作才能正在生效。而现在却发生了还没线程陷入沉睡,条件变量却存在了一个残留的唤醒信号,所以下一个调用 pthread_cond_timedwait 函数的线程将会检测到条件变量有信号,于是直接返回。
其实在文章的代码中,我也贴出了阻止虚假唤醒的代码:
case WAIT_OBJECT_0:
{
dwError = 0;//必须确保错误码就是0
#if 1 /*允许虚假唤醒*/
bWake = TRUE; //允许虚假唤醒
//其实最多只有 nWaitCount 等于 nTotalWait的情况
//nWaitCount 是不可能大于 nTotalWait,
//assert(cond_t->nTotalWait >= cond_t->nWaitCount)
if (cond_t->nWaitCount == cond_t->nTotalWait)
{
dwError = ERROR_BAD_ENVIRONMENT;//环境错误
//因为nTotalWait此时还没递减,所以nTotalWait必然大于等于 1
//间接地证明了 nWaitCount 也必然大于等于 1
InterlockedDecrement(&cond_t->nMaybeCount);
cond_t->nWaitCount--;
}
#else /*不允许虚假唤醒*/
if (cond_t->nTotalWait > cond_t->nWaitCount)//判断一下是否符合唤醒的条件
{
bWake = TRUE;
}
/*else
* {
* bWake = FALSE; //虚假唤醒
* }
*/
#endif
}break;
那为什么我没用使用 #else 方案来阻止虚假唤醒返回呢?因为如果阻止了虚假唤醒返回,那 pthread_cond_timedwait 将会再一次调用 WaitForSingleObjectEx 陷入沉睡,醒来后又再次调用 EnterCriticalSection 函数抢锁,要知道这两个函数的还是占用一定的开销的。如果是在并发量不高的环境中,也不差这点开销,但如果是并发量很高的情况下,快速地将线程唤醒,让它投入工作,能有效提升性能 —— 因为他反正不醒也醒了。好比8点的闹钟,而你在 7点59分59秒 醒来,即时你继续闭眼睡,下一秒还是要醒来的。高并发的环境也是如此,即时你让虚假唤醒的线程继续沉睡,下一刻很可能它就会马上被唤醒,还不如让它返回上层的函数看看,到底有没有任务要处理。
总结:虚假唤醒是由于上一个 pthread_cond_timedwait 产生错误,而导致下一次的 pthread_cond_timedwait 调用被残留的信号唤醒。而 pthread_cond_timedwait 函数内部并不是不能处理虚假唤醒,只是为了性能考虑,它特意没有进行处理。
pthread_cond_signal 不会惊群
网上一些对于 pthread_cond_signal 惊群的解释是,pthread_cond_signal 有可能唤醒两条以上的线程。额,反正根据我的认知,不会产生这么不严谨的做法。因为所谓线程唤醒,就是把线程对象,从阻塞队列移动到就绪队列,而且这个动作是通过自旋锁实现的互斥行为。所以上面所说的唤醒两条以上的线程,相当于在有互斥的保证下,不小心把多个线程对象从阻塞队列移动到就绪队列,这点我认为不太可能。
而网上也给出了一些证明惊群的测试代码,大致做法就是,创建一个循环调用 pthread_cond_signal 的线程(我们假设它叫A),再创建两个循环调用 pthread_cond_timedwait 的线程(我们假设它叫B)。然后三个线程共同处理一个计数变量,由于计数变量得到一个他们认为不应该出现的值,最后得出惊群的结论。从网上的代码结果看,貌似他们的结论是对的,但留意他们的证明过程就相当有问题,因为他们总是认为线程的调度顺序是 ABB-ABB-ABB 这样执行的。大家要知道,没有进行额外的串行处理,线程的调度顺序是完全不可控的,也就是说,线程的调用顺序可能是 AABB-AAAAABB-AAAA-BAAAAB,这样的顺序是完全有可能的,很明显,这个时候如果不能够理解线程调度方式的影响,就容易直接得出 pthread_cond_signal 会惊群的结论。
条件变量和事件对象,那个性能更好
在没有条件变量之前,生产者消费者的等待唤醒,都是利用事件对象或者互斥量完成的。而条件变量正是基于事件对象封装而成的。条件变量和事件对象的差别在于:条件变量内部增加了线程等待计数,可以利用计数的方式避免了不必要SetEvent 调用,这正是条件变量优于事件对象的地方。
但是并发量较低的场景,总是有线程处于沉睡状态,也就是唤醒函数中的 SetEvent 总是能够得到调用,那么这中情况下,条件变量中的临界区和判断条件,反而成为了额外的性能负担。所以准确来说,高并发场景,条件变量性能优于事件对象,低并发场景,事件对象性能优于条件变量。这也侧面说明了,为什么 pthread_cond_timedwait 内部不处理虚假唤醒的原因,因为条件变量是基于高并发场景设计的,它不希望在任务量负担过重的情况下,还重新陷入等待而造成任务延迟处理。
而实际的开发场景中,不管并发量高低,我们都是使用条件变量,这是因为,低并发场景,我们不缺这点性能。而且鬼知道当前业务将来会不会有高并发的需求。
条件变量内部的临界区锁,可以换成自旋锁吗
有些同学留意到了,条件变量内部加锁的代码,面积和质量都很小,除了SetEvent 函数,其他都是简单的加减运算。那么我们用自旋锁是否比使用临界区更加合适呢?
首先说下临界区的原理,临界区内部是【自旋锁 + 事件对象】封装而成的,当自旋锁抢夺锁权限失败,才陷入事件等待。也就是说,临界区属于【自适应系列的自旋锁】,而临界区自旋锁默认是旋转 4000 次抢锁。我之所以使用临界区,是因为我担心线上环境中 SetEvent 的性能开销过高,所以我使用了一个有自适应能力的自旋锁(临界区)。如果大家实际测试到 SetEvent 的性能消耗极小,确实可以使用自旋锁代替条件变量内部的临界区。
此外还有一个解决办法是,把 SetEvent 移动到条件变量的锁外部执行,大概方法就是提供一个布尔值标记,离开条件变量的内部锁后,通过标记来判断是否需要执行 SetEvent 函数。不过这个方法说起来简单,实现起来还是有处理难度的,因为当前函数的 SetEvent 在外部失败,而另外一处的 SetEvent 调用成功,将可能导致 nWaitCount 变量无法还原。大家可以思考和测试一下这种方法是否可行。
写在最后
对于条件变量的介绍到这里就结束了。文章中不仅讲述了条件变量的封装实现,也提到了一些性能上的封装思想。其实主要是希望大家明白到,一个锁的封装或模拟,不是说实现了这个特性就可以了,我们还要思考它主攻的应用场景和性能问题。而且对于技术的实现,我也希望各位同学不要过度依赖权威的答案,必须有自己的一套见解。权威可以作为有力的参考,但不能百分百作为实现标准。如果你不能对技术有一番自己的见解,也就无法实现创新。