第二十二章 多线程与并发(进阶)

← 返回目录

本章目标:线程创建、数据竞争、互斥锁、条件变量、 原子操作、async/launch 策略、线程安全的正确姿势。 并发是 C++ 最容易被写错的部分,重点讲"如何不出错"。

22.1 线程基础

#include <thread>
void worker(int id) {
    std::println("线程 {} 开始", id);
}

std::thread t1(worker, 1);   // 创建线程,立即开始跑
std::thread t2([]{ /* lambda 也行 */ });
t1.join();                   // 等 t1 结束(join = 汇合)
t2.join();

关键规则(背下来):

  1. 线程对象必须 join 或 detach 其中之一,否则析构时

直接 terminate 崩溃

  1. join:等它跑完(主线程阻塞等)
  2. detach:放它自由(主线程不管了,之后无法等待)

危险:detach 的线程访问已销毁变量 = 悬空

  1. 建议总是 join,永远别 detach(除非你非常清楚在干嘛)

传入引用参数要小心:

void f(int& x);
std::thread t(f, std::ref(x));   // 不写 std::ref 就是拷贝!

22.2 数据竞争:并发最可怕的 bug

两个线程同时读写同一个变量 = 数据竞争 = 未定义行为。 结果不可预测(可能是崩溃、错误值、读一半的数据)。

int counter = 0;
// 两个线程各自 counter++ 一万次
// 期望 20000,实际经常是 19000 左右!
// 原因:counter++ 是"读-改-写"三步,两个线程交错执行

人话:counter++ 在 CPU 层面是三条指令 (读内存 → 加 1 → 写回内存)。 两个线程同时执行这三步会互相覆盖。 "原子性"被破坏 → 数据竞争 → 未定义行为。

预防方案(二选一):

  1. 互斥锁(mutex):同一时间只允许一个线程进临界区
  2. 原子类型(atomic):单条硬件指令完成操作

22.3 std::mutex:互斥锁

#include <mutex>
int counter = 0;
std::mutex mtx;

void safe_inc() {
    std::lock_guard<std::mutex> lock(mtx);   // 上锁(RAII)
    ++counter;                               // 临界区
}   // 出作用域自动解锁(RAII 保证异常安全)

lock_guard:构造上锁,析构解锁。永远用它,别手动 lock/unlock (手动容易忘记解锁或异常时不解锁 → 死锁/卡死)。

多个锁避免死锁的铁律:所有线程按**相同顺序**上锁。 (两个线程分别以"先 A 后 B"和"先 B 后 A"上锁 → 互相等待 → 死锁)

22.4 原子操作 std::atomic

#include <atomic>
std::atomic<int> counter{0};

void worker() { ++counter; }        // 原子自增,无锁!
// 10 个线程各 +1 万次 → 永远正好 100000

原子 = 硬件保证单条指令完成,不需要锁。 性能:比 mutex 快得多,但只能做简单操作。 ("读-改-写"类操作如 fetch_add 都是原子的)

什么时候用 atomic,什么时候用 mutex?

内存序(memory order)——进阶概念,新手先默认默认值:

std::atomic<int> x{0};
x.store(5);          // 写
int v = x.load();    // 读
x.fetch_add(1);      // 原子加
默认"顺序一致性",简单场景绝对够用。

22.5 条件变量:线程间"通知"

场景:生产者线程生成数据,消费者线程等数据到了再处理。 轮询(忙等)浪费 CPU;条件变量让消费者"睡觉"直到被通知。

#include <condition_variable>
std::mutex cv_mtx;
std::condition_variable cv;
std::vector<int> queue;
bool done = false;

// 消费者线程:
{
    std::unique_lock<std::mutex> lock(cv_mtx);
    cv.wait(lock, [] { return !queue.empty() || done; });
    // 等条件满足才继续(自动释放锁,被唤醒后重新上锁)
    int item = queue.back();   // 处理数据
}

// 生产者线程:
{
    std::lock_guard<std::mutex> lock(cv_mtx);
    queue.push_back(item);
    cv.notify_one();           // 叫醒一个等待者
}

