Appearance
线程池设计(HPC 必考手写)
一、为什么需要线程池
- 线程创建/销毁开销大(系统调用、栈 8MB 虚映射、调度器状态)。
- 高频任务若每任务开线程 → 调度器压力 + 上下文切换爆炸 + 缓存冷。
- 线程池:固定 N 个常驻工作线程 + 共享任务队列,主线程只投递任务,吞吐和延迟稳定。
HPC 用途:并行 for、流水线 stage(解压→预处理→计算→写出)、推理批处理 worker、异步 IO 回调。
二、核心设计要素
- 工作线程数 W:CPU 密集 ≈
hardware_concurrency()(扣掉默认归一的 AVX512 频率考量);混合 IO 按需放大。 - 任务队列:经典单锁 +
std::condition_variable(互斥队列);高性能用无锁 MPMC 队列或每线程本地队列 + 工作窃取。 - 任务模型:
std::function<void()>(需类型擦除,小任务可能堆分配),或侵入式任务节点(链表、避免每任务 alloc)。 - 优雅关闭:stop 标志 + notify_all + join。
- 扩展:返回值(future)、优先级、批量提交、依赖(DAG)。
三、经典互斥版线程池(面试能写对即过)
cpp
#include <condition_variable>
#include <functional>
#include <future>
#include <mutex>
#include <queue>
#include <thread>
#include <vector>
class ThreadPool {
public:
explicit ThreadPool(size_t n)
: stop_(false) {
for (size_t i = 0; i < n; ++i)
workers_.emplace_back([this]{ workerLoop(); });
}
~ThreadPool() {
{ std::lock_guard<std::mutex> lk(m_); stop_ = true; }
cv_.notify_all();
for (std::thread& t : workers_) if (t.joinable()) t.join();
}
// 提交并返回 future;完美转发,形参列表给 emplace
template <class F, class... Args>
auto submit(F&& f, Args&&... args)
-> std::future<std::invoke_result_t<F, Args...>> {
using R = std::invoke_result_t<F, Args...>;
auto task = std::make_shared<std::packaged_task<R()>>(
std::bind(std::forward<F>(f), std::forward<Args>(args)...));
std::future<R> res = task->get_future();
{
std::lock_guard<std::mutex> lk(m_);
if (stop_) throw std::runtime_error("thread pool stopped");
tasks_.emplace([task]{ (*task)(); });
}
cv_.notify_one();
return res;
}
private:
void workerLoop() {
for (;;) {
std::function<void()> job;
{
std::unique_lock<std::mutex> lk(m_);
cv_.wait(lk, [&]{ return stop_ || !tasks_.empty(); });
if (stop_ && tasks_.empty()) return;
job = std::move(tasks_.front());
tasks_.pop();
}
job();
}
}
std::vector<std::thread> workers_;
std::queue<std::function<void()>> tasks_;
std::mutex m_;
std::condition_variable cv_;
bool stop_;
};面试追问点应对
- 为什么 packaged_task 包一层 shared_ptr + lambda?—— packaged_task 不可拷贝,放队列需可移动;shared 让 lambda 可拷贝捕获,返回 future 外传。
- 为什么 submit 里 lock 后还判 stop_?—— 池已关闭再投递应报错而非静默丢任务。
- 析构顺序 / join:先置 stop、notify_all(唤醒全部沉睡线程),再逐个 join(见注释);若线程仍在跑大任务会阻塞直到完成——生产可加「等待当前任务完成再退出」语义。
- 竞态:worker 判 stop 且队列空才退出 —— 防止「停了但队列还有残留任务没跑完」的丢任务。
四、进阶:无锁任务队列 & 工作窃取(work-stealing)
单锁队列在多生产者多消费者下争用高。高性能池方案:
- 每线程一个本地任务 deque(LIFO 取自己刚投的,缓存友好);
- 本地空时去其它线程队尾 steal(MPMC 部分);
- 本地结构做成无锁(CAS 管理头尾),降低往返。
依赖实现库里通常叫 work-stealing scheduler(如 TBB task_group、OneTBB)。框架题常考:任务递归拆分→本地压栈→末端 steal。
cpp
// 每工作线程:
struct ThreadCtx {
std::deque<Task> local; // 简化;真正无锁用 ring/stack + CAS
bool isBusy();
Task steal(); // 从其它 worker 队尾取
};能答出「本地 LIFO + 远端 steal FIFO → 更好 cache 与负载均衡」即显著加分;再提 TBB/enkiTS/Intel 实现即可。
五、HPC 追问:什么时候别用线程池
- 任务粒度 < 调度开销(几微秒级)→ 直接串行或 SIMD/向量化。
- 有明确依赖/数据分块 → 用并行算法(tbb::parallel_for)而非手写循环。
- GPU 卸载这类工作 → 交给 GPU/设备队列,CPU 池不用管。
六、易错清单(八股高频)
- cv.wait 必须配 unique_lock 且带谓词。
- notify 别持锁(否则唤醒了又阻塞在拿锁)。
- 析构时先 stop + notify_all,再 join——顺序不能反。
- 任务队列存 movable,别存不可移动的 packaged_task 裸体。
- 异常:任务内异常会被 future.get() 拿到;run-to-completion 池要把异常捕获,别让 worker 崩。