
简介这份资源面向具备一定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 的--toolhelgrind能检测锁顺序问题和数据竞争--leak-checkfull能发现任务节点和线程数组的泄漏。我一般会在测试代码里故意投递几千个任务然后立即销毁线程池用 helgrind 跑一遍能暴露大部分同步问题。还有一个实用技巧是给线程池加统计信息累计处理任务数、当前队列长度、活跃线程数通过一个thread_pool_stats函数暴露出来。线上出问题时这些数字比日志更直观。比如队列长度持续增长说明消费者不够活跃线程数始终等于线程总数说明需要扩容累计任务数增长但队列为空说明线程数过剩。最后说一个我自己的习惯每次写完线程池先写一个「压力测试 优雅关闭」的用例投递一万个任务等三秒销毁用 valgrind 跑。这个用例能过基本就敢往项目里放了。线程池这东西写出来容易写对不容易写好了能省下大量排查并发问题的时间。希望帮到你。本文还有配套的精品资源点击获取