跳到主要内容

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(); // 通知放在锁外
}

三条硬规则:

  1. 必须用谓词版 wait,否则会遭遇虚假唤醒(spurious wakeup);
  2. 修改共享状态的代码必须在锁内;
  3. 通知一般在锁外进行(避免被通知线程立刻阻塞)。

五、原子操作​

#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直接 terminateRAII 守卫 / jthread
用 volatile 做线程同步无同步语义std::atomic
持锁调用外部代码/回调死锁缩小临界区
条件变量没用谓词虚假唤醒导致逻辑错谓词版 wait
加锁顺序不一致死锁scoped_lock 一次加锁
detach 后访问栈上变量悬空传值或延长生命周期

八、并发设计的两个替代思路​

  1. 避免共享:每个线程持有自己的数据,最后合并(map-reduce 风格)。这是最省心的并发方案;
  2. 消息传递:用无锁队列或有锁队列传递所有权,线程之间不共享裸数据。原理上比"共享内存 + 锁"更容易证明正确。

九、起步清单​

  1. 线程对象一定被 join 或 detach,用 RAII 兜底;
  2. 所有跨线程访问的可变数据都在锁内,或本身是 atomic;
  3. 条件变量一律用谓词版 wait;
  4. 多把锁用 scoped_lock;
  5. 每次改并发代码都跑一遍 TSan;
  6. 临界区里不做 I/O、不调用未知回调。

并发的正确性是设计出来的,不是调试出来的。