前言多线程编程的核心难点不在「怎么开线程」,而在「多个线程同时访问同一份数据时,如何保证结果可预期」。C++ 标准把没有同步保护的并发读写称为数据竞争(data race),而数据竞争是未定义行为(undefined behavior, UB)——不是「结果偶尔不对」,而是编译器可以假设它不存在,从而做出任意优化。一个常见误解是「只要不加锁就不会死锁,所以无锁最好」。实际上无锁代码要自己处理内存序、ABA 问题和对象生命周期,写错的概率远高于加锁。另一个误解是把volatile当成同步手段,它既不做原子性保证也不建立线程间的可见性顺序,只能用于内存映射 I/O 等特殊场景。本文按「互斥量、条件变量、原子操作、读写锁」四类梳理 C++ 的线程同步手段,说明各自保证什么、不保证什么,并给出可以直接对照的示例。文中代码以 C++17 为基准,涉及更新的标准会单独标注。一、同步的本质:建立 happens-before线程同步要解决两件事:互斥(同一时刻只有一个线程进入临界区)和可见性(一个线程的写入能被另一个线程看到)。C++ 内存模型用 happens-before 关系描述后者:如果写操作 A happens-before 读操作 B,那么 B 一定能看到 A 的结果。互斥量通过 acquire/release 语义建立这种关系:加锁是 acquire,解锁是 release,同一互斥量上的 unlock synchronizes-with 后续的 lock。原子操作则通过内存序参数控制同样的事情。理解这一点,才能明白为什么「两个线程各写各的变量」也需要同步——只要它们共享缓存行或存在逻辑依赖,缺少 happens-before 就可能读到旧值。方式头文件解决的问题典型开销主要限制std::mutex<mutex>临界区互斥无竞争时接近原子指令,有竞争时可能休眠不可递归,不可拷贝std::condition_variable<condition_variable>等待某个条件成立等待时释放锁并休眠必须搭配互斥量与谓词std::atomic<atomic>单个对象的原子读改写通常是一条或几条原子指令只保护它自己,不保护关联数据std::shared_mutex<shared_mutex>读多写少场景的读写分离比普通互斥量重写者可能饥饿,C++17 起提供二、互斥量:最通用的一把锁std::mutex提供lock()、unlock()、try_lock()。手写 lock/unlock 在异常路径上极易漏掉 unlock,因此应当用 RAII 包装器:std::lock_guard<std::mutex>:构造时加锁,析构时解锁,不能提前释放,最轻量。std::unique_lock<std::mutex>:可延迟加锁、可提前unlock()、可转移所有权,条件变量必须用它。std::scoped_lock(C++17):可一次锁多个互斥量,内部用死锁避免算法,替代std::lock加多个lock_guard的写法。// 适用于 C++17#include <mutex>class Counter {public:void add(int n) {std::lock_guard<std::mutex> lk(m_);value_ += n;}int get() const {std::lock_guard<std::mutex> lk(m_);return value_;}private:mutable std::mutex m_;int value_ = 0;};这里m_声明为mutable,是因为get()是 const 成员函数但仍需加锁。这不是风格问题:如果 const 函数不加锁就读value_,它与并发的add()构成数据竞争。std::recursive_mutex允许同一线程重复加锁,但它常被用来掩盖「锁的职责不清」的设计问题。能用普通互斥量把临界区规划清楚时,不要用递归锁。三、条件变量:等待状态变化条件变量解决的是「线程要等到某个条件成立才继续」。它的正确用法有三条硬性要求:等待前必须持有互斥量;必须用谓词形式等待;修改条件后要通知。// 适用于 C++17#include <condition_variable>#include <mutex>#include <queue>#include <utility>template <typename T>class BlockingQueue {public:void push(T value) {{std::lock_guard<std::mutex> lk(m_);q_.push(std::move(value));}cv_.notify_one(); // 通知放在解锁之后,减少被唤醒线程的等待}bool pop(T& out) {std::unique_lock<std::mutex> lk(m_);cv_.wait(lk, [this] { return !q_.empty() || closed_; });if (q_.empty()) return false; // 已关闭且队列为空out = std::move(q_.front());q_.pop();return true;}void close() {{std::lock_guard<std::mutex> lk(m_);closed_ = true;}cv_.notify_all();}private:std::mutex m_;std::condition_variable cv_;std::queue<T> q_;bool closed_ = false;};cv_.wait(lk, pred)的语义是:在持有锁的状态下求值pred,为假则原子地释放锁并阻塞;被唤醒后重新加锁并再次求值。谓词解决了两个问题——虚假唤醒(spurious wakeup,等待可能无故返回)和丢失唤醒(通知发生在 wait 之前)。绝不能用裸wait(lk)配if,因为被唤醒时条件未必已经成立。notify_one与notify_all的选择取决于等待者是否都在等同一个条件:消费者队列用notify_one即可,而「关闭」「广播配置变更」必须用notify_all,否则部分线程永远醒不过来。四、原子操作与读写锁std::atomic<T>让对单个对象的读、写、读改写成为不可分割的操作。最常用的成员是load、store、fetch_add、exchange和compare_exchange_weak/strong。// 适用于 C++17#include <atomic>#include <thread>#include <vector>std::atomic<long long> hits{0};void worker(int rounds) {for (int i = 0; i < rounds; ++i) {hits.fetch_add(1, std::memory_order_relaxed);}}int main() {std::vector<std::thread> pool;for (int i = 0; i < 4; ++i) pool.emplace_back(worker, 100000);for (auto& t : pool) t.join();return hits.load() == 400000 ? 0 : 1;}memory_order_relaxed只保证这个计数器自身的原子性,不建立与其他变量的顺序关系。这里只需要计数,所以够用;一旦要用计数器去「发布」另一块数据(例如标志某块缓冲区已写好),就必须用release/acquire配对,或者干脆用默认的seq_cst。原子类型不能替代互斥量的场景很明确:需要同时修改两个及以上相互关联的变量时,单个atomic无法保证这组修改的整体一致性。std::atomic<T>对非平凡类型(比如较大的结构体)也不保证无锁,is_lock_free()的结果依赖实现。读写锁:读多写少的分工std::shared_mutex(C++17)提供两种所有权:独占(写)和共享(读)。用std::unique_lock<std::shared_mutex>获取写锁,用std::shared_lock<std::shared_mutex>获取读锁。多个读者可以同时进入,写者必须独占。// 适用于 C++17#include <map>#include <shared_mutex>#include <string>#include <utility>class Config {public:std::string get(const std::string& key) const {std::shared_lock<std::shared_mutex> lk(m_);auto it = data_.find(key);return it == data_.end() ? std::string{} : it->second;}void set(std::string key, std::string value) {std::unique_lock<std::shared_mutex> lk(m_);data_[std::move(key)] = std::move(value);}private:mutable std::shared_mutex m_;std::map<std::string, std::string> data_;};读写锁并非「一定比互斥量快」。它的内部状态更复杂,短临界区下额外开销可能抵消并发收益;而且不同标准库实现的公平策略不同,读请求持续不断时写者可能长期得不到锁(写者饥饿)。是否值得,要在自己的负载下用std::chrono::steady_clock测量,而不是凭直觉。C++20 还提供了std::counting_semaphore和std::binary_semaphore(需要 C++20 及以上),适合「限制同时访问某资源的线程数」这类计数型同步;它们不提供所有权概念,使用时必须自己保证配对。常见坑点❌ 用volatile标志位做线程间通知,认为它「禁止优化所以能同步」。✅ 用std::atomic<bool>配acquire/release,或直接用互斥量;volatile不建立 happens-before。❌ 只给写操作加锁,读操作直接读共享变量,认为「读不会出问题」。✅ 读也要加锁或用原子类型;并发的读与写同样构成数据竞争。❌cv.wait(lk)外面套if (q.empty()),被虚假唤醒后直接访问空队列。✅ 用cv.wait(lk, pred)或while (!pred()) cv.wait(lk);。❌ 先notify_one()再解锁,且通知的对象依赖锁内状态。✅ 一般应在修改完共享状态并解锁后再通知;至少在持有锁时修改状态,通知位置按语义选择。❌ 用两个互斥量时各自lock(),两个线程加锁顺序相反导致死锁。✅ 用 C++17 的std::scoped_lock lk(m1, m2);一次获取。❌ 把一个std::atomic<int>当作「整个结构体的锁」,同时读写结构体其他字段。✅ 原子类型只保护自己;复合状态用互斥量整体保护。❌ 在持锁期间调用未知的回调、写日志或做阻塞 I/O。✅ 在锁内只做数据修改,把耗时操作移到锁外,缩小临界区。❌std::thread对象析构前既不join()也不detach()。✅ 确保每条路径都汇合或分离,否则析构时调用std::terminate。总结同步手段保证不保证适用场景std::mutex临界区互斥、unlock 与后续 lock 的可见性不防死锁、不可重入绝大多数共享状态保护std::condition_variable等待/通知配对,配合谓词防虚假唤醒不存储通知,不替代互斥量生产者消费者、等待状态变化std::atomic单个对象的原子性与指定内存序不保护其他变量,不保证无锁计数器、标志位、无锁结构的基础件std::shared_mutex多读单写的并发控制不保证公平,不保证更快读远多于写的配置、缓存四种方式不是互斥的选择题,而是一套工具箱:互斥量是默认答案,条件变量用来等待,原子类型用于单点计数与标志,读写锁用于读多写少且临界区不太短的情况。真正决定程序正确性的,是有没有为每一份共享数据明确「谁在什么时候可以访问」,而不是用了哪种锁。
前言多线程编程的核心难点不在「怎么开线程」,而在「多个线程同时访问同一份数据时,如何保证结果可预期」。C++ 标准把没有同步保护的并发读写称为数据竞争(data race),而数据竞争是未定义行为(undefined behavior, UB)——不是「结果偶尔不对」,而是编译器可以假设它不存在,从而做出任意优化。一个常见误解是「只要不加锁就不会死锁,所以无锁最好」。实际上无锁代码要自己处理内存序、ABA 问题和对象生命周期,写错的概率远高于加锁。另一个误解是把volatile当成同步手段,它既不做原子性保证也不建立线程间的可见性顺序,只能用于内存映射 I/O 等特殊场景。本文按「互斥量、条件变量、原子操作、读写锁」四类梳理 C++ 的线程同步手段,说明各自保证什么、不保证什么,并给出可以直接对照的示例。文中代码以 C++17 为基准,涉及更新的标准会单独标注。一、同步的本质:建立 happens-before线程同步要解决两件事:互斥(同一时刻只有一个线程进入临界区)和可见性(一个线程的写入能被另一个线程看到)。C++ 内存模型用 happens-before 关系描述后者:如果写操作 A happens-before 读操作 B,那么 B 一定能看到 A 的结果。互斥量通过 acquire/release 语义建立这种关系:加锁是 acquire,解锁是 release,同一互斥量上的 unlock synchronizes-with 后续的 lock。原子操作则通过内存序参数控制同样的事情。理解这一点,才能明白为什么「两个线程各写各的变量」也需要同步——只要它们共享缓存行或存在逻辑依赖,缺少 happens-before 就可能读到旧值。方式头文件解决的问题典型开销主要限制std::mutex<mutex>临界区互斥无竞争时接近原子指令,有竞争时可能休眠不可递归,不可拷贝std::condition_variable<condition_variable>等待某个条件成立等待时释放锁并休眠必须搭配互斥量与谓词std::atomic<atomic>单个对象的原子读改写通常是一条或几条原子指令只保护它自己,不保护关联数据std::shared_mutex<shared_mutex>读多写少场景的读写分离比普通互斥量重写者可能饥饿,C++17 起提供二、互斥量:最通用的一把锁std::mutex提供lock()、unlock()、try_lock()。手写 lock/unlock 在异常路径上极易漏掉 unlock,因此应当用 RAII 包装器:std::lock_guard<std::mutex>:构造时加锁,析构时解锁,不能提前释放,最轻量。std::unique_lock<std::mutex>:可延迟加锁、可提前unlock()、可转移所有权,条件变量必须用它。std::scoped_lock(C++17):可一次锁多个互斥量,内部用死锁避免算法,替代std::lock加多个lock_guard的写法。// 适用于 C++17#include <mutex>class Counter {public:void add(int n) {std::lock_guard<std::mutex> lk(m_);value_ += n;}int get() const {std::lock_guard<std::mutex> lk(m_);return value_;}private:mutable std::mutex m_;int value_ = 0;};这里m_声明为mutable,是因为get()是 const 成员函数但仍需加锁。这不是风格问题:如果 const 函数不加锁就读value_,它与并发的add()构成数据竞争。std::recursive_mutex允许同一线程重复加锁,但它常被用来掩盖「锁的职责不清」的设计问题。能用普通互斥量把临界区规划清楚时,不要用递归锁。三、条件变量:等待状态变化条件变量解决的是「线程要等到某个条件成立才继续」。它的正确用法有三条硬性要求:等待前必须持有互斥量;必须用谓词形式等待;修改条件后要通知。// 适用于 C++17#include <condition_variable>#include <mutex>#include <queue>#include <utility>template <typename T>class BlockingQueue {public:void push(T value) {{std::lock_guard<std::mutex> lk(m_);q_.push(std::move(value));}cv_.notify_one(); // 通知放在解锁之后,减少被唤醒线程的等待}bool pop(T& out) {std::unique_lock<std::mutex> lk(m_);cv_.wait(lk, [this] { return !q_.empty() || closed_; });if (q_.empty()) return false; // 已关闭且队列为空out = std::move(q_.front());q_.pop();return true;}void close() {{std::lock_guard<std::mutex> lk(m_);closed_ = true;}cv_.notify_all();}private:std::mutex m_;std::condition_variable cv_;std::queue<T> q_;bool closed_ = false;};cv_.wait(lk, pred)的语义是:在持有锁的状态下求值pred,为假则原子地释放锁并阻塞;被唤醒后重新加锁并再次求值。谓词解决了两个问题——虚假唤醒(spurious wakeup,等待可能无故返回)和丢失唤醒(通知发生在 wait 之前)。绝不能用裸wait(lk)配if,因为被唤醒时条件未必已经成立。notify_one与notify_all的选择取决于等待者是否都在等同一个条件:消费者队列用notify_one即可,而「关闭」「广播配置变更」必须用notify_all,否则部分线程永远醒不过来。四、原子操作与读写锁std::atomic<T>让对单个对象的读、写、读改写成为不可分割的操作。最常用的成员是load、store、fetch_add、exchange和compare_exchange_weak/strong。// 适用于 C++17#include <atomic>#include <thread>#include <vector>std::atomic<long long> hits{0};void worker(int rounds) {for (int i = 0; i < rounds; ++i) {hits.fetch_add(1, std::memory_order_relaxed);}}int main() {std::vector<std::thread> pool;for (int i = 0; i < 4; ++i) pool.emplace_back(worker, 100000);for (auto& t : pool) t.join();return hits.load() == 400000 ? 0 : 1;}memory_order_relaxed只保证这个计数器自身的原子性,不建立与其他变量的顺序关系。这里只需要计数,所以够用;一旦要用计数器去「发布」另一块数据(例如标志某块缓冲区已写好),就必须用release/acquire配对,或者干脆用默认的seq_cst。原子类型不能替代互斥量的场景很明确:需要同时修改两个及以上相互关联的变量时,单个atomic无法保证这组修改的整体一致性。std::atomic<T>对非平凡类型(比如较大的结构体)也不保证无锁,is_lock_free()的结果依赖实现。读写锁:读多写少的分工std::shared_mutex(C++17)提供两种所有权:独占(写)和共享(读)。用std::unique_lock<std::shared_mutex>获取写锁,用std::shared_lock<std::shared_mutex>获取读锁。多个读者可以同时进入,写者必须独占。// 适用于 C++17#include <map>#include <shared_mutex>#include <string>#include <utility>class Config {public:std::string get(const std::string& key) const {std::shared_lock<std::shared_mutex> lk(m_);auto it = data_.find(key);return it == data_.end() ? std::string{} : it->second;}void set(std::string key, std::string value) {std::unique_lock<std::shared_mutex> lk(m_);data_[std::move(key)] = std::move(value);}private:mutable std::shared_mutex m_;std::map<std::string, std::string> data_;};读写锁并非「一定比互斥量快」。它的内部状态更复杂,短临界区下额外开销可能抵消并发收益;而且不同标准库实现的公平策略不同,读请求持续不断时写者可能长期得不到锁(写者饥饿)。是否值得,要在自己的负载下用std::chrono::steady_clock测量,而不是凭直觉。C++20 还提供了std::counting_semaphore和std::binary_semaphore(需要 C++20 及以上),适合「限制同时访问某资源的线程数」这类计数型同步;它们不提供所有权概念,使用时必须自己保证配对。常见坑点❌ 用volatile标志位做线程间通知,认为它「禁止优化所以能同步」。✅ 用std::atomic<bool>配acquire/release,或直接用互斥量;volatile不建立 happens-before。❌ 只给写操作加锁,读操作直接读共享变量,认为「读不会出问题」。✅ 读也要加锁或用原子类型;并发的读与写同样构成数据竞争。❌cv.wait(lk)外面套if (q.empty()),被虚假唤醒后直接访问空队列。✅ 用cv.wait(lk, pred)或while (!pred()) cv.wait(lk);。❌ 先notify_one()再解锁,且通知的对象依赖锁内状态。✅ 一般应在修改完共享状态并解锁后再通知;至少在持有锁时修改状态,通知位置按语义选择。❌ 用两个互斥量时各自lock(),两个线程加锁顺序相反导致死锁。✅ 用 C++17 的std::scoped_lock lk(m1, m2);一次获取。❌ 把一个std::atomic<int>当作「整个结构体的锁」,同时读写结构体其他字段。✅ 原子类型只保护自己;复合状态用互斥量整体保护。❌ 在持锁期间调用未知的回调、写日志或做阻塞 I/O。✅ 在锁内只做数据修改,把耗时操作移到锁外,缩小临界区。❌std::thread对象析构前既不join()也不detach()。✅ 确保每条路径都汇合或分离,否则析构时调用std::terminate。总结同步手段保证不保证适用场景std::mutex临界区互斥、unlock 与后续 lock 的可见性不防死锁、不可重入绝大多数共享状态保护std::condition_variable等待/通知配对,配合谓词防虚假唤醒不存储通知,不替代互斥量生产者消费者、等待状态变化std::atomic单个对象的原子性与指定内存序不保护其他变量,不保证无锁计数器、标志位、无锁结构的基础件std::shared_mutex多读单写的并发控制不保证公平,不保证更快读远多于写的配置、缓存四种方式不是互斥的选择题,而是一套工具箱:互斥量是默认答案,条件变量用来等待,原子类型用于单点计数与标志,读写锁用于读多写少且临界区不太短的情况。真正决定程序正确性的,是有没有为每一份共享数据明确「谁在什么时候可以访问」,而不是用了哪种锁。