☰
Linux C语言线程池实现:pthread、互斥锁与条件变量详解
2026/10/14 21:06:05 网站建设 项目流程

简介:这份资源面向具备一定C语言基础、希望深入理解Linux多线程编程与并发模型的学习者,提供一套可直接编译运行的简单线程池实现。包内共17个文件,以10个C源文件为核心,配合2个头文件、1个Makefile及若干Markdown说明文档,压缩包仅6KB,结构紧凑、便于快速通读。内容围绕线程创建与管理、互斥锁与条件变量同步、任务队列设计、线程池初始化与销毁流程展开,并涉及动态调整线程数量等性能优化思路,帮助读者掌握pthread系列接口在真实场景中的协作方式。已有146人学习,适合作为课程实验、面试准备或系统编程练手的参考实现,通过阅读与调试代码可加深对多线程资源管理与任务调度的理解。

1. 线程池不是“线程多”就行:从一份 Linux C 语言实现说起

很多人第一次在 Linux 下写并发服务,直觉就是“来一个连接就 pthread_create 一个线程”。压测一跑,连接数一上来,CPU 全耗在创建销毁线程上,内存被线程栈吃掉,系统调用 clone 频繁触发,延迟抖动得像心电图。线程池要解决的就是这件事:预先创建一批工作线程,让它们阻塞在一个任务队列上,主线程只管往队列里塞任务。这份“Linux 环境下 C 语言实现简单线程池”的方案,核心就是 pthread + 互斥锁 + 条件变量 + 任务队列这四件套,代码量不大,但把生产者-消费者模型、线程同步、优雅退出这几个关键点全串起来了。它适合谁?适合已经会写基本 socket 服务、想搞明白并发框架底层怎么搭的 C 语言开发者,也适合面试前想把线程池手写一遍的人。下面我按“能跑起来、能看懂、能改对”的顺序拆开讲。

2. 线程池的骨架:任务队列、工作线程与同步原语怎么搭

2.1 为什么是“队列 + 条件变量”而不是忙等

线程池的本质是一个生产者-消费者模型。主线程(生产者)把任务放进队列,工作线程(消费者)从队列取任务执行。问题在于:工作线程怎么知道队列里有活了?最笨的办法是 while 循环不停查队列,这叫忙等(busy-wait),CPU 空转,一个线程就能吃满一个核。正确做法是用条件变量(condition variable):队列空的时候工作线程调用 pthread_cond_wait 挂起,不占 CPU;主线程放任务后调用 pthread_cond_signal 唤醒一个等待的线程。

这里有个经典坑:条件变量必须和互斥锁配合使用,而且判断“队列是否为空”必须用 while 而不是 if。原因是 pthread_cond_wait 返回后,线程重新拿到锁,但从被唤醒到重新持锁之间,队列状态可能已经被别的线程改了(惊群或虚假唤醒)。用 while 重新检查条件,才能保证逻辑正确。这个细节在后面避坑章节还会展开。

任务队列本身用链表或环形缓冲区都行。简单实现用带头结点的单链表,每个节点存一个函数指针和参数指针。函数指针类型定义为void (*)(void *),这样任务可以携带任意参数,通用性够用。

2.2 结构体定义与初始化:把线程池拆成三个部分

先把数据结构定清楚,后面代码才不会乱。线程池分三块:任务节点、任务队列、线程池本体。

#include <pthread.h> #include <stdlib.h> #include <stdio.h> #include <unistd.h> /* 任务节点:函数指针 + 参数 */ typedef struct task_node { void (*func)(void *arg); /* 任务函数 */ void *arg; /* 任务参数 */ struct task_node *next; } task_node_t; /* 任务队列:头尾指针 + 互斥锁 + 条件变量 + 关闭标志 */ typedef struct task_queue { task_node_t *head; task_node_t *tail; pthread_mutex_t mutex; pthread_cond_t cond; int shutdown; /* 0 运行,1 关闭 */ } task_queue_t; /* 线程池:线程数组 + 队列 + 线程数 */ typedef struct thread_pool { pthread_t *threads; int thread_count; task_queue_t queue; } thread_pool_t;

逻辑说明:task_node_t用链表串任务,func和arg分开存,调用时执行func(arg)。task_queue_t里的shutdown是退出信号,工作线程在等待前会检查它。thread_pool_t持有线程 ID 数组,方便后续 join。

