很多搞 Linux 后端和嵌入式开发的朋友,第一次接触多线程的时候,基本都会卡在同一个地方:进程用得挺顺手,为什么非要引入线程?pthread_create那一堆参数到底怎么传?写出来的多线程程序跑着跑着就段错误、卡死,又该怎么排查?这篇文章就围绕 Linux 线程的概念与控制,把"线程到底是个啥""怎么创建和回收""怎么同步互斥""实际踩过的坑"一次讲清楚,适合刚接触多线程、或者用过但没系统梳理过的开发者。
1. 线程到底是个啥:先把概念掰扯清楚
1.1 进程与线程:资源分配和调度执行的差别
很多人最初对线程的困惑,来自对"进程"这个概念的误解。进程是资源分配的基本单位,它拥有完整的地址空间、文件描述符表、信号处理函数表等一堆"家产"。而线程是CPU调度的基本单位,它只有自己独立的栈、寄存器上下文和线程局部存储,其他东西全部跟同进程内的兄弟线程共享。
用个生活化的类比:进程像一个公司,有办公楼、会议室、打印机这些固定资产;线程就像是公司的员工,大家共享工位和打印机,但每个人手里有自己的笔记本和茶杯。公司的资产是固定的,但员工可以随时招聘、辞退、调动。Linux 下创建一个进程用fork(),代价是"复制一份家产";创建一个线程用pthread_create(),代价只是"招一个新员工"。
所以两者的关键差异就出来了:
- 开销:
fork()需要复制页表、文件描述符、信号处理器等,即使有写时复制技术,也比创建线程重得多。线程创建只需要分配一个栈和一个task_struct内核数据结构,轻一个数量级。 - 通信:进程间通信要靠管道、消息队列、共享内存这些"跨公司协议";线程间通信直接读同一块内存,效率极高,但代价是要处理同步问题。
- 健壮性:进程之间相互隔离,一个进程挂了不影响别的进程;线程共享地址空间,一个线程越界写坏内存,整个进程直接崩掉。
我见过不少人为了省事,用fork()写并发程序,跑起来倒是简单,但一旦并发量上来,创建销毁的开销立刻变成瓶颈。反过来,也有人滥用线程,把本来应该独立部署的服务全塞进一个进程,出了问题排查起来非常痛苦。选进程还是线程,本质是"隔离性优先"还是"性能优先"的取舍。
1.2 Linux 线程的实现:轻量级进程的真相
Linux 里的线程,其实有一个听起来很反直觉的事实:内核里根本没有专门的线程数据结构。在 2.6 版本之后的内核中,线程是用"轻量级进程"(LWP, Lightweight Weight Process)实现的,它跟普通进程一样,都是一个task_struct。区别在于,通过clone()系统调用创建时,传入的标志位决定父子之间共享什么资源。
clone()可以精细控制共享内容,这是 Linux 线程一切行为的底层基础。pthread_create()在底层调用的就是clone()并传入了一组标志位,比如CLONE_VM(共享地址空间)、CLONE_FS(共享文件系统信息)、CLONE_FILES(共享文件描述符表)、CLONE_SIGHAND(共享信号处理器)。
理解了这个底层机制,很多上层现象就说得通了:
- 为什么线程之间
errno会"串台"?因为共享地址空间,errno是线程库通过线程局部存储实现的,但如果你用某些不遵守规范的库函数,或者直接访问全局变量,就会互相干扰。 - 为什么一个线程调用
exit()会把整个进程搞退?因为exit()会触发进程级清理,所有线程跟着完蛋。想只退出当前线程,必须用pthread_exit()。 - 为什么多线程程序里
fork()要格外小心?因为fork()只复制当前调用线程,其他线程就"消失"了,如果子进程里还要访问之前被其他线程持有的锁,极容易死锁。
这里要特别提醒一点:很多教材把线程抽象成"轻量级进程"就够了,但真正出问题的时候,你得知道"每个线程在/proc/<pid>/task/下面都有自己的子目录",每个任务都有一个独立的内核栈。排查线程数异常爆增时,直接数/proc/<pid>/task/下的目录数量,比ps -eLf更直观。
1.3 线程模型:用户态和内核态的博弈
理解了线程的底层实现,再看线程模型就顺理成章了。经典的线程模型有三种:
| 模型 | 描述 | 代表实现 | 优点 | 缺点 |
|---|---|---|---|---|
| 多对一 | 多个用户线程映射到一个内核线程 | 早期线程库 | 创建快、切换快 | 一个线程阻塞全组阻塞 |
| 一对一 | 每个用户线程对应一个内核线程 | Linux pthread | 并发能力强 | 创建开销略大,内核对象多 |
| 多对多 | 用户线程与内核线程交错映射 | 高级调度库 | 兼顾并发与开销 | 实现复杂 |
Linux 上 glibc 的 NPTL(Native POSIX Thread Library)实现的是一对一模型,这也是为什么 Linux 线程并发能力强、能利用多核,但当你创建上万线程时,内核调度压力会暴增。我之前做过一个压测程序,开了 5 万个线程单纯空转,CPU 全部耗在上下文切换上,实际业务吞吐量反而下降。这种情况下,正确的做法是引入线程池或者改用协程/异步模型,而不是继续堆线程。
2. 创建和控制线程:从 pthread_create 说起
2.1 线程创建的完整流程与参数
动手写代码之前,先把最常用的几个 API 牢记。创建线程的函数是:
#include <pthread.h> int pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *(*start_routine)(void *), void *arg);参数含义不复杂,但有几个坑倒了不少新手:
thread:用于接收线程 ID 的指针,注意这不是 Linux 内核的 TID(线程号),而是 pthread 库层面维护的句柄。内核里真正调度用的是 TID,pthread_self()返回的是 pthread_t,两者不一样。attr:线程属性对象,通常传NULL用默认属性。需要自定义栈大小、调度策略时,先用pthread_attr_init()初始化,再用pthread_attr_setstacksize()等函数设置。start_routine:线程入口函数,签名固定为void *(*)(void *),返回值和参数都是void *。不要试图传普通函数进去,编译不过。arg:传递给入口函数的参数。注意这里传的是指针值,线程是否能安全使用这个指针,取决于指针指向的内存生命周期,这点下一节专门讲。
一个最基础的示例:
#include <stdio.h> #include <pthread.h> void *worker(void *arg) { long id = (long)arg; printf("thread %ld is running\n", id); return (void *)(id * 2); } int main(void) { pthread_t tid; long input = 42; int rc = pthread_create(&tid, NULL, worker, (void *)input); if (rc != 0) { fprintf(stderr, "pthread_create failed: %d\n", rc); return 1; } void *ret = NULL; pthread_join(tid, &ret); printf("thread returned %ld\n", (long)ret); return 0; }编译时记得加-pthread,否则链接会报找不到pthread_create:
gcc -o demo demo.c -pthread注意-pthread不只是链接 libpthread,它还会帮你定义_REENTRANT宏,确保某些库函数使用线程安全版本,所以别图省事只写-lpthread,两者不是完全等价。
编译好之后跑一下,输出应该是:
thread 42 is running thread returned 842.2 参数传递:值拷贝与指针陷阱
pthread_create的arg参数是void *,给了你极大的自由,也埋了极大的雷。最常见的问题是把局部变量的地址传给线程:
void bad_example(void) { int local = 100; pthread_t tid; pthread_create(&tid, NULL, worker, &local); // 危险! // local 可能在 worker 运行前就销毁了 }worker函数里*(int *)arg读到的值,可能已经被别的栈帧覆盖成了垃圾数据。这是典型的使用未定义行为,程序偶尔正常、偶尔输出乱值、偶尔直接段错误。
正确做法有三种:
- 传值:如果参数就一个整数或指针本身,直接把值强转为
void *传进去。示例里我传的long就是这么玩的。 - 堆分配:如果参数是结构体,用
malloc()分配一块内存,在线程函数里使用完成后free()。这是最通用的方案,但要记得"谁分配、谁释放",或者在线程函数内部free。 - 用数组包一层:本质上也是值拷贝,比如
int *arg = malloc(sizeof(int)),然后在线程入口函数里取出值后立即free。
还有一类参数是"共享可变状态",比如多个线程要操作同一个全局结构体,那就不能单纯传拷贝了,必须配合互斥锁保证安全。这部分在下一章详细展开。
pthread_join的返回值也有讲究。线程函数的返回值同样是个void *,用pthread_join(tid, &ret)可以拿到。如果线程不是因为正常返回而终止,而是被pthread_cancel取消,联合的返回值是PTHREAD_CANCELED。判断线程是否是正常退出,要结合返回值和errno的语义。
2.3 回收与分离:别让线程变孤儿
线程终止后,内核资源不会立刻全部释放,线程的结构信息会保留,直到有人调用pthread_join()回收。如果线程一直不被join,它就成了"僵尸线程",积累多了会耗尽进程的资源,现象就是ps -eLf看到一堆<defunct>线程。
解决僵尸线程有两种思路:
- 显式 join:主线程创建完子线程后,在合适的时机调用
pthread_join()等待子线程结束。这是最干净的做法,也能顺带拿到返回值。 - 设为分离态:调用
pthread_detach(tid)或者创建时用attr设置PTHREAD_CREATE_DETACHED。分离后的线程,结束时会自动回收资源,不需要也不能再join了。
有种说法是"pthread_detach可以避免资源泄漏",严格说,不join也不detach才会泄漏,detach只是把回收责任交给了系统。实际开发中,如果你的线程生命周期很长、你不需要它的返回值,用分离态省心;如果线程是"任务"性质的、需要知道执行结果,就必须显式 join。
补充一个容易被忽略的点:主线程退出不等于进程退出。如果main函数执行到最后一句return,整个进程就结束了,所有线程会瞬间被干掉,不管它们跑没跑完。想只让主线程退出、留着子线程继续跑,得调用pthread_exit(NULL),这样进程退出发生在所有线程结束后。这个细节我在初期踩过一次:主线程 return 后,子线程里的日志一条都没打出来,还以为是代码问题,查了半天才发现是进程直接退出了。
3. 线程控制的核心:同步与互斥
3.1 互斥锁:把临界区保护起来
线程之间共享地址空间,听起来很方便,但多个线程同时改同一个变量,后果很随机。举个简单例子:多个线程各自对全局计数器加 1,counter++在 CPU 层面是"读-改-写"三步,两个线程交错执行时,可能同时读到旧值,然后各自加 1,最终只加了 1 次,这就是竞态条件。
解决竞态条件最基础的工具是互斥锁(mutex)。使用前初始化,进临界区前加锁,出临界区后解锁。glibc 提供了两种初始化方式:
// 静态初始化(推荐全局锁) pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER; // 动态初始化(局部锁或堆锁) pthread_mutex_t lock; pthread_mutex_init(&lock, NULL);加锁解锁的规范写法:
pthread_mutex_lock(&lock); // 临界区代码 counter++; pthread_mutex_unlock(&lock);关键点是:临界区内的代码一定要短,只包裹真正需要保护的语句,不然锁的粒度太粗,并发性能直线下降。我自己见过最离谱的例子,是把整个业务处理流程都包在锁里,结果多线程跑出单线程的速度,还加了日志打印占着锁不放,其他线程全部干等。
pthread_mutex_lock和pthread_mutex_trylock的选择也要注意:前者阻塞等待,后者立即返回EBUSY。在性能敏感的场景,trylock配合自旋重试可以避免线程被挂起再唤醒的上下文切换开销,但要注意忙等会消耗 CPU。还有一种PTHREAD_MUTEX_RECURSIVE递归锁,允许同一个线程重复加锁,常用于递归函数里保护共享资源,但除非必要,不建议滥用,容易掩盖设计问题。
3.2 条件变量:让线程学会等待和通知
互斥锁解决的是"同时访问"的问题,但很多场景是"某个条件满足后,另一个线程才继续"。比如生产者往队列里放数据,消费者得等队列非空才能取。如果消费者自己死循环检查队列状态,CPU 白耗不说,还要反复抢锁,效率极差。
条件变量(condition variable)就是为这个设计的。核心 API 只有三个:
pthread_cond_t cond = PTHREAD_COND_INITIALIZER; pthread_cond_wait(&cond, &mutex); // 等待条件满足 pthread_cond_signal(&cond); // 唤醒一个等待线程 pthread_cond_broadcast(&cond); // 唤醒所有等待线程注意pthread_cond_wait有两个参数,第二个是已经加锁的互斥量。这个设计容易让新手懵,实际上它做的是"原子三步":释放互斥锁、挂起线程等待通知、被唤醒后重新获取互斥锁。如果没有这个原子操作,在"你检查条件"和"你去睡觉"之间会有窗口期,可能导致丢失唤醒。
一个标准的生产者消费者模型长这样:
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER; pthread_cond_t has_data = PTHREAD_COND_INITIALIZER; int queue_empty = 1; /* 消费者 */ pthread_mutex_lock(&lock); while (queue_empty) { pthread_cond_wait(&has_data, &lock); } /* 此时队列非空,可以安全消费 */ pthread_mutex_unlock(&lock); /* 生产者 */ pthread_mutex_lock(&lock); queue_empty = 0; pthread_cond_signal(&has_data); pthread_mutex_unlock(&lock);有个经典细节必须强调:判断条件要用while而不是if。因为pthread_cond_wait可能被"虚假唤醒"(spurious wakeup),就算没有线程发信号,它也可能意外返回。用while重新检查条件,是为了确保条件真正满足才继续。这是 Linux 线程编程里最容易被忽略的坑之一。
条件变量还有一个重要规则:pthread_cond_signal最好在持有锁的状态下调用。虽然 POSIX 规范允许不持锁调用,但如果你在无锁状态下发信号,可能发生"信号发出时,等待线程还没来得及进入wait,导致信号丢失"的微妙问题。实测下来,持锁发信号最稳。
3.3 死锁:四个条件和对症下药
死锁是线程控制里最让人头疼的问题。线程 A 持有锁 1 等待锁 2,线程 B 持有锁 2 等待锁 1,两人就这么僵持着,谁也无法推进。经典的死锁四个必要条件:
- 互斥:资源一次只能被一个线程占用。
- 持有并等待:线程持有已有资源,还在等别的资源。
- 不可剥夺:资源只能由持有者主动释放。
- 循环等待:存在线程—资源的环形链路。
实际开发中,破坏任何一个条件就能解决死锁。最常见的手段是破坏"循环等待":所有线程按全局统一的顺序加锁。比如规定先加锁 1 再加锁 2,线程 B 也得遵守这个顺序,就不会出现 A 等锁 2、B 等锁 1 的僵局。
还有一种场景是"锁内调用外部函数",外部函数内部可能又去抢锁,这种嵌套最危险。经验法则:不要在持锁状态下调用你不完全熟悉的行为,尤其不能调用用户回调和第三方库接口。如果必须调用,先把锁拷贝一份或者把数据抠出来再解锁调用。
排查死锁问题时,gdb的thread apply all bt能列出所有线程的堆栈,死锁现场通常一眼就能看出来:两个线程的栈底都停在__lll_lock_wait附近。配合info threads可以看清线程间的等待关系。这个问题下一章展开讲。
4. 踩坑实录与调试技巧锦集
4.1 gdb 多线程调试:三板斧定位问题
多线程程序出问题,最怕的是"复现不出来"。事件顺序一变,症状完全不一样。我的习惯是,一旦程序挂掉,第一时间保留现场,用gdb挂到 core dump 文件上:
ulimit -c unlimited gdb ./your_program core进去之后先看主线程栈bt,再用info threads列出所有线程,然后用thread <编号>切换到具体线程,逐个看bt。频繁切换时可以set scheduler-locking on,让调试时只有当前线程运行,避免其他线程干扰复现。
我自己常用的几个 gdb 多线程命令组合:
| 命令 | 作用 |
|---|---|
info threads | 列出所有线程及其 ID、状态 |
thread 3 | 切换到线程 3 |
bt | 查看当前线程调用栈 |
frame 2 | 切到栈的第 2 层 |
set scheduler-locking on | 调试时只运行当前线程 |
p *mutex | 查看互斥锁的状态结构 |
有一次线上程序无响应,我 dump 下来用thread apply all bt一看,所有线程都卡在futex相关的系统调用上,再往下追,两个线程分别持有不同锁在等对方释放,死锁实锤。这种"每个线程看一眼栈"的做法,哪怕没有 dump,在程序卡死时用gdb attach <pid>也一样有效。
4.2 常见问题速查:从段错误到僵尸线程
多线程编程的报错信息很少直接告诉你"线程有问题",多数时候是段错误、卡死、数据错乱等间接症状。下面这份速查表是我根据实际项目经验整理的:
| 症状 | 可能原因 | 排查手段 |
|---|---|---|
| 直接段错误 | 访问了已释放的栈变量、野指针 | gdb看挂掉线程的bt,检查传参 |
| 程序卡死不动 | 死锁、条件变量信号丢失 | attach后用thread apply all bt |
| 计数器结果总是偏小 | 竞态条件,没有加锁 | 用valgrind --tool=helgrind检测竞态 |
线程创建失败返回EAGAIN | 线程数超限、内存不足 | 查看ulimit -u、cat /proc/sys/vm/max_map_count |
| 输出日志乱序 | 多线程打印未加锁 | printf不是原子的,加锁或用fprintf前锁定 |
| 内存持续上涨 | 线程未 join 也未 detach | 统计/proc/<pid>/task/目录数判断是否线程泄漏 |
| 定时崩溃,偶发性强 | 栈溢出 | ulimit -s看栈大小,可用pthread_attr_setstacksize调大 |
线程栈大小默认通常是 8MB(ulimit -s可见),如果入口函数里有巨大的局部数组,很容易爆栈。我自己遇到过一次,函数里声明了一个 16MB 的局部缓冲区,程序一跑到那个函数就段错误,初始还怀疑是内存越界,最后pthread_attr_getstacksize一查才反应过来是栈不够用。
4.3 线程安全:可重入函数与 errno 的坑
写了几个线程之后,你会发现很多 C 库函数在 multithread 环境里并不安全。典型的就是strtok(),它内部用了静态缓冲区保存上次位置,多线程同时调用就会串数据,正确做法是用可重入版本strtok_r()。不止是strtok,凡是_r后缀的函数(rand_r、localtime_r、asctime_r)都是线程安全版本,能选_r就选_r。
errno这个全局变量也是线程安全的重灾区。POSIX 标准规定errno是线程局部存储的,所以每个线程都有自己的errno,看起来是安全的。但有个前提:库函数必须遵守这个约定。如果你用到了一个老旧的库,里面把errno当作普通全局变量处理,多线程下就乱了。遇到"错误码偶尔对不上"的问题,优先怀疑这种非线程安全的旧库。
还要警惕一个隐藏问题:fork()加线程的组合。如果进程里有多个线程,某个线程调用了fork(),子进程只会复制调用线程,其他线程直接消失。子进程里如果接着用父进程遗留的锁,极可能死锁。这是很多网络服务框架要注册pthread_atfork处理器来清理锁的原因。写多线程程序时,能用fork()就尽量只在单线程环境下用,多线程进程里要并发执行外部任务,建议用posix_spawn()或者起线程解决。
4.4 性能与语言进阶:别把所有问题都交给锁
最后一个心得,解决了正确性问题之后,再看性能。锁是最简单的同步方式,但不是最高效的。高并发场景下,锁竞争会导致线程排队等待,实际吞吐反而下降。我见过一些后端服务,并行度一上来,性能提升微乎其微,原因就是业务逻辑里 90% 的时间在抢锁。
降低锁竞争的手段,按优先级从小到大:
- 缩小临界区:只锁最小必要代码,别把文件 IO、网络请求也锁在临界区里。
- 读写锁:读多写少的场景,用
pthread_rwlock_t替代互斥锁,允许多个读者并行。 - 无锁数据结构:用原子操作
__atomic_*或者 C11 的stdatomic.h实现计数、队列等简单结构,避免加锁。 - 线程局部存储:能用
__thread变量解决的问题,就别让多个线程共享可变状态。
不过要泼一盆冷水:无锁编程的正确性极难论证,一个memory_order选错就是隐蔽的数据错乱。非性能瓶颈不要轻易上无锁方案,先用锁跑通功能,再用 perf 抓一下热点,确认锁竞争确实是瓶颈,再考虑优化。这个顺序,让我至少少写了三倍的调 bug 时间。
最后再分享一个实用小技巧:调试多线程问题时,别老盯着代码看。给线程加上名字(用prctl(PR_SET_NAME, "worker-x")或者 gdb 里的set thread name),ps -eLf和 gdb 输出里就能直接显示每一个线程在干什么。线上服务维护时,这个操作能救你命——几十个线程 ID 对着看日志,比记一串数字好使太多。