一、线程池——多线程编程的“必考题”
说实话,我做了十几年系统编程,面试过不下两百个候选人。问线程池实现,能真正写明白的,不超过三成。
为什么?因为很多人背了接口,却没理解本质。线程池说白了就是:提前创建一批线程,等着干活。你想想看,如果每次来一个任务就 new 一个线程,那系统开销得多大?线程创建销毁的成本可不低。
我在早期的一个网络代理项目里就吃过这个亏。当时并发请求一上来,系统直接卡死——线程数飙到上千,上下文切换把 CPU 都吃光了。后来改成线程池,问题迎刃而解。
二、线程池的核心设计思路
一个线程池,核心就三样东西:
- 任务队列:存放待执行的任务
- 工作线程:从队列里取任务执行
- 同步机制:保证线程安全
我习惯用 pthread_mutex_t 配合 pthread_cond_t 来实现。为什么?因为条件变量能高效地让线程“等活干”,而不是空转轮询。
2.1 数据结构设计
先看一个我常用的线程池结构体:
typedef struct {
void (*function)(void *arg);
void *arg;
} task_t;
typedef struct {
task_t *tasks; // 任务数组
int queue_size; // 队列容量
int head, tail; // 环形队列指针
int count; // 当前任务数
pthread_t *threads; // 工作线程数组
int thread_count; // 线程数
pthread_mutex_t mutex; // 互斥锁
pthread_cond_t cond; // 条件变量
int shutdown; // 销毁标志
} thread_pool_t;
这里有个细节:我用的是环形队列。为什么不用链表?因为任务队列的访问模式是 FIFO,环形队列内存连续、缓存友好,性能更好。我在一个高频交易系统里做过对比,环形队列比链表快了将近 30%。
三、任务提交与执行——核心流程
任务提交的流程其实很简单:
- 加锁
- 检查队列是否满
- 把任务放入队列
- 发送信号唤醒一个工作线程
- 解锁
工作线程的逻辑则是:
- 加锁
- 检查队列是否空,空则等待条件变量
- 取出任务
- 解锁
- 执行任务
- 回到步骤 1
3.1 任务提交函数
int thread_pool_submit(thread_pool_t *pool,
void (*func)(void *), void *arg) {
pthread_mutex_lock(&pool->mutex);
// 队列满了?等一会儿再试
while (pool->count == pool->queue_size &&
!pool->shutdown) {
pthread_cond_wait(&pool->cond, &pool->mutex);
}
if (pool->shutdown) {
pthread_mutex_unlock(&pool->mutex);
return -1;
}
// 放入任务
pool->tasks[pool->tail].function = func;
pool->tasks[pool->tail].arg = arg;
pool->tail = (pool->tail + 1) % pool->queue_size;
pool->count++;
// 唤醒一个工作线程
pthread_cond_signal(&pool->cond);
pthread_mutex_unlock(&pool->mutex);
return 0;
}
嗯,这里要注意:pthread_cond_wait 必须在 while 循环里,不能用 if。为什么?因为存在虚假唤醒——线程可能在没有收到信号的情况下被唤醒。这是 POSIX 标准允许的,我当年第一次写线程池就踩了这个坑,排查了整整一个下午。
3.2 工作线程函数
void *worker_thread(void *arg) {
thread_pool_t *pool = (thread_pool_t *)arg;
while (1) {
pthread_mutex_lock(&pool->mutex);
// 没任务就等着
while (pool->count == 0 && !pool->shutdown) {
pthread_cond_wait(&pool->cond, &pool->mutex);
}
if (pool->shutdown && pool->count == 0) {
pthread_mutex_unlock(&pool->mutex);
pthread_exit(NULL);
}
// 取任务
task_t task = pool->tasks[pool->head];
pool->head = (pool->head + 1) % pool->queue_size;
pool->count--;
// 通知提交者:队列有空位了
pthread_cond_signal(&pool->cond);
pthread_mutex_unlock(&pool->mutex);
// 执行任务(注意:这里已经解锁了)
task.function(task.arg);
}
return NULL;
}
看到没?任务执行是在解锁之后进行的。这是一个非常重要的设计原则:不要在持有锁的时候执行耗时操作。否则其他线程都得等着,线程池就退化成串行执行了。
四、线程池的销毁与资源回收
销毁线程池,比创建要复杂得多。我见过不少代码,销毁时直接 pthread_cancel,结果资源泄漏得一塌糊涂。
正确的做法是:
- 设置
shutdown标志 - 广播唤醒所有工作线程
- 等待所有线程退出
- 释放任务队列和线程数组
- 销毁互斥锁和条件变量
4.1 优雅销毁的实现
void thread_pool_destroy(thread_pool_t *pool) {
pthread_mutex_lock(&pool->mutex);
pool->shutdown = 1;
// 广播唤醒所有等待的线程
pthread_cond_broadcast(&pool->cond);
pthread_mutex_unlock(&pool->mutex);
// 等待所有线程结束
for (int i = 0; i < pool->thread_count; i++) {
pthread_join(pool->threads[i], NULL);
}
// 释放资源
free(pool->tasks);
free(pool->threads);
pthread_mutex_destroy(&pool->mutex);
pthread_cond_destroy(&pool->cond);
}
这里用 pthread_cond_broadcast 而不是 pthread_cond_signal,是因为我们需要所有线程都醒来检查 shutdown 标志。如果只唤醒一个,其他线程可能永远卡在 pthread_cond_wait 里。
五、线程池的核心流程图
下面这张图,是我画给团队新人的。它清晰地展示了线程池的完整工作流程:
六、避坑指南与最佳实践
- 任务粒度要适中:太小的任务(比如加个整数)用线程池反而亏,线程切换开销比任务本身还大
- 线程数不是越多越好:一般设置为 CPU 核心数 + 1,I/O 密集型可以适当增加
- 注意死锁:如果任务内部又提交任务到同一个线程池,可能造成死锁
pool->active_count 字段,记录当前正在执行任务的线程数。这样在监控面板上就能看到线程池的负载情况,排查问题非常方便。
最后说一句:线程池看起来简单,但真正写好、写稳,需要你对并发控制有深刻理解。我建议你亲手写一遍,跑几个压力测试,看看会不会出现内存泄漏、死锁、任务丢失等问题。只有踩过坑,才能真正掌握。