参数说明:thread_count一般设成 CPU 核心数或核心数的 1~2 倍。纯 CPU 计算任务设成sysconf(_SC_NPROCESSORS_ONLN)即可;如果任务里有阻塞 IO,可以适当放大,但别超过核心数太多,否则上下文切换开销反而拖慢。

初始化函数要做三件事:初始化锁和条件变量、初始化队列头尾、创建线程。

static void queue_init(task_queue_t *q) { q->head = q->tail = NULL; q->shutdown = 0; pthread_mutex_init(&q->mutex, NULL); pthread_cond_init(&q->cond, NULL); } int thread_pool_init(thread_pool_t *pool, int thread_count) { if (thread_count <= 0) return -1; pool->thread_count = thread_count; pool->threads = malloc(sizeof(pthread_t) * thread_count); if (!pool->threads) return -1; queue_init(&pool->queue); for (int i = 0; i < thread_count; i++) { if (pthread_create(&pool->threads[i], NULL, worker, pool) != 0) { /* 创建失败要回收已创建的线程,这里简化处理 */ return -1; } } return 0; }

逻辑说明:worker是工作线程入口函数,参数传pool指针,这样线程能访问队列。创建失败时理论上要回滚,简单实现可以先返回错误,生产代码里要补上已创建线程的唤醒和 join。

参数说明:pthread_create第二个参数传 NULL 用默认属性,栈大小默认 8MB(Linux 常见值)。如果线程数很多,比如几百个,8MB 栈会吃掉大量虚拟内存,可以用pthread_attr_setstacksize把栈调到 256KB 或 512KB,但要注意任务里别开大数组。

2.3 工作线程主循环:等待、取任务、执行、再等待

工作线程的代码是整个池子的心脏,写错一个判断就会挂死或丢任务。

static void *worker(void *arg) { thread_pool_t *pool = (thread_pool_t *)arg; task_queue_t *q = &pool->queue; while (1) { pthread_mutex_lock(&q->mutex); /* 用 while 而非 if:防止虚假唤醒和状态被改 */ while (q->head == NULL && !q->shutdown) { pthread_cond_wait(&q->cond, &q->mutex); } /* 关闭且队列为空,线程退出 */ if (q->shutdown && q->head == NULL) { pthread_mutex_unlock(&q->mutex); pthread_exit(NULL); } /* 从队头取任务 */ task_node_t *node = q->head; q->head = node->next; if (q->head == NULL) { q->tail = NULL; } pthread_mutex_unlock(&q->mutex); /* 执行任务,注意此时不持锁 */ node->func(node->arg); free(node); } return NULL; }

逻辑说明:加锁后先 while 检查队列空且未关闭,满足就 wait。被唤醒后重新检查,如果关闭且队列空就退出。取任务时从队头摘节点,摘完解锁再执行任务——执行任务期间绝不能持锁,否则整个池子退化成串行。执行完 free 节点。

参数说明:pthread_cond_wait会自动释放锁并在返回时重新加锁,所以调用前必须已经 lock。pthread_exit(NULL)让线程正常退出,资源由 join 回收。

2.4 投递任务与关闭:生产者一侧怎么写

主线程投递任务就是加锁、挂节点、发信号。

int thread_pool_add(thread_pool_t *pool, void (*func)(void *), void *arg) { task_node_t *node = malloc(sizeof(task_node_t)); if (!node) return -1; node->func = func; node->arg = arg; node->next = NULL; task_queue_t *q = &pool->queue; pthread_mutex_lock(&q->mutex); if (q->shutdown) { pthread_mutex_unlock(&q->mutex); free(node); return -1; } if (q->tail == NULL) { q->head = q->tail = node; } else { q->tail->next = node; q->tail = node; } pthread_cond_signal(&q->cond); /* 唤醒一个等待线程 */ pthread_mutex_unlock(&q->mutex); return 0; }

逻辑说明:先分配节点填好数据,再加锁挂到队尾。如果池子已关闭就拒绝并释放节点。挂好后 signal 唤醒一个线程。用 signal 而不是 broadcast,是因为一次只放一个任务,唤醒一个就够,broadcast 会造成惊群,多个线程醒来抢锁又睡回去,浪费。

参数说明:pthread_cond_signal在持锁时调用没问题,但有些实现建议解锁后再 signal 以减少唤醒线程立刻阻塞在锁上的概率。简单实现持锁调用即可,性能敏感场景可以调整。

