【多线程】c++11多线程编程(六)——条件变量(Condition Variable)

既看既用

详细

条件变量可减少判断次数(减少CPU消耗)

两种用法

参考


既看既用

std::mutex mu;
std::condition_variable cond;

void function_1() {
  ……   
       {
        std::unique_lock<std::mutex> locker(mu);
        q.push_front(count);
        locker.unlock();
        cond.notify_one();  //唤醒线程
       }// 自动解锁
       ……
    }
}


void function_2() {
  ……
    while ( data != 1) {
      std::unique_lock<std::mutex> locker(mu);  //自动锁定
      cond.wait(locker,[]{return !q.empty();}); //获取锁,然后判断[]{return !q.empty()}是否为true,如果是则进入wait
  ……
    }
}



或


void function_2() {
  ……
    while ( data != 1) {
        std::unique_lock<std::mutex> locker(mu);
        while(q.empty())
            cond.wait(locker); // Unlock mu and wait to be notified
  ……
    }
}

使用while (q.empty()) cond.wait(locker) 循环是为了处理虚假唤醒(spurious wakeup),cond.wait(lock, predicate) 实际上是对 while 循环的封装。

虚假唤醒(Spurious Wakeup)

虚假唤醒是指线程在等待条件变量时,可能会在没有收到明确通知(notify_one 或 notify_all)的情况下被唤醒。这种行为是由操作系统或底层线程库的实现引起的,是条件变量的一个固有特性。

详细

条件变量可减少判断次数(减少CPU消耗)

互斥锁std::mutex是一种最常见的线程间同步的手段,但是在有些情况下不太高效。

假设想实现一个简单的消费者生产者模型,一个线程往队列中放入数据,一个线程往队列中取数据,取数据前需要判断一下队列中确实有数据,由于这个队列是线程间共享的,所以,需要使用互斥锁进行保护,一个线程在往队列添加数据的时候,另一个线程不能取,反之亦然。用互斥锁实现如下:

#include <iostream>
#include <deque>
#include <thread>
#include <mutex>

std::deque<int> q;
std::mutex mu;

void function_1() {
    int count = 10;
    while (count > 0) {
        std::unique_lock<std::mutex> locker(mu);
        q.push_front(count);
        locker.unlock();
        std::this_thread::sleep_for(std::chrono::seconds(1));
        count--;
    }
}

void function_2() {
    int data = 0;
    while ( data != 1) {
        std::unique_lock<std::mutex> locker(mu);
        if (!q.empty()) {
            data = q.back();
            q.pop_back();
            locker.unlock();
            std::cout << "t2 got a value from t1: " << data << std::endl;
        } else {
            locker.unlock();
        }
    }
}
int main() {
    std::thread t1(function_1);
    std::thread t2(function_2);
    t1.join();
    t2.join();
    return 0;
}

//输出结果
//t2 got a value from t1: 10
//t2 got a value from t1: 9
//t2 got a value from t1: 8
//t2 got a value from t1: 7
//t2 got a value from t1: 6
//t2 got a value from t1: 5
//t2 got a value from t1: 4
//t2 got a value from t1: 3
//t2 got a value from t1: 2
//t2 got a value from t1: 1

可以看到,互斥锁其实可以完成这个任务,但是却存在着性能问题。

首先,function_1函数是生产者,在生产过程中,std::this_thread::sleep_for(std::chrono::seconds(1));表示延时1s,所以这个生产的过程是很慢的;function_2函数是消费者,存在着一个while循环,只有在接收到表示结束的数据的时候,才会停止,每次循环内部,都是先加锁,判断队列不空,然后就取出一个数,最后解锁。所以说,在1s内,做了很多无用功!这样的话,CPU占用率会很高,可能达到100%(单核)。如图:

CPU占用率.png

解决办法之一是给消费者也加一个小延时,如果一次判断后,发现队列是空的,就惩罚一下自己,延时500ms,这样可以减小CPU的占用率。

