每个任务开一个线程为什么会失控?从任务队列到可关闭线程池

时间:2026/04/09

下面的异步写法在任务很少时完全能用:

1
2
3
4
5
for (auto& request : requests) {
std::thread([request] {
handle(request);
}).detach();
}

一旦请求突然增加,它就会把流量直接转换成线程数量。线程创建和销毁有成本,每个线程需要栈空间,过多可运行线程还会增加上下文切换。更棘手的是,detach() 让线程生命周期脱离管理:程序退出时,很难证明任务已经完成、依赖对象仍然存在。

线程池解决的不是“怎样让所有代码并行”,而是怎样用受控数量的工作线程消费任务流,并为提交、结果、异常和停止建立清晰协议。本文将在上一篇阻塞队列的基础上,实现一个能够返回 future、排空任务并安全 join 的最小线程池。


1. 线程池比“每个任务一个线程”多解决了什么?

线程池通常由三部分组成:

1
2
3
4
提交线程 ──> [ 任务队列 ] ──> worker 1
│ ├─> worker 2
│ └─> worker N
└──── 停止与背压协议

预先创建的 worker 反复从队列取任务,因此线程数不会随任务数线性增长。任务队列吸收短时速度差,停止协议则决定不再接收任务后怎样处理积压。

这三个部分各有明确职责:

  • 任务队列负责跨线程传递任务和同步状态;
  • worker负责执行任务,不关心任务的具体返回类型;
  • 线程池生命周期负责拒绝新任务、唤醒线程并等待它们退出。

消息队列与线程池不是同一个概念。消息队列强调传递,消费者可以是任意组件;线程池强调复用一组线程执行任务,只是内部通常需要一个任务队列。

2. 为什么线程数量不是越多越好?

对于主要进行计算的 CPU 密集型任务,同时运行的 worker 通常不应远超可用硬件并发数。线程太多不会产生更多计算单元,只会增加调度和缓存压力。

I/O 密集型任务在等待磁盘或网络时不占用 CPU,可以容纳更多线程,但“更多”仍需要通过延迟、吞吐和资源占用测量,而不是套用固定倍数。

std::thread::hardware_concurrency() 可以作为初始参考,却允许返回 0,也不反映容器 CPU 配额、CPU 亲和性和任务特征。工程中应给出可靠的回退值,并允许通过经过验证的配置选择线程数。

线程池也不适合所有异步工作。成千上万个长时间阻塞连接更适合事件循环或异步 I/O;有依赖关系、优先级和取消需求的任务可能需要更完整的调度器。

3. 队列里怎样保存返回类型不同的任务?

一个任务可能返回 int,另一个返回字符串,还有一个只产生副作用。如果队列直接保存这些具体类型,线程池接口会迅速变复杂。

常见做法是把所有任务擦除成统一的:

1
std::function<void()>

worker 只调用它,不需要知道原始返回类型。返回值和异常则由 std::packaged_task<R()> 保存到共享状态,提交方通过 std::future<R> 取得结果。

1
2
3
4
5
用户函数 R()
↓ 包装
packaged_task<R()> ─────> future<R>
↓ 再包装
function<void()> ───────> 任务队列 ──> worker 执行

packaged_task 只能移动,而 C++17 的 std::function 要求其目标可复制。因此可以把 packaged_task 放进 shared_ptr,再让队列中的小函数复制这个智能指针。这里的共享所有权有清晰边界:提交函数和队列任务需要共同保证 packaged task 活到执行或销毁。

4. 一个最小可运行的线程池

下面的 C++17 示例采用“停止接收、排空队列”的关闭语义:停止后 submit 抛出异常,已入队任务仍会执行,队列空后 worker 退出。

为保持重点清晰,这个版本使用无界任务队列,并约定 shutdown() 由线程池所有者调用,不能由 worker 自己调用,也不与其他 shutdown() 并发。背压策略将在后文讨论。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
#include <condition_variable>
#include <cstddef>
#include <functional>
#include <future>
#include <iostream>
#include <memory>
#include <mutex>
#include <queue>
#include <stdexcept>
#include <thread>
#include <type_traits>
#include <utility>
#include <vector>

