C++ 多线程基础:std::thread 与同步原语
C++11 之前写线程要用 pthread,现在标准库就够了。工具变简单了,但并发问题的本质没变:共享可变状态 + 时序不确定。
一、std::thread 的生存规则
#include <thread>
void work(int id) { /* ... */ }
std::thread t(work, 1); // 参数按值拷贝进线程
t.join(); // 等待结束
关键规则:std::thread 对象析构时必须处于"已 join 或已 detach"状态,否则 std::terminate。
// ❌ 抛异常路径下 join 被跳过 → 崩溃
std::thread t(work, 1);
mayThrow();
t.join();
修复:用 RAII 包装(C++20 有 std::jthread,析构自动 join):
class ThreadGuard {
public:
explicit ThreadGuard(std::thread& t) : t_(t) {}
~ThreadGuard() { if (t_.joinable()) t_.join(); }
private:
std::thread& t_;
};
std::thread t(work, 1);
ThreadGuard g(t); // 任何退出路径都会 join
mayThrow();
二、传参的坑
// ❌ 悬空引用:i 可能在线程执行前就已销毁
void f() {
int i = 0;
std::thread t([]{ use(i); }); // 引用捕获
t.detach();
}
// ✅ 按值传参(线程内部持有副本)
std::thread t(work, 1);
// 需要引用语义时用 std::ref
std::thread t(work, std::ref(counter));
另外注意:如果线程函数参数是 const std::string&,而传入的是 const char*,转换发生在新线程内,实参所指内存可能已失效。显式构造 std::string 更安全:
std::thread t(work, std::string(buf));
三、mutex 家族
std::mutex mtx;
// 简单加锁
{
std::lock_guard<std::mutex> lk(mtx);
++counter;
}
// 需要手动解锁 / 配合条件变量
{
std::unique_lock<std::mutex> lk(mtx);
// ...
lk.unlock();
}
// 读写分离(C++17)
std::shared_mutex rw;
{
std::shared_lock<std::shared_mutex> r(rw); // 读
}
{
std::unique_lock<std::shared_mutex> w(rw); // 写
}
死锁预防:scoped_lock(C++17)
std::mutex m1, m2;
// ❌ 不同线程加锁顺序不一致 → 死锁
std::lock_guard<std::mutex> a(m1);
std::lock_guard<std::mutex> b(m2);
// ✅ 一次性加多把锁,内部按固定顺序
std::scoped_lock lk(m1, m2);
C++11 环境下用 std::lock(m1, m2) + std::adopt_lock 达到同样效果。
四、条件变量:等待与通知
std::mutex mtx;
std::condition_variable cv;
std::queue<int> q;
bool done = false;
// 消费者
void consume() {
std::unique_lock<std::mutex> lk(mtx);
cv.wait(lk, [] { return !q.empty() || done; }); // 带谓词,防止虚假唤醒
if (!q.empty()) { int v = q.front(); q.pop(); use(v); }
}
// 生产者
void produce(int v) {
{
std::lock_guard<std::mutex> lk(mtx);
q.push(v);
}
cv.notify_one(); // 通知放在锁外
}
三条硬规则:
- 必须用谓词版
wait,否则会遭遇虚假唤醒(spurious wakeup); - 修改共享状态的代码必须在锁内;
- 通知一般在锁外进行(避免被通知线程立刻阻塞)。
五、原子操作
#include <atomic>
std::atomic<int> counter{0};
counter.fetch_add(1, std::memory_order_relaxed); // 只关心计数,无需同步其他内存
++counter; // 默认 seq_cst,最安全也最慢
内存序速查:
| 内存序 | 语义 | 场景 |
|---|---|---|
relaxed | 仅保证原子性 | 计数器、统计 |
acquire / release | 建立"发布-订阅"关系 | 生产者-消费者共享指针 |
acq_rel | 两者兼有 | 读改写操作 |
seq_cst | 全局顺序一致 | 不确定时用这个 |
经验:默认用 seq_cst,性能确实敏感且能证明正确性时再降级。
原子操作替代简单锁:
std::atomic<bool> ready{false};
ready.store(true, std::memory_order_release);
while (!ready.load(std::memory_order_acquire)) { /* spin */ }
六、数据竞争检测
g++ -fsanitize=thread -g -O1 app.cpp -o app
TSan 能在运行期捕获大部分数据竞争。并发代码没有 TSan 报告,不能算写对。
七、常见误用清单
| 误用 | 后果 | 修复 |
|---|---|---|
thread 析构前未 join/detach | 直接 terminate | RAII 守卫 / jthread |
用 volatile 做线程同步 | 无同步语义 | std::atomic |
| 持锁调用外部代码/回调 | 死锁 | 缩小临界区 |
| 条件变量没用谓词 | 虚假唤醒导致逻辑错 | 谓词版 wait |
| 加锁顺序不一致 | 死锁 | scoped_lock 一次加锁 |
detach 后访问栈上变量 | 悬空 | 传值或延长生命周期 |
八、并发设计的两个替代思路
- 避免共享:每个线程持有自己的数据,最后合并(map-reduce 风格)。这是最省心的并发方案;
- 消息传递:用无锁队列或有锁队列传递所有权,线程之间不共享裸数据。原理上比"共享内存 + 锁"更容易证明正确。
九、起步清单
- 线程对象一定被 join 或 detach,用 RAII 兜底;
- 所有跨线程访问的可变数据都在锁内,或本身是
atomic; - 条件变量一律用谓词版
wait; - 多把锁用
scoped_lock; - 每次改并发代码都跑一遍 TSan;
- 临界区里不做 I/O、不调用未知回调。
并发的正确性是设计出来的,不是调试出来的。