循环拆成多个线程后,为什么结果偶尔错误,退出时还会崩溃?

适用范围:以 C++11 标准库并发组件为主,包括 <thread><mutex><condition_variable><future><atomic><chrono>

一个求和循环似乎很容易并行化:让多个线程各处理一段,全部把结果加到同一个变量。

1
2
3
4
5
6
7
std::uint64_t total = 0;

auto worker = [&](std::size_t begin, std::size_t end) {
for (std::size_t i = begin; i < end; ++i) {
total += values[i]; // 多个线程同时读写 total
}
};

程序可能偶尔得到正确结果,也可能丢失部分累加。即使加上互斥锁,若每个元素都锁一次,线程越多反而可能越慢。函数结束时如果某个 std::thread 仍可连接(joinable),其析构还会直接调用 std::terminate()

多线程的难点因此不是“创建几个线程”,而是同时解决三件事:划分独立工作、建立正确同步、控制线程生命周期。本文会从错误的共享求和开始,逐步实现一个可运行的分块版本,并解释锁、原子、条件变量和异步任务分别适合什么问题。

1. total += value 为什么不是一次不可分割的操作?

源码中的 += 通常包含读取、计算和写回:

1
2
线程 A:读取 total ── 加 valueA ── 写回
线程 B:读取 total ── 加 valueB ── 写回

如果两个线程都读取旧值,其中一次写回会覆盖另一次结果。更严格地说,当两个线程无同步地访问同一内存位置、至少一个访问是写入时,程序发生数据竞争(data race);在 C++ 内存模型中,数据竞争导致未定义行为,不只是“可能少加一次”。

调试输出、运行速度变化或更换优化级别都可能让错误表现改变。一次测试恰好正确不能证明没有竞态。

给每次加法加锁就解决了吗?

正确性可以这样修复:

1
2
3
4
5
6
std::mutex mutex;

for (std::size_t i = begin; i < end; ++i) {
std::lock_guard<std::mutex> lock(mutex);
total += values[i];
}

std::lock_guard 通过 RAII 在离开作用域时自动解锁,即使中途抛异常也不会漏掉 unlock()。但锁位于最内层循环,所有线程仍要排队更新同一个变量,还额外支付锁和缓存一致性成本。这可能比单线程更慢。

更好的思路是改变数据流:每个线程先在自己的局部变量中求和,只在结束时写一次独立结果,最后由调用线程归约。

2. 怎样把共享写变成独立工作?

假设数据范围为 [0, n),可以切成不重叠的连续块:

1
2
3
4
5
6
7
8
values: [-------------------------- n --------------------------)
线程 0: [0, b0)
线程 1: [b0, b1)
线程 2: [b1, b2)
线程 3: [b2, n)

partial[0] partial[1] partial[2] partial[3]
└──────────── 主线程最终相加 ────────────┘

每个工作线程只读取共享的 values,并把最终局部和写入不同的 partial[index]。线程执行期间不改变输入容器的大小和容量,也不同时访问同一个部分结果,因此不需要为循环中的每个元素加锁。

join() 不只是等待线程退出。它还建立同步关系:工作线程结束前的写入,在成功 join() 返回后对等待线程可见。因此主线程必须先连接所有工作线程,再读取 partial

3. 如何写出一个生命周期安全的最小版本?

下面的程序只使用 C++11 标准库。JoiningThreads 是一个很小的 RAII 守卫:无论正常离开作用域,还是创建后续线程时抛异常,它都会连接已经成功创建的线程。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
#include <algorithm>
#include <cstddef>
#include <cstdint>
#include <iostream>
#include <numeric>
#include <thread>
#include <vector>

class JoiningThreads {
public:
explicit JoiningThreads(std::vector<std::thread>& threads)
: threads_(threads) {}

~JoiningThreads() {
for (std::size_t i = 0; i < threads_.size(); ++i) {
if (threads_[i].joinable()) {
threads_[i].join();
}
}
}

JoiningThreads(const JoiningThreads&) = delete;
JoiningThreads& operator=(const JoiningThreads&) = delete;

private:
std::vector<std::thread>& threads_;
};