关键点:

  1. wait 必须用 unique_lock(可反复锁/解锁),lock_guard 不行
  2. wait 的第二个参数是"唤醒后的检查条件",防止假唤醒
  3. notify_one 叫醒一个;notify_all 叫醒全部

22.6 std::async:最简单的并发(推荐入门)

#include <future>
auto result = std::async(std::launch::async, [] {
    // 耗时计算,另一个线程执行
    int sum = 0;
    for (int i = 1; i <= 1'000'000; ++i) sum += i;
    return sum;
});

// 主线程做别的事...
std::println("结果: {}", result.get());   // 等它完成并取回结果

async 的好处:

future 只能 get 一次。多个任务并行 + 等全部:

std::vector<std::future<int>> futures;
for (int i = 0; i < 4; ++i)
    futures.push_back(std::async(std::launch::async, f, i));
for (auto& fu : futures) results.push_back(fu.get());   // 依次取

22.7 线程安全地共享数据(正确姿势总结)

  1. 能不共享就不共享(每个线程独立数据)——最好方案
  2. 只读共享:const 对象随便读(没有写就是安全)
  3. 需要写:原子变量 > 互斥锁 + lock_guard
  4. 线程间传数据:async + future 最省心
  5. 永远不要:裸指针指向栈上变量丢进线程(悬空)

例子:并行求和(分块,无共享写):

template <typename It>
long long parallel_sum(It begin, It end) {
    auto n = std::distance(begin, end);
    if (n <= 0) return 0;
    const int nthreads = 4;
    std::vector<std::future<long long>> futures;
    for (int t = 0; t < nthreads; ++t) {
        auto lo = begin + t * n / nthreads;
        auto hi = begin + (t + 1) * n / nthreads;
        futures.push_back(std::async(std::launch::async,
            [lo, hi] {
                return std::accumulate(lo, hi, 0LL);
            }));
    }
    long long total = 0;
    for (auto& fu : futures) total += fu.get();
    return total;
}

22.8 死锁、活锁与数据竞争的辨识

死锁:两个线程互相等对方手里的锁 → 都永远卡住

症状:程序卡死不动
预防:统一锁顺序;用 std::scoped_lock 一次拿多个锁

// 同时拿两把锁(C++17):
std::scoped_lock lock(m1, m2);   // 内部按固定顺序加锁,防死锁

活锁:线程反复让出资源,永远不进展(罕见) 数据竞争:未定义行为,症状千奇百怪(值不对、崩溃、随机结果)

22.9 线程性能注意事项

  1. 线程数不是越多越好:和 CPU 核数相当即可

std::thread::hardware_concurrency() 查核数

  1. 线程创建有开销:任务太小不值得开线程

(阈值经验:任务 > 几十微秒才值得并发)

  1. 锁的粒度:只保护真正需要保护的代码(临界区越小越好)
  2. 原子变量也有缓存一致性开销:别滥用
  3. 并行算法:C++17 起可以直接用执行策略:
   #include <execution>
   std::sort(std::execution::par, v.begin(), v.end());

一行搞定并行排序(标准库自动分块调度)!

22.10 陷阱清单

  1. 忘了 join/detach → terminate 崩溃
  2. 线程访问已销毁的局部变量 → 悬空 → 崩溃
  3. 共享变量不加锁/不加原子 → 数据竞争未定义行为
  4. lock_guard 用成手动 lock/unlock → 异常时死锁
  5. 两把锁顺序不一致 → 死锁
  6. future.get() 调用多次 → 报错
  7. 在 main 返回后线程还在跑 → 程序已退出,线程被强杀
  8. 用锁保护了"读",但"写"忘了锁 → 白锁

本章小结

练习题(配套测试:测试_第22章_多线程.cpp)


  1. 10 个线程各加 1 万次,验证 mutex 版本恒为 10 万。
  2. 同上用 atomic 版本。
  3. 用 async 并行计算 1..N 的平方和,验证结果一致。
  4. 用条件变量实现生产者-消费者队列(生产 20 个消费 20 个)。
  5. 给 100 万个数并行求和,对比串行结果。