暂时做简单记录

C++中的条件变量condition_variable

unique_lock最简单的用法

由于这里还没介绍unique_lock,这里先介绍一个最简单的用法:

std::mutex mtx;
std::condition_variable cv;
int data = 0;

void producer() {
    std::unique_lock<std::mutex> lock(mtx);
    data = 42;  // 设置数据
    cv.notify_one();  // 发出通知
}

void consumer() {
    std::unique_lock<std::mutex> lock(mtx);
    cv.wait(lock, [] { return data != 0; });  // 等待条件满足
    std::cout << "Consumed data: " << data << std::endl;
}

这里std::unique_lock<std::mutex> lock(mtx) 能够mtx加锁,并且在当前作用域结束后立刻释放mtx。于是它实现了对data的互斥访问,很像408中的如下写法:

semaphore mutex = 1;
int data = 0;
producer{
   P(mutex);
   //操作data
   V(mutex);
}
consumer{
   P(mutex);
   //操作data
   V(mutex);
}

意思几乎是一样的,就是通过mutex对data上锁。详细的介绍在后面unique_lock处会讲

wait()与notify_one()

回到上面这段demo:

std::mutex mtx;
std::condition_variable cv;
int data = 0;

void producer() {
    std::unique_lock<std::mutex> lock(mtx);
    data = 42;  // 设置数据
    cv.notify_one();  // 发出通知
}

void consumer() {
    std::unique_lock<std::mutex> lock(mtx);
    cv.wait(lock, [] { return data != 0; });  // 等待条件满足
    std::cout << "Consumed data: " << data << std::endl;
}

std::condition_variable cv ,条件变量代表某种同步互斥的条件,每个条件变量都有自己的阻塞队列。 下面通过一种可能的顺序来加深对条件变量的理解:

  1. 首先producer在std::unique_lock<std::mutex> lock(mtx) 对mtx加锁,然后consumer也在std::unique_lock<std::mutex> lock(mtx) 中尝试对mtx加锁,但此时mtx已经被锁住,故consumer在此处被阻塞,等待mtx被释放

  2. producer执行完毕,由于到达作用域外,mtx被自动释放,导致此时consumer被唤醒。

  3. consumer执行到cv.wait(lock, [] { return data != 0; }) 首先释放lock,并检查条件data != 0 是否满足。检测到满足,故不阻塞,线程继续执行完毕

从上面的步骤可以看出,cv.wait()是不一定阻塞的,如果一开始条件就满足,它就不会阻塞。下面给出另一种顺序:

  1. 首先consumer在std::unique_lock<std::mutex> lock(mtx) 对mtx加锁,然后producer也在std::unique_lock<std::mutex> lock(mtx) 中尝试对mtx加锁,但此时mtx已经被锁住,故producer在此处被阻塞,等待mtx被释放

  2. consumer执行到cv.wait(lock, [] { return data != 0; }) 首先释放lock,并检查条件data != 0 是否满足。检测到不满足,线程阻塞。

  3. producer执行到cv.notify_one() ,通知cv阻塞队列中的一个线程。此时consumer被通知,故consumer检查条件data != 0 是否满足。检测到满足,故唤醒consumer,重新获取锁定lock,线程继续执行完毕。

可以看到,notify_one() 本质逻辑是通知阻塞队列中的一个线程检查自己是否达到谓词的条件。换句话说,notify_one() 就是去提醒线程检查一下条件是否满足的。

一个线程从wait(lock) 中被唤醒后,会重新获取并锁定lock,wait(lock) 中阻塞时首先释放锁的逻辑也很好懂:线程可能即将被阻塞,一个被阻塞的线程怎么能持有相关变量的锁呢?

两种wait()

wait()有两种最常用的重载版本,区别在于是否具有predicate条件。

  • 下面是不带条件的wait()版本:

    void wait (unique_lock<mutex>& lck);

    执行cv.wait(lck)后,线程就会进入阻塞状态,等待cv.notify_one() 通知唤醒

  • 带条件的版本:

    void wait (unique_lock<mutex>& lck, Predicate pred);

    执行cv.wait(lck,pred)后,如果收到通知,其效果类似于如下代码:

    while (!pred()) {
        wait(lck);
    }

为什么需要两种版本的wait()?实际上一开始只有不带谓词条件的wait()。带谓词条件的wait()实际上是为了解决下面要提到的虚假唤醒问题。

虚假唤醒

先来看一个例子:

std::mutex mtx;
std::condition_variable cv;
std::queue<int> queue;

void producer() {
    std::unique_lock<std::mutex> lock(mtx);
    queue.push(42);  // 生产者放入数据
    std::cout << "生产者放入数据: " << 42 << std::endl;
    lock.unlock();
    cv.notify_one();  // 通知消费者
}

void consumer() {
    std::unique_lock<std::mutex> lock(mtx);
    cv.wait(lock);  // 等待队列非空
    int data = queue.front();
    queue.pop();
    std::cout << "消费者取出数据: " << data << std::endl;
}

这个代码有一个潜在的问题,如果线程间按以下顺序执行:

  1. producer执行完lock.unlock() ,随后被调度

  2. 有第三个线程获取并锁定了lock

  3. consumer被cv.notify_one() 唤醒,尝试获得锁但失败,继续阻塞等待锁的释放

  4. 第三个线程消费了queue中的数据,此时queue为空。该线程释放锁

  5. consumer成功获得锁,从cv.wait(lock) 中被唤醒,但此时queue中并不像预期的那样有一个待消费的数据(事实上cv.wait(lock) 正是为了等待queue中数据而阻塞)

上述问题就是虚假唤醒,consumer线程被唤醒时,预期的唤醒条件并未满足

为了解决这个问题,传统的方式是手动循环检查条件,比如:

void consumer() {
    std::unique_lock<std::mutex> lock(mtx);

    while (queue.empty()) {
        cv.wait(lock);
    }  // 等待队列非空

    int data = queue.front();
    queue.pop();
    std::cout << "消费者取出数据: " << data << std::endl;
}

此时就能保证进入cv.wait(lock) 时一定满足了唤醒条件,而cv.wait(lock) 是原子操作。

而带谓词的wait()能够更清晰高效的完成这件事:cv.wait(lock, pred) 首先会原子性地释放lock,然后等待通知;通知到达后再原子性地获取lock,并检查pred是否满足;若满足则唤醒进程,若不满足则继续原子性释放lock,进入下一次阻塞等待通知。

unique_lock