第八章:并发编程基础:std::thread与线程管理、互斥量与锁、条件变量、死锁预防

并发编程,说白了就是让程序同时干多件事。我刚开始接触多线程时,觉得不就是开几个线程跑任务嘛,结果线上程序三天两头崩溃,查得我头皮发麻。后来才明白——并发编程的核心不是“怎么开线程”,而是“怎么让线程安全地协作”。

8.1 std::thread:线程的创建与管理

C++11 引入了 std::thread,终于不用再跟平台 API 打交道了。我个人习惯用 lambda 表达式创建线程,代码更紧凑,上下文也更清晰。

#include <thread>
#include <iostream>

void worker(int id) {
    std::cout << "线程 " << id << " 开始工作\n";
}

int main() {
    std::thread t1(worker, 1);
    std::thread t2([]{
        std::cout << "lambda 线程\n";
    });

    t1.join();  // 等待线程结束
    t2.join();
    return 0;
}

嗯,这里要注意:join()detach() 必须二选一。如果线程对象析构时还没决定,程序会直接 terminate。我曾经在项目里漏掉一个分支的 join,结果压测时偶发崩溃,查了两天才定位到。

⚠️ 重要提醒: 线程一旦 detach,就再也无法 join 了。而且 detach 后的线程如果访问了已销毁的局部变量,后果是未定义行为。我个人建议:能用 join 就别用 detach。

8.2 std::mutex:互斥量的正确用法

多个线程同时读写同一份数据,就会出问题。你想想看,两个线程同时执行 counter++,这个操作不是原子的——它分“读-改-写”三步。如果时间片切得巧,结果就少加了一次。

互斥量就是用来解决这个问题的。我习惯用 std::lock_guard,因为它 RAII 风格,出了作用域自动解锁,不容易忘。

#include <mutex>

std::mutex mtx;
int counter = 0;

void safe_increment() {
    std::lock_guard<std::mutex> lock(mtx);
    ++counter;  // 临界区
}
💡 个人经验: 临界区要尽量小。只把真正需要保护的代码放进去,不要在锁里做 IO 或复杂计算。我在项目中见过有人把整个函数体都锁住,性能直接掉了一个数量级。

8.3 std::lock_guard 与 std::unique_lock

std::lock_guard 简单粗暴,构造时加锁,析构时解锁。但如果你需要更灵活的控制——比如手动解锁、延迟加锁、与条件变量配合——那就得上 std::unique_lock

特性 lock_guard unique_lock
加锁/解锁时机 构造/析构 可手动控制
支持条件变量
性能开销 极低 略高(内部维护状态)
推荐场景 简单临界区 需要灵活控制

我的建议是:默认用 lock_guard,只有需要条件变量或手动解锁时才换 unique_lock。别为了“万一以后要用”而提前上 unique_lock,性能虽差不大,但代码意图会模糊。

8.4 std::condition_variable:线程间的信号机制

条件变量解决的是“等待某个条件成立”的问题。比如生产者-消费者模型:消费者要等队列里有数据才能取,不能空转轮询。

#include <condition_variable>

std::mutex mtx;
std::condition_variable cv;
std::queue<int> data_queue;

void consumer() {
    std::unique_lock<std::mutex> lock(mtx);
    cv.wait(lock, []{ return !data_queue.empty(); });
    int val = data_queue.front();
    data_queue.pop();
    // 处理 val
}

注意 wait() 的第二个参数——谓词。这是防止“虚假唤醒”的关键。我曾经踩过这个坑:没有谓词,结果线程被唤醒了但条件其实不满足,直接读取空队列,崩溃。

🔑 核心要点: 条件变量的 wait 必须配合 while 循环或谓词使用。永远不要假设被唤醒时条件一定成立。

8.5 死锁预防:四个必要条件与应对策略

死锁是并发编程里最让人头疼的问题之一。我记得有一次线上服务突然 hang 住,所有请求都不响应了。上去一看,两个线程互相等着对方释放锁——典型的死锁。

死锁发生的四个必要条件:

  1. 互斥:资源一次只能被一个线程占用
  2. 持有并等待:线程拿着一个锁,又去等另一个锁
  3. 不可剥夺:锁只能由持有者主动释放
  4. 循环等待:线程之间形成等待环路

预防策略其实就围绕打破这些条件:

  • 固定加锁顺序:所有线程按相同顺序获取锁。比如先锁 A 再锁 B,就不会出现环路
  • 使用 std::lock():一次性锁住多个互斥量,避免分步加锁
  • 使用 scoped_lock:C++17 引入,等价于 lock_guard 但支持多个锁
  • 避免嵌套锁:能用一个锁就别用两个。我见过有人为了“性能优化”把一把锁拆成三把,结果死锁频发
// 使用 std::lock 避免死锁
std::mutex mtx1, mtx2;

void safe_operation() {
    std::lock(mtx1, mtx2);  // 一次性锁住
    std::lock_guard<std::mutex> lock1(mtx1, std::adopt_lock);
    std::lock_guard<std::mutex> lock2(mtx2, std::adopt_lock);
    // 临界区
}
⚠️ 避坑指南: 我曾经在项目里用递归锁(std::recursive_mutex)来解决一个“同一个线程多次加锁”的问题。虽然不死了,但代码逻辑变得极其混乱,后来重构时全部改成了非递归锁 + 重构设计。递归锁不是银弹,它只是掩盖了设计问题。

8.6 本章知识体系图

下面这张图梳理了本章的核心脉络,从线程创建到同步机制,再到死锁预防,一目了然。

并发编程基础:知识体系 std::thread std::mutex condition_variable 死锁预防 join / detach lambda / 函数对象 lock_guard unique_lock scoped_lock (C++17) wait / notify_one notify_all 虚假唤醒处理 固定加锁顺序 std::lock 一次性锁 避免嵌套锁 核心原则 临界区尽量小 · 默认用 lock_guard · 条件变量必须配谓词 死锁预防优先于死锁检测 · 设计上避免多锁嵌套

并发编程没有银弹。工具就这些——thread、mutex、condition_variable——但怎么组合、怎么设计,才是真正的功力所在。我个人觉得,多线程代码写得好不好,不在于用了多高级的特性,而在于能不能让每个线程都“知道自己该干什么,什么时候该等,什么时候该走”。

📌 最后一句: 如果你刚开始学并发,别急着上无锁编程或原子操作。先把 mutex 和 condition_variable 用熟,把死锁预防刻在脑子里。基础打牢了,后面那些高阶玩法才接得住。

公众号:蓝海资料掘金营,微信 deep3321