简介这份PDF资料面向Linux系统开发初学者与需要巩固并发编程的工程师围绕多线程编程的核心概念与实战示例展开帮助读者理解线程引入的动机、pthread接口用法以及线程安全等关键问题。资源包共1个PDF文件大小约134KB内容紧凑适合作为随查随用的技术参考。资料从线程与进程的对比切入说明多线程在资源占用、通信效率、响应性和多核利用方面的优势随后给出基于pthread_create与pthread_join的完整示例代码并延伸至互斥锁、读写锁、条件变量等同步机制以及线程局部存储和线程优先级调整等进阶话题。已有1282人学习适合希望快速掌握Linux多线程编程要点、理解并发调度与数据共享风险的开发者阅读。1. 从一次线上 CPU 飙高说起这份 pthread 实例包到底能解决什么线上服务跑了半年一直挺稳某天突然 CPU 单核打满、接口 P99 从 80ms 飙到 2s登机器一看top -H里一个线程吃掉了整颗核。翻代码发现是同事在日志模块里用pthread_create起了个后台线程做异步落盘但没设分离属性、也没join线程对象一直挂着更糟的是多个线程往同一个std::string上追加没加锁内存被写坏后触发了死循环。这类问题不是靠背 API 能解决的得有一份能跑、能改、能对照的实例代码把 pthread 的坑一个个踩出来。这份《Linux 下的多线程编程实例解析》就是干这个的。它从最朴素的pthread_create起步一路讲到线程属性、TSD 线程私有数据、互斥锁、条件变量、信号量每个知识点都配了可编译的 C 代码。适合两类人一是刚接触 Linux 多线程、想照着敲一遍把 API 用顺的开发者二是线上被并发问题折腾过、想回头把同步原语边界搞清楚的运维和后端。它不教你写框架但能让你在遇到线程泄漏、死锁、数据竞争时知道该盯哪一行。2. 把第一个 pthread 程序跑起来编译链接与三个核心函数2.1 为什么是 pthread 而不是自己造轮子Linux 的用户态线程标准就是 POSIX threads简称 pthread。内核层面它靠clone()系统调用实现clone和fork长得像但fork复制出独立地址空间clone可以选择共享地址空间、文件描述符表、信号处理表这正是线程“轻”的根源。原文里提到一个进程开销约是线程的 30 倍这个数字随内核版本和负载浮动但量级方向没错——线程共享了页表、堆、全局数据段创建时省掉了大量copy和数据结构初始化。选 pthread 而不是别的理由很实际glibc 自带、文档全、和 C/C 无缝、调试工具gdb、perf、valgrind 的 helgrind都认它。你在嵌入式 Linux 上跑交叉编译工具链里也一定有libpthread。所以这份实例包用 pthread 作为主线是符合工程现实的。2.2 编译命令里那个 -lpthread 不能省先看原文给的第一个例子我把它整理成能直接编译的版本/* example1.c —— 最简线程创建与等待 */ #include stdio.h #include stdlib.h #include pthread.h void *thread_fn(void *arg) { int i; for (i 0; i 3; i) printf(This is a pthread.\n); return NULL; } int main(void) { pthread_t id; int i, ret; /* 创建线程默认属性入口 thread_fn无参数 */ ret pthread_create(id, NULL, thread_fn, NULL); if (ret ! 0) { printf(Create pthread error: %d\n, ret); exit(1); } for (i 0; i 3; i) printf(This is the main process.\n); /* 等待子线程结束回收其资源 */ pthread_join(id, NULL); return 0; }编译gcc example1.c -lpthread -o example1 ./example1-lpthread必须显式加因为pthread_create、pthread_join这些符号在libpthread.so里glibc 2.34 之后部分符号并入了 libc但为了兼容老发行版和交叉工具链养成加-lpthread的习惯不会错。运行两次输出顺序不一样这是线程争抢 CPU 的正常表现不是 bug。2.3 pthread_create 的四个参数逐个拆原型是int pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *(*start_routine)(void *), void *arg);第一个参数thread是输出参数成功后写入线程标识符类型pthread_t在bits/pthreadtypes.h里定义为unsigned long int。第二个attr传NULL表示用默认属性后面讲属性时再展开。第三个是函数指针签名必须是void *(*)(void *)返回void *、接收void *这是 pthread 的硬约定写成void fn(void)编译器会警告甚至运行崩溃。第四个arg是传给线程函数的参数没有就传NULL。返回值成功返回 0失败返回错误码注意它不设置 errno直接判断返回值。常见错误码EAGAIN表示系统线程数到上限或内存不足EINVAL表示属性对象非法。我一般会在错误分支里把返回值打出来比perror更准。2.4 pthread_join 与 pthread_exit 的配合pthread_join原型int pthread_join(pthread_t th, void **thread_return);它阻塞调用线程直到th结束然后回收其资源。第二个参数接收线程函数的返回值不关心就传NULL。一个线程只能被一个线程 join第二个来 join 的会拿到ESRCH这是原文特别提醒的点线上如果多个地方都去 join 同一个线程就会看到这个错误码。线程结束有两条路函数return或者调用pthread_exit。pthread_exit原型void pthread_exit(void *retval);retval会被pthread_join的第二个参数接走。区别在于如果线程函数里调了pthread_exit即使中间还有代码也不会执行而return是从函数正常返回。主线程如果调用pthread_exit其他线程还能继续跑主线程如果return或exit整个进程结束所有线程一起没。这个差异在写守护线程时很关键。3. 线程属性怎么设绑定、分离、优先级与栈3.1 属性对象必须先 init 再 setpthread_attr_t是个不透明结构不能直接赋值必须走pthread_attr_init初始化用完pthread_attr_destroy销毁。默认属性是非绑定、非分离、栈 1MB、继承父线程调度策略和优先级。原文列了绑定、分离、栈地址、栈大小、优先级这几项我按实际会改的顺序讲。pthread_attr_t attr; pthread_attr_init(attr); /* 必须先初始化 */ /* ... 各种 set ... */ pthread_create(tid, attr, fn, NULL); pthread_attr_destroy(attr); /* 创建完就可以销毁 */注意pthread_attr_destroy只是销毁属性对象本身不影响已经创建出来的线程。3.2 绑定属性与轻进程 LWP绑定属性通过pthread_attr_setscope设置pthread_attr_setscope(attr, PTHREAD_SCOPE_SYSTEM); /* 绑定 */ pthread_attr_setscope(attr, PTHREAD_SCOPE_PROCESS); /* 非绑定 */绑定的意思是这个线程固定对应一个内核调度实体轻进程 LWPCPU 时间片按 LWP 分配所以响应更可预测适合对实时性有要求的场景。非绑定则由系统在多个 LWP 之间调度线程灵活但抖动大。原文提到“被绑定的线程具有较高的响应速度”这个结论在负载高时更明显。不过 Linux 的 NPTL 实现里PTHREAD_SCOPE_PROCESS实际上不被支持调用会返回EINVAL所以你在 Linux 上基本只能用PTHREAD_SCOPE_SYSTEM这点原文没展开但实测会撞到。3.3 分离属性与那个“线程跑太快”的坑分离状态决定线程结束后资源是否自动回收pthread_attr_setdetachstate(attr, PTHREAD_CREATE_DETACHED); /* 分离 */ pthread_attr_setdetachstate(attr, PTHREAD_CREATE_JOINABLE); /* 非分离默认 */分离线程不能被join结束后系统自动回收。非分离线程必须有人join否则线程描述符和栈一直占着这就是我开头说的线程泄漏。原文提醒了一个很阴的坑如果设成分离线程而线程函数执行极快可能在pthread_create返回之前就结束了线程号被系统回收再分配给别人于是pthread_create写回tid时拿到的是个已经失效或属于别人的号。解决办法是在线程函数开头加一点同步等待比如用pthread_cond_timedwait等一小段确保pthread_create先返回。原文特别强调不要用wait()因为wait是等子进程的会让整个进程睡眠解决不了线程同步问题。3.4 优先级设置的正确姿势优先级存在struct sched_param里通过pthread_attr_getschedparam和pthread_attr_setschedparam读写struct sched_param param; int policy; pthread_attr_getschedparam(attr, param); /* 先取 */ param.sched_priority 20; /* 改 */ pthread_attr_setschedparam(attr, param); /* 再存 */原文说“先取优先级修改后再存回去”这是对的因为sched_param里可能还有其他字段直接构造容易漏。但要注意光设sched_priority不够还得设调度策略pthread_attr_setschedpolicy可选SCHED_FIFO、SCHED_RR、SCHED_OTHER。而且实时优先级1-99需要CAP_SYS_NICE权限普通用户跑会失败返回EPERM。我一般只在确实需要实时响应的采集线程上用业务线程用默认的SCHED_OTHER就够了滥用优先级容易引发优先级反转。3.5 栈大小按需调别盲目加大默认栈 1MB嵌入式环境可能更小。如果线程函数里有大数组或深递归会栈溢出表现为段错误但 gdb 里看不出明显原因。可以这样调size_t stacksize 4 * 1024 * 1024; /* 4MB */ pthread_attr_setstacksize(attr, stacksize);但栈不是越大越好每个线程的栈是独立映射的虚拟内存线程多了会吃掉大量地址空间。我见过有人把栈设成 64MB 开 200 个线程结果虚拟内存爆掉。合理做法是先按默认跑真溢出再针对性调大同时用ulimit -s确认系统栈限制。4. 线程数据与同步原语TSD、互斥锁、条件变量、信号量4.1 TSD 线程私有数据errno 为什么不会串多线程里全局变量是所有线程共享的局部变量是每个线程栈上独立的但有些数据需要“每个线程一份、又能在该线程的多个函数间访问”这就是 TSDThread-Specific Data。最典型的例子是errnoA 线程出错不该影响 B 线程读到的错误码。TSD 用四个函数操作pthread_key_t key; /* 创建键可带析构函数 */ pthread_key_create(key, free); /* 在本线程绑定数据 */ pthread_setspecific(key, ptr); /* 在本线程读取数据 */ void *p pthread_getspecific(key); /* 删除键 */ pthread_key_delete(key);pthread_key_create的第二个参数是析构函数线程结束时系统会对该线程绑定的数据调用它常用来free。原文提到它常和pthread_once配合保证键只创建一次pthread_once_t once_control PTHREAD_ONCE_INIT; pthread_once(once_control, init_routine);pthread_once保证init_routine在整个进程生命周期内只被执行一次多线程同时调用也安全。有个容易翻车的点pthread_setspecific给同一个键设新值时旧值不会自动释放得自己先free。而pthread_key_delete只释放键本身占的内存不释放各线程绑定的数据也不触发析构函数。所以正确顺序是先让各线程自己清理数据再删键。原文专门强调了这点我踩过一次键删了但数据泄漏valgrind 里一堆 still reachable。4.2 互斥锁读写缓冲区的经典模型互斥锁保证同一时刻只有一个线程进入临界区。原文给的读写缓冲区例子很典型一个char buffer一个int buffer_has_item标志读写线程通过pthread_mutex_t互斥访问。pthread_mutex_t mutex; pthread_mutex_init(mutex, NULL); /* NULL 表示默认属性 */ pthread_mutex_lock(mutex); /* 临界区 */ pthread_mutex_unlock(mutex);pthread_mutex_init的第二个参数是属性默认属性是PTHREAD_PROCESS_PRIVATE即只用于同进程内线程同步。如果要跨进程同步得设PTHREAD_PROCESS_SHARED并且互斥锁本身要放在共享内存里。互斥锁类型有PTHREAD_MUTEX_NORMAL、ERRORCHECK、RECURSIVE、DEFAULT一般用默认。RECURSIVE允许同一线程重复加锁适合递归函数但用多了说明设计有问题。死锁的经典场景原文讲得很清楚线程 A 先锁 mutex1 再锁 mutex2线程 B 先锁 mutex2 再锁 mutex1两边各持一把等对方卡死。规避手段有两个一是所有线程按同一顺序加锁二是用pthread_mutex_trylock非阻塞尝试拿不到就释放已持有的锁重来。if (pthread_mutex_trylock(mutex2) ! 0) { pthread_mutex_unlock(mutex1); /* 拿不到就退让避免死锁 */ /* 重试或做其他处理 */ }4.3 条件变量为什么必须配 while 而不是 if条件变量解决互斥锁“只有锁/非锁两态”的不足让线程在条件不满足时阻塞等待条件满足时被唤醒。核心函数pthread_cond_t cond; pthread_cond_init(cond, NULL); pthread_cond_wait(cond, mutex); /* 阻塞等待 */ pthread_cond_signal(cond); /* 唤醒一个 */ pthread_cond_broadcast(cond); /* 唤醒全部 */ pthread_cond_timedwait(cond, mutex, abstime); /* 带超时 */pthread_cond_wait做三件事释放 mutex、阻塞在 cond 上、被唤醒后重新获取 mutex。所以它必须和一把互斥锁配合使用。原文给的计数例子pthread_mutex_t count_lock; pthread_cond_t count_nonzero; unsigned count; void decrement_count(void) { pthread_mutex_lock(count_lock); while (count 0) /* 注意是 while */ pthread_cond_wait(count_nonzero, count_lock); count count - 1; pthread_mutex_unlock(count_lock); } void increment_count(void) { pthread_mutex_lock(count_lock); if (count 0) pthread_cond_signal(count_nonzero); count count 1; pthread_mutex_unlock(count_lock); }这里while (count 0)是血泪经验。用if的话线程被唤醒后不再检查条件直接往下走但此时条件可能又被别的线程改回去了惊群或虚假唤醒导致count减成负数。POSIX 标准明确允许虚假唤醒所以必须用while循环重新判断。pthread_cond_signal唤醒哪个线程由调度策略决定不确定pthread_cond_broadcast唤醒全部但被唤醒的线程要重新竞争锁用不好会引发惊群性能反而下降。4.4 信号量非负计数器控制公共资源信号量本质是个非负整数计数器sem_post加一sem_wait减一为 0 时阻塞sem_trywait是非阻塞版。相关函数在semaphore.hsem_t sem; sem_init(sem, 0, 0); /* pshared0 线程间共享初值 0 */ sem_post(sem); /* 加一唤醒等待者 */ sem_wait(sem); /* 减一为 0 则阻塞 */ sem_destroy(sem);原文的sem.c例子是 4 个线程两个读文件往缓冲区写两个从缓冲区读出来做加法和乘法。读线程每写一条sem_post处理线程sem_wait后取数据。编译gcc sem.c -lpthread -o sem准备1.dat和2.dat内容分别是1 2 3 4 5 6 7 8 9 10和-1 -2 ... -10运行后能看到加法和乘法交替输出顺序不固定。原文指出数值没按预期顺序显示是因为size被多个线程任意修改。这其实暴露了那个例子的一个隐患size的读写没有保护sem_post和size之间、sem_wait和--size之间都可能被其他线程插入。正确做法是把size的修改也放进临界区或者用互斥锁保护。这个例子适合理解信号量语义但直接拿去生产会翻车。5. 避坑与排查多线程程序最常见的五类翻车5.1 线程创建成功但程序立刻退出现象pthread_create返回 0但子线程的printf一条都没输出进程就结束了。原因主线程没等子线程main直接return整个进程终止所有线程被强杀。解决主线程用pthread_join等待或者用pthread_exit让主线程退出但保留进程。原文的sem.c里用pthread_join(t1, NULL)无限等待就是这个目的。更规范的做法是维护一个线程数组逐个 join。5.2 段错误但 gdb 栈看不出问题现象程序随机段错误gdb backtrace 指向某个库函数或地址 0x0。原因多半是线程栈溢出或者共享数据被写坏。栈溢出常见于线程函数里定义大数组默认 1MB 栈不够。解决先用ulimit -s看系统栈限制再用pthread_attr_setstacksize给该线程单独调大。如果是数据竞争写坏内存用valgrind --toolhelgrind ./program跑一遍它会报出未加锁的共享访问。我一般还会开-fsanitizethread编译TSan 对数据竞争定位比 helgrind 更准。5.3 pthread_join 返回 ESRCH现象join 某个线程时报ESRCHNo such process。原因该线程已经被别的线程 join 过或者被设成了分离状态。一个线程只能被 join 一次。解决检查代码里是否有多个地方 join 同一个pthread_t或者创建时误设了PTHREAD_CREATE_DETACHED。分离线程本来就不该 join设计上要统一要么全部 joinable 由主线程统一回收要么全部 detached 自生自灭别混着来。5.4 条件变量唤醒后逻辑错乱现象用if判断条件后pthread_cond_wait偶尔出现计数变负或数据错乱。原因虚假唤醒或者被broadcast唤醒后条件已被其他线程改掉if不再检查直接往下走。解决无条件改成while循环判断。这是 POSIX 的硬要求不是风格问题。另外pthread_cond_signal调用时最好持有对应的互斥锁虽然标准允许不持锁调用但持锁能避免信号在测试条件和 wait 之间丢失的竞态。5.5 信号量例子里的 size 竞争现象跑原文的sem.c输出顺序混乱偶尔数据错位。原因size是全局变量读线程size和处理线程--size没有互斥保护sem_post只保证了“有数据”这个信号没保证size本身的原子性。解决把size的修改放进互斥锁保护的临界区或者干脆用信号量的值本身来表达缓冲区元素个数不再单独维护size。这个坑很典型——信号量管的是资源计数不是数据结构的完整性两者要分开考虑。6. 进阶验证用 helgrind 和 TSan 把隐藏竞争揪出来写完多线程代码光靠跑几遍看输出正常是不够的数据竞争往往在特定时序下才触发。我现在的习惯是任何涉及共享可变状态的多线程模块提交前必须过一遍动态检测工具。先看 helgrind它是 valgrind 的一个工具不需要重新编译valgrind --toolhelgrind --history-levelfull ./example1--history-levelfull会记录完整的访问栈报错时能直接看到是哪两行代码在竞争。输出里Possible data race就是嫌疑点lock order violated是锁顺序不一致导致的潜在死锁。缺点是慢大概 20 到 100 倍开销只适合小规模测试用例。再看 ThreadSanitizer需要编译时插桩gcc -fsanitizethread -g -O1 example1.c -lpthread -o example1_tsan ./example1_tsanTSan 比 helgrind 快很多报错信息也更清晰会直接给出两个竞争访问的调用栈和涉及的变量地址。-O1是推荐级别-O2以上可能因为优化改变内存访问模式导致误报或漏报。注意 TSan 和 helgrind 不能同时用选一个就行。对于条件变量的验证我一般会写一个压力用例起 8 个生产者、8 个消费者跑 100 万次增减最后检查计数是否为 0。如果while写成了if这个用例几乎必挂。代码骨架大概这样#define PRODUCERS 8 #define CONSUMERS 8 #define ROUNDS 1000000 /* 生产者加一后 signal */ /* 消费者while(count0) wait然后减一 */ /* 主线程 join 全部后断言 count 0 */跑通这个用例条件变量的基本用法才算过关。信号量同理把sem_wait/sem_post换成信号量版本再压一遍。最后说个我自己的教训。早些年我图省事线程属性对象用完不pthread_attr_destroy觉得反正进程要退出系统会回收。结果在一个长期运行的服务里每次热更新配置就创建一批线程属性对象几个月后内存缓慢上涨查了半天才定位到。从那以后我每次pthread_attr_init之后不管创建成功失败都强制走一遍pthread_attr_destroy这已经成了肌肉记忆。多线程的坑大多不在 API 本身而在这些“反正会回收”的侥幸心理上。希望帮到你。本文还有配套的精品资源点击获取