【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   ym65536  阅读(278)  评论(0编辑  收藏  举报

努力加载评论中...
点击右上角即可分享
微信分享提示