这篇文章介绍 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 可以发起异步任务并获取返回值,比手动管理线程更简洁。
多线程编程的核心难点不在于创建线程,而在于线程之间的同步和通信。这篇只介绍了最基础的 mutex 和 lock_guard,C++ 标准库还提供了 condition_variable(条件变量)、atomic(原子操作)等更高级的同步工具,感兴趣的话可以进一步学习。