std::uint64_t parallel_sum(
const std::vector<std::uint32_t>& values,
std::size_t requested_threads) {
if (values.empty()) {
return 0;
}

const std::size_t thread_count = std::min(
std::max<std::size_t>(requested_threads, 1),
values.size());
const std::size_t block_size =
(values.size() + thread_count - 1) / thread_count;

std::vector<std::uint64_t> partial(thread_count, 0);

{
std::vector<std::thread> threads;
threads.reserve(thread_count);
JoiningThreads joiner(threads);

for (std::size_t thread_index = 0;
thread_index < thread_count;
++thread_index) {
const std::size_t begin = thread_index * block_size;
const std::size_t end = std::min(
begin + block_size, values.size());

threads.push_back(std::thread(
[&, thread_index, begin, end] {
std::uint64_t local = 0;
for (std::size_t i = begin; i < end; ++i) {
local += values[i];
}
partial[thread_index] = local;
}));
}
} // joiner 在这里连接全部线程

return std::accumulate(
partial.begin(), partial.end(), std::uint64_t{0});
}

int main() {
std::vector<std::uint32_t> values(100000);
std::iota(values.begin(), values.end(), std::uint32_t{0});

const std::uint64_t sequential = std::accumulate(
values.begin(), values.end(), std::uint64_t{0});
const std::uint64_t parallel = parallel_sum(values, 4);

std::cout << "sequential = " << sequential << '\n'
<< "parallel = " << parallel << '\n';

return sequential == parallel ? 0 : 1;
}

在 macOS 或 Linux 的 Clang 环境中可以这样编译:

1
2
clang++ -std=c++11 -O2 -Wall -Wextra -pedantic -pthread parallel_sum.cpp -o parallel_sum
./parallel_sum

预期输出:

1
2
sequential = 4999950000
parallel = 4999950000

MSVC 使用 /std:c++14 或更新标准选项及其线程运行库配置,命令行与 Clang 不同,需要结合 Visual Studio 版本验证。示例选择 std::uint64_t 累加,避免 099999 的和溢出 32 位整数。

4. 这段代码为何没有数据竞争?

values 在启动线程前已经构造完成,所有工作线程只读它。主线程在工作结束前不修改或销毁该容器,因此引用捕获的生命周期有效。

thread_indexbeginend 按值捕获。如果把循环变量全部按引用捕获,线程真正运行时外层循环可能已经改变这些值,多个线程就可能处理错误区间。

每个线程在栈上的 local 中完成整个块的累加。只有一次 partial[thread_index] = local 写入,而且不同线程使用不同下标。普通 std::vector<T> 的不同元素可由不同线程并发修改;std::vector<bool> 是这一规则的重要例外。

内部花括号保证 JoiningThreads 在最终 accumulate 前析构并完成所有 join()。如果直接在守卫仍存活时写 return accumulate(...),返回表达式会在局部对象析构前求值,主线程可能过早读取部分结果。

为什么守卫要在创建线程之前存在?

std::thread 构造可能因资源不足抛出异常。如果已经创建三个线程,第四次失败,栈展开会销毁线程容器;其中仍为 joinable 的线程对象析构会触发 std::terminate()。先创建守卫,异常路径也会连接已存在的线程。

5. joindetach 和线程对象生命周期有什么关系?

一个 std::thread 对象可以是 joinable 或非 joinable。成功启动线程后通常是 joinable;调用 join()detach() 后变为非 joinable。

1
2
std::thread worker(do_work);
worker.join();

若 joinable 的 std::thread 析构,程序调用 std::terminate()。这项设计迫使程序员明确决定线程如何结束,而不是悄悄泄漏后台执行。

detach() 让执行线程与 std::thread 对象分离,并不延长它捕获对象的寿命:

1
2
3
4
void unsafe() {
std::string message = "hello";
std::thread([&] { use(message); }).detach();
} // message 销毁后,后台线程可能仍在访问它

这是悬空引用和进程退出竞态。只有系统级、生命周期由其他机制完整管理的场景才应谨慎使用分离线程;普通业务任务优先使用可连接线程、任务执行器或更高层并发设施。

C++20 的 std::jthread 会在析构时请求停止并连接线程,能减少部分生命周期错误,但它不属于 C++11,且工作函数必须配合停止协议才能及时退出。

6. 什么时候用互斥锁,什么时候用原子变量?

互斥锁适合保护需要作为整体保持不变量的共享状态:

1
2
3
4
5
6
7
std::mutex mutex;
std::queue<Job> jobs;

{
std::lock_guard<std::mutex> lock(mutex);
jobs.push(job);
}

锁的临界区应只包含需要互斥的状态操作。昂贵计算、阻塞 I/O 和用户回调若能安全移出锁,应在解锁后执行,减少其他线程等待。

std::atomic<T> 适合一个变量上的简单原子状态,例如计数器或标志:

1
2
std::atomic<std::uint64_t> completed{0};
completed.fetch_add(1, std::memory_order_relaxed);

这里若计数只用于统计,relaxed 可能足够;如果计数还承担发布其他数据的同步职责,就必须设计正确内存序。原子只保证指定原子对象的操作,不会自动维护多个字段之间的不变量。

问题 常见工具
多字段状态要一起改变 std::mutex + RAII 锁
独立计数或简单标志 std::atomic,明确内存序
等待队列从空变为非空 std::condition_variable
获得任务结果或异常 std::future

锁与原子都可能产生缓存行争用。把共享更新从每个元素一次降为每线程一次,往往比把锁机械换成原子更有效。

7. 条件变量为什么要搭配谓词?

消费者等待队列时,循环 sleep_for 会增加延迟或浪费轮询。条件变量允许线程阻塞,直到生产者通知状态可能发生变化:

1
2
3
4
std::unique_lock<std::mutex> lock(mutex);
condition.wait(lock, [&] {
return closed || !jobs.empty();
});

等待过程中,wait 原子地释放互斥锁并阻塞;被唤醒后重新取得锁,再检查谓词。谓词不可省略,因为条件变量允许虚假唤醒,通知发生时条件也可能已被其他消费者改变。

一个可关闭队列通常遵循这套状态机:

1
2
3
4
开放且队列空 ── 等待
开放且有任务 ── 取出任务
关闭但有任务 ── 处理剩余任务
关闭且队列空 ── 消费者退出

修改谓词所依赖的 jobsclosed 时必须持有同一互斥锁。关闭后要唤醒所有等待消费者,否则它们可能永远无法观察退出条件。

通知放在解锁前还是解锁后需要结合状态和实现讨论;关键正确性来自“状态在锁下改变 + 等待时检查谓词”,不是通知本身保存了一条消息。

8. std::asyncfuture 能解决什么?

只需要异步返回值和异常传播时,std::async 比手工管理线程简单:

1
2
3
4
5
6
7
8
9
std::future<int> result = std::async(
std::launch::async,
[] { return calculate(); });

try {
std::cout << result.get() << '\n';
} catch (const std::exception& error) {
std::cerr << error.what() << '\n';
}

工作函数的返回值或异常存入共享状态,get() 等待并取出结果;同一个普通 futureget() 通常只能调用一次。

不显式指定策略时,实现可以选择异步执行或延迟执行。若必须在另一执行线程异步运行,应明确使用 std::launch::asyncstd::launch::deferred 会等到 get()wait() 时在等待线程执行。

std::async 不是可配置线程池接口。大量细粒度任务可能带来线程创建和调度成本,其资源策略也依实现。任务数量大、需要背压、优先级或可控关闭时,应使用经过验证的执行器或线程池,而不是在基础篇中临时拼装一个不完整实现。

