循环拆成多个线程后,为什么结果偶尔错误,退出时还会崩溃?
适用范围:以 C++11 标准库并发组件为主,包括
<thread>、<mutex>、<condition_variable>、<future>、<atomic>与<chrono>。
一个求和循环似乎很容易并行化:让多个线程各处理一段,全部把结果加到同一个变量。
1 | std::uint64_t total = 0; |
程序可能偶尔得到正确结果,也可能丢失部分累加。即使加上互斥锁,若每个元素都锁一次,线程越多反而可能越慢。函数结束时如果某个 std::thread 仍可连接(joinable),其析构还会直接调用 std::terminate()。
多线程的难点因此不是“创建几个线程”,而是同时解决三件事:划分独立工作、建立正确同步、控制线程生命周期。本文会从错误的共享求和开始,逐步实现一个可运行的分块版本,并解释锁、原子、条件变量和异步任务分别适合什么问题。
1. total += value 为什么不是一次不可分割的操作?
源码中的 += 通常包含读取、计算和写回:
1 | 线程 A:读取 total ── 加 valueA ── 写回 |
如果两个线程都读取旧值,其中一次写回会覆盖另一次结果。更严格地说,当两个线程无同步地访问同一内存位置、至少一个访问是写入时,程序发生数据竞争(data race);在 C++ 内存模型中,数据竞争导致未定义行为,不只是“可能少加一次”。
调试输出、运行速度变化或更换优化级别都可能让错误表现改变。一次测试恰好正确不能证明没有竞态。
给每次加法加锁就解决了吗?
正确性可以这样修复:
1 | std::mutex mutex; |
std::lock_guard 通过 RAII 在离开作用域时自动解锁,即使中途抛异常也不会漏掉 unlock()。但锁位于最内层循环,所有线程仍要排队更新同一个变量,还额外支付锁和缓存一致性成本。这可能比单线程更慢。
更好的思路是改变数据流:每个线程先在自己的局部变量中求和,只在结束时写一次独立结果,最后由调用线程归约。
2. 怎样把共享写变成独立工作?
假设数据范围为 [0, n),可以切成不重叠的连续块:
1 | values: [-------------------------- n --------------------------) |
每个工作线程只读取共享的 values,并把最终局部和写入不同的 partial[index]。线程执行期间不改变输入容器的大小和容量,也不同时访问同一个部分结果,因此不需要为循环中的每个元素加锁。
join() 不只是等待线程退出。它还建立同步关系:工作线程结束前的写入,在成功 join() 返回后对等待线程可见。因此主线程必须先连接所有工作线程,再读取 partial。
3. 如何写出一个生命周期安全的最小版本?
下面的程序只使用 C++11 标准库。JoiningThreads 是一个很小的 RAII 守卫:无论正常离开作用域,还是创建后续线程时抛异常,它都会连接已经成功创建的线程。
1 |
|
在 macOS 或 Linux 的 Clang 环境中可以这样编译:
1 | clang++ -std=c++11 -O2 -Wall -Wextra -pedantic -pthread parallel_sum.cpp -o parallel_sum |
预期输出:
1 | sequential = 4999950000 |
MSVC 使用 /std:c++14 或更新标准选项及其线程运行库配置,命令行与 Clang 不同,需要结合 Visual Studio 版本验证。示例选择 std::uint64_t 累加,避免 0 到 99999 的和溢出 32 位整数。
4. 这段代码为何没有数据竞争?
values 在启动线程前已经构造完成,所有工作线程只读它。主线程在工作结束前不修改或销毁该容器,因此引用捕获的生命周期有效。
thread_index、begin 和 end 按值捕获。如果把循环变量全部按引用捕获,线程真正运行时外层循环可能已经改变这些值,多个线程就可能处理错误区间。
每个线程在栈上的 local 中完成整个块的累加。只有一次 partial[thread_index] = local 写入,而且不同线程使用不同下标。普通 std::vector<T> 的不同元素可由不同线程并发修改;std::vector<bool> 是这一规则的重要例外。
内部花括号保证 JoiningThreads 在最终 accumulate 前析构并完成所有 join()。如果直接在守卫仍存活时写 return accumulate(...),返回表达式会在局部对象析构前求值,主线程可能过早读取部分结果。
为什么守卫要在创建线程之前存在?
std::thread 构造可能因资源不足抛出异常。如果已经创建三个线程,第四次失败,栈展开会销毁线程容器;其中仍为 joinable 的线程对象析构会触发 std::terminate()。先创建守卫,异常路径也会连接已存在的线程。
5. join、detach 和线程对象生命周期有什么关系?
一个 std::thread 对象可以是 joinable 或非 joinable。成功启动线程后通常是 joinable;调用 join() 或 detach() 后变为非 joinable。
1 | std::thread worker(do_work); |
若 joinable 的 std::thread 析构,程序调用 std::terminate()。这项设计迫使程序员明确决定线程如何结束,而不是悄悄泄漏后台执行。
detach() 让执行线程与 std::thread 对象分离,并不延长它捕获对象的寿命:
1 | void unsafe() { |
这是悬空引用和进程退出竞态。只有系统级、生命周期由其他机制完整管理的场景才应谨慎使用分离线程;普通业务任务优先使用可连接线程、任务执行器或更高层并发设施。
C++20 的 std::jthread 会在析构时请求停止并连接线程,能减少部分生命周期错误,但它不属于 C++11,且工作函数必须配合停止协议才能及时退出。
6. 什么时候用互斥锁,什么时候用原子变量?
互斥锁适合保护需要作为整体保持不变量的共享状态:
1 | std::mutex mutex; |
锁的临界区应只包含需要互斥的状态操作。昂贵计算、阻塞 I/O 和用户回调若能安全移出锁,应在解锁后执行,减少其他线程等待。
std::atomic<T> 适合一个变量上的简单原子状态,例如计数器或标志:
1 | std::atomic<std::uint64_t> completed{0}; |
这里若计数只用于统计,relaxed 可能足够;如果计数还承担发布其他数据的同步职责,就必须设计正确内存序。原子只保证指定原子对象的操作,不会自动维护多个字段之间的不变量。
| 问题 | 常见工具 |
|---|---|
| 多字段状态要一起改变 | std::mutex + RAII 锁 |
| 独立计数或简单标志 | std::atomic,明确内存序 |
| 等待队列从空变为非空 | std::condition_variable |
| 获得任务结果或异常 | std::future |
锁与原子都可能产生缓存行争用。把共享更新从每个元素一次降为每线程一次,往往比把锁机械换成原子更有效。
7. 条件变量为什么要搭配谓词?
消费者等待队列时,循环 sleep_for 会增加延迟或浪费轮询。条件变量允许线程阻塞,直到生产者通知状态可能发生变化:
1 | std::unique_lock<std::mutex> lock(mutex); |
等待过程中,wait 原子地释放互斥锁并阻塞;被唤醒后重新取得锁,再检查谓词。谓词不可省略,因为条件变量允许虚假唤醒,通知发生时条件也可能已被其他消费者改变。
一个可关闭队列通常遵循这套状态机:
1 | 开放且队列空 ── 等待 |
修改谓词所依赖的 jobs 和 closed 时必须持有同一互斥锁。关闭后要唤醒所有等待消费者,否则它们可能永远无法观察退出条件。
通知放在解锁前还是解锁后需要结合状态和实现讨论;关键正确性来自“状态在锁下改变 + 等待时检查谓词”,不是通知本身保存了一条消息。
8. std::async 和 future 能解决什么?
只需要异步返回值和异常传播时,std::async 比手工管理线程简单:
1 | std::future<int> result = std::async( |
工作函数的返回值或异常存入共享状态,get() 等待并取出结果;同一个普通 future 的 get() 通常只能调用一次。
不显式指定策略时,实现可以选择异步执行或延迟执行。若必须在另一执行线程异步运行,应明确使用 std::launch::async;std::launch::deferred 会等到 get() 或 wait() 时在等待线程执行。
std::async 不是可配置线程池接口。大量细粒度任务可能带来线程创建和调度成本,其资源策略也依实现。任务数量大、需要背压、优先级或可控关闭时,应使用经过验证的执行器或线程池,而不是在基础篇中临时拼装一个不完整实现。
9. chrono、休眠和超时怎样正确使用?
测量耗时通常使用单调的 std::chrono::steady_clock,因为它不会因系统墙上时间调整而倒退:
1 | const std::chrono::steady_clock::time_point begin = |
sleep_for 和 sleep_until 表示至少等待到某个条件,线程可能因调度而更晚恢复,不适合提供硬实时保证。周期任务用递增的绝对截止时间通常比每轮工作结束后再 sleep_for(period) 更少累积漂移:
1 | std::chrono::steady_clock::time_point next = |
这里还必须设计退出条件,并处理工作时间已经超过周期的情况。休眠不能替代条件变量或正确同步;“多睡一会儿,等另一个线程写完”仍是竞态。
10. 两把锁为什么可能造成死锁?
线程 A 先锁 left 再等 right,线程 B 先锁 right 再等 left,就形成循环等待。
C++11 可以用 std::lock 一次协调多把锁,再用 std::adopt_lock 交给 RAII 对象:
1 | std::lock(left_mutex, right_mutex); |
C++17 提供更简洁的 std::scoped_lock。无论使用哪个工具,都要避免在持锁时调用未知外部代码,因为回调可能以另一顺序重新获取锁。
更高层的策略是统一全局加锁顺序、缩小共享状态,或通过消息传递减少同时持有多把锁的需要。
11. 为什么线程越多不一定越快?
示例中的十万个整数求和很可能小到不值得创建四个线程。并行执行至少增加:
- 线程创建和销毁;
- 调度与上下文切换;
- 分块与最终归约;
- 缓存一致性和内存带宽竞争。
std::thread::hardware_concurrency() 只返回实现提供的提示,可能为零,也不等于这个任务的最佳线程数。容器、虚拟机、节能核心和其他进程都会影响可用资源。
根据 Amdahl 定律,不能并行的部分会限制总加速比。内存带宽受限任务还可能很快达到平台上限。性能结论应使用 Release 构建和足够工作量,重复测量单线程与不同线程数,而不是默认线程数等于核心数。
12. 工作线程抛异常时会发生什么?
异常不能自动跨越 std::thread 边界。如果工作函数中的异常逃出线程入口,程序会调用 std::terminate():
1 | std::thread worker([] { |
需要回传异常时,可以在线程入口捕获并保存 std::exception_ptr,或使用 std::promise/std::future、std::packaged_task、std::async 等带共享状态的设施。主线程在连接或等待后重新抛出并处理。
错误路径还要通知其他线程停止,并确保所有线程最终退出。C++11 没有 stop_token,通常需要受同步保护的停止标志和明确的唤醒协议。
13. 常见误区如何纠正?
误区一:程序多跑几次都正确,就没有数据竞争
不对。竞态依赖时序,数据竞争属于未定义行为。使用锁、原子或不共享设计建立标准保证,并借助 ThreadSanitizer 等工具辅助检测;检测器未报告也不能替代代码审查。
误区二:detach() 能避免忘记 join()
它只是放弃管理权,往往把明确的终止错误变成隐蔽的生命周期错误。默认选择可连接线程。
误区三:把共享整数换成 atomic,算法就可扩展
原子能修复该变量上的数据竞争,但所有核心仍争用同一缓存行。优先减少共享更新频率,例如线程局部累加。
误区四:收到条件变量通知就说明队列非空
不对。可能虚假唤醒,也可能任务已被另一个消费者取走。始终在锁下检查谓词。
误区五:sleep_for 可以作为线程同步
不对。调度速度没有可靠上限,另一线程可能尚未完成。用 join、条件变量、future 或其他同步关系表达依赖。
误区六:线程数越接近 CPU 核心数越好
不对。任务粒度、串行比例、内存带宽、超线程和系统负载都会改变最佳值,必须测量。
14. 什么时候应该直接使用 std::thread?
std::thread 适合线程数量少、工作生命周期明确,并且调用方确实需要控制线程连接的底层场景,例如长期工作线程或并发设施内部实现。
以下情况通常更适合高层工具:
- 单个任务需要返回值和异常:
std::async或任务系统; - 大量短任务:成熟线程池或执行器;
- 数据并行算法:并行算法、TBB、OpenMP 等符合项目约束的工具;
- C++20 以上且需要协作取消:考虑
std::jthread与停止令牌。
不要仅为了“用了多线程”就为很小的循环创建线程。先确认任务彼此独立、工作量足够,并能定义清晰的退出和错误处理协议。
15. 总结
回到开头,共享 total 出错的根因不是加法语法,而是多个线程无同步地读写同一内存位置。给每次加法加锁虽能修正结果,却把循环串行化;线程局部累加加最终归约同时简化了同步和性能模型。
需要记住五点:
- 数据竞争是未定义行为,测试偶尔正确没有证明力。
- 优先划分不重叠数据,让线程少共享、少写共享状态。
std::thread析构前必须join或detach,RAII 能保护异常路径。- 互斥锁维护复合不变量,原子处理有限的共享状态,条件变量负责等待状态变化。
- 多线程有创建、调度和带宽成本,性能必须随线程数和真实数据规模测量。
实践中,在创建第一个线程前先写下四件事:每个线程读什么、写什么、何时退出、错误如何回传。若这四个问题没有清晰答案,代码还不具备安全并行的条件。