class ThreadPool {
public:
explicit ThreadPool(std::size_t worker_count) {
if (worker_count == 0) {
throw std::invalid_argument("worker_count must be greater than zero");
}

try {
workers_.reserve(worker_count);
for (std::size_t i = 0; i < worker_count; ++i) {
workers_.emplace_back([this] { worker_loop(); });
}
} catch (...) {
{
std::lock_guard<std::mutex> lock(mutex_);
stopping_ = true;
}
ready_.notify_all();
join_workers();
throw;
}
}

~ThreadPool() {
shutdown();
}

ThreadPool(const ThreadPool&) = delete;
ThreadPool& operator=(const ThreadPool&) = delete;

template <class F>
auto submit(F&& function)
-> std::future<std::invoke_result_t<std::decay_t<F>&>> {
using Function = std::decay_t<F>;
using Result = std::invoke_result_t<Function&>;

auto task = std::make_shared<std::packaged_task<Result()>>(
std::forward<F>(function));
std::future<Result> result = task->get_future();

{
std::lock_guard<std::mutex> lock(mutex_);
if (stopping_) {
throw std::runtime_error("thread pool is stopping");
}
tasks_.push([task] { (*task)(); });
}

ready_.notify_one();
return result;
}

void shutdown() noexcept {
{
std::lock_guard<std::mutex> lock(mutex_);
stopping_ = true;
}
ready_.notify_all();
join_workers();
}

private:
void worker_loop() {
while (true) {
std::function<void()> task;
{
std::unique_lock<std::mutex> lock(mutex_);
ready_.wait(lock, [this] {
return stopping_ || !tasks_.empty();
});

if (stopping_ && tasks_.empty()) {
return;
}

task = std::move(tasks_.front());
tasks_.pop();
}
task();
}
}

void join_workers() noexcept {
for (std::thread& worker : workers_) {
if (worker.joinable()) {
worker.join();
}
}
}

std::mutex mutex_;
std::condition_variable ready_;
std::queue<std::function<void()>> tasks_;
bool stopping_ = false;
std::vector<std::thread> workers_;
};

int main() {
ThreadPool pool(2);

auto answer = pool.submit([] {
return 6 * 7;
});

auto failure = pool.submit([]() -> int {
throw std::runtime_error("task failed");
});

std::cout << "answer = " << answer.get() << '\n';

try {
static_cast<void>(failure.get());
} catch (const std::exception& error) {
std::cout << "caught: " << error.what() << '\n';
}
}

在 macOS、Linux 或支持 C++17 线程库的平台上编译:

1
2
3
clang++ -std=c++17 -O2 -Wall -Wextra -pedantic -pthread \
thread_pool.cpp -o thread_pool
./thread_pool

预期输出:

1
2
answer = 42
caught: task failed

两个任务的实际执行顺序不确定,但 future::get() 让主线程按明确顺序取得结果。第二个任务抛出的异常没有逃出 worker,而是由 packaged_task 保存,随后在 failure.get() 中重新抛出。

5. worker 为什么不会在析构时永远等待?

worker 的等待谓词是:线程池正在停止,或者队列不为空。

1
2
3
ready_.wait(lock, [this] {
return stopping_ || !tasks_.empty();
});

shutdown() 在锁内设置 stopping_,随后 notify_all()。所有睡眠 worker 都会醒来:

  • 队列仍有任务时,继续取任务;
  • stopping_ 为真且队列为空时,退出循环;
  • 主线程最后通过 join() 等待每个 worker 真正结束。

这形成了清晰顺序:

1
2
3
4
5
6
7
8
9
拒绝新任务

唤醒所有 worker

排空已提交任务

worker 退出

join 完成,线程池资源才可销毁

如果只设置标志却不通知,空队列上的 worker 可能永远睡眠;如果只通知却没有受锁保护的停止状态,线程醒来后也不知道是否应该退出。

6. 为什么任务执行一定要放在锁外?

worker 只在取任务时持有互斥锁,随后离开临界区再执行:

1
2
3
4
5
{
std::unique_lock<std::mutex> lock(mutex_);
// 等待并取走任务
}
task(); // 已经释放队列锁

如果持锁执行任务,一次慢任务会阻止:

  • 其他 worker 取得不同任务;
  • 提交线程把新任务放入队列;
  • 线程池所有者开始关闭。

更严重的是,任务内部如果再次调用同一线程池的 submit,还可能等待自己持有的锁。线程池的锁只保护队列和停止状态,不能延伸到用户代码。

7. future 怎样传递返回值和异常?

packaged_task、共享状态与 future 组成了一条结果通道:

1
2
3
4
5
worker 调用 packaged_task
├─ 正常返回 ─> 结果写入共享状态
└─ 抛出异常 ─> exception_ptr 写入共享状态

future::get()

get() 会等待任务完成,并且只能成功取值一次。若调用方永远不调用 get(),任务仍会执行,只是返回值或异常没有被观察。

这也是为什么不能简单地把任意用户函数直接放进 worker 后任其抛异常。异常如果逃出线程入口函数,程序会调用 std::terminate。使用 packaged_task 可以把任务异常传回提交方;其他内部任务仍应在 worker 边界显式捕获和记录异常。

