☰
C++多线程互斥锁和条件变量的详解
2026/10/8 2:37:28 网站建设 项目流程

前言

多线程里最基础的一对工具就是互斥锁(mutex)和条件变量(condition variable)。互斥锁解决"同一时刻只有一个线程能碰这块数据"的问题,条件变量解决"数据还没准备好时,别在那儿空转烧 CPU,睡一觉等别人叫醒"的问题。两者配合,才是完整的"等待—通知"模型。

关于它们,流传最广的两个误解是:第一,认为"条件变量自己会记住通知",即notify_one()在没人等待时也不会丢——事实恰好相反,条件变量不保存任何状态,错过的通知就永远错过了;第二,认为volatile可以用来做线程同步——这是错的,volatile既不提供原子性,也不建立 happens-before 关系,它在 C++ 内存模型里和多线程同步毫无关系(它的语义是"禁止编译器优化掉这次读写",面向的是内存映射 I/O 和信号处理场景)。

本文先讲互斥锁的家族与 RAII 锁包装器,再讲条件变量的正确等待姿势(谓词循环),然后给出一个可以直接编译运行的线程安全阻塞队列,最后用对照表列出真正会踩的坑。代码以 C++17 为基准,在 GCC 13 / Clang 17 / MSVC 19.3x 上编译时都需要链接线程库(GCC/Clang 用-pthread)。

一、互斥锁家族与 RAII 包装器

C++ 标准库提供的互斥量(mutex,标准拼写std::mutex)主要有四种,它们的区别在"重入"和"共享"两个维度上:

类型同线程重复加锁多读者并发头文件标准
std::mutex死锁(UB)不允许mutexC++11
std::recursive_mutex允许,需等量解锁不允许mutexC++11
std::timed_mutex死锁(UB)不允许mutexC++11
std::shared_mutex—允许shared_mutexC++17
std::shared_timed_mutex—允许shared_mutexC++14

注意第一行"死锁"后面括号里是 UB:同一个线程对同一个std::mutex连续加锁两次是未定义行为,绝大多数实现上表现为直接死锁,但标准不保证任何特定行为。

直接调用lock()/unlock()是危险的——只要中间任何一步抛异常或者提前return,锁就漏解锁了。所以标准库提供了 RAII(Resource Acquisition Is Initialization)包装器:

包装器何时加锁可手动解锁可移动适用场景
std::lock_guard<std::mutex>构造时否否作用域内独占,最轻量
std::unique_lock<std::mutex>构造时(可延迟)是是需要配合条件变量、需要中途解锁
std::scoped_lock<...>构造时(C++17)否否同时锁定多个互斥量,内部用std::lock防死锁
std::shared_lock<std::shared_mutex>构造时(C++14)是是共享(读)模式的 RAII 包装

std::scoped_lock需要 C++17;在 C++11/14 里要多锁时用std::lock(m1, m2)配合std::lock_guard的std::adopt_lock构造标签:

#include <mutex> std::mutex m1, m2; void cpp17_way() { std::scoped_lock lk(m1, m2); // C++17:一次性锁住两个,内部避免死锁 } void cpp11_way() { std::lock(m1, m2); // 先原子地锁住两个 std::lock_guard<std::mutex> g1(m1, std::adopt_lock); // 接管已有锁,不再加锁 std::lock_guard<std::mutex> g2(m2, std::adopt_lock); }

这里的关键概念是"临界区(critical section)"的边界由 RAII 对象的生命周期决定。锁的作用域越短越好:在锁内只做必要的数据操作,把耗时工作(I/O、内存分配、调用外部函数)移到锁外。

关于内存模型,有一点必须说清楚:mutex的lock()是一个 acquire 操作,unlock()是一个 release 操作;配合起来,前一个线程在临界区里写的所有东西,对下一个成功获取同一把锁的线程都可见。这就是所谓的 happens-before 关系。所以互斥锁不只是"排队",它还负责内存可见性——这正是无锁编程里要用memory_order_acquire/memory_order_release手动模拟的东西。

二、条件变量:谓词循环才能真正防住虚假唤醒

std::condition_variable提供三个核心操作:


  • wait(std::unique_lock<std::mutex>&):原子地解锁并睡眠,被唤醒时重新加锁后返回。

  • wait(std::unique_lock<std::mutex>&, Predicate):等价于while (!pred()) wait(lock);。

  • notify_one()/notify_all():唤醒一个/全部等待者。


标准规定的两个事实决定了"必须用谓词循环"这条铁律:


  1. 虚假唤醒(spurious wakeup):wait可能在没有任何人notify的情况下返回。标准明确允许这种情况。所以if (!ready) wait(lk);是错的——一次虚假唤醒就让你带着"没准备好的数据"继续往下跑。

  2. 通知不保存:如果notify发生时没有线程在wait,这次通知就直接丢失。所以"条件变量 + 一个受同一把锁保护的谓词变量"才是完整的一套,光有cv是不够的。


