简介:这份资源面向具备一定C语言基础、希望深入理解Linux多线程编程与系统资源管理的开发者,提供一套可直接参考的简单线程池实现范例。内容围绕线程创建与管理、互斥锁同步、条件变量唤醒、任务队列设计以及线程池的初始化、调度与销毁流程展开,并涉及动态调整线程数量等性能优化思路,适合用于课程实验、并发编程练习或项目前期技术预研。压缩包共17个文件,以10个C源文件为核心,配合2个头文件、1个Makefile及若干Markdown说明文档,整体约6KB,结构紧凑、便于快速编译运行与逐模块阅读。目前已有146人学习下载。通过这份代码,读者可以对照理解线程池从任务提交、空闲线程唤醒到资源回收的完整链路,掌握互斥锁与条件变量的配合用法,并在此基础上尝试加入任务优先级、超时机制等扩展功能,为后续多线程项目开发打下实践基础。
1. 从零手写线程池:为什么面试总问、生产环境却很少自己造
Linux 环境下用 C 语言实现一个简单线程池,这个标题背后其实藏着两拨人:一拨是准备面试的开发者,被「手写线程池」这道题反复折磨;另一拨是刚接手某个 C 项目、发现每次来请求就pthread_create一把、QPS 一高就卡成 PPT 的工程师。线程池要解决的核心问题很朴素——线程的创建和销毁是有成本的,频繁创建销毁既浪费 CPU 又让延迟抖动,于是我们预先创建一批线程放在那里待命,任务来了就丢给它们,用完不销毁,继续等下一个任务。听起来简单,但真动手写,条件变量怎么配合互斥锁、任务队列满了怎么办、线程怎么优雅退出,每一个点都能让人翻车。这篇笔记就按「能跑起来、能看懂、能改参数」的标准,把最小可用线程池从设计到落地讲透,适合会写基本 pthread 代码、想搞明白线程池内部机制的读者。
2. 线程池的骨架:三个结构体撑起整个模型
2.1 为什么是「任务队列 + 工作线程 + 管理者」三件套
线程池的经典模型可以用一个比喻说清楚:任务队列是传送带,工作线程是流水线上的工人,管理者是决定招多少人、什么时候关厂的厂长。在 C 语言里,这三者分别对应三个结构体:任务节点、线程池本体、以及可选的线程数组。任务节点负责承载一个函数指针和它的参数,线程池本体持有队列头尾指针、互斥锁、两个条件变量(一个通知「有活了」,一个通知「可以下班了」)、当前线程数和存活线程数。为什么需要两个条件变量而不是一个?因为「队列非空」和「线程该退出」是两个独立事件,混用一个条件变量会导致惊群或者误唤醒,这是最常见的翻车点之一。
从选型上说,任务队列用链表而不是数组,是因为任务数量不确定,链表插入删除都是 O(1),不需要预估容量。互斥锁保护队列的读写,条件变量负责线程间的同步等待。这套组合是 POSIX 线程库的标准玩法,没有玄学,但顺序写错就出问题。
2.2 结构体定义与初始化代码
先看结构体定义,这是整个线程池的地基:
#include <pthread.h> #include <stdlib.h> #include <stdio.h> #include <unistd.h> // 任务节点:每个任务就是一个函数指针加参数 typedef struct Task { void (*func)(void *arg); // 任务函数 void *arg; // 任务参数 struct Task *next; // 链表下一节点 } Task; // 线程池本体 typedef struct ThreadPool { Task *head; // 队列头 Task *tail; // 队列尾 pthread_mutex_t mutex; // 保护队列的互斥锁 pthread_cond_t cond_not_empty; // 队列非空条件 pthread_cond_t cond_not_full; // 队列非满条件(有界队列时用) int queue_size; // 当前任务数 int queue_max; // 队列最大容量,0 表示无界 int thread_count; // 线程总数 int shutdown; // 关闭标志,1 表示要退出 pthread_t *threads; // 线程 ID 数组 } ThreadPool;这段代码里几个参数值得说明。queue_max设为 0 表示队列无界,任务可以无限堆积,适合任务产生速度可控的场景;如果设为正数,队列满时生产者需要等待,适合内存敏感或需要背压的场景。shutdown标志是线程退出的关键,工作线程在等待任务时会检查这个标志,一旦为 1 就跳出循环自行退出。threads数组用来保存线程 ID,方便后续pthread_join回收资源。
初始化函数负责分配内存、初始化锁和条件变量、创建线程:
ThreadPool *thread_pool_create(int thread_count, int queue_max) { ThreadPool *pool = (ThreadPool *)malloc(sizeof(ThreadPool)); if (!pool) return NULL; pool->head = pool->tail = NULL; pool->queue_size = 0; pool->queue_max = queue_max; pool->thread_count = thread_count; pool->shutdown = 0; // 初始化互斥锁和条件变量 pthread_mutex_init(&pool->mutex, NULL); pthread_cond_init(&pool->cond_not_empty, NULL); pthread_cond_init(&pool->cond_not_full, NULL); // 分配线程 ID 数组 pool->threads = (pthread_t *)malloc(sizeof(pthread_t) * thread_count); if (!pool->threads) { free(pool); return NULL; } // 创建工作线程,每个线程执行 worker 函数 for (int i = 0; i < thread_count; i++) { if (pthread_create(&pool->threads[i], NULL, worker, pool) != 0) { // 创建失败时,设置关闭标志并回收已创建的线程 pool->shutdown = 1; pthread_cond_broadcast(&pool->cond_not_empty); for (int j = 0; j < i; j++) { pthread_join(pool->threads[j], NULL); } free(pool->threads); free(pool); return NULL; } } return pool; }初始化逻辑里有个容易忽略的点:如果第 i 个线程创建失败,不能直接返回,必须把已经创建成功的 i 个线程回收掉,否则这些线程会一直阻塞在条件变量上,造成资源泄漏。这里用pthread_cond_broadcast唤醒所有线程,配合shutdown标志让它们退出,再逐个pthread_join。这个错误处理路径很多示例代码直接省略,但在生产环境里是必须的。
3. 工作线程与任务投递:条件变量到底怎么配合
3.1 worker 函数的等待与唤醒逻辑
工作线程是线程池的消费者,它的生命周期就是一个循环:加锁、检查队列、空则等待、非空则取任务、解锁、执行任务。这个顺序不能乱,尤其是「解锁」必须在「执行任务」之前,否则任务执行期间锁一直被占用,其他线程无法投递新任务,线程池退化成单线程。
void *worker(void *arg) { ThreadPool *pool = (ThreadPool *)arg; while (1) { pthread_mutex_lock(&pool->mutex); // 队列为空且未关闭时,等待条件变量 while (pool->queue_size == 0 && !pool->shutdown) { pthread_cond_wait(&pool->cond_not_empty, &pool->mutex); } // 收到关闭信号且队列为空,线程退出 if (pool->shutdown && pool->queue_size == 0) { pthread_mutex_unlock(&pool->mutex); pthread_exit(NULL); } // 从队列头取出一个任务 Task *task = pool->head; if (task != NULL) { pool->head = task->next; if (pool->head == NULL) { pool->tail = NULL; } pool->queue_size--; // 如果队列有界且之前满了,唤醒等待投递的生产者 if (pool->queue_max > 0) { pthread_cond_signal(&pool->cond_not_full); } } pthread_mutex_unlock(&pool->mutex); // 执行任务,注意此时锁已释放 if (task != NULL) { task->func(task->arg); free(task); } } return NULL; }这里有几个关键参数和逻辑需要说明。pthread_cond_wait必须放在while循环里而不是if里,因为条件变量存在虚假唤醒的可能,被唤醒不代表条件一定满足。shutdown && queue_size == 0的判断保证了「优雅关闭」:先把队列里剩余任务执行完,再退出线程。如果希望立即关闭、丢弃剩余任务,可以把条件改成只判断shutdown。任务执行前先解锁,这是并发性能的关键,锁的持有时间越短越好。
3.2 任务投递函数与有界队列的背压处理
生产者调用thread_pool_add投递任务,逻辑是加锁、检查队列是否满、满则等待、未满则插入队尾、唤醒一个等待的工作线程:
int thread_pool_add(ThreadPool *pool, void (*func)(void *), void *arg) { if (!pool || !func) return -1; pthread_mutex_lock(&pool->mutex); // 有界队列满时,生产者等待 while (pool->queue_max > 0 && pool->queue_size >= pool->queue_max && !pool->shutdown) { pthread_cond_wait(&pool->cond_not_full, &pool->mutex); } // 线程池已关闭,拒绝新任务 if (pool->shutdown) { pthread_mutex_unlock(&pool->mutex); return -1; } // 创建任务节点并插入队尾 Task *task = (Task *)malloc(sizeof(Task)); if (!task) { pthread_mutex_unlock(&pool->mutex); return -1; } task->func = func; task->arg = arg; task->next = NULL; if (pool->tail == NULL) { pool->head = pool->tail = task; } else { pool->tail->next = task; pool->tail = task; } pool->queue_size++; // 唤醒一个等待的工作线程 pthread_cond_signal(&pool->cond_not_empty); pthread_mutex_unlock(&pool->mutex); return 0; }pthread_cond_signal和pthread_cond_broadcast的选择有讲究。投递任务时用signal唤醒一个线程就够了,因为一个任务只需要一个消费者;如果用broadcast,所有线程都被唤醒但只有一个能拿到任务,其余线程白白经历一次上下文切换,这就是所谓的「惊群」。关闭线程池时才需要用broadcast,因为要让所有线程都醒来检查退出条件。
有界队列的背压逻辑是:当queue_size >= queue_max时,生产者阻塞在cond_not_full上,直到消费者取走任务后发出信号。这个机制防止任务无限堆积导致内存耗尽,但也要注意,如果生产者和消费者在同一个线程里(比如任务里又投递任务),可能造成死锁,这是使用有界队列时需要特别小心的地方。
4. 关闭与资源回收:别让线程变成僵尸
4.1 优雅关闭的完整流程
线程池的销毁函数需要做四件事:设置关闭标志、唤醒所有线程、等待所有线程结束、释放锁和条件变量以及队列剩余内存。顺序很重要,先设置标志再广播,否则线程可能错过信号继续等待。
void thread_pool_destroy(ThreadPool *pool) { if (!pool) return; pthread_mutex_lock(&pool->mutex); pool->shutdown = 1; // 设置关闭标志 pthread_cond_broadcast(&pool->cond_not_empty); // 唤醒所有工作线程 pthread_cond_broadcast(&pool->cond_not_full); // 唤醒可能阻塞的生产者 pthread_mutex_unlock(&pool->mutex); // 等待所有线程退出 for (int i = 0; i < pool->thread_count; i++) { pthread_join(pool->threads[i], NULL); } // 释放队列中剩余任务(如果有) Task *task = pool->head; while (task) { Task *next = task->next; free(task); task = next; } // 销毁同步原语并释放内存 pthread_mutex_destroy(&pool->mutex); pthread_cond_destroy(&pool->cond_not_empty); pthread_cond_destroy(&pool->cond_not_full); free(pool->threads); free(pool); }pthread_join会阻塞直到对应线程退出,所以这一步保证了所有线程都执行完毕后再释放资源。如果跳过join直接free,线程可能还在访问已经释放的内存,这是典型的 use-after-free,用 valgrind 跑一下就能看到一堆报错。队列中剩余任务的释放也要做,否则每个未执行的任务节点都会泄漏。
4.2 一个可编译运行的最小示例
把上面的代码拼起来,再加一个main函数测试,就是一个完整可运行的程序:
// 测试任务:打印参数并短暂休眠 void test_task(void *arg) { int id = *(int *)arg; printf("task %d is running on thread %lu\n", id, (unsigned long)pthread_self()); usleep(100000); // 模拟 100ms 工作 free(arg); // 参数是 malloc 出来的,任务内释放 } int main() { ThreadPool *pool = thread_pool_create(4, 10); // 4 个线程,队列最多 10 个任务 if (!pool) { fprintf(stderr, "create pool failed\n"); return 1; } for (int i = 0; i < 20; i++) { int *arg = (int *)malloc(sizeof(int)); *arg = i; if (thread_pool_add(pool, test_task, arg) != 0) { fprintf(stderr, "add task %d failed\n", i); free(arg); } } sleep(3); // 等待任务执行完 thread_pool_destroy(pool); return 0; }编译命令是gcc -o pool pool.c -lpthread,运行后可以看到 4 个线程轮流处理 20 个任务。注意test_task里free(arg)的位置,参数的所有权约定要明确:如果约定由任务函数释放,投递失败时生产者要自己释放;如果约定由线程池释放,那任务函数里就不能 free。这个约定不写清楚,就是内存泄漏和 double free 的温床。
5. 避坑指南:线程池调试中最容易踩的五个坑
5.1 现象:程序偶尔卡死,gdb 看所有线程都在 cond_wait
原因通常是条件变量的判断条件写成了if而不是while,或者signal在unlock之后才调用。前者导致虚假唤醒后线程直接往下执行,取到空任务;后者导致信号丢失,等待的线程永远醒不过来。解决方法是严格用while包裹pthread_cond_wait,并且确保signal在持有锁时调用。
5.2 现象:任务执行顺序混乱,甚至同一个任务被执行两次
原因是任务出队和队列指针更新不是原子操作,或者多个线程同时操作了head指针。检查是否在取出任务后、解锁前正确更新了head和queue_size。另一个可能是任务节点被重复插入,检查tail->next的赋值逻辑。
5.3 现象:关闭线程池时程序崩溃,报 double free
原因是thread_pool_destroy释放了队列中的任务节点,但某个工作线程刚好也取到了同一个节点并准备 free。解决办法是在destroy里先join所有线程,确保没有线程还在操作队列,再释放剩余节点。顺序反了就会出问题。
5.4 现象:线程数设为 0 时创建成功但任务永远不执行
thread_pool_create没有校验thread_count是否大于 0,创建了 0 个线程的池子,任务投进去没人消费。在创建函数开头加参数校验,thread_count <= 0直接返回 NULL。同理queue_max为负数也要拒绝。
5.5 现象:高并发下 CPU 占用率飙升但吞吐量不涨
大概率是用了broadcast唤醒所有线程,或者锁的粒度过大导致线程频繁上下文切换。把投递任务的broadcast改成signal,检查任务执行期间是否还持有锁。如果任务本身很短,线程数不宜设得过多,一般设为 CPU 核心数的 1 到 2 倍比较合适。
6. 从能用走向好用:动态扩缩容与调试技巧
最小线程池跑通之后,下一步自然是让它更聪明。固定线程数在负载波动大的场景下要么浪费资源要么处理不过来,动态扩缩容的思路是:维护一个「忙碌线程数」和「存活线程数」,当任务队列长度超过阈值且存活线程数未达上限时,创建新线程;当线程空闲时间超过设定值且存活线程数大于核心线程数时,让线程自行退出。实现上可以用pthread_cond_timedwait替代pthread_cond_wait,给等待加上超时,超时后检查是否需要缩容。
调试线程池最有效的工具组合是 gdb 加 valgrind。gdb 的thread apply all bt可以一次性打印所有线程的调用栈,快速定位卡在哪个条件变量上;valgrind 的--tool=helgrind能检测锁顺序问题和数据竞争,--leak-check=full能发现任务节点和线程数组的泄漏。我一般会在测试代码里故意投递几千个任务然后立即销毁线程池,用 helgrind 跑一遍,能暴露大部分同步问题。
还有一个实用技巧是给线程池加统计信息:累计处理任务数、当前队列长度、活跃线程数,通过一个thread_pool_stats函数暴露出来。线上出问题时,这些数字比日志更直观。比如队列长度持续增长说明消费者不够,活跃线程数始终等于线程总数说明需要扩容,累计任务数增长但队列为空说明线程数过剩。
最后说一个我自己的习惯:每次写完线程池,先写一个「压力测试 + 优雅关闭」的用例,投递一万个任务,等三秒,销毁,用 valgrind 跑。这个用例能过,基本就敢往项目里放了。线程池这东西,写出来容易,写对不容易,写好了能省下大量排查并发问题的时间。希望帮到你。
本文还有配套的精品资源,点击获取