0. 关于本文
本文将使用C++通过多线程thread/join形式解决大数组求和的效率问题,并引入互斥锁mutex的概念来解决多线程同时读写主线程变量时产生的竞争问题。我们将在上文分支比喻的基础上,讲解多线程如何加快CPU密集程序的运行效率。一并介绍其中各个概念的C++语法。
1. 场景预设
我们要做的事很简单:尽可能快地把一亿个一加起来。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
constexpr size_t ARRAY_SIZE = 100'000'000; // 太可怕了
int main() {
std::vector<int> data(ARRAY_SIZE, 1);
int total = 0;
// 运算开始
auto start = std::chrono::high_resolution_clock::now();
for (int i=0; i<ARRAY_SIZE; i++) {
total += data[i];
}
// 运算结束
auto end = std::chrono::high_resolution_clock::now();
auto duration = std::chrono::duration_cast<std::chrono::milliseconds>(end-start);
std::cout << duration.count() << "ms" << std::endl;
std::cout << total << std::endl;
}
终端打印:
1
2
127ms
100000000
传统方法遍历vector逐个相加,花了127ms才终于算出结果。这是因为此时我们的多核CPU只使用了其中一个核心,尚未用尽全力。
如果我们能够将大数组分成多份,每一份分支出一个全新的线程,并行执行计算,想必就能很好地提升效率吧。
2. Fork-Join模式——建立线程与合并线程
和上一篇文章的async/await类似,每个线程都需要提供一个函数来进行计算。由于线程不能像future一样进行值的返回,这些函数必须通过修改外部变量的形式进行结果输出。
我们建立worker匿名函数来进行计算工作。它接受两个整数作为分段的头尾,并直接与函数外的变量total进行交互,遍历数组并直接把1加到total里。
1
2
3
4
5
6
7
int total = 0;
auto worker = [&](const int from, const int to) {
for (int i = from; i < to; i++) {
total += data[i];
}
};
建立线程容器。我们将在容器里进行线程的实例化。在实例化完成的一瞬间,运算就会开始。
1
std::vector<std::thread> threads{};
实例化使用emplace_back提高性能,避免多余的复制或移动操作。
1
2
3
4
5
int chunk = ARRAY_SIZE / NUM_THREADS;
for (int i = 0; i < NUM_THREADS; i++) {
threads.emplace_back(worker, i * chunk, (i + 1) * chunk);
}
此时,NUM_THREADS个线程已经建立,并独立于主线程开始了各自的计算。主线程需等待它们全部完成再打印结果,此时需要线程合并join。
1
2
3
4
5
6
7
8
for (auto& thread : threads) {
if (thread.joinable()) thread.join();
}
// 运算结束
end = std::chrono::high_resolution_clock::now();
duration = std::chrono::duration_cast<std::chrono::milliseconds>(end-start);
std::cout << duration.count() << "ms" << std::endl;
std::cout << total << std::endl;
其中,对于joinable的判断纯属个人习惯,可增加线程合并的安全性。现在观察打印结果:
1
2
442ms # 2线程下
51759032 # 明显低于预设值
时间反而更高了,并且答案严重错误。更糟糕的是,每次运行这个答案都不一样。
为什么呢?这是因为,total += data[i]是一个线程不安全操作。
3. 线程不安全操作和互斥锁
total += data[i]在CPU底层指令上分为读、算、写三步,这三个步骤对于多个线程来说可能是交叉执行的。两个线程先后读到total = 325,接着先后执行加一运算,最后将寄存器里的326先后重新写入内存。
此时,两次加一操作只让325变成了326,这就造成了数字的丢失,导致最终求和结果小于预期。并且,CPU对于线程的调度是随机的,三个步骤可能交叉也可能不交叉,导致最终结果不可控制。
我们需要使用一种方法来阻止读、算、写三步指令相互交叉,这种方法就是互斥锁。
互斥锁在主线程中通过std::mutex mtx{};声明并获取对象mtx。在worker内部使用mtx.lock()来使线程进入锁定状态,此时该线程将由CPU确保一直执行下去,而不会遭遇其它线程的交叉。在不安全操作结束后,使用mtx.unlock()来重新回到多线程状态。
让我们为worker加锁。
1
2
3
4
5
6
7
8
9
std::mutex mtx{};
auto worker = [&](const int from, const int to) {
mtx.lock();
for (int i = from; i < to; i++) {
total += data[i];
}
mtx.unlock();
};
输出:
1
2
221ms # 2线程下 慢于遍历相加
100000000 # 总算算对了
此时运算速度甚至低于直接相加,为什么?
4. 真正的并行运算——进一步提速
根据阿姆达尔定律(Amdahl’s Law),一个系统性能提升的上限取决于其串行部分的占比。
现在,这个worker函数由于全程被互斥锁包围,导致函数的执行实际上是串行的,一个线程执行完才能执行下一个,无论开多少个线程都无法提升其效率。必须改造这个函数,分离运算和结果传递,只在结果传递时才进行上锁处理。
1
2
3
4
5
6
7
8
9
auto worker = [&](const int from, const int to) {
int out = 0;
for (int i = from; i < to; i++) {
out += data[i];
}
mtx.lock();
total += out;
mtx.unlock();
};
输出:
1
2
83ms # 2线程下 提速1.5倍
100000000 # 线程安全
并且,我们观察到,提升线程数真的可以加速程序的运行,这是程序真正蜕变为并行计算的象征。
1
2
3
41ms # 4线程下 提速3倍
22ms # 8线程下 提速6倍
22ms # 16线程下 确认串行瓶颈 极速为6倍
5. 原子操作——无需上锁
使用std::atomic<int> total = 0;可以建立一个原子对象,原子对象的许多方法可以直接保证线程安全,无需重复加锁。
1
2
3
4
5
6
7
8
9
std::atomic<int> total = 0;
auto worker = [&](const int from, const int to) {
int out = 0;
for (int i = from; i < to; i++) {
out += data[i];
}
total.fetch_add(out); // 简直是方便的
};
total.fetch_add(out)在操作上等价于上了锁的total += out
6. 来看看异步——与多线程的对比
这个流程可以被异步模型完全复刻:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
auto worker_with_ret = [&](const int from, const int to) {
int out = 0;
for (int i = from; i < to; i++) {
out += data[i];
}
return out;
};
// 运算开始
start = std::chrono::high_resolution_clock::now();
std::vector<std::future<int>> futures{};
for (int i = 0; i < NUM_THREADS; i++) {
futures.push_back(std::async(worker_with_ret, i * chunk, (i + 1) * chunk));
}
for (auto& future : futures) {
total += future.get();
}
// 运算结束
end = std::chrono::high_resolution_clock::now();
duration = std::chrono::duration_cast<std::chrono::milliseconds>(end-start);
std::cout << duration.count() << "ms" << std::endl;
std::cout << total << std::endl;
性能与并行多线程相仿。
异步的底层实现依赖多线程,可以把异步认为是多线程的一种抽象封装,拥有更方便的接口和更优秀的可读性。如无特殊需求,可优先使用异步。
下面关于必须使用多线程的场景由qwen3.5-397b-a17b生成
基于 C++ 多线程机制和
thread/join与mutex的底层特性,以下场景通常必须直接使用多线程(std::thread),而难以或无法使用std::async:
- 需要精细控制线程生命周期的场景
- 需要长期运行的后台服务或守护线程
- 需要设置线程特定属性(如栈大小、优先级、亲和性)
- 需要复杂的线程间通信模式(非简单的返回值)
- 异常处理机制要求不同
7. 结语
话说,为什么先讲异步再讲多线程?
大部分多线程场景使用异步更容易上手,也更有可读性。从易到难,和学编程先学Python一样。
异步模型封装了底层细节,能安全解决 90% 的并发需求;而直接操作多线程虽灵活,却需手动管理锁与生命周期,极易引入竞争条件。先掌握高层抽象可快速上手,待遇到性能瓶颈或特殊控制需求时,再深入底层机制也不迟。
和上一篇文章一样,欢迎各位来到新大陆。