9. chrono、休眠和超时怎样正确使用?

测量耗时通常使用单调的 std::chrono::steady_clock,因为它不会因系统墙上时间调整而倒退:

1
2
3
4
5
6
7
const std::chrono::steady_clock::time_point begin =
std::chrono::steady_clock::now();

do_work();

const std::chrono::duration<double, std::milli> elapsed =
std::chrono::steady_clock::now() - begin;

sleep_forsleep_until 表示至少等待到某个条件,线程可能因调度而更晚恢复,不适合提供硬实时保证。周期任务用递增的绝对截止时间通常比每轮工作结束后再 sleep_for(period) 更少累积漂移:

1
2
3
4
5
6
7
8
std::chrono::steady_clock::time_point next =
std::chrono::steady_clock::now();

while (!should_stop()) {
next += period;
do_one_iteration();
std::this_thread::sleep_until(next);
}

这里还必须设计退出条件,并处理工作时间已经超过周期的情况。休眠不能替代条件变量或正确同步;“多睡一会儿,等另一个线程写完”仍是竞态。

10. 两把锁为什么可能造成死锁?

线程 A 先锁 left 再等 right,线程 B 先锁 right 再等 left,就形成循环等待。

C++11 可以用 std::lock 一次协调多把锁,再用 std::adopt_lock 交给 RAII 对象:

1
2
3
4
5
std::lock(left_mutex, right_mutex);
std::lock_guard<std::mutex> left_lock(
left_mutex, std::adopt_lock);
std::lock_guard<std::mutex> right_lock(
right_mutex, std::adopt_lock);

C++17 提供更简洁的 std::scoped_lock。无论使用哪个工具,都要避免在持锁时调用未知外部代码,因为回调可能以另一顺序重新获取锁。

更高层的策略是统一全局加锁顺序、缩小共享状态,或通过消息传递减少同时持有多把锁的需要。

11. 为什么线程越多不一定越快?

示例中的十万个整数求和很可能小到不值得创建四个线程。并行执行至少增加:

  • 线程创建和销毁;
  • 调度与上下文切换;
  • 分块与最终归约;
  • 缓存一致性和内存带宽竞争。

std::thread::hardware_concurrency() 只返回实现提供的提示,可能为零,也不等于这个任务的最佳线程数。容器、虚拟机、节能核心和其他进程都会影响可用资源。

根据 Amdahl 定律,不能并行的部分会限制总加速比。内存带宽受限任务还可能很快达到平台上限。性能结论应使用 Release 构建和足够工作量,重复测量单线程与不同线程数,而不是默认线程数等于核心数。

12. 工作线程抛异常时会发生什么?

异常不能自动跨越 std::thread 边界。如果工作函数中的异常逃出线程入口,程序会调用 std::terminate()

1
2
3
std::thread worker([] {
throw std::runtime_error("failure"); // 若未在线程内捕获,将终止进程
});

需要回传异常时,可以在线程入口捕获并保存 std::exception_ptr,或使用 std::promise/std::futurestd::packaged_taskstd::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 出错的根因不是加法语法,而是多个线程无同步地读写同一内存位置。给每次加法加锁虽能修正结果,却把循环串行化;线程局部累加加最终归约同时简化了同步和性能模型。

需要记住五点:

  1. 数据竞争是未定义行为,测试偶尔正确没有证明力。
  2. 优先划分不重叠数据,让线程少共享、少写共享状态。
  3. std::thread 析构前必须 joindetach,RAII 能保护异常路径。
  4. 互斥锁维护复合不变量,原子处理有限的共享状态,条件变量负责等待状态变化。
  5. 多线程有创建、调度和带宽成本,性能必须随线程数和真实数据规模测量。

实践中,在创建第一个线程前先写下四件事:每个线程读什么、写什么、何时退出、错误如何回传。若这四个问题没有清晰答案,代码还不具备安全并行的条件。