关闭函数要唤醒所有线程并 join。

void thread_pool_destroy(thread_pool_t *pool) { task_queue_t *q = &pool->queue; pthread_mutex_lock(&q->mutex); q->shutdown = 1; pthread_cond_broadcast(&q->cond); /* 唤醒全部线程 */ pthread_mutex_unlock(&q->mutex); for (int i = 0; i < pool->thread_count; i++) { pthread_join(pool->threads[i], NULL); } /* 清理队列残留节点 */ task_node_t *p = q->head; while (p) { task_node_t *next = p->next; free(p); p = next; } pthread_mutex_destroy(&q->mutex); pthread_cond_destroy(&q->cond); free(pool->threads); }

逻辑说明:置 shutdown 标志后 broadcast 唤醒所有 wait 的线程,它们醒来发现关闭且队列空就退出。主线程逐个 join 等它们结束,再清理队列里可能残留的节点(关闭前没执行完的任务),最后销毁锁和条件变量。

参数说明:pthread_join会阻塞直到目标线程退出,所以顺序必须是先 broadcast 再 join,否则线程还在 wait,join 永远不返回。

3. 编译、压测与验证:让线程池真的跑起来

3.1 编译命令与链接选项

Linux 下用 gcc 编译,必须链接 pthread 库。

gcc -o pool_test pool.c -lpthread -O2 -Wall -g

逻辑说明:-lpthread链接线程库,现代 glibc 里 pthread 已并入 libc,但显式写更保险。-O2开优化,-Wall打开警告能提前发现类型问题,-g保留调试符号方便 gdb。

参数说明:如果用到pthread_condattr等高级特性,可能需要-D_GNU_SOURCE。纯基础接口不需要。

3.2 写一个能看出问题的测试任务

测试任务要能暴露并发问题,别只打印 hello。用一个带共享计数器和随机睡眠的任务。

static int g_counter = 0; static pthread_mutex_t g_counter_lock = PTHREAD_MUTEX_INITIALIZER; void test_task(void *arg) { int id = *(int *)arg; /* 模拟计算:累加共享计数器 */ pthread_mutex_lock(&g_counter_lock); g_counter++; pthread_mutex_unlock(&g_counter_lock); /* 模拟 IO 或计算耗时 */ usleep((id % 10) * 1000); printf("task %d done, counter=%d\n", id, g_counter); free(arg); } int main(void) { thread_pool_t pool; if (thread_pool_init(&pool, 4) != 0) { fprintf(stderr, "pool init failed\n"); return 1; } for (int i = 0; i < 100; i++) { int *id = malloc(sizeof(int)); *id = i; thread_pool_add(&pool, test_task, id); } sleep(2); /* 等任务跑完,实际项目用条件变量或计数等待 */ thread_pool_destroy(&pool); printf("final counter=%d\n", g_counter); return 0; }

逻辑说明:投 100 个任务,每个任务对全局计数器加一。如果线程池同步正确,最终 counter 应该等于 100。如果出现丢任务或重复执行,counter 会不对。usleep模拟任务耗时,让多个线程有机会交错执行。

参数说明:thread_pool_init的线程数设 4,任务数 100,比例 25:1,能充分测试队列调度。sleep(2)是偷懒的等待方式,生产代码应该用一个完成计数器加条件变量来精确等待所有任务结束。

3.3 用 helgrind 和 gdb 验证同步正确性

光看输出不够,要用工具查数据竞争。valgrind 的 helgrind 工具能检测锁使用错误和竞争。

valgrind --tool=helgrind ./pool_test

逻辑说明:helgrind 会报告潜在的数据竞争、锁顺序问题、条件变量误用。如果输出里出现 “Possible data race”,就要回去检查是不是有共享变量没加锁。

参数说明:helgrind 会显著拖慢程序,测试时把任务数调小,比如 20 个,否则跑很久。另外可以用--history-level=full获得更详细的调用栈。

如果程序挂死,用 gdb attach 上去看所有线程的栈。

gdb -p $(pgrep pool_test) (gdb) thread apply all bt

逻辑说明:thread apply all bt打印所有线程的调用栈。如果工作线程都停在pthread_cond_wait,说明队列空了在正常等待;如果停在pthread_mutex_lock,可能是死锁,要检查是否有嵌套加锁或忘记解锁。

参数说明:attach 需要权限,普通用户只能 attach 自己的进程。容器里可能要先--cap-add=SYS_PTRACE。

