为什么要并发:别让程序干等

很多程序大半时间在"等":等网络返回、等磁盘读完、等一段计算跑完。并发就是让程序别干等——在等这一件事的时候,顺手把另一件事也做了。多核 CPU 普及之后,把一个大任务切成几块分给多个线程同时算,还能直接缩短总耗时。

C++11 之后标准库内置了 <thread> 这一套并发设施,不再依赖操作系统原生 API。你写的线程代码在 Windows、Linux、macOS 上用同一套 std::thread、std::mutex 即可。

并发不是"让代码跑得快一点"这么简单,它首先是"让正确变难"。多线程最大的坑不在启动线程,而在线程之间共享数据时谁先谁后无法预料。
本文核心知识点
  • std::thread代表一个可执行线程对象,必须 join 或 detach。
  • 竞态条件多个线程同时读写同一变量,结果取决于执行先后。
  • std::mutex互斥锁,用 lock_guard 自动加锁解锁临界区。
  • std::atomic对单变量做原子操作,无需加锁即可安全共享。
C++ 并发配图:蓝调渐变背景上三到四条平行的青蓝紫车道上各有发光方块高速流动,车道交汇处被一把发光的锁形盾牌管住,旁边有原子闪光与同步节点圆点
多车道并行,但在交汇点必须有一把"锁"管住顺序——这就是并发里互斥存在的意义。

std::thread:起一个线程并 join

一个 std::thread 对象就代表一个执行中的线程。把要跑的函数(或 lambda)交给它,线程立刻开始执行。关键是:线程对象要么 join(等它跑完),要么 detach(彻底放手),否则程序析构时会直接终止。

#include <iostream>
#include <thread>

void worker(int id) {
    std::cout << "线程 " << id << " 正在干活\n";
}

int main() {
    std::thread t1(worker, 1);   // 线程马上开始跑
    std::thread t2(worker, 2);

    t1.join();                    // 等 t1 跑完
    t2.join();                    // 等 t2 跑完
    std::cout << "全部结束\n";
    return 0;
}

join() 是"我等你干完再走"。两个线程同时打印时,输出顺序是不确定的——这正是并发的本质:你无法预设哪个线程先跑完一行。

共享数据与竞态:两个线程改同一变量

起线程不难,难在两个线程同时改一个变量。下面这个计数器,两个线程各加十万次,理论结果应该是二十万——但实际经常不到。

#include <thread>

int counter = 0;

void add100k() {
    for (int i = 0; i < 100000; ++i) {
        counter++;          // 看似一行,其实是 读-改-写 三步
    }
}

int main() {
    std::thread a(add100k);
    std::thread b(add100k);
    a.join();
    b.join();
    // counter 往往不是 200000
    return 0;
}

问题在于 counter++ 不是一步完成:它先读出值、加一、再写回。两个线程可能同时读到同一个旧值,各自加一写回,结果只加了一次。这就是竞态条件——结果取决于两个线程在时间上的交错方式。

std::mutex:用锁包住临界区

解法是互斥锁 std::mutex:同一时刻只允许一个线程进入被锁住的代码段。把"改 counter"这一段包起来,就成了串行的临界区。用 std::lock_guard 是最佳实践——它在构造时加锁、析构时自动解锁,哪怕中间 return 也不会忘记释放。

#include <thread>
#include <mutex>

int counter = 0;
std::mutex m;

void add100k() {
    for (int i = 0; i < 100000; ++i) {
        std::lock_guard<std::mutex> lock(m);   // 加锁
        counter++;                              // 临界区
    }   // 出作用域自动解锁
}

int main() {
    std::thread a(add100k), b(add100k);
    a.join();
    b.join();        // 这次 counter 一定是 200000
    return 0;
}

锁把并发改成了串行,正确性有了,但代价是性能。所以锁要尽量小——只包住真正必须互斥的那几行,别把整个函数都锁起来。

std::atomic:无需锁的计数器

像计数器这种"只对单个变量做简单操作"的场景,不必动用重锁。std::atomic 让对它的读改写在硬件层面一步完成,编译器保证不会被其他线程中途打断。

#include <atomic>
#include <thread>

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

void add100k() {
    for (int i = 0; i < 100000; ++i) {
        counter++;      // 原子操作,无需 mutex
    }
}

int main() {
    std::thread a(add100k), b(add100k);
    a.join();
    b.join();        // counter 一定是 200000
    return 0;
}

同样是二十万,这版没有用任何 mutex,速度更快、也更不容易出错。atomic 是应对"单变量共享"的首选,它把互斥的责任交给了硬件指令,而不是让你手动加锁。

锁的代价:什么时候用 atomic

怎么在 mutex 和 atomic 之间选?一条实用的分界线:

  • 单个变量、操作简单(计数器、标志位、状态标记):用 std::atomic,轻量高效。
  • 多个变量必须作为整体保持一致(如同时改余额和流水):用 std::mutex 把这段逻辑整体锁住,atomic 管不住"几个变量一起变"。
  • 能用 lock_guard 就别手动 lock/unlock——异常或提前 return 时,RAII 自动解锁,避免死锁。

死锁的另一个来源是两个线程以相反顺序抢两把锁。原则是:所有线程按同一固定顺序加锁,或干脆尽量少用多把锁。