下面是这两条规则的直接对照。很关键的一点:cv.wait要求的是std::unique_lock<std::mutex>&,std::lock_guard没有unlock(),根本传不进去——这会在编译期就被拦下来。

#include <condition_variable> #include <mutex> #include <queue> std::mutex mtx; std::condition_variable cv; std::queue<int> q; // ❌ 错误一:用 lock_guard,编译期就报错 void wrong_type() { std::lock_guard<std::mutex> lk(mtx); // cv.wait(lk); // 编译错误:无法把 lock_guard 绑定到 unique_lock& } // ❌ 错误二:用 if 而不是 while,挡不住虚假唤醒 void wrong_if() { std::unique_lock<std::mutex> lk(mtx); if (q.empty()) { // 虚假唤醒后 q 仍然是空的就往下跑了 cv.wait(lk); } int v = q.front(); // 若 q 为空,front() 是 UB (void)v; } // ✅ 正确:谓词重载,标准等价于 while (!pred()) wait(lk); void correct(int& out) { std::unique_lock<std::mutex> lk(mtx); cv.wait(lk, [] { return !q.empty(); }); out = q.front(); q.pop(); }

三、实战:可关闭的线程安全阻塞队列

下面这个例子把前面的规则落成一个完整、可直接编译运行的程序。它同时演示了三件事:谓词循环、close()触发的优雅退出、以及"在锁外 notify"的推荐写法。

#include <condition_variable> #include <iostream> #include <mutex> #include <queue> #include <thread> #include <utility> template <typename T> class BlockingQueue { public: // 生产者:入队后唤醒一个等待者 void push(T value) { { std::lock_guard<std::mutex> lk(mtx_); queue_.push(std::move(value)); } // 先解锁 cv_.notify_one(); // 再通知:避免把被唤醒的线程立刻又堵在锁上 } // 消费者:阻塞直到取到元素,或队列已关闭且取空 bool pop(T& out) { std::unique_lock<std::mutex> lk(mtx_); cv_.wait(lk, [this] { return closed_ || !queue_.empty(); }); if (queue_.empty()) { return false; // 只会因为 closed_ 为真走到这里 } out = std::move(queue_.front()); queue_.pop(); return true; } // 关闭:置位标志并唤醒全部等待者(生产者可能不止一个) void close() { { std::lock_guard<std::mutex> lk(mtx_); closed_ = true; } cv_.notify_all(); } private: std::mutex mtx_; std::condition_variable cv_; std::queue<T> queue_; bool closed_ = false; }; int main() { BlockingQueue<int> q; std::thread producer([&q] { for (int i = 0; i < 5; ++i) { q.push(i * 10); } q.close(); // 告诉消费者"不会再有数据了" }); std::thread consumer([&q] { int value = 0; while (q.pop(value)) { std::cout << "consume " << value << '\n'; } std::cout << "consumer exit\n"; }); producer.join(); consumer.join(); return 0; }

编译命令(本机无编译器,以下为需要的命令行写法):

g++ -std=c++17 -Wall -Wextra -pthread queue_demo.cpp -o queue_demo

几点设计说明:


  • push里用内层花括号把锁的作用域收紧,退出后立刻notify_one。持锁notify也不是错误,但被唤醒的线程会先抢锁失败、再被挂起一次,多一次上下文切换。

  • close()用notify_all而不是notify_one:等待者可能不止一个,只叫醒一个会让其余线程永远睡下去。

  • 元素用std::move取出,避免T是std::string这类类型时的多余拷贝。

  • 这个队列的pop返回bool,把"关闭"和"拿到数据"区分开。这是一个设计取舍:也可以让pop返回std::optional<T>,效果类似。


常见坑点

坑 1:用if而不是while/谓词,被虚假唤醒打穿。

// ❌ 一次虚假唤醒就带着空数据继续执行,front() 是 UB { std::unique_lock<std::mutex> lk(mtx); if (q.empty()) cv.wait(lk); auto v = q.front(); } // ✅ 谓词重载,标准保证等价于 while (!pred()) wait(lk); { std::unique_lock<std::mutex> lk(mtx); cv.wait(lk, [&] { return !q.empty(); }); auto v = q.front(); }

坑 2:以为条件变量能"存住"通知。如果生产者先notify、消费者后wait,这次通知就丢了,消费者会永远睡下去。防住它的唯一办法是把"状态"放在一个受同一把互斥锁保护的变量里,notify只是"状态可能变了,你看看"的提示:

// ❌ 没有共享状态,通知先到就丢失 void producer_bad(std::condition_variable& cv) { cv.notify_one(); } void consumer_bad(std::condition_variable& cv, std::mutex& m) { std::unique_lock<std::mutex> lk(m); cv.wait(lk); // 可能永远不返回 } // ✅ 状态 + 锁 + 条件变量,三者缺一不可

坑 3:状态修改方不持锁,导致"检查—睡眠"之间出现窗口。wait(lock, pred)的内部次序是"在持锁状态下求值谓词 → 原子地解锁并阻塞"。如果写方既不加锁、也不按这个次序与读方串行化,那么它的notify就可能正好落在这两步之间,被丢掉。把状态换成std::atomic也救不了——原子性解决的是数据竞争,解决不了"通知丢在窗口里"这个时序问题。

