前言
多线程编程的核心难点不在「怎么开线程」,而在「多个线程同时访问同一份数据时,如何保证结果可预期」。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 | 多读单写的并发控制 | 不保证公平,不保证更快 | 读远多于写的配置、缓存 |
四种方式不是互斥的选择题,而是一套工具箱:互斥量是默认答案,条件变量用来等待,原子类型用于单点计数与标志,读写锁用于读多写少且临界区不太短的情况。真正决定程序正确性的,是有没有为每一份共享数据明确「谁在什么时候可以访问」,而不是用了哪种锁。