4. 避坑与排查:线程池写挂的五个血泪现场

4.1 现象:程序偶尔卡死,gdb 显示线程全在等锁

原因:最常见的是在持锁状态下执行了任务函数,而任务函数里又去投递新任务或访问队列,造成自死锁。另一个可能是pthread_cond_wait用了if判断,虚假唤醒后队列已空却继续取任务,取到 NULL 解引用崩溃或逻辑错乱。

解决:严格执行“取任务时持锁,执行任务时不持锁”。条件判断一律用 while。代码 review 时专门检查pthread_mutex_lock和unlock是否成对,中间有没有 return 或 break 跳过 unlock。

4.2 现象:关闭线程池时 join 卡住不返回

原因:thread_pool_destroy里先 join 后 broadcast,或者 broadcast 时没持锁导致信号丢失。工作线程还在 wait,join 永远等不到。

解决:顺序必须是加锁、置 shutdown、broadcast、解锁、再 join。broadcast 必须在持锁时发,否则可能在工作线程检查完条件但还没进入 wait 的窗口期发信号,信号丢失,线程永远睡下去。

4.3 现象:任务执行顺序混乱,甚至同一个任务被执行两次

原因:队列节点摘除逻辑写错,比如摘头节点后没更新 head,或者 tail 指针没在队列空时置 NULL,导致后续挂节点挂到已释放的内存上。

解决:摘节点时严格判断q->head = node->next; if (q->head == NULL) q->tail = NULL;。每次 free 节点前确认没有其他指针还引用它。用 helgrind 跑一遍能发现大部分内存和竞争问题。

4.4 现象:线程数设成 100,程序启动就报资源不足

原因:每个线程默认栈 8MB,100 个线程就是 800MB 虚拟内存。虽然虚拟内存不等于物理内存,但某些系统限制或容器配额会直接拒绝创建。

解决:用pthread_attr_setstacksize把栈调到 256KB 或 512KB。注意任务函数里别开大局部数组,否则栈溢出。线程数不是越多越好,CPU 密集任务设成核心数,IO 密集任务可以到核心数的 2~4 倍,再往上收益递减。

4.5 现象:压测时 CPU 占用不高但吞吐上不去

原因:锁竞争太激烈。所有线程抢同一把队列锁,任务又很短,大部分时间花在抢锁上。或者用了 broadcast 导致惊群,每次放任务唤醒所有线程,它们抢锁后只有一个能拿到任务,其余回去睡,上下文切换开销巨大。

解决:单队列单锁在任务极短、线程多的时候确实是瓶颈。可以改成多队列加哈希分片,每个线程绑一个队列,减少竞争。简单场景下,把pthread_cond_signal用对(一次任务唤醒一个线程),避免 broadcast,就能缓解不少。另外任务粒度别太细,几个小操作合并成一个任务投递。

5. 从能跑到好用:给线程池加动态扩缩容和任务优先级

基础版跑通后,下一步通常是让它更实用。我一般会先加两个东西:动态扩缩容和任务优先级。动态扩缩容的思路是维护一个“忙碌线程数”和“空闲线程数”的统计,当队列积压超过阈值且空闲线程为 0 时,创建新线程;当空闲线程过多且持续一段时间,让多余线程退出。实现上可以用一个管理线程定期检查,或者每次投递任务时判断。注意扩缩容本身也要加锁,别把管理逻辑写进工作线程的主循环里,否则容易和退出逻辑打架。

任务优先级用多个队列实现,比如高、中、低三个链表,工作线程按优先级从高到低取。这里有个坑:低优先级任务可能饿死。常见做法是给每个优先级设权重,或者用老化机制,等太久就提升优先级。简单场景下,两个队列加一个计数器轮转就够了。

验证扩缩容是否生效,可以写一个测试:先投 10 个慢任务,观察线程数是否增长;再等一段时间,观察线程数是否回落。用top -H -p <pid>看线程数变化,或者在线程池里加日志打印当前线程数。注意日志本身要加锁,否则输出会交错。

最后说个我自己的习惯:每次改完线程池,先跑 helgrind,再跑一个 10 万任务的压测,最后用 gdb 在 destroy 处断点确认所有线程都正常退出。线程池这东西,写对一次不难,难的是改一处不崩别处。把同步原语的配对关系刻在脑子里,比背任何框架都管用。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询