最近在调一个Linux下的消息转发服务,单线程模型压测时CPU跑满了,QPS却卡在几千上不去。把模型改成多线程后,QPS翻了三倍,但后续的数据竞争、偶发崩溃、死锁问题接踵而至,排查成本比写功能代码高得多。这篇文章就把我在Linux下用线程做并发编程的实践、原理和踩坑记录完整梳理一遍,覆盖进程与线程的边界、pthread编程模型、同步原语的选型、线程安全的排查手段、死锁的调试,以及线程池的参数设计和提交接口等核心问题,希望对准备深入Linux并发编程的读者有帮助。
1. 什么在共享、什么在隔离——线程和进程的边界先搞清楚
1.1 进程是资源分配单位,线程是调度单位
很多人一上来就写pthread_create,但被问到"线程到底和进程差在哪"就支支吾吾。这里先把这个基础问题说透。
进程是操作系统资源分配的基本单位。每个进程有独立的地址空间、独立的页表、独立的文件描述符表、独立的信号处理句柄。线程是操作系统调度的基本单位,同一个进程内的多个线程共享该进程的地址空间、全局变量、堆内存、文件描述符、信号处理函数等。用个直白的类比:进程像一座工厂,工厂有自己的厂房、设备和原材料仓库;线程就像工厂里的工人,大家共用同一个厂房和设备,但各自有自己干活时用的工具包和工位。
共享的东西越多,协作效率越高,但出问题的概率也越大。两个线程读写同一个全局变量,如果没有同步机制,轻则结果不对,重则直接崩溃。这是线程编程的所有复杂度来源。
1.2 Linux线程的底层真相:轻量级进程与clone
教科书讲的"线程模型"在不同操作系统上差别很大。Windows的线程是真正的内核线程,而Linux在实现上走了一条特殊的路线——Linux线程本质上是轻量级进程(Lightweight Process, LWP),是通过clone系统调用创建的。
clone和fork最大的区别在于:fork创建子进程时默认是"不共享",clone可以通过标志位精确控制父子之间共享什么、隔离什么。比如CLONE_VM标志表示共享地址空间,CLONE_FILES表示共享文件描述符表,CLONE_FS表示共享文件系统信息(当前目录、umask等)。pthread_create底层就是调用clone并传入了这些共享标志。
这就解释了一个很常见的现象:在Linux上用ps -eLf看线程,每个线程都有一个独立的LWP号(线程ID),看起来和进程没啥区别。所以排查问题时,普通ps命令看不出来,必须用ps -eLf、ps -T -p 进程号或者top -H才能看到进程内到底起了多少线程。这个细节在做性能分析和故障排查时非常关键。
1.3 线程切换为什么比进程切换便宜
常说的"线程切换比进程切换代价小"并不完全准确,在Linux上,线程和进程在调度器眼里都是task_struct,切换流程大致相同。真正省下的开销主要集中在地址空间的切换上。
进程切换时,内核必须切换页表、刷新TLB(快表),这会导致后续的缓存命中率下降。线程切换因为共享地址空间,页表不需要切换,TLB也不需要完全失效,上下文切换的成本更低。但要注意,这只是"更便宜",不是"免费"。如果线程数量失控,比如几千个线程同时抢几个CPU核心,光上下文切换就能把性能拖垮。线程池的一个重要目的就是控制线程数量,避免调度开销吃掉业务收益。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 从pthread_create到线程生命周期——Linux线程编程的基础设施
2.1 最小可运行示例与编译链接细节
在Linux上写线程程序,最直接的方式就是POSIX线程库(pthread)。这里给一个最小示例:
c复制#include <stdio.h>
#include <pthread.h>
void* worker(void* arg) {
int id = *(int*)arg;
printf("thread %d running\n", id);
return NULL;
}
int main(void) {
pthread_t tid;
int id = 42;
pthread_create(&tid, NULL, worker, &id);
pthread_join(tid, NULL);
return 0;
}
编译命令是:
bash复制gcc -o demo demo.c -pthread
这里有一个特别容易踩的坑:-pthread这个选项不能省。老版本gcc下缺了它,链接阶段会报找不到pthread_create;新版gcc在某些发行版上即使不写也能过,因为glibc 2.34之后把libpthread合并进了libc,但依然建议显式加上-pthread,因为它还会定义一些编译期宏,影响多线程相关特性的行为。
另一个经典坑是往线程函数传参数时传了局部变量地址。上面例子中id是局部变量,pthread_create返回后main函数继续执行,一旦离开作用域,线程再去读这块内存,结果就是未定义行为。正确做法是传堆上分配的内存,或者用int* arg = malloc(sizeof(int)),线程函数内用完再free。还有一种省事的做法是把值强转成void*直接传,比如pthread_create(&tid, NULL, worker, (void*)42),线程函数里再转回来,适用于小整数参数,但不适用于结构体。