void function_2() {
    int data = 0;
    while ( data != 1) {
        std::unique_lock<std::mutex> locker(mu);
        if (!q.empty()) {
            data = q.back();
            q.pop_back();
            locker.unlock();
            std::cout << "t2 got a value from t1: " << data << std::endl;
        } else {
            locker.unlock();
            std::this_thread::sleep_for(std::chrono::milliseconds(500));
        }
    }
}

如图:

使用延时的CPU占用率.png

然后困难之处在于,如何确定这个延时时间呢,假如生产者生产的很快,消费者却延时500ms,也不是很好,如果生产者生产的更慢,那么消费者延时500ms,还是不必要的占用了CPU。

这就引出了条件变量(condition variable),c++11中提供了#include <condition_variable>头文件,其中的std::condition_variable可以和std::mutex结合一起使用,其中有两个重要的接口,notify_one()wait()wait()可以让线程陷入休眠状态,在消费者生产者模型中,如果生产者发现队列中没有东西,就可以让自己休眠,但是不能一直不干活啊,notify_one()就是唤醒处于wait中的其中一个条件变量(可能当时有很多条件变量都处于wait状态)。那什么时刻使用notify_one()比较好呢,当然是在生产者往队列中放数据的时候了,队列中有数据,就可以赶紧叫醒等待中的线程起来干活了。

使用条件变量修改后如下:

#include <iostream>
#include <deque>
#include <thread>
#include <mutex>
#include <condition_variable>

std::deque<int> q;
std::mutex mu;
std::condition_variable cond;

void function_1() {
    int count = 10;
    while (count > 0) {
        std::unique_lock<std::mutex> locker(mu);
        q.push_front(count);
        locker.unlock();
        cond.notify_one();  // Notify one waiting thread, if there is one.
        std::this_thread::sleep_for(std::chrono::seconds(1));
        count--;
    }
}

void function_2() {
    int data = 0;
    while ( data != 1) {
        std::unique_lock<std::mutex> locker(mu);
        while(q.empty())
            cond.wait(locker); // Unlock mu and wait to be notified
        data = q.back();
        q.pop_back();
        locker.unlock();
        std::cout << "t2 got a value from t1: " << data << std::endl;
    }
}
int main() {
    std::thread t1(function_1);
    std::thread t2(function_2);
    t1.join();
    t2.join();
    return 0;
}

此时CPU的占用率也很低。

使用条件变量时的CPU占用率.png

上面的代码有三个注意事项:

  1. function_2中,在判断队列是否为空的时候,使用的是while(q.empty()),而不是if(q.empty()),这是因为wait()从阻塞到返回,不一定就是由于notify_one()函数造成的,还有可能由于系统的不确定原因唤醒(可能和条件变量的实现机制有关),这个的时机和频率都是不确定的,被称作伪唤醒,如果在错误的时候被唤醒了,执行后面的语句就会错误,所以需要再次判断队列是否为空,如果还是为空,就继续wait()阻塞。
  2. 在管理互斥锁的时候,使用的是std::unique_lock而不是std::lock_guard,而且事实上也不能使用std::lock_guard,这需要先解释下wait()函数所做的事情。可以看到,在wait()函数之前,使用互斥锁保护了,如果wait的时候什么都没做,岂不是一直持有互斥锁?那生产者也会一直卡住,不能够将数据放入队列中了。所以,wait()函数会先调用互斥锁的unlock()函数,然后再将自己睡眠,在被唤醒后,又会继续持有锁,保护后面的队列操作。lock_guard没有lockunlock接口,而unique_lock提供了。这就是必须使用unique_lock的原因。
  3. 使用细粒度锁,尽量减小锁的范围,在notify_one()的时候,不需要处于互斥锁的保护范围内,所以在唤醒条件变量之前可以将锁unlock()

还可以将cond.wait(locker);换一种写法,wait()的第二个参数可以传入一个函数表示检查条件,这里使用lambda函数最为简单,如果这个函数返回的是truewait()函数不会阻塞会直接返回,如果这个函数返回的是falsewait()函数就会阻塞着等待唤醒,如果被伪唤醒,会继续判断函数返回值。

