多线程编程基础

这篇文章介绍 C++ 的多线程编程基础。多线程在算法题中用得不多,但在实际开发和系统设计中非常重要。了解这些概念,能帮你更好地理解操作系统、网络编程等进阶主题。

同步、异步、并行、并发

在写代码之前,我们先搞清楚几个经常混用的概念。

想象你一个人在厨房,需要同时完成煮饭、炒菜、煲汤三件事。

最笨的办法是站在电饭锅前面,盯着饭熟了,再去炒菜,炒完菜再煲汤。一件事做完了才做下一件,这就是同步。同步的特点是简单可靠,但效率低,因为等饭熟的那段时间你什么都没干。

聪明一点的做法是:按下电饭锅的开关,趁煮饭的间隙去炒菜,炒菜等油热的时候顺手把汤锅架上。你还是只有一个人(一个线程),但通过在等待的间隙切换任务,同时推进了多件事,这就是异步。异步的本质是利用等待时间,实际上同一时刻你只在做一件事。

如果你叫来两个帮手,三个人各守一个灶台同时干活,那就是并行。并行是真正的同时执行,需要多个执行单元(多个 CPU 核心)。

最后说并发,这个词其实是一个比较模糊的总称。只要多件事在同一段时间内都在推进,就可以叫并发。不管你是一个人异步切换(单线程),还是多个人同时干(多线程),都算并发。换句话说,异步和并行都是实现并发的手段。

C++ 从 C++11 开始在标准库中内置了多线程支持,主要提供了 std::thread(创建线程)和 std::async(异步任务)两套工具。相比 C 语言的 pthread 库,C++ 的线程 API 更安全也更好用,我们这篇就来学习它们的基本用法。

创建线程 std::thread

基本用法

std::thread<thread> 头文件中,创建线程的方式很简单:把一个函数和它的参数传进去,线程就开始执行了。

thread t(sayHello, "C++") 创建了一个新线程去执行 sayHello("C++") 函数。t.join() 的作用是让主线程等待 t 执行完毕再继续往下走,如果不调用 join(),主线程可能在子线程还没执行完就结束了,程序会直接崩溃。

join 和 detach

线程创建后,你必须选择以下两种方式之一来处理它:

  • join():等待线程执行完毕。主线程会阻塞在这里,直到子线程结束。
  • detach():让线程在后台运行,不再管它。线程会在执行完毕后自动销毁。

实际开发中 join() 用得更多,因为你通常需要等所有任务完成后再继续。detach() 适合那些"发出去就不管"的后台任务,但用不好容易出问题,初学阶段建议总是用 join()

用 lambda 创建线程

除了传函数名,更常见的写法是用 lambda 表达式直接创建线程,代码更紧凑:

这里创建了 3 个线程分别执行不同的任务。注意 lambda 的捕获列表 [i] 是按值捕获循环变量 i,每个线程拿到的是自己那一轮的 i 值。

由于多个线程是并行执行的,它们的输出顺序是不确定的,每次运行可能不一样,这是多线程编程的正常现象。

线程安全与互斥锁

竞态条件

多个线程并行执行时,如果它们同时读写同一个变量,就会产生竞态条件(Race Condition),导致结果不可预期。

来看一个经典的例子:两个线程各对一个全局变量累加 10 万次,理论上结果应该是 20 万,但实际运行往往不是。

你可以多次运行这段代码,每次得到的结果都不一样,而且几乎都小于 200000。

原因在于 counter++ 看起来是一行代码,实际上分为三步:读取 counter 的值、加 1、写回 counter。两个线程可能同时读到了相同的旧值,各自加 1 后写回去,导致其中一次加法被覆盖了。比如两个线程都读到 counter = 100,各自加 1 写回 101,本该变成 102 的 counter 最终只变成了 101。

std::mutex 互斥锁

解决竞态条件最直接的方式就是给共享资源加锁。std::mutex(互斥锁)确保同一时刻只有一个线程能访问被保护的代码区域:

#include <mutex>

mutex mtx;

void safeAdd() {
    mtx.lock();     // 加锁:其他线程到这里会等待
    counter++;      // 只有拿到锁的线程才能执行
    mtx.unlock();   // 解锁:其他线程可以继续了
}

但手动调用 lock()unlock() 有一个风险:如果中间的代码抛出异常,unlock() 就不会被执行,导致死锁。所以 C++ 提供了更安全的方式。

lock_guard 自动管理锁

std::lock_guard 在创建时自动加锁,在离开花括号作用域时自动解锁,保证锁一定会被释放:

lock_guard<mutex> lock(mtx) 在创建的时候自动对 mtx 加锁,当 lock 变量离开当前的花括号作用域时(每次循环结束),自动解锁。这样即使中间出了异常,锁也能被正确释放,不用担心死锁问题。

实际开发中,建议总是用 lock_guard 而不是手动调用 lock()/unlock()

异步任务 std::async / std::future

基本用法

std::thread 适合"启动一个线程去做事",但如果你想拿到线程的返回值,就比较麻烦了,需要用共享变量加锁来传递结果。

std::async 提供了更简洁的方式:它发起一个异步任务,返回一个 std::future 对象,你可以通过 future.get() 获取任务的结果。

async(launch::async, compute, 100) 会创建一个新线程去执行 compute(100),返回一个 future<int> 对象。调用 f1.get() 时,如果任务还没执行完,主线程会在这里等待;如果已经执行完了,就直接拿到结果。

launch::async 参数表示"立即在新线程中启动任务"。如果省略这个参数,系统可能会选择延迟执行(等你调用 get() 的时候才执行),那就达不到并行的效果了。

和 std::thread 的对比

std::async 比手动创建 std::thread 有几个优点:

  • 可以直接获取函数的返回值,不需要用共享变量来传递结果
  • 自动管理线程的生命周期,不需要手动调用 join()
  • 如果任务抛出异常,get() 会重新抛出这个异常,方便错误处理

简单来说,如果你需要"启动一个任务拿到结果",用 async;如果你需要更细粒度地控制线程(比如设置线程属性、共享复杂状态),用 thread

实际例子:并行求和

最后来看一个完整的例子,把前面学的知识综合运用起来。我们用多线程并行计算一个大数组的元素之和,然后和单线程做对比:

这个例子把一千万个数分成 4 段,每段交给一个线程去计算部分和,最后把 4 个部分和加起来。注意 ref(data) 的用法:std::async 默认会拷贝参数,对于大数组来说拷贝开销很大,用 std::ref() 可以传引用。

这就是分治思想在多线程中的典型应用:把大任务拆分成小任务,并行计算后合并结果。

小结

这篇介绍了 C++ 多线程编程的基础知识。同步是等一件事做完再做下一件,异步是利用等待间隙切换任务,并行是多个线程真正同时执行,并发是这些方式的总称。std::thread 创建线程,用 join() 等待完成。多线程同时修改共享变量会产生竞态条件,用 mutex 加锁保护,lock_guard 自动管理锁的生命周期。std::async / std::future 可以发起异步任务并获取返回值,比手动管理线程更简洁。

多线程编程的核心难点不在于创建线程,而在于线程之间的同步和通信。这篇只介绍了最基础的 mutexlock_guard,C++ 标准库还提供了 condition_variable(条件变量)、atomic(原子操作)等更高级的同步工具,感兴趣的话可以进一步学习。