8. 无界队列为什么仍然可能让线程池失控?

固定 worker 数量控制了线程资源,却没有控制排队任务数量。如果提交速度长期高于处理速度,无界队列仍会持续占用内存,并让尾部任务等待越来越久。

工程中的过载策略通常有以下选择:

策略 队列满时 主要代价
阻塞提交 等待空位 提交线程可能被拖住或发生依赖死锁
立即拒绝 返回失败或抛异常 调用方必须处理降级
超时提交 等待有限时间 接口和错误处理更复杂
丢弃任务 按规则丢新或丢旧 只适合允许数据损失的业务
调用方执行 提交线程自己处理 延迟转移到调用方,形成自然背压

选择哪一种取决于任务能否丢失、提交线程能否阻塞以及端到端延迟预算。不能只给队列加一个容量数字,却不定义满载行为。

9. 线程池内部提交任务为什么可能死锁?

假设线程池只有两个 worker,两个正在执行的父任务都向同一线程池提交子任务,然后立即调用子任务的 future.get()。此时两个 worker 都在等待,子任务却只能由这两个 worker 执行:

1
2
3
worker 1:等待 child A ─┐
├─ 队列中 child A/B 无人执行
worker 2:等待 child B ─┘

增加线程数只能降低出现概率,不能修复依赖环。可行策略包括:避免 worker 同步等待同池子任务、让等待线程协助执行队列任务,或使用理解任务依赖的调度器。最小线程池不应被误当成通用任务图系统。

10. 动态扩缩容应该什么时候考虑?

增加 worker 看起来只需向 workers_ 添加线程,实际还要与 shutdown()、线程创建失败、最大线程数和容器并发访问协调。缩容更难,因为 C++ 没有可以安全强杀任意线程的标准机制,worker 必须在协议允许的安全点主动退出。

对于 CPU 密集任务,固定大小线程池通常更可预测。只有任务阻塞特征明显变化,并且测量证明固定线程数无法兼顾资源与延迟时,才值得引入空闲超时、最小/最大线程数和扩缩容状态机。动态线程数不是免费的“高级版本”。

11. 工程中最容易踩哪些坑?

误区一:析构时直接 detach worker

分离线程不再可 join,却仍可能访问已经析构的队列和线程池对象。线程池应拥有 worker,并在销毁成员前等待它们退出。

误区二:停止后仍允许提交

如果 submit 与停止状态没有由同一把锁协调,任务可能在 worker 全部退出后才进入队列,永远不会执行。检查停止状态和入队必须是一个临界区内的原子决策。

误区三:用线程数量代替背压

不断增加 worker 会消耗更多栈、调度和下游资源,也无法修复下游服务容量不足。队列上限和过载策略比无限扩线程更重要。

误区四:在 worker 内销毁线程池

示例的 shutdown()join 所有 worker。如果某个 worker 调用它,就会尝试 join 自己,导致错误或死锁。线程池的所有权和关闭职责应位于 worker 之外。

误区五:忽略构造过程中的线程创建失败

创建第 N 个线程可能抛出 std::system_error。如果此前创建的 std::thread 仍处于 joinable 状态就直接展开离开,析构它们会终止程序。示例在构造函数的异常路径中先通知并 join 已创建线程,再重新抛出异常。

12. 什么时候适合使用这个线程池?

这种固定大小的简单线程池适合一批相互独立、执行时间有限、可以通过 future 取结果的后台任务,例如 CPU 数据处理、少量阻塞作业或把同步工作移出主线程。

它不适合直接承担:

  • 需要优先级、定时、取消和任务依赖的复杂调度;
  • 大量长期阻塞的网络连接;
  • 必须严格限制排队内存、但尚未实现背压的生产入口;
  • 需要 worker 内部安全地等待同池子任务的递归工作流。

在这些场景中,应选择具备对应语义的执行器、异步 I/O 框架或任务调度系统。

13. 总结

开头“每个任务一个分离线程”的问题,不只是线程创建慢,而是系统完全失去了对并发数量和生命周期的控制。线程池把这两件事收回到一个明确边界中。

  1. 固定 worker 复用线程,任务队列负责连接提交速度与处理速度;
  2. packaged_taskfuture 让不同返回类型与异常通过统一任务队列传递;
  3. 停止状态、任务入队和 worker 等待必须由同一同步协议协调;
  4. 关闭时先拒绝提交、排空任务,再 join worker,不能依赖 detach
  5. 有界队列、过载策略和任务依赖决定线程池能否真正用于生产环境。

实现线程池前,先写下四个答案:最多有多少 worker、最多积压多少任务、队列满时怎么办、关闭时剩余任务怎么办。模板技巧只是实现细节,这四个边界才决定系统是否可控。