线程池与任务系统:从原理到实践
说实话,线程池这个话题,我在面试中问过不下百人。能讲清楚原理的不少,但真正动手实现过、踩过坑的,真不多。今天我就把自己这些年积累的经验,掰开揉碎了讲给你听。
为什么需要线程池?
你想想看,每次来一个任务就 new 一个线程,做完就销毁——这像什么?像极了每次吃饭都现种水稻。线程的创建和销毁是有代价的,上下文切换更是吃性能的老虎。
线程池的核心思想很简单:提前创建一批线程,让它们等着干活。任务来了,扔给空闲线程;没空闲了,任务排队。就这么个思路,但实现起来门道不少。
核心要点:线程池 = 线程复用 + 任务队列 + 生命周期管理
线程池设计原理
我习惯把线程池拆成三个模块来看:
- 线程管理器:负责线程的创建、销毁、数量控制
- 任务队列:存放待执行的任务,通常是线程安全的队列
- 调度策略:决定哪个线程执行哪个任务
先看一个最简实现:
class ThreadPool {
std::vector<std::thread> workers;
std::queue<std::function<void()>> tasks;
std::mutex queue_mutex;
std::condition_variable condition;
bool stop;
public:
ThreadPool(size_t threads) : stop(false) {
for(size_t i = 0; i < threads; ++i)
workers.emplace_back([this] {
while(true) {
std::function<void()> task;
{
std::unique_lock<std::mutex> lock(this->queue_mutex);
this->condition.wait(lock, [this] {
return this->stop || !this->tasks.empty();
});
if(this->stop && this->tasks.empty())
return;
task = std::move(this->tasks.front());
this->tasks.pop();
}
task();
}
});
}
template<class F>
void enqueue(F&& f) {
{
std::unique_lock<std::mutex> lock(queue_mutex);
tasks.emplace(std::forward<F>(f));
}
condition.notify_one();
}
~ThreadPool() {
{
std::unique_lock<std::mutex> lock(queue_mutex);
stop = true;
}
condition.notify_all();
for(std::thread &worker : workers)
worker.join();
}
};
我的经验:这个实现虽然能用,但生产环境千万别这么写。为什么?因为任务队列是全局锁,高并发下锁竞争会卡死性能。我曾经在一个项目里用这种简单实现,8核机器跑出了单核的效果...后来改成无锁队列,吞吐量直接翻了6倍。
工作窃取算法
刚才那个实现有个大问题:每个线程从同一个队列取任务。如果某个线程的任务特别重,其他线程闲着,它就忙死。这叫「负载不均」。
工作窃取算法就是来解决这个问题的。思路是这样的:
- 每个线程有自己的任务队列(双端队列)
- 线程优先处理自己队列里的任务
- 自己队列空了,就去偷别人的任务
- 偷的时候从队尾偷,减少竞争
为什么要从队尾偷?因为线程自己从队首取任务,偷任务从队尾取,这样冲突概率最小。这个设计很巧妙,我在做分布式计算框架时用过类似思路。
class WorkStealingQueue {
std::deque<std::function<void()>> queue;
std::mutex mutex;
public:
void push(std::function<void()> task) {
std::lock_guard<std::mutex> lock(mutex);
queue.push_front(std::move(task));
}
std::function<void()> pop() {
std::lock_guard<std::mutex> lock(mutex);
if(queue.empty()) return nullptr;
auto task = std::move(queue.front());
queue.pop_front();
return task;
}
std::function<void()> steal() {
std::lock_guard<std::mutex> lock(mutex);
if(queue.empty()) return nullptr;
auto task = std::move(queue.back());
queue.pop_back();
return task;
}
};
注意:工作窃取虽然好,但偷任务本身也有开销。如果线程数太多,大家都在偷来偷去,反而得不偿失。我建议线程数不超过CPU核心数的2倍,超过这个阈值,窃取的开销就开始大于收益了。
任务调度策略
调度策略决定了「谁来做、什么时候做」。常见的策略有:
| 策略 | 适用场景 | 优缺点 |
|---|---|---|
| FIFO(先进先出) | 任务之间无依赖 | 公平,但可能有队头阻塞 |
| 优先级调度 | 有紧急任务 | 灵活,但可能饿死低优先级任务 |
| 分片调度 | 大数据处理 | 减少锁竞争,但实现复杂 |
| 亲和性调度 | CPU缓存敏感型 | 提升缓存命中率,但灵活性差 |
我个人比较喜欢用「优先级 + 分片」的组合策略。举个例子:
enum class TaskPriority { LOW, MEDIUM, HIGH };
class PriorityTaskQueue {
std::array<std::queue<Task>, 3> queues; // 0: HIGH, 1: MEDIUM, 2: LOW
std::mutex mutex;
public:
void enqueue(Task task, TaskPriority priority) {
std::lock_guard<std::mutex> lock(mutex);
queues[static_cast<int>(priority)].push(std::move(task));
}
Task dequeue() {
std::lock_guard<std::mutex> lock(mutex);
for(auto& q : queues) {
if(!q.empty()) {
auto task = std::move(q.front());
q.pop();
return task;
}
}
return Task{};
}
};
避坑指南:我曾经在一个实时渲染引擎里用了纯优先级调度,结果低优先级的日志任务永远得不到执行。后来加了「老化机制」——任务等待时间越长,优先级自动提升。这才解决了饿死问题。
C++20 协程初探
说到协程,很多人觉得玄乎。其实说白了,协程就是可以暂停和恢复的函数。线程池里用协程,最大的好处是:不用阻塞线程等I/O。
看个例子:
#include <coroutine>
#include <thread>
#include <queue>
struct Task {
struct promise_type {
Task get_return_object() { return {}; }
std::suspend_never initial_suspend() { return {}; }
std::suspend_never final_suspend() noexcept { return {}; }
void return_void() {}
void unhandled_exception() {}
};
};
// 一个简单的协程任务
Task async_read_file(const std::string& path) {
// 模拟异步读取
co_await std::suspend_always{};
// 读取完成后继续执行
std::cout << "File read: " << path << std::endl;
co_return;
}
这里的关键是 co_await。遇到这个关键字,协程就暂停了,线程可以去做别的事。等I/O完成了,再恢复执行。这就避免了线程空转等待。
协程 + 线程池 = 王炸组合
线程池负责管理线程资源,协程负责管理任务粒度。两者结合,既能充分利用CPU,又能避免线程浪费在等待上。
知识体系总览
下面这张图,是我梳理的线程池与任务系统的核心脉络:
这张图把三个核心模块的关系理清楚了。线程池管「人」,任务系统管「事」,协程管「怎么干」。三者配合,才能写出高性能的并发程序。
我的建议:初学者别一上来就搞协程。先把线程池和任务队列玩明白,协程只是锦上添花。我在团队里带新人,都是让他们先手写一个线程池,跑通之后再引入协程。基础不牢,地动山摇。
好了,线程池的核心内容就这些。记住:设计线程池,本质是在管理资源。线程是资源,任务是资源,时间也是资源。怎么分配、怎么调度、怎么避免浪费——想清楚这些,你就能写出真正高性能的系统。