C++ / a working model

73 / 80   ·   C++11   ·   约 11 分钟

条件变量:谓词、丢失通知与虚假唤醒

先记住这句话

条件变量只负责让线程等待和重新检查,不保存事件。把真实条件存进共享状态,在同一互斥锁下修改与检查,并使用带谓词的 wait;这样通知提前发生或出现虚假唤醒,都不会破坏业务逻辑。

本篇内容
  1. 等待的是状态,不是通知次数
  2. wait 的原子解锁与谓词重查
  3. 把关闭协议写进正常控制流
  4. 运行示例
  5. 动手练习

等待的是状态,不是通知次数

condition_variable 不是计数信号量,不会把无人接收的通知排队。若只执行一次无谓词 wait,生产者可能在消费者等待前就完成通知,消费者随后睡下,再也没有人唤醒它。正确设计把“可以继续”的事实保存为队列非空、任务完成或关闭标志。

消费者先取得互斥锁并检查状态;如果条件已经成立,就不睡眠。生产者在同一把锁下更新状态,再发出通知。通知即使早到,状态仍然留下来了。示例的谓词是 closed || !queue.empty():既支持收到数据,也支持生产结束后的退出。

wait 的原子解锁与谓词重查

wait(lock, pred) 在持锁时判断谓词,不满足才等待;等待操作把释放互斥锁和进入等待作为原子步骤,醒来后重新取得锁,再次判断。这样生产者不能插入到“已检查为假,但尚未开始等待”的危险空隙里。标准条件变量使用 unique_lock<mutex>,因为等待期间必须暂时释放锁。

等待可能虚假唤醒,多个消费者也可能竞争同一项任务:被唤醒不代表轮到当前线程时还有数据。谓词重查同时处理这两种情况,不能用一次 if 检查替代循环。单纯把 ready 改成 atomic,也不会自动修复条件检查与休眠之间的通知窗口,仍需完整的等待协议。

把关闭协议写进正常控制流

示例只有一个生产者和消费者,生产者提交三个整数后,在锁内设置 closed 并通知。消费者被唤醒后优先取完队列,只有队列为空才退出;因谓词已经成立,此时空队列意味着关闭。主线程最后 join,确保条件变量和互斥锁销毁前已无人使用它们。

通常在解锁后通知,可以减少刚醒来又争锁的机会;这不是正确性的必需条件,前提是条件变量仍然存活。一个新任务可用 notify_one,关闭时要用 notify_all 让所有等待者检查退出条件。有超时需求时应明确绝对截止时间,避免循环中反复等待完整时长导致总超时不断延长;超时返回也不等于条件已经满足。

容易答错的地方

  • 不要把 notify_one 当作给下一个消费者保存的一张凭证;保存工作数量的是队列或计数状态。
  • 不要在持有消费者所需互斥锁时 join 生产者,也不要在等待线程仍可能访问时销毁条件变量。

运行一个例子

最低标准 C++11 · 完整程序 · 下载 .cpp

#include <cassert>
#include <condition_variable>
#include <iostream>
#include <mutex>
#include <queue>
#include <thread>

int main() {
    std::mutex mutex;
    std::condition_variable changed;
    std::queue<int> queue;
    bool closed = false;
    std::thread producer([&] {
        for (int value = 1; value <= 3; ++value) {
            {
                std::lock_guard<std::mutex> lock(mutex);
                queue.push(value);
            }
            changed.notify_one();
        }
        {
            std::lock_guard<std::mutex> lock(mutex);
            closed = true;
        }
        changed.notify_all();
    });

    int sum = 0;
    for (;;) {
        std::unique_lock<std::mutex> lock(mutex);
        changed.wait(lock, [&] { return closed || !queue.empty(); });
        if (queue.empty()) break;
        const int value = queue.front();
        queue.pop();
        lock.unlock();
        sum += value;
    }
    producer.join();
    assert(sum == 6);
    assert(queue.empty() && closed);
    std::cout << sum << '\n';
}

在本地编译

g++ -std=c++11 -Wall -Wextra -Wpedantic -pthread concurrency-condition-variable.cpp -o example && ./example

预期结果

6

CHECK YOUR UNDERSTANDING

合上答案,试着解释。

生产者若在消费者首次 wait 之前就提交三个元素并关闭,消费者会不会永久阻塞?若只写 wait(lock) 又会怎样?

查看参考答案

带谓词版本不会阻塞:首次检查就看到 closed 为真,逐次取走已有元素;队列空后退出。早先的通知是否被接收无关紧要。无谓词版本没有读取已保存状态,会直接等待,所有通知又可能已经结束,因此可能永久阻塞。修复是恢复谓词,不是增加 sleep 让生产者晚一点执行。

继续查证

标准草案链接会随工作草案更新;本文版本标记对应示例最低要求,不表示草案中的所有新规则都适用于旧标准。

回到目录