【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
 
作者:马牛
链接:https://www.zhihu.com/question/24116967/answer/26848581
来源:知乎
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

有几篇30多年前的论文极大地影响了现代操作系统中进程/线程的同步机制的实现,尤其是楼主问题中的实现。一篇是 Monitors: An Operating System Structuring Concept ( ),还有一篇是 Experience with Processes and Monitors in Mesa ()。另外,还有讨论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
};
需要特别注意,这是一个monitor(不是class)。其中的acquire()和release()不能也不会同时active。我们注意到,这里的nonbusy.wait()并没有使用lock作为参数。但是,Hoare其实是假设有的。只是在论文中,他把这个lock叫做monitor invariant。论文中,Hoare解释了为什么要在conditional wait时用到这个值(也就解释了楼主的问题)。我原文引用一下:
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).

posted on 2018-09-03 01:19  ym65536  阅读(273)  评论(0编辑  收藏  举报