chapter 9 高级线程管理 — 归纳总结

第 9 章 高级线程管理 --- 归纳总结

本章仓库中无对应源码目录;可对照 ch6_designingLockBased/thread_safe_queue*.h 理解任务队列部分。

前面各章都是直接构造 std::thread 来管理线程。这种方式在某些场景下并不适用 ------ 你得照看线程从创建到结束的全过程,还要依据硬件情况决定开多少个线程。理想做法是把代码拆成尽可能小的可并发单元,"之后交给处理器和标准库进行性能优化"。

另一个场景是提前终止:结果已经明确、出现错误、或用户主动中止时,需要向线程投递停止请求,让它"放弃任务,清理,然后尽快停止"。

本章脉络

线程池的五步演进(9.1):

flowchart TD A[&#34;最简池<br/>function&#34;] --> A2[&#34;等待任务<br/>function_wrapper&#34;] A2 --> A3[&#34;等待依赖任务<br/>run_pending_task&#34;] A3 --> A4[&#34;本地队列<br/>thread_local&#34;] A4 --> A5[&#34;任务窃取&#34;]

协作式中断(9.2):

flowchart TD B1[&#34;interrupt_flag<br/>thread_local&#34;] --> B2[&#34;中断点&#34;] B2 --> B3[&#34;中断条件变量等待&#34;] B3 --> B4[&#34;处理中断&#34;]

9.1 线程池

类比 :给每个雇员配车不现实,但可以准备一批共享车辆按需借还,没车时就得等。线程池同理 ------ 为每个任务开一个线程不切实际,于是用固定的一组工作线程去消费任务队列。

建池时的关键设计问题:线程数量多少、任务如何高效分配、是否需要等待某个任务完成。后面五个小节就是逐步回答这些问题。

9.1.1 最简单的线程池

代码 9.1 ------ 固定数量工作线程,任务无返回值、无需等待,用 std::function<void()> 封装即可:

cpp 复制代码
class thread_pool {
  std::atomic_bool done;
  thread_safe_queue<std::function<void()>> work_queue;   // 1 第 6 章的线程安全队列
  std::vector<std::thread> threads;                      // 2
  join_threads joiner;                                   // 3 第 8 章的 RAII 汇聚器

  void worker_thread() {
    while (!done) {                                      // 4
      std::function<void()> task;
      if (work_queue.try_pop(task)) task();              // 5 6
      else std::this_thread::yield();                    // 7 队列空则让出时间片
    }
  }
public:
  thread_pool() : done(false), joiner(threads) {
    unsigned const thread_count = std::thread::hardware_concurrency();   // 8
    try {
      for (unsigned i = 0; i < thread_count; ++i)
        threads.push_back(std::thread(&thread_pool::worker_thread, this));  // 9
    } catch (...) {
      done = true;                                       // 10 保证已启动线程能停下
      throw;
    }
  }
  ~thread_pool() { done = true; }                        // 11

  template<typename FunctionType>
  void submit(FunctionType f) {
    work_queue.push(std::function<void()>(f));           // 12
  }
};

要点:

  • 构造期间若某个线程启动抛异常,try-catchdone 再重抛,配合 joiner 保证已启动线程能停下并汇入。
  • 成员声明顺序至关重要donework_queue 必须在 threads 之前,threads 又必须在 joiner 之前,销毁顺序才正确。
  • 局限 :任务无返回值、任务间不能相互等待,否则可能死锁。这类简单场景下 std::async 往往更合适。

9.1.2 等待线程池中的任务

思路是让 submit() 返回一个 std::future,调用方可以等待完成并取回结果。

障碍std::packaged_task<> 只可移动、不可拷贝 ,而 std::function<> 要求可复制构造的函数对象 ------ 所以队列元素类型得换。

代码 9.2 function_wrapper ------ 一个类型擦除的包装类,只需支持"无参、无返回"的调用,一个虚函数就够:

cpp 复制代码
class function_wrapper {
  struct impl_base {
    virtual void call() = 0;
    virtual ~impl_base() {}
  };
  std::unique_ptr<impl_base> impl;

  template<typename F>
  struct impl_type : impl_base {
    F f;
    impl_type(F&& f_) : f(std::move(f_)) {}
    void call() { f(); }
  };
public:
  template<typename F>
  function_wrapper(F&& f) : impl(new impl_type<F>(std::move(f))) {}

  void operator()() { impl->call(); }
  function_wrapper() = default;
  function_wrapper(function_wrapper&& other) : impl(std::move(other.impl)) {}
  function_wrapper& operator=(function_wrapper&& other) {
    impl = std::move(other.impl);
    return *this;
  }
  function_wrapper(const function_wrapper&) = delete;      // 只可移动
  function_wrapper& operator=(const function_wrapper&) = delete;
};

代码 9.3 submit 返回 future

cpp 复制代码
template<typename FunctionType>
std::future<std::invoke_result_t<FunctionType>> submit(FunctionType f) {
  using result_type = std::invoke_result_t<FunctionType>;   // 书中用 std::result_of
  std::packaged_task<result_type()> task(std::move(f));
  std::future<result_type> res(task.get_future());
  work_queue.push(std::move(task));                        // 移动进队列
  return res;
}

9.1.3 等待依赖任务的任务

问题出在递归型算法上。快排把数据切成两段再递归,如果每层都向固定大小的线程池提交子任务然后阻塞等待,线程很快被等待占满:

sequenceDiagram participant P as 线程池(4 线程) participant Q as 任务队列 P->>Q: 4 个线程各提交子任务 P->>P: 4 个线程全部阻塞等待子任务 Q->>Q: 子任务在队列里排着 Note over P,Q: 没有线程去执行队列里的任务 ------ 死锁

原文的说法是"所有线程都在等某一个数据块进行排序,不过没有线程在做这块数据的排序"。

std::async 不会有这个问题(标准库可以选择在 get() 时同步执行);第 8 章的手写版本靠"等待的线程自己去栈上拉任务"绕开。线程池里的解法是把这个能力做进池子本身:

代码 9.4 ------ 本质是把 worker_thread() 的循环体单独抽出来:

cpp 复制代码
void thread_pool::run_pending_task() {
  function_wrapper task;
  if (work_queue.try_pop(task)) task();
  else std::this_thread::yield();
}

代码 9.5 用线程池的快排 ------ 比第 8 章的版本简单得多,线程管理全部下沉到线程池:

cpp 复制代码
template<typename T>
struct sorter {
  thread_pool pool;                                         // 1

  std::list<T> do_sort(std::list<T>& chunk_data) {
    // 取中轴、std::partition 切分、splice 出 new_lower_chunk
    std::future<std::list<T>> new_lower = pool.submit(       // 2 提交下半部分
        std::bind(&sorter::do_sort, this, std::move(new_lower_chunk)));

    std::list<T> new_higher(do_sort(chunk_data));            // 上半部分本线程递归
    result.splice(result.end(), new_higher);

    while (new_lower.wait_for(std::chrono::seconds(0)) ==
           std::future_status::timeout) {
      pool.run_pending_task();                               // 3 等待期间干活
    }
    result.splice(result.begin(), new_lower.get());
    return result;
  }
};

要点:提交时用 std::bindthis,并对 new_lower_chunkstd::move(移动比拷贝便宜);等待循环里调 run_pending_task() 保证等待线程不空转、也不死锁

遗留缺陷submit()run_pending_task() 抢的是同一个队列。多线程改同一份数据的代价在第 8 章已经讲过(乒乓缓存)。

9.1.4 避免任务队列上的竞争

处理器越多,全局任务队列上的竞争越激烈。换成无锁队列能免掉明显的等待,但缓存行在核间来回弹(乒乓缓存)依然吃掉大量时间

办法是每个线程一份本地队列

flowchart TD S[&#34;submit 被调用&#34;] --> C{&#34;是池内线程吗&#34;} C -->|&#34;是&#34;| L[&#34;推进自己的<br/>local_work_queue&#34;] C -->|&#34;否&#34;| G[&#34;推进 pool_work_queue&#34;] W[&#34;worker 取任务&#34;] --> W1{&#34;本地队列有吗&#34;} W1 -->|&#34;有&#34;| W2[&#34;取本地<br/>无竞争&#34;] W1 -->|&#34;没有&#34;| W3[&#34;取全局队列&#34;]

代码 9.6thread_local 实现:

cpp 复制代码
class thread_pool {
  thread_safe_queue<function_wrapper> pool_work_queue;
  using local_queue_type = std::queue<function_wrapper>;         // 1 普通 queue 即可
  static thread_local std::unique_ptr<local_queue_type> local_work_queue;

  void worker_thread() {
    local_work_queue.reset(new local_queue_type);                // 2 池内线程才建
    while (!done) run_pending_task();
    local_work_queue.reset();
  }
public:
  void run_pending_task() {
    function_wrapper task;
    if (local_work_queue && !local_work_queue->empty()) {        // 3 先看本地
      task = std::move(local_work_queue->front());
      local_work_queue->pop();
      task();
    } else if (pool_work_queue.try_pop(task)) {                  // 4 再看全局
      task();
    } else {
      std::this_thread::yield();
    }
  }
};

本地队列可以用普通 std::queue(无需加锁),因为只有拥有它的线程会访问。

新问题 :任务分配可能极不均匀 ------ 某个线程的本地队列堆了一大堆,别的线程却闲着。这引出任务窃取。

9.1.5 任务窃取

让空闲线程去别的线程的队列尾部 偷任务。这要求本地队列可被其他线程访问,所以要换成专门的双端队列:

代码 9.7 work_stealing_queue ------ 用 std::deque 的两端做区分:

cpp 复制代码
class work_stealing_queue {
  using data_type = function_wrapper;
  std::deque<data_type> the_queue;
  mutable std::mutex the_mutex;
public:
  void push(data_type data) {                    // 拥有者:从前端推入
    std::lock_guard<std::mutex> lock(the_mutex);
    the_queue.push_front(std::move(data));
  }
  bool try_pop(data_type& res) {                 // 拥有者:从前端取出
    std::lock_guard<std::mutex> lock(the_mutex);
    if (the_queue.empty()) return false;
    res = std::move(the_queue.front());
    the_queue.pop_front();
    return true;
  }
  bool try_steal(data_type& res) {               // 小偷:从后端取出
    std::lock_guard<std::mutex> lock(the_mutex);
    if (the_queue.empty()) return false;
    res = std::move(the_queue.back());
    the_queue.pop_back();
    return true;
  }
};

关键设计 :拥有者用前端 (push/pop 都在 front),小偷用后端 (steal 在 back)------ 两端操作减少了竞争,只有队列里只剩一个元素时才会真正碰头。

代码 9.8 完整的窃取线程池

cpp 复制代码
void run_pending_task() {
  function_wrapper task;
  if (pop_task_from_local_queue(task) ||        // 1 先本地
      pop_task_from_pool_queue(task) ||         // 2 再全局
      pop_task_from_other_thread_queue(task)) { // 3 最后去偷
    task();
  } else {
    std::this_thread::yield();
  }
}

bool pop_task_from_other_thread_queue(function_wrapper& task) {
  for (unsigned i = 0; i < queues.size(); ++i) {
    unsigned const index = (my_index + i + 1) % queues.size();   // 错开起点
    if (queues[index]->try_steal(task)) return true;
  }
  return false;
}

注意 pop_task_from_other_thread_queue 里的 (my_index + i + 1) % size ------ 每个线程从不同位置开始扫,避免所有线程都去偷同一个队列。


9.2 中断线程

长时间运行的线程需要"打招呼式"地停下来,而不是被强行掐断。C++11 没有内置这套机制 (提案 P0660 把协作式中断留给了后续标准,即后来的 std::jthread / std::stop_token),但自己实现并不难。作者的思路是用一个统一机制,而不是每个场景各写一套。

9.2.1 启动和中断另一个线程

外部接口就是 std::thread 的接口再加一个 interrupt()

cpp 复制代码
class interruptible_thread {
public:
  template<typename FunctionType> interruptible_thread(FunctionType f);
  void join();
  void detach();
  bool joinable() const;
  void interrupt();
};

线程内部需要一个无参的 interruption_point(),靠 thread_local 变量找到"当前线程的"中断数据结构。这个 thread_local 标志正是不能直接用裸 std::thread 的原因 ------ 构造时必须把新线程里那个标志的地址捞出来:

cpp 复制代码
thread_local interrupt_flag this_thread_interrupt_flag;

template<typename FunctionType>
interruptible_thread(FunctionType f) {
  std::promise<interrupt_flag*> p;
  internal_thread = std::thread([f, &p]{
    p.set_value(&this_thread_interrupt_flag);
    f();
  });
  flag = p.get_future().get();      // 阻塞直到新线程报告地址
}
void interrupt() { if (flag) flag->set(); }

关键论断 :对局部变量 p 取引用不会悬空,因为构造函数会一直等到 p 不再被使用为止 。不过这份实现没处理 join/detach 之后 flag 的生命周期。

9.2.2 检测线程是否中断

cpp 复制代码
void interruption_point() {
  if (this_thread_interrupt_flag.is_set()) throw thread_interrupted();
}

放在循环里能用,但局限很明确 :线程阻塞时根本跑不到这一行 ------ "这时的线程不能运行,也就不能调用 interruption_point() 函数"。

9.2.3 中断条件变量的等待

朴素版本:等待前把 cv 注册到 interrupt_flagset()notify_all() 唤醒它。

cpp 复制代码
void interruptible_wait(std::condition_variable& cv,
                        std::unique_lock<std::mutex>& lk) {
  interruption_point();
  this_thread_interrupt_flag.set_condition_variable(cv);
  cv.wait(lk);                                       // 有问题
  this_thread_interrupt_flag.clear_condition_variable();
  interruption_point();
}

两个缺陷

  1. wait() 可能抛异常,注册的指针没清掉 → 用 RAII 析构函数清理
  2. 存在竞争 ------ 最后一次检查和进入 wait() 之间的窗口。想用 lk 的互斥量去保护,就得把一个生命周期未知 的互斥量引用交给中断方,可能死锁或访问已销毁对象,所以此路不通

折中方案是超时轮询

cpp 复制代码
void interruptible_wait(std::condition_variable& cv,
                        std::unique_lock<std::mutex>& lk) {
  interruption_point();
  this_thread_interrupt_flag.set_condition_variable(cv);
  interrupt_flag::clear_cv_on_destruct guard;         // RAII 清理
  interruption_point();
  cv.wait_for(lk, std::chrono::milliseconds(1));      // 只等 1ms
  interruption_point();
}

代价是响应延迟最多 1ms,好处是不再有那个竞争窗口。作者承认这不优雅,但可用。

9.2.4 中断 condition_variable_any 的等待

std::condition_variable_any 能配任意锁类型,所以可以传入自定义的锁 ,在 lock()/unlock() 里插入中断检查 ------ 这样就不需要超时轮询了。这是 condition_variable_any 相对 condition_variable 的实质优势。

9.2.5 中断其他阻塞调用

条件变量之外的阻塞(future 的 wait()、I/O、互斥量)没有统一解法。通用手法仍是改成带超时的版本 + 循环检查

cpp 复制代码
while (!done) {
  interruption_point();
  if (f.wait_for(std::chrono::milliseconds(1)) == std::future_status::ready)
    break;
}

原则:能设超时就设超时,把长阻塞切成一串短阻塞,中断检查插在缝隙里。

9.2.6 处理中断

中断通过抛 thread_interrupted 异常实现,所以处理方式就是正常的异常处理

cpp 复制代码
try {
  do_something();
} catch (thread_interrupted&) {
  handle_interruption();       // 清理,也可以选择继续
}

关键论断 :如果异常逃出线程入口函数,std::thread 会调用 std::terminate ------ 所以每个可中断线程的入口函数都要包一层 catch

cpp 复制代码
internal_thread = std::thread([f, &p]{
  p.set_value(&this_thread_interrupt_flag);
  try {
    f();
  } catch (thread_interrupted const&) {}     // 吞掉,正常退出
});

9.2.7 从桌面消息处理中中断后台线程

一个完整场景:GUI 应用启动后台线程做长任务,用户点"取消"时中断它。

cpp 复制代码
std::mutex config_mutex;
std::vector<interruptible_thread> background_threads;

void background_thread(int disk_id) {
  while (true) {
    interruption_point();               // 1 定期检查
    fs_change fsc = get_fs_changes(disk_id);
    if (fsc.has_changes()) update_index(fsc);
  }
}

void start_background_processing() {
  background_threads.push_back(interruptible_thread(background_thread, disk_1));
  background_threads.push_back(interruptible_thread(background_thread, disk_2));
}

int main() {
  start_background_processing();
  process_gui_until_exit();                                    // GUI 主循环
  std::unique_lock<std::mutex> lk(config_mutex);
  for (auto& t : background_threads) t.interrupt();             // 2 先全部请求中断
  for (auto& t : background_threads) t.join();                  // 3 再逐个等待
}

注意 ② 和 ③ 分成两个循环 :先给所有线程发中断请求,再统一等待。如果合成一个循环(发一个、等一个),线程们就会串行地退出,白白拖长退出时间。


9.3 本章总结

本章讲了两件事:

线程池 ------ 从最简单的 function<void()> 队列出发,逐步解决五个问题:任务无返回值(→ function_wrapper + future)、任务间依赖导致死锁(→ run_pending_task)、全局队列竞争(→ thread_local 本地队列)、负载不均(→ 任务窃取)。

中断线程 ------ C++11 没有内置机制,用 thread_local interrupt_flag + 中断点 + 异常自己实现;难点在于阻塞中的线程跑不到中断点 ,条件变量场景要靠超时轮询或 condition_variable_any 配自定义锁绕开。


速查表

线程池演进路线

版本 解决的问题 关键手段
代码 9.1 基本可用 function<void()> + 线程安全队列
代码 9.2/9.3 任务要返回值、要能等 function_wrapper(类型擦除,只可移动)+ packaged_task
代码 9.4/9.5 任务间依赖会死锁 run_pending_task() ------ 等待时顺手干活
代码 9.6 全局队列竞争 + 乒乓缓存 thread_local 本地队列(可用普通 queue
代码 9.7/9.8 负载不均 任务窃取,拥有者用前端、小偷用后端

中断机制的三层难度

场景 能否中断 手段
正常运行的循环 容易 循环里插 interruption_point()
等条件变量 有竞争窗口 wait_for(1ms) 轮询,或用 condition_variable_any + 自定义锁
等 future / I/O / 互斥量 最难 能设超时就设,把长阻塞切成短阻塞

实践准则

  1. 线程池的成员声明顺序决定销毁顺序 ------ done/队列 → threadsjoiner
  2. 构造线程池时要 try-catch:某个线程启动失败要置 done 并让已启动的线程停下。
  3. packaged_task 只可移动,不能塞进 std::function ------ 需要自己写类型擦除的包装。
  4. 向线程池提交任务后又阻塞等待它,会死锁 ;等待时必须调 run_pending_task()
  5. 本地队列只有拥有者访问,用普通 std::queue 就够,不用加锁。
  6. 任务窃取时每个线程从不同位置开始扫,避免都去偷同一个队列。
  7. 可中断线程的入口函数必须 catch thread_interrupted ,否则异常逃出会 terminate
  8. 批量中断时先全部 interrupt、再全部 join,别发一个等一个。
  9. C++20 起优先用 std::jthread + std::stop_token,不必自己造这套轮子。

与后续标准的对应

本章手写 标准设施
interruptible_thread C++20 std::jthread
interrupt_flag / interruption_point() C++20 std::stop_token / std::stop_source
thread_pool 标准仍无线程池;C++17 并行算法(第 10 章)内部有类似机制

参考

相关推荐
linx2951 小时前
阶段三讲义:类型与对象(Classes & Essential Operations)
c++
飞鸟真人1 小时前
C++ lambda 捕获完整梳理
c++·闭包·lamda
東隅已逝,桑榆非晚2 小时前
c++内存管理
c++·笔记
是个西兰花2 小时前
C++11:线程库与线程安全问题
开发语言·c++
少司府2 小时前
C++进阶:智能指针
开发语言·数据结构·c++·b树·算法·c·智能指针
水饺编程3 小时前
编程数学:三角函数基础01,直角三角函数
c语言·c++·windows·visual studio
初願致夕霞3 小时前
C/C++传统程序内存分布(代码实测)
java·c语言·c++
牛油果子哥q3 小时前
C++对接LLM完整工程:异步HTTP请求、JSON解析、超时容错、重连兜底
c++·http·json
会周易的程序员3 小时前
aiDgeController软PLC控制通讯协议文档
c++·物联网·网关·iot·ipc·进程间通讯