多线程算法 — 从零精通算法与数据结构——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

本章小结

  1. Work/Span 模型:work 是总计算量,span 是关键路径——加速比上限 = T₁/T_∞
  2. Fork-join 是分治并行化的核心模式——递归分治直到粒度阈值
  3. 归并排序的 span = O(n)(合并瓶颈),矩阵乘法的 span = O(log n)(近乎完美并行)
  4. Grain size 权衡:太小→线程开销主导,太大→并行度不够
  5. C++17+ 提供 std::execution::par 策略,多数场景优先使用标准库

关键术语

术语 释义
Work (T₁) 算法在 1 个处理器上的总计算量
Span (T_∞) 算法的关键路径长度——∞ 个处理器也无法突破的瓶颈
Fork-Join 并行模式:fork 创建新线程处理一半,当前线程处理另一半,join 同步
Race Condition 多线程同时访问共享数据且至少有一个写——结果不可预测
Grain Size 并行递归的停止阈值——小于此规模转为串行处理
posted @ 2026-06-22 01:09  Yobeeo  阅读(10)  评论(0)    收藏  举报