ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

手写C语言线程池:从pthread到高并发服务的核心实践

手写C语言线程池:从pthread到高并发服务的核心实践 简介面向Linux环境下C语言开发者这份资源以线程池实现为核心讲解如何通过预创建线程、任务队列、互斥锁与条件变量解决并发场景下频繁创建线程的性能问题。资源共17个文件包含10个.c源码、2个.h头文件、3个md说明文档、1个Makefile构建脚本及测试文件压缩包仅6KB结构轻量适合初学者快速阅读。已有145人学习下载。通过源码与配套说明读者可以掌握pthread_create、pthread_join、pthread_detach等线程管理函数的使用理解互斥锁与条件变量如何协作保证任务队列安全并了解线程池初始化、任务提交、线程调度和销毁的完整流程还可借Makefile和测试用例自行编译运行验证线程复用与资源回收效果为进一步扩展动态线程池或任务优先级打下基础。 平时在Linux下写C语言服务一旦碰到并发场景很多人的第一反应就是pthread_create一把梭。我前两年接手过一个内部工具就是典型的例子每来一个请求就创建线程跑完就销毁线上稍微一压测线程数量飙升到几千内存和调度开销直接把接口拖垮。后来把线程部分全部改成线程池问题才真正解决。这篇就从零聊一下怎么用C语言在Linux环境下手写一个简单但可用的线程池它解决什么问题、核心的数据结构和锁怎么设计、代码怎么写、以及那些只有踩过坑才知道的细节。适合正在学pthread但不知道线程池该怎么组织代码的朋友也适合想在项目里自己掌控线程模型而不是依赖外部库的团队参考。1. 为什么在Linux下写C服务的人迟早得面对线程池1.1 线程创建销毁的成本比想象中高很多新手觉得pthread_create很快但实际上它并不是一次普通函数调用。创建线程要进入内核态分配task_struct、栈空间还要把线程交给调度器管理。Linux默认线程栈是8MB虚拟内存虽然大部分只是虚拟地址空间但线程数量一多系统负担立刻上来。销毁线程也不是免费的同样需要和内核打交道。如果一个任务本身只运行几十微秒创建线程的开销可能比任务本身还大。服务端接口如果每秒来几千次并发请求每次都动态创建销毁线程CPU会大量消耗在线程管理上而不是业务上。我在本地做过一个简单测试一万个计算任务每个任务只做几次累加用一次性创建线程的方式耗时比线程池高出一个数量级。这个差距在高频短任务场景下非常明显。1.2 C语言里没有现成的线程池只能自己造Java里有ThreadPoolExecutor网上关于线程池参数合理配置、阻塞队列选择、拒绝策略的讨论能翻好几页。但C标准库和pthread只提供线程原语不提供线程池这种组合工具。nginx有自己那套线程池libuv也有但它们是重量级框架的一部分你不可能为了用线程池把整个nginx引进来。所以线程池在C项目里通常都是自己写。它本质上是一个生产者-消费者模型业务代码生产任务固定数量的工作线程作为消费者不停取任务执行。这样线程数量可控任务可以排队性能和资源使用都稳定。2. 数据结构选型任务队列、锁和条件变量怎么配合2.1 结构体定义写一个线程池先要确定三个东西任务怎么存储、线程怎么管理、并发控制用什么。我先给出常用的结构体设计typedef struct task_node { void (*func)(void *arg); void *arg; struct task_node *next; } task_node_t; typedef struct thread_pool { pthread_t *threads; int thread_count; task_node_t *head; task_node_t *tail; int task_count; pthread_mutex_t mutex; pthread_cond_t not_empty; pthread_cond_t all_idle; int busy; int shutdown; } thread_pool_t;字段太多容易忘我用表格列一下字段作用threads工作线程句柄数组创建后固定不变head / tail / task_count任务队列的头尾指针和队列长度mutex保护队列、busy、shutdown 这几块共享状态not_empty队列从空变成非空时用来唤醒等待中的工作线程all_idle队列空且所有线程都不忙时用来通知等待销毁的人busy当前正在执行任务的线程数量shutdown线程池进入销毁流程的标志2.2 任务队列为什么选链表任务队列最常见的实现是单链表FIFO。入队在tail后面挂一个节点出队直接取head两边都是O(1)。用数组也不是不行但头部出队后要搬移元素或者用环形队列把下标绕起来。问题在于数组预分配多少任务槽位很难拍板开太小高峰期任务放不下开太大平时白白占着内存。链表天然支持任务数不固定按需malloc节点就行。这其实对应Java线程池里阻塞队列选择的问题。Java里ArrayBlockingQueue和LinkedBlockingQueue争吵了很多年结论通常是任务量小选有界数组队列可能积压时选链表更灵活。我们的C实现只要管住tail和head就是一种轻量的LinkedBlockingQueue。2.3 两个条件变量的分工这里最容易想不明白的是为什么需要两个条件变量not_empty 是给工作线程等待用的。队列空的时候工作线程wait在not_empty上有人提交任务后signal一下叫醒一个线程去干活。all_idle 是给调用pool_destroy的人等待用的。销毁线程池时如果还有任务在执行不能直接释放线程否则正在运行的代码可能访问已被free的内存。调用者要wait在all_idle上直到所有任务都执行完、队列也清空再开始回收线程。这两个条件变量服务的是两类不同的等待者用同一个条件变量会互相唤醒、逻辑混乱。3. 核心实现创建、提交、worker工作循环3.1 创建线程池创建线程池的流程不算复杂分配结构体、初始化锁和条件变量、创建线程数组、启动所有工作线程。thread_pool_t *pool_create(int num_threads) { if (num_threads 0) num_threads 4; thread_pool_t *pool calloc(1, sizeof(*pool)); if (pool NULL) return NULL; pthread_mutex_init(pool-mutex, NULL); pthread_cond_init(pool-not_empty, NULL); pthread_cond_init(pool-all_idle, NULL); pool-threads calloc(num_threads, sizeof(pthread_t)); if (pool-threads NULL) { free(pool); return NULL; } pool-thread_count num_threads; for (int i 0; i num_threads; i) { pthread_create(pool-threads[i], NULL, worker_loop, pool); } return pool; }这里有个容易被忽略的点pthread_create失败时怎么处理。简单写法是直接返回NULL但这个函数里已经创建了一部分线程不回收的话它们会一直挂在worker_loop里等任务。严谨的写法是创建失败时把已创建的线程都join回收再释放资源。我在示例里没写这么重但生产代码里强烈建议处理这一步。3.2 任务提交逻辑任务提交的核心是入队、更新队尾、通知工作线程。int pool_add_task(thread_pool_t *pool, void (*func)(void *), void *arg) { task_node_t *task malloc(sizeof(*task)); if (task NULL) return -1; task-func func; task-arg arg; task-next NULL; pthread_mutex_lock(pool-mutex); if (pool-shutdown) { pthread_mutex_unlock(pool-mutex); free(task); return -1; } if (pool-tail NULL) { pool-head pool-tail task; } else { pool-tail-next task; pool-tail task; } pool-task_count; pthread_cond_signal(pool-not_empty); pthread_mutex_unlock(pool-mutex); return 0; }这里为什么要检查shutdown因为销毁一旦开始就不允许新任务再入队否则线程都被回收了任务还挂在队列里永远没人执行内存也泄漏了。这就类似Java里线程池关闭后拒绝新任务的行为。通知时用pthread_cond_signal而不是broadcast因为只要一个空闲线程起来取任务就够了。如果任务很多worker执行完还会回来循环取下一个广播反而会惊动所有线程造成无意义的上下文切换。3.3 worker执行循环worker是线程池的核心也是最容易写错的地方。void *worker_loop(void *arg) { thread_pool_t *pool (thread_pool_t *)arg; while (1) { pthread_mutex_lock(pool-mutex); while (pool-task_count 0 !pool-shutdown) { pthread_cond_wait(pool-not_empty, pool-mutex); } if (pool-shutdown) { pthread_mutex_unlock(pool-mutex); break; } task_node_t *task pool-head; pool-head task-next; if (pool-head NULL) pool-tail NULL; pool-task_count--; pool-busy; pthread_mutex_unlock(pool-mutex); task-func(task-arg); free(task); pthread_mutex_lock(pool-mutex); pool-busy--; if (pool-busy 0 pool-task_count 0) { pthread_cond_broadcast(pool-all_idle); } pthread_mutex_unlock(pool-mutex); } return NULL; }关键点在于task-func(task-arg)是在锁外执行的。如果把这行放到锁里面看起来也能跑但后果很严重一个线程执行任务的时候其他线程想入队都拿不到锁池子基本退化成单线程串行执行。很多人第一次写线程池都会犯这个错排查起来还很隐蔽因为任务少的时候性能差异不明显任务一多就露馅。4. 销毁线程池优雅关闭里全是细节4.1 两种销毁方式的取舍销毁线程池有两种策略立即销毁和优雅关闭。策略行为适用场景立即销毁一旦调用销毁接口取消所有线程丢弃排队中的任务程序退出、服务下线不再关心剩余任务优雅关闭让队列里已提交的任务全部执行完再回收线程服务升级、平滑重启希望处理完存量请求优雅关闭听起来更友好但实现上需要仔细处理竞态条件否则很容易卡死或崩溃。4.2 优雅销毁的完整流程我用下面这个流程来实现优雅关闭void pool_destroy(thread_pool_t *pool) { if (pool NULL) return; pthread_mutex_lock(pool-mutex); while (pool-busy 0 || pool-task_count 0) { pthread_cond_wait(pool-all_idle, pool-mutex); } pool-shutdown 1; pthread_mutex_unlock(pool-mutex); pthread_cond_broadcast(pool-not_empty); for (int i 0; i pool-thread_count; i) { pthread_join(pool-threads[i], NULL); } pthread_mutex_destroy(pool-mutex); pthread_cond_destroy(pool-not_empty); pthread_cond_destroy(pool-all_idle); free(pool-threads); free(pool); }第一步是等worker把现有任务清空。busy表示正在执行任务的线程数task_count表示队列里还没执行的任务数两个都归零说明池子处于完全空闲状态。这时候再置shutdown1然后广播not_empty把阻塞在wait上的worker全部唤醒。这里有个很常见的错误用了pthread_cond_signal而不是broadcast。假如有8个worker都在等not_empty只唤醒一个其余7个就永远睡在那里后面的pthread_join全部卡死。销毁时必须用broadcast让所有worker都醒来检查shutdown标志。4.3 销毁与提交并发怎么处理等待空闲的循环里必须持锁吗必须。否则就会出现这种情况线程A正在执行最后一个任务线程B看到task_count0就开始销毁可线程A执行完后还要回到循环里去更新busy字段那时锁已经被销毁了结果是未定义行为。所以shutdown、busy、task_count这些状态字段的读写都要在mutex的保护下进行这是线程池能安全退出的前提。5. 实测中踩过的坑条件变量、锁的范围与任务生命周期5.1 条件变量必须配while循环worker里判断队列是否为空如果写成if就会踩到条件变量虚假唤醒的坑。pthread_cond_wait返回时并不代表条件一定满足可能被信号量唤醒但队列还是空的——多个线程同时被唤醒时只有一个能抢到任务另外几个醒来后发现队列空了。如果用的是if它们会继续往下走从空队列里取出一个NULL的head指针直接段错误。正确写法是while (task_count 0 !shutdown) wait醒来后重新检查条件。5.2 不要拿着锁执行任务前面代码注释里已经提过我再单独拿出来强调一遍。有些初学者把整个while循环体都包在lock里觉得反正要操作队列锁包大点更安全。结果就是一个线程执行任务期间所有提交任务的调用方全部阻塞线程池性能还不如单线程。正确做法是取任务和更新队列状态时加锁执行task-func时完全放开锁。5.3 任务里的arg生命周期要交给任务自己线程池只管调用void (*func)(void *arg)至于arg是谁分配的、什么时候释放线程池完全不知道。如果arg是堆上分配的任务函数执行完必须自己free或者由提交方确保在任务执行完之前不释放。我见过不少内存泄漏就是这里来的任务函数只管计算忘了释放参数内存。5.4 不要在任务里再等待同一个线程池销毁还有一个隐蔽的坑如果任务回调里调用了pool_destroy(pool)销毁循环会等busy归零而当前线程就是busy的一员它被自己等待的锁卡住形成死锁。任务里向同一个池子提交新任务倒是可以前提是销毁流程已经先等待队列清空但如果你在任务里提交任务又等待它执行完同样要小心条件变量逻辑。这种场景最简单的规避方式是任务别碰池子本身的生命周期。如果业务确实需要任务里继续提交任务那销毁逻辑就不能无限等待要么设置超时要么往队列里塞一个毒丸任务让worker遇到后主动退出。问题现象正确做法条件变量用if偶发段错误用while重新检查锁内执行任务高并发下性能崩塌取完任务就解锁arg不释放内存持续上涨明确生命周期任务里销毁池子死锁卡死禁止任务管理池生命周期6. 验证与压测怎么证明线程池真的能用6.1 编写一个基础的压测用例写线程池出来第一步要验证两个东西功能正确和性能达标。我习惯先提交大量短任务比如构造一个简单的计算任务void dummy_task(void *arg) { long *p (long *)arg; for (int i 0; i 1000; i) { *p i; } }然后压测代码向池子里提交10000个这样的任务线程数开4。跑完看两点一是所有任务数字和是否符合预期二是进程能否正常退出。我给的pool_destroy是优雅关闭它会等全部任务执行完所以你只需要在main函数里提交任务后调用pool_destroy如果进程能正常退出说明销毁逻辑没问题线程没有残留。6.2 内存和线程状态检查光能跑还不行要查内存泄漏。用valgrind --toolmemcheck ./main跑一遍正常情况下应该没有任何definitely lost。如果任务函数里负责释放arg却忘了free线程池里的任务节点valgrind会直接指出分配的代码行排查起来很方便。线程状态可以通过gdb查看跑起来后按CtrlC中断程序再执行info threads能看到有几个线程阻塞在pthread_cond_wait有几个正在执行任务。如果线程数量和预期不一致多半是创建失败或回收不彻底。6.3 结合真实业务场景做扩展线程池的任务函数是void (*)(void *)这个签名保证了几乎所有业务都能包装成任务。举个例子之前我在一个文本处理工具里用线程池并行计算多个向量的余弦相似度每个向量对就是一个任务arg里塞两个向量指针任务函数算完把结果写到预分配的数组里。改造起来很简单但吞吐提升非常明显。线程数怎么配也是老生常谈的问题。简单经验CPU密集型任务线程数约等于CPU核数IO密集型或等待型任务线程数可以适当翻倍甚至更多因为大部分时间线程都在等IO返回。这个思路和Java线程池参数合理配置里讲的完全一致只是C里要自己根据业务做选择。我自己的体会是线程池这种组件代码量不大但每一处设计都在回答一个现实问题谁来等待、谁被唤醒、锁什么时候放开、谁的生命周期归谁。把它完整写一遍比看十篇原理文章都管用。你如果在自己的项目里用建议先跑完上面的压测再加业务别一上来就塞复杂逻辑等基础版本稳定了再往动态扩缩容、优先级队列、按序执行这些方向去扩展地基就是这套生产者-消费者模型。本文还有配套的精品资源点击获取
返回列表