多线程算法 — 从零精通算法与数据结构——Google 面试系统备战 第16篇
第16章:多线程算法
本章目标
读完本章你会:
- 理解 work/span 模型,计算任意并行算法的理论加速比
- 用 fork-join 模式并行化归并排序和矩阵乘法
- 分析并行循环(par-for)的 grain size 对性能的影响
- 识别 race condition 并用适当的同步机制保护
- 理解 C++17/20 并行 STL(
<execution>)的使用
知识讲解
从一个生活例子开始
你要整理 1000 本书,按作者字母排序。
串行: 你一个人做,一本一本排——T₁ = 1000 时间单位。
并行: 叫来 7 个朋友,8 个人一起。先把书分成 8 堆,每人排自己那堆,然后合并。但合并必须有人等别人——这就是关键路径(span)。
如果分堆和合并占 50 单位,排序占 125 单位:T_∞ = 50 + 125 + 50 = 225 单位。8 个人的加速比 ≈ 1000/225 ≈ 4.4×——达不到理想的 8×。这就是 Amdahl 定律:加速比受限于不可并行化的部分。
工作原理
16.1 Work/Span 模型
| 概念 | 含义 | 类比 |
|---|---|---|
| Work (T₁) | 总工作量——1 个处理器完成所需时间 | 一本书一个人排的总时间 |
| Span (T_∞) | 关键路径——∞ 个处理器所需时间 | 必须串行完成的链条 |
关键定理:
- 加速比: T₁ / T_p ≤ T₁ / T_∞(span 给出了加速比的上限)
- Brent 定理: T_p ≤ T₁/p + T_∞(p 个处理器的运行时间上界)
直觉: 即使有 ∞ 个处理器,你的程序也不快于 T_∞——span 是并行化的终极瓶颈。
16.2 Fork-Join 模式
par_for(start, end, grain_size):
if end - start < grain_size:
串行处理 [start, end)
else:
mid = (start + end) / 2
fork: par_for(start, mid)
par_for(mid, end) ← 当前线程做这个
join
Grain size(粒度): 递归分到多小开始串行处理。太小 → fork/join 开销主导;太大 → 并行度不够。通常设为 1000-10000 次迭代。C++ <execution> 的 par_unseq 策略自动处理粒度。
16.3 并行算法分析示例
并行归并排序(经典例子):
串行: T₁ = O(n log n)
并行:
- 分:O(1)
- 并行排序两半
- 合并:O(n)(串行)
Span: T_∞(n) = T_∞(n/2) + O(n) = O(n)
↑ 合并是瓶颈——不能并行!
Work: T₁(n) = O(n log n)(与串行相同)
加速比上限 = T₁/T_∞ = O(log n)。n=10⁶ 时理论加速比 ≈ 20×。
并行矩阵乘法(更好的并行性):
C = A × B,C[i][j] = Σ A[i][k]·B[k][j]
Work: T₁ = O(n³)
Span: T_∞ = O(log n)(用分治乘法的递归树)
加速比上限 = n³ / log n —— 接近完美线性加速!
16.4 C++ 并行实践
// C++17 并行排序
#include <execution>
#include <algorithm>
std::sort(std::execution::par, data.begin(), data.end());
// 并行策略选项:
// - seq: 串行
// - par: 并行
// - par_unseq: 并行 + 向量化(SIMD)
// - unseq: 仅向量化
// 手动线程池处理
std::vector<std::thread> threads;
for (int t = 0; t < num_threads; ++t) {
threads.emplace_back([&, t]() {
// 处理 t 号分片
});
}
for (auto& th : threads) th.join();
⚠ 关键:
std::execution::par需要 TBB(Intel Threading Building Blocks)支持——Windows 上 MSVC 默认有,Linux 上可能需要libtbb-dev。
16.5 Race Condition 与同步
Race condition: 两个线程同时访问同一内存,至少一个是写操作。
// ❌ 坏——data race
int counter = 0;
par_for(0, n, [&](int i) { ++counter; });
// ✅ 好——原子操作
std::atomic<int> counter{0};
par_for(0, n, [&](int i) { counter.fetch_add(1); });
// ✅ 好——归约
std::vector<int> partial_sums(num_threads);
// 每个线程写自己的 partial_sums[tid]
// 最后串行求和
代码实战
include/algo/parallel.h
#ifndef ALGO_PARALLEL_H_
#define ALGO_PARALLEL_H_
#include <algorithm>
#include <functional>
#include <thread>
#include <vector>
namespace algo {
// ========== 并行归并排序 ==========
template <typename RandomIt>
void ParallelMergeSort(RandomIt begin, RandomIt end, int depth = 0);
// ========== 通用 par_for ==========
// 在 [0, n) 上并行执行 func(i),grain_size 控制粒度
void ParFor(int n, std::function<void(int)> func,
int grain_size = 1000);
// ========== 硬件并行度 ==========
inline int NumHardwareThreads() {
return static_cast<int>(std::thread::hardware_concurrency());
}
} // namespace algo
#endif // ALGO_PARALLEL_H_
include/algo/parallel_impl.h
namespace algo {
namespace detail {
// 最大 fork 深度根据硬件线程数动态计算
inline int MaxForkDepth() {
int cores = NumHardwareThreads();
return std::max(1, static_cast<int>(std::log2(cores)));
}
constexpr int kSeqThreshold = 10000;
template <typename RandomIt>
void ParallelMergeSortImpl(RandomIt begin, RandomIt end, int depth) {
auto size = std::distance(begin, end);
if (size <= 1) return;
if (size <= kSeqThreshold || depth >= MaxForkDepth()) {
std::sort(begin, end); // 串行排序
return;
}
auto mid = begin + size / 2;
// Fork: 左半在新线程
std::thread left_thread([&]() {
ParallelMergeSortImpl(begin, mid, depth + 1);
});
// 当前线程处理右半
ParallelMergeSortImpl(mid, end, depth + 1);
left_thread.join();
// 合并(必须串行——这是瓶颈)
std::inplace_merge(begin, mid, end);
}
} // namespace detail
template <typename RandomIt>
void ParallelMergeSort(RandomIt begin, RandomIt end, int depth) {
detail::ParallelMergeSortImpl(begin, end, 0);
}
inline void ParFor(int n, std::function<void(int)> func, int grain_size) {
if (n <= grain_size) {
for (int i = 0; i < n; ++i) func(i);
return;
}
int num_threads = std::min(NumHardwareThreads(), n / grain_size);
std::vector<std::thread> threads;
for (int t = 0; t < num_threads; ++t) {
threads.emplace_back([&, t]() {
int start = t * n / num_threads;
int end = (t + 1) * n / num_threads;
for (int i = start; i < end; ++i) func(i);
});
}
for (auto& th : threads) th.join();
}
} // namespace algo
本章小结
- Work/Span 模型:work 是总计算量,span 是关键路径——加速比上限 = T₁/T_∞
- Fork-join 是分治并行化的核心模式——递归分治直到粒度阈值
- 归并排序的 span = O(n)(合并瓶颈),矩阵乘法的 span = O(log n)(近乎完美并行)
- Grain size 权衡:太小→线程开销主导,太大→并行度不够
- C++17+ 提供
std::execution::par策略,多数场景优先使用标准库
关键术语
| 术语 | 释义 |
|---|---|
| Work (T₁) | 算法在 1 个处理器上的总计算量 |
| Span (T_∞) | 算法的关键路径长度——∞ 个处理器也无法突破的瓶颈 |
| Fork-Join | 并行模式:fork 创建新线程处理一半,当前线程处理另一半,join 同步 |
| Race Condition | 多线程同时访问共享数据且至少有一个写——结果不可预测 |
| Grain Size | 并行递归的停止阈值——小于此规模转为串行处理 |

浙公网安备 33010602011771号