第 9 章 高级线程管理 --- 归纳总结
本章仓库中无对应源码目录;可对照
ch6_designingLockBased/thread_safe_queue*.h理解任务队列部分。
前面各章都是直接构造 std::thread 来管理线程。这种方式在某些场景下并不适用 ------ 你得照看线程从创建到结束的全过程,还要依据硬件情况决定开多少个线程。理想做法是把代码拆成尽可能小的可并发单元,"之后交给处理器和标准库进行性能优化"。
另一个场景是提前终止:结果已经明确、出现错误、或用户主动中止时,需要向线程投递停止请求,让它"放弃任务,清理,然后尽快停止"。
本章脉络
线程池的五步演进(9.1):
协作式中断(9.2):
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-catch置done再重抛,配合joiner保证已启动线程能停下并汇入。 - 成员声明顺序至关重要 :
done与work_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 等待依赖任务的任务
问题出在递归型算法上。快排把数据切成两段再递归,如果每层都向固定大小的线程池提交子任务然后阻塞等待,线程很快被等待占满:
原文的说法是"所有线程都在等某一个数据块进行排序,不过没有线程在做这块数据的排序"。
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::bind 绑 this,并对 new_lower_chunk 用 std::move(移动比拷贝便宜);等待循环里调 run_pending_task() 保证等待线程不空转、也不死锁。
遗留缺陷 :submit() 和 run_pending_task() 抢的是同一个队列。多线程改同一份数据的代价在第 8 章已经讲过(乒乓缓存)。
9.1.4 避免任务队列上的竞争
处理器越多,全局任务队列上的竞争越激烈。换成无锁队列能免掉明显的等待,但缓存行在核间来回弹(乒乓缓存)依然吃掉大量时间。
办法是每个线程一份本地队列:
代码 9.6 用 thread_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_flag,set() 时 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();
}
两个缺陷:
wait()可能抛异常,注册的指针没清掉 → 用 RAII 析构函数清理- 存在竞争 ------ 最后一次检查和进入
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 / 互斥量 | 最难 | 能设超时就设,把长阻塞切成短阻塞 |
实践准则
- 线程池的成员声明顺序决定销毁顺序 ------
done/队列 →threads→joiner。 - 构造线程池时要 try-catch:某个线程启动失败要置
done并让已启动的线程停下。 packaged_task只可移动,不能塞进std::function------ 需要自己写类型擦除的包装。- 向线程池提交任务后又阻塞等待它,会死锁 ;等待时必须调
run_pending_task()。 - 本地队列只有拥有者访问,用普通
std::queue就够,不用加锁。 - 任务窃取时每个线程从不同位置开始扫,避免都去偷同一个队列。
- 可中断线程的入口函数必须 catch
thread_interrupted,否则异常逃出会terminate。 - 批量中断时先全部 interrupt、再全部 join,别发一个等一个。
- 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 章)内部有类似机制 |
参考
- 原文:simonhancrew.github.io/CppConcuren...
- P0660 ------ 协作式中断提案,最终以
std::jthread/std::stop_token进入 C++20