// ❌ 写方不持锁:notify 可能落在"谓词求值为假"与"真正阻塞"之间 std::atomic<bool> flag{false}; std::condition_variable cv; std::mutex m; void writer_bad() { flag.store(true); // 没有锁 → 与读方的检查无法串行化 cv.notify_one(); // 可能被丢掉,读方永远睡下去 } void reader_bad() { std::unique_lock<std::mutex> lk(m); cv.wait(lk, [] { return flag.load(); }); } // ✅ 写方持锁改状态(notify 可在锁外),此时读方要么还没检查,要么已经阻塞 bool flag2 = false; // 用普通 bool 即可,由互斥锁保护 void writer_ok() { { std::lock_guard<std::mutex> lk(m); flag2 = true; // 在锁内修改状态 } cv.notify_one(); // 在锁外通知(这一步的顺序正确性由上面的锁保证) } void reader_ok() { std::unique_lock<std::mutex> lk(m); cv.wait(lk, [] { return flag2; }); }

坑 4:notify_all与notify_one用错。多个消费者在同一个条件变量上等待同一种条件、且每次通知只产生一个可消费的元素时,notify_one是对的;但如果等待的条件不止一种(例如"队列非空"和"队列未满"共用一个cv),notify_one可能唤醒一个条件不满足的线程,而真正该醒的那个还在睡——这时要么改用两个条件变量,要么用notify_all。

// ❌ 非空 / 未满两个条件共用一个 cv,notify_one 可能叫错人 // ✅ 拆成 not_empty_ 与 not_full_ 两个条件变量,各自 notify_one

坑 5:用volatile做同步。

volatile bool ready = false; // ❌ volatile 不提供原子性,也不建立 happens-before // ✅ 用 std::atomic<bool> + memory_order_acquire/release(有先后依赖) // ✅ 或者老老实实用 mutex + condition_variable(推荐,最简单也最不容易错)

volatile只是告诉编译器"这个变量可能被程序之外的东西改变,别优化掉对它的读写"。它不保证读写的原子性,也不保证线程间的可见性顺序。把volatile用于线程同步是标准明确不支持的用法。

坑 6:std::thread忘记join()或detach(),析构时std::terminate。std::thread的析构函数会检查joinable(),若为真就直接调用std::terminate()——程序直接死掉,没有异常可捕获。

// ❌ 抛异常时直接终止进程 void bad() { std::thread t([]{ /* ... */ }); might_throw(); // 这里抛出,t 的析构触发 std::terminate t.join(); } // ✅ 用 RAII 守卫,或把 join 放进 catch struct ThreadGuard { std::thread& t; ~ThreadGuard() { if (t.joinable()) t.join(); } };

坑 7:持锁做耗时操作,把并发变成串行。临界区里做磁盘 I/O、DNS 解析、或者调用一个自己会加锁的函数(有锁序反转风险),会让所有线程排队,并发度退化到接近单线程;更糟的是可能造成死锁。

// ❌ 锁内做慢操作 void slow() { std::lock_guard<std::mutex> lk(mtx_); auto value = fetch_from_network(); // 几百毫秒 cache_ = value; } // ✅ 先在锁外取数据,再进锁更新共享状态

坑 8:无条件变量时用轮询代替等待。

// ❌ 忙等:白白烧掉一个核,还会和内存总线较劲 while (!done.load(std::memory_order_acquire)) { /* spin */ } // ✅ 让出 CPU 给等待者 std::unique_lock<std::mutex> lk(mtx); cv.wait(lk, [&] { return done; });

轮询只在"预期等待极短(数十纳秒级)且线程数远小于核数"的极端场景下才值得考虑,而且通常要配std::this_thread::yield()或_mm_pause之类的提示指令。绝大多数业务代码应该用条件变量。

总结

要点结论
互斥锁的作用互斥 + 内存可见性。lock()是 acquire,unlock()是 release
锁的持有永远用 RAII 包装器,lock_guard优先,需要 wait 时才上unique_lock
多锁同时获取C++17 用std::scoped_lock;C++11/14 用std::lock+adopt_lock
条件变量等待必须用带谓词的wait(lk, pred),它等价于while (!pred()) wait(lk);
为什么必须循环虚假唤醒(标准允许)+ 丢失唤醒(通知不保存)两个机制各自独立成立
谓词配合的状态必须和锁用同一个互斥量保护,三件套:mutex + cv + 受保护的谓词
通知的时机锁内改状态、锁外 notify;通知方至少要有notify_one,多等待者或多条件时考虑notify_all
volatile不能用于线程同步;要原子用std::atomic,要等待用 mutex + cv
std::thread生命周期析构前必须join()或detach(),否则std::terminate

一句话概括:互斥锁保护数据,条件变量只是"数据可能变了"的广播,真正记住状态的是那把锁保护下的变量。把这三者当成一个不可分割的整体来写,绝大多数并发 bug 就自然消失了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询