void function_2() {
    int data = 0;
    while ( data != 1) {
        std::unique_lock<std::mutex> locker(mu);
        cond.wait(locker, [](){ return !q.empty();} );  // Unlock mu and wait to be notified
        data = q.back();
        q.pop_back();
        locker.unlock();
        std::cout << "t2 got a value from t1: " << data << std::endl;
    }
}

除了notify_one()函数,c++还提供了notify_all()函数,可以同时唤醒所有处于wait状态的条件变量。

两种用法

while (q.empty()) {
    cond.wait(locker); // 解锁并等待通知
}
工作原理
  1. 检查条件

    • 在进入 wait 之前,先检查条件 q.empty()

    • 如果条件为真(队列为空),则进入等待状态。

  2. 解锁并等待

    • 调用 cond.wait(locker) 时,会自动释放锁locker),并让当前线程进入等待状态。

  3. 被唤醒后

    • 当其他线程调用 cond.notify_one() 或 cond.notify_all() 时,当前线程被唤醒。

    • 被唤醒后,cond.wait(locker) 会重新获取锁locker)。

    • 然后再次检查条件 q.empty(),如果条件仍然为真,则继续等待。

特点
  • 手动检查条件

    • 需要显式地使用 while 循环来检查条件。

    • 这种模式称为 "条件等待循环",是条件变量的经典用法。

  • 潜在问题

    • 如果条件变量的通知是虚假唤醒(spurious wakeup),线程可能会被错误地唤醒,因此需要循环检查条件。


2. cv.wait(lock, [] { return !q.empty(); });

代码示例

cpp

复制

cv.wait(lock, [] { return !q.empty(); }); // 等待队列非空
工作原理
  1. 检查条件

    • cv.wait 的第二个参数是一个谓词(lambda 函数 [] { return !q.empty(); })。

    • 在进入等待状态之前,cv.wait 会先调用谓词检查条件。

  2. 解锁并等待

    • 如果谓词返回 false(队列为空),则调用 cv.wait 会自动释放锁lock),并让当前线程进入等待状态。

  3. 被唤醒后

    • 当其他线程调用 cv.notify_one() 或 cv.notify_all() 时,当前线程被唤醒。

    • 被唤醒后,cv.wait 会重新获取锁lock),并再次调用谓词检查条件。

    • 如果谓词返回 true(队列非空),则 cv.wait 返回,继续执行后续代码。

    • 如果谓词返回 false(队列仍为空),则继续等待。

特点
  • 自动检查条件

    • cv.wait 内部会自动处理条件检查和循环等待,避免了手动编写 while 循环。

  • 简洁性

    • 代码更简洁,逻辑更清晰。

    • 避免了手动处理虚假唤醒的问题。


3. 区别对比

特性while(q.empty()) cond.wait(locker);cv.wait(lock, [] { return !q.empty(); });
条件检查需要手动编写 while 循环检查条件。自动通过谓词检查条件。
虚假唤醒处理需要手动处理虚假唤醒(通过 while 循环)。自动处理虚假唤醒。
代码简洁性代码稍显冗长。代码更简洁。
可读性需要理解条件变量的经典用法。更直观,逻辑更清晰。
适用场景适用于需要复杂条件检查的场景。适用于简单的条件检查场景。

4. 推荐用法

  • 推荐使用 cv.wait(lock, predicate)

    • 这种方式更简洁、更安全,避免了手动处理虚假唤醒的问题。

    • 适用于大多数简单的条件等待场景。

  • 使用 while(condition) cond.wait(locker)

    • 如果条件检查逻辑非常复杂,或者需要额外的处理逻辑,可以使用这种方式。

    • 适用于需要更灵活控制的场景。

参考

  1. C++并发编程实战
  2. C++ Threading #6: Condition Variable



作者:StormZhu
链接:https://www.jianshu.com/p/c1dfa1d40f53
来源:简书
 

join的必要:

举个例子,现在有 A, B, C 三件事情,只有做完 A 和 B 才能去做 C,而 A 和 B 可以并行完成。

int main(){
    thread t = new thread(A);
    B();  // 此时 A 与 B 并行进行
    t.join();  // 确保 A 完成
    C();
}

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值