【C++工程实践】条件变量
1、linux条件变量简介
先看看linux下条件变量的api:
1 #include <pthread.h> 2 int pthread_cond_wait(pthread_cond_t *cond, pthread_mutex_t *mutex); 3 int pthread_cond_signal(pthread_cond_t *cond);
pthread_cond_wait:These functions atomically release mutex and cause the calling thread to block on the condition variable cond.
pthread_cond_signal : call unblocks at least one of the threads that are blocked on the specified condition variable cond (if any threads are blocked on cond).
这里包括了一个解锁的操作,会引入一个疑问,为什么wait里需要互斥器mutex?
2、linux锁的使用
对于自旋锁,相当于一直尝试获取锁:
while (lock(mutex) == false) { } // do something
如果其他thread一直持有该锁,会导致本线程一直while抢锁,浪费CPU做无用功.
理论上不用一直判断lock,只需要在lock失败后判断mutex是否有变化即可。抢锁失败后只要锁的持有状态一直没有改变,那就让出 CPU 给别的线程先执行好了。
这就是互斥锁:
while (lock(mutex) == false) { thread sleep untile lock state change }
操作系统负责线程调度,为了实现锁的状态发生改变时再唤醒,mutex_lock sleep需要操作系统处理,因此pthread_mutex_lock涉及上下文切换,开销比较大。
自旋锁和互斥锁都是保证能够排它地访问被锁保护的资源。
3、条件变量分析
很多情况下,我们并不需要完全排他性的占有某些资源,以生产者消费者为例:
生产者向Queue中添加元素,消费者从Queue中消费元素,使用互斥锁mutex用于生产者/消费者Queue同步:
lock(mutex); // mutex 保护对 queue 的操作 while (queue.isEmpty()) { // 队列为空时等待 unlock(mutex); // wait, 这里让出锁,让生产者有机会往 queue 里安放数据 lock(mutex); } data = queue.pop(); // 至此肯定非空,所以能对资源进行操作 unlock(mutex); consume(data); // 在临界区外做其它处理
这里的while,相当于又搞出了一个自旋锁,一直等待queue非空。
有了前面自旋锁、互斥器的经验就不难想到:「只要条件没有发生改变,while 里就没有必要再去解锁、 判断、条件不成立、加锁,完全可以让出 CPU 给别的线程」。不过由于「条件是否达成」属于业务逻辑, 操作系统没法管理,需要让能够作出这一改变的代码来手动「通知」,比如上面的例子里就需要在生产者 往 queue 里 push 后「通知」!queue.isEmpty() 成立。
因此我们希望把while改成这种形式:
while (queue.isEmpty()) { 解锁后等待通知唤醒再加锁(用来收发通知的东西, lock); }
而通知机制则为:
触发通知(用来收发通知的东西); // 一般有两种方式: // 通知所有在等待的(notifyAll / broadcast) // 通知一个在等待的(notifyOne / signal)
这就是条件变量,它解决的不是互斥,而是等待。
上述的解锁后等待通知再加锁,就是
pthread_cond_wait(pthread_cond_t *cond, pthread_mutex_t *mutex);
换句话说,pthread_cond_wait本质上包含了三个操作:
pthread_mutex_unlock(mtx);
pthread_cond_just_wait(cv);
pthread_mutex_lock(mtx);
上面三行代码的并不是pthread_cond_wait(cv, mtx)的内联展开。其中第一行和第二行必须“原子化”,而第三行是可以分离出去的。那么为什么第一行和第二行不能分离呢?这是因为必须得保证:
如果线程A先进入wait函数(即使没有进入实际的等待状态,比如正在释放mtx),那么必须得保证其他线程在其之后调用的broadcast必须能够将线程A唤醒。
4、条件变量使用总结
条件变量只有一种正确使用的方式,几乎不可能用错。对于 wait 端:
1. 必须与 mutex 一起使用,该布尔表达式的读写需受此 mutex 保护。
2. 在 mutex 已上锁的时候才能调用 wait()。
3. 把判断布尔条件和 wait() 放到 while 循环中。
对于 signal/broadcast 端:
1. 不一定要在 mutex 已上锁的情况下调用 signal (理论上)。
2. 在 signal 之前一般要修改布尔表达式。
3. 修改布尔表达式通常要用 mutex 保护(至少用作 full memory barrier)。
4. 注意区分 signal 与 broadcast:“broadcast 通常用于表明状态变化,signal 通常用于表示资源可用。(broadcast should generally be used to indicate state change rather than resource availability。)”
如果用条件变量来实现一个“事件等待器/Waiter”,正确的做法是怎样的?我的最终答案见 WaiterInMuduo class。“事件等待器”的一种用途是程序启动时等待初始化完成,也可以直接用 muduo::CountDownLatch 到达相同的目的,将初值设为 1 即可。
只要记住 Pthread 的条件变量是边沿触发(edge trigger),即 signal()/broadcast() 只会唤醒已经等在 wait() 上的线程(s),我们在编码时必须要考虑 signal() 早于 wait() 的可能,那么就很容易判断以下各个版本的正误了
总结: 使用条件变量,调用 signal() 的时候无法知道是否已经有线程等待在 wait() 上。因此一般总是要先修改“条件”,使其为 true,再调用 signal();这样 wait 线程先检查“条件”,只有当条件不成立时才去 wait(),避免了丢事件的可能。换言之,通过使用“条件”,将边沿触发(edge trigger)改为电平触发(level trigger)。这里“修改条件”和“检查条件”都必须在 mutex 保护下进行,而且这个 mutex 必须用于配合 wait()。
tips:
spurious wakeup?Wikipedia中是这样说的:
Spurious wakeup describes a complication in the use of condition variables as provided by certain multithreading APIs such as POSIX Threads and the Windows API. Even after a condition variable appears to have been signaled from a waiting thread's point of view, the condition that was awaited may still be false. One of the reasons for this is a spurious wakeup; that is, a thread might be awoken from its waiting state even though no thread signaled the condition variable.
spurious wakeup 指的是一次 signal() 调用唤醒两个或以上 wait()ing 的线程,或者没有调用 signal() 却有线程从 wait() 返回,虚假唤醒。
APUE上这样说:
POSIX规范为了简化实现,允许pthread_cond_signal在实现的时候可以唤醒不止一个线程。
在发生的spurious wakeup时候,waiting线程被意外的唤醒,然后到真正signal的时候,waiting线程在之前已经spurious wakeup唤醒了。
5、条件变量原理
链接:https://www.zhihu.com/question/24116967/answer/26848581
来源:知乎
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
有几篇30多年前的论文极大地影响了现代操作系统中进程/线程的同步机制的实现,尤其是楼主问题中的实现。一篇是 Monitors: An Operating System Structuring Concept ( http://www.vuse.vanderbilt.edu/~dowdy/courses/cs381/monitor.pdf),还有一篇是 Experience with Processes and Monitors in Mesa (http://msr-waypoint.com/en-us/um/people/blampson/23-ProcessesInMesa/Acrobat.pdf)。另外,还有讨论semaphore,生产者/消费者问题,哲学家就餐问题等等的论文。
这里,我先介绍这两篇论文的内容,然后引出问题的答案。
第一篇是Tony Hoare写的,通常被叫做Hoare's Monitor。你可能不知道Tony Hoare是谁,但是你应该知道他发明的quicksort。你也应该知道他得过的那个图灵奖。Monitor是一种用来同步对资源的访问的机制。Monitor里有一个或多个过程(procedure),在一个时刻只能有一个过程被激活(active)。让我来给个例子:MONITOR account {
int balance; //initial value is 0;
procedure add_one() {
balance++
}
procedure remove_one() {
balance--
}
}
如果这是一个monitor,add_one()和remove_one()中只能有一个中被激活的。也就是说,balance++和balance--不可能同时执行,没有race。
正如论文的标题所说的,monitor只是一个概念,他可以被几乎任何现代的语言实现。如果我们要用C++来实现Monitor,伪代码差不多就是这样(Java的Synchronization是Monitor的一个实现):class Account {
private:
int balance; //initial value is 0;
lock mutex;
public:
void add_one() {
pthread_mutex_lock(&mutex);
balance++;
pthread_mutex_unlock(&mutex);
}
void remove_one() {
pthread_mutex_lock(&mutex);
balance--
pthread_mutex_unlock(&mutex);
}
};
论文中也有条件变量(conditional variable),使用形式是cond_var.wait()和cond_var.signal()。让我们来看一下论文里最简单的一个例子。这是同步对单个资源访问的monitor。原文中的代码(好像)是用Pascal写的,我这里有C-style重写了一下。
//注意这是一个monitor,这里的acquire()和release()不能也不会同时active。
Monitor SingleResource {
private:
bool busy;
condition_variable nonbusy;
public:
void acquire() {
if ( busy == true ) {
nonbusy.wait()
}
busy = true
}
void release() {
busy = false;
nonbusy.signal()
}
busy = false; //initial value of busy
};
Since other programs may invoke a monitor procedure during a wait, a waiting program must ensure that the invariant t for the monitor is true beforehand.
换句话说,当线程A等待时,为了确保其他线程可以调用monitor的procedure,线程A在等待前,必须释放锁。例如,在使用monitor SingleResource时,线程A调用acquire()并等待。线程A必须在实际睡眠前释放锁,要不然,即使线程A已经不active了,线程B也没法调用acquire()。(当然,你也可以不释放锁,另一个线程根本不检查锁的状态,然后进入对条件变量的等待!! 但是,首先,这已经不是一个monitor了,其次,看下文。)
pthread只是提供了一种同步的机制,你可以使用这种机制来做任何事情。有的对,有的错。Hoare在论文里的一段话也说更能解答楼主的问题:The introduction of condition variables makes it possible to write monitors subject to the risk of deadly embrace [7]. It is the responsibility of the programmer to avoid this risk, together with other scheduling disasters (thrashing, indefinitely repeated overtaking, etc. [11]).
有兴趣的同学可以读读这篇文章,文中有一节专门解释了楼主的问题。楼主的问题显然是很深刻的。
另外,为什么上面的代码里acquire()的实现使用的是:
if ( busy == true ) {
while ( busy == true ) {
?
这是因为,在Hoare的假设里,当线程A调用nonbusy.signal()之后,线程A必须立即停止执行,正在等待的线程B必须紧接着立即开始执行。这样,就可以确保线程B开始执行时 busy==false。这正是我们想要的。
但是,在现代的系统中,这个假设并不成立。现代操作系统中的机制跟Mesa中描述的一致:在condvar.signal()被调用之后,正在等待的线程并不需要立即开始执行。等待线程可以在任何方便的时候恢复执行(优点之一:这样就把同步机制和调度机制分开了)。
在Mesa的假设下,上面的Monitor SingleResource的代码是错的。试想下面的执行过程:1. 线程A调用acquire()并等待,2. 线程B调用release(),2.线程C调用acquire(),现在busy=true,3. 线程A恢复执行,但是此时busy已经是true了! 这就会导致线程A和线程C同时使用被这个monitor保护的资源!!! void acquire() {
if ( busy == true ) {
nonbusy.wait()
}
//assert(busy != true)
busy = true
}
在Mesa中,Butler Lampson和David Redell提出了一个简单的解决方案-把 if 改成 while。这样的话,在线程A恢复执行时,还要再检查一下busy的值。如果还不是想要的,就会再次等待。
If you do not lock the mutex in the codepath that changes the condition and signals, you can lose wakeups. Consider this pair of processes:
Process A:
pthread_mutex_lock(&mutex);
while (condition == FALSE)
pthread_cond_wait(&cond, &mutex);
pthread_mutex_unlock(&mutex);
Process B (incorrect):
condition = TRUE;
pthread_cond_signal(&cond);
Then consider this possible interleaving of instructions, where condition
starts out as FALSE
:
Process A Process B
pthread_mutex_lock(&mutex);
while (condition == FALSE)
condition = TRUE;
pthread_cond_signal(&cond);
pthread_cond_wait(&cond, &mutex);
The condition
is now TRUE
, but Process A is stuck waiting on the condition variable - it missed the wakeup signal. If we alter Process B to lock the mutex:
Process B (correct):
pthread_mutex_lock(&mutex);
condition = TRUE;
pthread_cond_signal(&cond);
pthread_mutex_unlock(&mutex);
...then the above cannot occur; the wakeup will never be missed.
(Note that you can actually move the pthread_cond_signal()
itself after the pthread_mutex_unlock()
, but this can result in less optimal scheduling of threads, and you've necessarily locked the mutex already in this code path due to changing the condition itself).