写线程相关的内容,我一直觉得最怕的不是API记不住,而是概念理解偏差。早些年在帮某位同学排查一个服务崩溃问题时,发现他写的多线程程序里,线程函数在访问一个已经被主线程 free 掉的堆指针,最终追查下来,根子不在代码而在对线程共享地址空间的认知缺了一块。这个案例后来成了我给新人讲线程时的固定开场:线程概念搞不清楚,写出来的代码大概率是 "能跑但是随时会炸"。
这篇就围绕 Linux 线程的概念和控制来展开,我会把"线程解决了什么问题""线程控制的核心 API 怎么用""底层的栈和线程 ID 细节""实战中的完整编排方式"以及"我踩过和见过的高频坑"这几块讲透。内容面向正在学进程线程、或者写过一点 pthread 但总觉得哪里没通的开发者,尽量用口语化的方式把原理和实操接到一起。
1. 线程到底解决了什么问题:从进程切换的代价说起
1.1 进程是"重装部队",线程是"轻步兵"
要想理解线程出现的原因,先要回到进程的"沉重"上。Linux 里使用 fork 创建子进程时,内核要复制一大堆东西:页表、文件描述符表、信号处理方式、当前工作目录、挂载的命名空间,等等。虽然现代 Linux 用了写时拷贝(COW)来偷懒,但复制页表结构的开销依然存在,更别说进程切换时要切换虚拟地址空间,导致 TLB(页表缓存)失效,下一次访问内存全部要重新走一遍页表翻译。
这就好比一支重装部队,每换一次阵地,所有重型装备都要跟着转移一次。进程之间的通信也是同样的道理:管道、消息队列、共享内存、信号、socket,无论是哪一种,要么涉及内核缓冲区的拷贝,要么得先建立一套同步机制。你只是想并行算几个数组的分块求和,却要 fork 一堆进程再搞 IPC,这明显划不来。
线程的方案则完全不同:同一进程内的多个线程共享同一份地址空间、同一份文件描述符表,它们的切换只是在用户态栈和寄存器层面做文章,不需要切换虚拟地址空间,TLB 也基本不受影响。所以线程被称为"轻量级进程",本质上是同一个进程内部多条独立执行流,切换代价比进程小一个数量级是不夸张的。
不过这里要泼一盆冷水:"轻量级进程"这个叫法容易让人误以为线程就是便宜的进程,其实在 Linux 内核实现里,线程和进程都是通过 clone 系统调用创建的,区别只在于创建时传给内核的共享标志位不同。线程之间共享内存地址空间、文件描述符、信号处置等资源,而进程之间默认这些是完全隔离的。理解了这一点,后面很多坑就能提前避开。
1.2 线程独有与共享的资源清单
新手最常见的概念混淆,就是不知道哪些数据是大家共用的,哪些是每个线程自己一份的。我整理了一张常用的对照表:
| 资源类型 | 线程间共享 | 线程私有一份 |
|---|---|---|
| 代码段(.text) | 是 | 无概念,大家都执行同一份代码 |
| 数据段(.data/.bss) | 是 | 无概念,所有线程都能读写同一份全局变量 |
| 堆(malloc 分配的内存) | 是 | 无概念,谁都能 new/free 同一块堆内存 |
| 文件描述符表 | 是 | 无概念,一个线程关闭 fd,其他线程再访问就报错 |
| 信号处理方式 | 是 | 信号掩码可不同,处理函数是进程级的 |
| 当前工作目录 | 是(绝大多数场景) | 可以各自 chdir,但后果由全进程承担 |
| 线程 ID(pthread_t) | 否 | 是 |
| 栈(调用栈、局部变量) | 否 | 是,每个线程有独立的栈 |
| 寄存器上下文(CPU 现场) | 否 | 是 |
| errno | 否 | 是,是一个线程局部变量 |
| 调度优先级 | 否 | 每个线程可独立设置 |
这张表里最容易出问题的就是"堆是共享的"和"栈是私有的"这两行。共享的堆意味着一个线程分配出来的对象,另一个线程可以直接用,但同时也意味着如果有两个线程同时写一个变量而不加锁,那就是数据竞争。私有的栈则意味着线程函数里的局部变量地址不能作为返回值传给外面,函数一返回栈帧就没了,外面拿到的是一块已失效的内存。
1.3 线程库不是内核接口:pthread 与系统调用的关系
很多人刚开始接触 pthread(POSIX Threads)的时候,会以为 pthread_create 之类的函数是内核直接提供的系统调用。其实不是。glibc 里实现的 pthread 库是对底层 clone 系统调用的封装,它负责在用户态做好线程栈的分配、线程描述符的管理、以及若干同步原语(比如互斥锁、条件变量)的实现。
这一点解释了为什么编译多线程程序时要加 -lpthread 选项。在较老版本的 glibc 中,libpthread 是独立动态库,不链接它就会在 pthread_create 处报"未定义的引用"。虽然较新版本(glibc 2.34 之后)已经将 pthread 函数直接并入 libc,但为了兼容性,我仍然建议编译时显式加上 -lpthread,毕竟你不知道程序将来会被部署到哪个版本的发行版上。
同时,正因为线程是用户态库管理、内核态只负责调度执行流,所以线程创建速度比 fork 快得多,这不仅仅因为共享地址空间省了页表复制,还因为线程栈和描述符的分配完全可以由用户态库快速完成,不需要每次陷入内核去复制一堆资源。
2. 线程控制核心 API 实操:创建、终止、等待和分离
2.1 pthread_create:每个参数背后都藏着细节
线程控制的起点是创建线程,原型如下:
#include <pthread.h> int pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *(*start_routine)(void *), void *arg);这个函数四个参数,每一个都值得说:
第一个参数 thread 是输出参数,用来接收创建出来的线程 ID。注意这个 ID 的类型是 pthread_t,在不同平台上有不同的底层实现,可能是一个无符号长整型,也可能是一个指针。因此任何时候都不要假设它的打印格式,如果非要打印,用 %lu 强制转换通常可行,但最稳妥的方式是使用 pthread_self() 在线程内部获取自己的 ID 后,用 pthread_equal 做比较,而不是直接比较数值。
第二个参数 attr 是线程属性,传入 NULL 表示使用默认属性。默认属性在大多数情况下是"可结合"(joinable)的,意味着线程结束后资源不会自动释放,必须有人 pthread_join 它。这其实是新手最常踩的坑,后面第 5 章我会专门展开。
第三个参数 start_routine 是线程函数指针,签名必须是 void()(void *),也就是"接收一个 void 指针、返回一个 void 指针"的函数。如果你想传多个参数,就得自己定义一个结构体,把一堆参数打包成结构体指针传进去;如果想返回多个结果,同样可以用结构体指针做返回值。线程函数就是一条独立执行流的入口,它 return 意味着这条执行流正常结束。
第四个参数 arg 是把参数传给 start_routine,类型也是 void*。这里危险的地方在于传指针时,你必须保证指针在目标线程使用它的整个过程中都有效。最常见的错误就是把一个局部变量的地址传给新线程,主线程函数随后立刻返回,局部变量随之失效,新线程拿着悬空指针胡写一通,崩溃就是迟早的事。
另外,pthread_create 的返回值务必检查。如果返回非 0,线程创建失败,常见的失败原因是资源不足(EAGAIN),比如线程数达到系统上限,或者虚拟内存不够。忽略返回值会让程序在压力很大的时候悄悄退化成单线程,这种 bug 很难复现,也很难查。
编译时不要忘记链接线程库:
gcc -o demo demo.c -lpthread2.2 线程终止的三种方式,以及它们的致命区别
线程函数运行完毕只是线程终止的一种方式。终止一个线程,可以从以下途径中选:
- 线程函数正常 return,返回值会作为线程的退出状态被保存。
- 在线程函数任意位置调用 pthread_exit(void *retval),主动结束当前线程,retval 同样作为退出状态。
- 被其他线程通过 pthread_cancel 取消,前提是目标线程设置了取消点。
这里面最容易误用的坑是:在一个线程里调用 exit() 或者从 main 函数里 return。exit() 会终止整个进程,所有线程一并消失,这不是"结束当前线程",而是进程级别的退出。main 函数里写 return 0,等价于调用 exit(0),也会把所有线程全部带走。如果你想让 main 线程结束但其他工作线程继续跑,正确做法是在 main 里调用 pthread_exit(NULL),这样进程会等待所有非 main 线程结束后才退出。
pthread_exit 和 return 还有一个细微差别:线程函数里如果有一些栈上的析构逻辑(比如 C++ 的局部对象析构),return 会正常走完这个过程,而 pthread_exit 则不会执行 C++ 栈上对象的析构——因为 pthread_exit 是直接跳出函数。写 C++ 多线程代码时,如果线程函数构造了 RAII 类型的局部对象,比如 std::lock_guard,请尽量使用 return 而不是 pthread_exit,否则非常容易造成锁没有释放的情况。
2.3 pthread_join:等待、回收、拿返回值
进程中子进程结束后会变成僵尸进程,需要父进程 waitpid 回收。线程也有类似机制,一个 joinable 的线程结束后,它的资源不会自动释放,而是需要另一个线程对它调用 pthread_join。
int pthread_join(pthread_t thread, void **retval);第一个参数是目标线程的 ID,第二个参数用于接收线程的返回值。线程返回值是一个 void*,所以这里要传 void**。如果你不关心返回值,传 NULL 即可。pthread_join 会阻塞调用者,直到目标线程终止,然后回收目标线程的资源,把退出状态写入 retval 指向的变量。
这里有两个关键点:第一,一个线程只能被 join 一次,对同一个线程调用第二次 pthread_join 是未定义行为,实际中很可能直接返回 ESRCH 之类的错误;第二,如果目标线程已经被 detach(后面讲),对它 join 也会失败。换句话说,joinable 和 detached 是线程的两个互斥状态,必须在创建时或运行中用属性/函数明确选择。
获取返回值时要格外小心。如果线程函数返回的是一个指向自身栈空间的指针,比如:
void *worker(void *arg) { int result = 42; return &result; // 错误!result 在线程栈上 }join 拿到的指针指向的是一块已经被回收的栈内存,访问它完全是未定义行为。正确做法是返回堆上的指针,并且约定好谁来释放;或者通过 arg 传入一块由调用方管理的缓冲区,线程把结果写入该缓冲区。
2.4 pthread_detach:让线程自己打扫战场
如果确实不需要知道线程什么时候结束,也不关心它的返回值,可以把线程设置为分离状态。
int pthread_detach(pthread_t thread);调用 pthread_detach 之后,线程结束时会自动释放自己的资源,不再需要其他线程 join。这个操作对在任意状态下的线程都可以调用,但调用后线程就不可再 join。
另外也可以在创建线程时就指定分离属性:
pthread_attr_t attr; pthread_attr_init(&attr); pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_DETACHED); pthread_create(&tid, &attr, worker, arg); pthread_attr_destroy(&attr);detach 适合那种"发出去就不管"的任务,比如日志落盘线程、心跳上报线程,它们往往运行到进程结束,本来也没有人 join。但注意:detach 不等于线程立刻消失,它只是决定了"结束之后资源由系统回收,不需要你操心"这一件事。如果你的逻辑里后续可能依赖这个线程执行完,比如它要写一个标记位再退出,你最好还是保留 join 能力,用条件变量或标志位控制同步,而不是把线程直接 detach 掉。
3. 线程栈、ID 映射和运行时数据的底层细节
3.1 线程栈大小:默认 8M,别小看它
每个线程都有独立的栈,用于存放函数调用帧和局部变量。默认线程栈大小由系统参数决定,通常取决于 ulimit -s 的值,很多 Linux 发行版上默认是 8MB(8192KB)。这里的"8MB"是线程栈的虚拟内存限制,不是一开始就全部物理占用,实际使用多少才逐渐加载多少页。
创建线程时可以修改栈大小,方式同样是通过 pthread_attr:
pthread_attr_t attr; pthread_attr_init(&attr); size_t stack_size = 16 * 1024 * 1024; // 16MB pthread_attr_setstacksize(&attr, stack_size); pthread_create(&tid, &attr, worker, arg); pthread_attr_destroy(&attr);我实际遇到的情况是:某位同事在线程函数里递归解析一棵很大的 JSON 树,层级深,局部变量又多,默认 8MB 栈直接爆了,程序段错误。查 glibc 的默认线程栈大小其实和主线程栈分开看,主线程栈是内核启动程序时分配的一大块虚拟地址空间,通常也是 8MB 上下,但线程栈是 pthread 库在创建线程时用 mmap 分配的,两者互不干扰。
栈溢出的排查手段:程序收到 SIGSEGV 后,用 gdb 打开 core 文件,bt 查看调用栈,再结合 pthread_attr_getstacksize 查看当前线程栈大小,基本能判断是不是栈空间不足。如果确认是栈溢出,增加线程栈大小或把大对象放到堆上都行,但通常更推荐的思路是减少栈深度和大对象,因为线程栈是虚拟地址空间里的稀缺资源,一个进程能创建的线程总数受到总地址空间里可用区域的限制,你给每个线程分配 64MB 栈,线程数量上限就会大幅下降。
3.2 pthread_t、gettid 与内核线程 ID:三者的对应关系
这是很多人忽略的细节。pthread_create 返回的 pthread_t,是 pthread 库自己维护的用户态句柄,主要用来在库内部定位线程描述符。它不等于内核里那个真实的线程 ID。内核里每个线程都有独立的进程描述符(task_struct),对应一个内核线程 ID,可以用 gettid 系统调用获取:
#include <sys/syscall.h> #include <unistd.h> pid_t tid = (pid_t)syscall(SYS_gettid);在 Linux 上,内核的调度单位本质上是这种轻量级进程,也就是"线程"。每个线程都是调度器眼里的一个独立实体。查看进程下的多个线程,可以用 ps -eLf,其中 LWP(LightWeight Process)列就是内核线程 ID;top 进入 H 模式也能看到线程粒度。用 gdb 附加调试多线程程序时,info threads列出的也是内核线程的概念。
为什么了解这个映射关系有用?因为 pthread_t 只在用户态有效,当你用 perf、gdb、strace 去观察程序时,看到的往往是内核线程 ID(LWP),不是 pthread_t。如果线程 A 异常,日志里记录的是 pthread_t,而操作系统的 core 文件、系统日志里显示的是 LWP,这两者如果不建立映射,排查问题的时候就会卡住。我一般在多线程程序里会打印两个 ID:一个 pthread_self(),一个 gettid(),日志里两个都记录,排查问题时就能两边对上了。
3.3 全局变量到底谁在改:线程局部存储与 errno
共享地址空间带来一个大问题:传统全局变量是所有线程都能写的,很容易互相踩踏。C 语言里 errno 很早就遇到了这个问题,每个线程必须拥有独立的 errno 值,否则一个线程调用 read 出错返回 -1,还没来得及检查 errno,另一个线程又调用 open,把 errno 覆盖掉,前面的错误码就丢了。
解决方案是线程局部存储(Thread Local Storage,TLS)。errno 就是一个典型的 TLS 变量,每个线程读写的是自己那份副本。在日常编程里,如果你想给"看起来像是全局变量,但每个线程要有独立副本"的变量预留位置,可以使用__thread关键字:
static __thread int thread_local_counter = 0;用__thread修饰的变量,生命周期是整个线程的生命周期,存储上每个线程各有一份,互不影响。这个特性在实现一些"每个线程独立的缓存"时非常有用,比如每个线程维护一个缓冲池,避免多线程访问同一个全局缓冲池时的锁竞争。
但是要明确:TLS 只是少数变量的特例,绝大多数的全局变量(未加__thread)仍然是所有线程共享的。如果两个线程同时对一个全局 int 执行counter++,这就不是一个原子操作,结果可能丢失更新。下面的章节我会结合实战代码把这点讲透。
4. 实战编排:一个多线程分块求和任务的完整代码
4.1 场景与设计思路
为了把前面讲的 API 串起来,我们实现一个经典的小任务:对一个包含 800 万个随机整数的数组求和。单线程直接累加,多线程则把数组切成 4 段,每个线程各算一段,最后把 4 个部分和加起来得到总和。
设计时要注意几点:
- 数组本身是共享的,所有线程都会读取它,但因为没有写操作,所以不需要加锁,这符合"读共享无竞争"的原则。
- 每个线程需要一个参数结构体,里面包含当前线程负责处理的数组区间(start 和 end),以及一个用于保存部分和的 out 字段。
- 由于部分和写到了参数结构体里,而该结构体由主线程分配并在线程 join 后读取,所以生命周期安全,不需要堆分配后再释放的复杂约定。
- 用 pthread_join 汇总每个线程的结果,确保看到的是线程真正跑完后的值。
4.2 完整可编译的代码
#include <pthread.h> #include <stdio.h> #include <stdlib.h> #include <unistd.h> #define ARRAY_SIZE 8000000 #define THREAD_NUM 4 struct range_arg { long *array; size_t start; size_t end; long partial_sum; }; void *sum_range(void *arg) { struct range_arg *ra = (struct range_arg *)arg; long sum = 0; for (size_t i = ra->start; i < ra->end; i++) { sum += ra->array[i]; } ra->partial_sum = sum; return NULL; } int main(void) { long *array = malloc(sizeof(long) * ARRAY_SIZE); if (!array) { perror("malloc"); exit(EXIT_FAILURE); } for (size_t i = 0; i < ARRAY_SIZE; i++) { array[i] = rand() % 100; } pthread_t tids[THREAD_NUM]; struct range_arg args[THREAD_NUM]; size_t chunk = ARRAY_SIZE / THREAD_NUM; // 打印每个线程的内核ID与用户态ID,方便后面观察 for (int i = 0; i < THREAD_NUM; i++) { args[i].array = array; args[i].start = i * chunk; args[i].end = (i == THREAD_NUM - 1) ? ARRAY_SIZE : (i + 1) * chunk; args[i].partial_sum = 0; // 注意arg是&args[i],这是主线程栈上的地址,要求主线程在join前一直存活 if (pthread_create(&tids[i], NULL, sum_range, &args[i]) != 0) { perror("pthread_create"); exit(EXIT_FAILURE); } printf("created thread %d: pthread_t=%lu kernel_tid=%d\n", i, (unsigned long)tids[i], (int)syscall(SYS_gettid)); } long total = 0; for (int i = 0; i < THREAD_NUM; i++) { pthread_join(tids[i], NULL); total += args[i].partial_sum; printf("partial[%d] = %ld\n", i, args[i].partial_sum); } printf("multi-thread total = %ld\n", total); long check = 0; for (size_t i = 0; i < ARRAY_SIZE; i++) { check += array[i]; } printf("single-thread total = %ld\n", check); free(array); return 0; }注意上面的 printf 里我没有直接打印"线程内部的 pthread_self",因为每个线程自己才能拿到自己的 pthread_t,而主线程拿到的是 tids[i],两者概念相同。如果你想在线程函数内部打印,可以在 sum_range 里加一行:
printf("thread self=%lu kernel_tid=%d\n", (unsigned long)pthread_self(), (int)syscall(SYS_gettid));这里有意的设计是:args 数组是主线程栈上的局部变量,4 个线程在 join 之前,主线程一直存活,所以这些地址一直有效。反过来如果主线程在创建完线程后立刻 return,或者把 args 改成某个函数里的局部变量,在函数结束后再让线程使用它,就会触发前面说的悬空指针问题。
编译运行:
gcc -o thread_sum thread_sum.c -lpthread ./thread_sum在我机器上的输出类似:
created thread 0: pthread_t=139795161926912 kernel_tid=28124 created thread 1: pthread_t=139795153534208 kernel_tid=28125 created thread 2: pthread_t=139795145141504 kernel_tid=28126 created thread 3: pthread_t=139795136748800 kernel_tid=28127 partial[0] = 39516410 partial[1] = 39705724 partial[2] = 39597910 partial[3] = 39744969 multi-thread total = 158565013 single-thread total = 158565013两个总和完全一致,说明 4 个线程的区间切分没有重叠也没漏掉元素。
4.3 观察线程执行状态的实用命令
运行这段代码时,可以另开一个终端观察:
ps -eLf | grep thread_sum能看到同一个进程下有 4 个 LWP,它们是这个进程创建出来的 4 个线程。也可以执行:
top -H -p PID按 H 键后,top 会按线程粒度展示 CPU 使用率,此时能看到 4 个线程分别占用接近 100% 的 CPU(如果机器有多核),在单核机器上则表现为合计约 100% 的 CPU 时间。这个观察很直观地验证了"线程是 CPU 调度的基本单位"这句话。
如果你用 strace 去跟踪这个程序,会看到大量 futex 调用和 clone 调用。clone 就是线程创建的系统调用,futex 则是 pthread 库在 join、锁等场景下用来让线程睡眠/唤醒的底层机制。
4.4 为什么多线程累加不一定线性加速
很多人在跑完上面代码后会惊讶:开了 4 个线程,按理应该快 4 倍,实际却很接近 4 倍,但偶尔会低一点。原因主要有几个:
第一,CPU 核数限制。如果机器只有 2 个核,4 个线程就只能在 2 个核上来回切换,加速比不可能达到 4。
第二,内存带宽瓶颈。800 万个 long 占用约 64MB 内存,4 个线程同时从内存里读数据,内存带宽会被打满,计算本身反而成为次要瓶颈。这个例子其实更接近内存密集型任务,而不是 CPU 密集型。
第三,线程创建和 join 的开销。每个线程创建和回收都需要时间,如果任务粒度太小,创建线程的开销可能把收益吃掉。设想过把 800 个元素的数组切成 4 段,每段 200 个元素,线程创建开销远大于计算本身的收益,多线程反而更慢。
实际开发中判断"要不要用多线程",核心指标就是任务是否足够大、能否并行、线程间同步开销是否可控。这个感知比任何 API 知识都重要。
5. 线程控制中高频踩坑与完整排查链路
5.1 坑一:线程函数传参指向悬空内存
我把这个过程拆成一次完整的排查,供大家参考:
某位同学写了类似代码后,发现线程里读到的 arg 内容是随机的:
void create_workers() { struct range_arg arg; arg.array = array; arg.start = 0; arg.end = 100; pthread_t tid; pthread_create(&tid, NULL, sum_range, &arg); // arg 是局部变量! } // 函数返回,arg 生命周期结束排查链路可以这样走:
- 第一步,先怀疑编译器优化或内存被修改,但把结构体改成 volatile 后问题依旧。
- 第二步,给 arg 加打印,发现线程内拿到指针后,指针本身有效,但指向的内容有一半是乱码。
- 第三步,想到函数嵌套调用后,栈帧被复用,局部变量 arg 所在的地址内容很可能被下一次函数调用的栈帧覆盖。
- 第四步,把 arg 改成 static 或通过 malloc 分配,问题消失,确认根因是生命周期悬空。
这个案例里我们验证了一个重要原则:传递给线程的参数内存,生命周期必须覆盖到 pthread_join 完成之后。你可以把参数放在主线程栈内存中,但前提是主线程保证在 join 之前不会失效;也可以放在堆上,并约定线程用完后由谁释放;还可以用 static 全局结构体,但要注意 static 变量是全局共享的,多个线程同时用同一个 static 结构体会互相覆盖,所以一般不建议。
5.2 坑二:线程结束后不 join,资源不释放
创建一个 joinable 线程后,如果从头到尾没有 join,线程结束后它的栈、线程描述符等资源不会自动释放。这就好比一个永远没人收尸的僵尸进程,虽然不再执行,但占用的资源一直悬着。线程数量多、频繁创建销毁时,这个问题会让进程的虚拟内存不断增长,最终触碰系统线程数上限,pthread_create 开始不断返回 EAGAIN。
排查这类资源泄漏,可以持续观察进程的 VIRT 或者 /proc/PID/status 中的 Threads 字段,或者直接数进程下的线程数量:
cat /proc/PID/status | grep Threads如果线程数只增不减,而你的代码里线程是短生命周期任务,基本就是不 join 导致的。解决办法有两种:一是必须 join,二是将线程设置为 detached 状态,让它结束之后自动回收。对于"发出去就不管"的短任务,我通常更推荐显式设置为 detached,比每次 join 的代码更简洁,也不会漏。
5.3 坑三:join 与 detach 混用
有的同学为了"保险",在线程函数内部和主线程里同时操作同一个线程的 detach/join,结果程序时不时崩溃或挂起:
void *worker(void *arg) { pthread_detach(pthread_self()); // 线程自己把自己分离了 // do something return NULL; }然后在主线程里又执行 pthread_join(tid, NULL)。这时 join 会因为目标线程已分离而失败,返回一个无效参数错误,而如果代码不检查返回值,这个失败会被忽略,后续的工作线程结果就可能没被正确汇总。更严重的是,如果线程已经结束且资源已被回收,join 拿到的返回值可能是已回收的内存,访问它直接崩。
这里的原则很明确:分离和等待是互斥的。你想"知道线程何时结束"就用 join,不想知道就用 detach,不要两个都做。如果系里某个模块既有 detach 又有 join,先对照创建时的属性判断线程默认处于什么状态,再把多余的调用删掉。
5.4 坑四:线程里 fork,子进程只留下当前线程
Linux 的 fork 在子进程里只会复制调用 fork 的那个线程,其他线程会全部消失。如果你的多线程程序需要 coredump 或者执行子进程逻辑,却调用了非 async-signal-safe 的函数,比如 printf、malloc,就有死锁风险。因为其他线程可能在 fork 时正持有锁,而子进程里这些锁永远得不到释放。
多线程程序中如果要执行外部程序,正确做法是使用 posix_spawn,或者在 fork 之后尽快调用 exec 系列函数。如果实在需要在 fork 后调用库函数,也要意识到风险,尽量在 fork 之前就把输出、日志、内存状态都准备完毕。这个坑在实际中非常隐蔽,因为同一段代码在单线程下完全正常,切到多线程才偶尔卡死。
5.5 排查工具:gdb、valgrind 与线程视角
日常排查多线程问题时,我用的工具链基本是这三件套:
gdb 的常用线程操作:
(gdb) info threads # 列出所有线程 (gdb) thread 2 # 切换到编号 2 的线程 (gdb) bt # 查看当前线程调用栈 (gdb) thread apply all bt # 打印所有线程的调用栈考虑一个"程序卡死"的场景,用 gdb attach 上去,如果发现某个线程卡在 futex 相关的系统调用里,而另一个线程卡在某个锁的获取上,基本能定位到死锁。再用thread apply all bt看谁持有了锁、谁在等待锁,就可以进一步分析。
valgrind 的 helgrind 工具专门用来检测数据竞争:
valgrind --tool=helgrind ./thread_sum它能报告两个线程在没有加锁的情况下同时访问同一块内存的竞争点。这个工具在早期发现"漏加锁"的问题上非常有用,虽然它报告的信息比较啰嗦,很多库的内部实现也会触发警告,但第一轮排查自己的代码仍值得跑一遍。
另外,启用编译器的线程错误检测,比如 gcc 的 -fsanitize=thread,也是一个好手段。它和 helgrind 类似,但性能开销更小,适合在 CI 里跑。
我在实际排查中还有一个习惯:多线程程序日志里一定要打线程标识。不管是 pthread_self 还是 gettid,至少记录一个,否则同一时间多个线程写日志,你根本分不清哪行是哪个线程打的,这会让所有排查都寸步难行。
6. 我对线程控制的几条实操心得
最后分享几个我在实际项目里沉淀下来的习惯,不一定写入教科书,但很实用。
第一,能用线程池就不频繁创建线程。创建线程的开销虽然比 fork 小,但毕竟涉及系统调用和栈分配,高频场景下还是会拖累性能。很多项目里明明可以复用固定数量的线程,却靠"每次 new 一个 pthread"来解决问题,最后线程数量爆炸,反而更慢。
第二,尽量降低线程之间的共享面。需要说明的是,共享地址空间是线程的优势,但共享越少,需要加锁的代码就越少,出问题的概率就越小。设计时问自己一句:这段数据真的需要所有线程都能看吗?如果只是每个线程各算各的,最后合并一次,就不要搞全局变量。
第三,join 还是 detach 要在创建线程时就定下来,不要在线程跑起来之后靠 pthread_detach 临时补。创建线程时设置 attr 为 PTHREAD_CREATE_DETACHED,代码意图清晰,也避免后续遗漏 join 导致资源泄漏。
第四,线程函数的 start_routine 千万不要写复杂的业务代码。它应该是清晰的执行单元,入参、出参都明确。一旦一个线程函数超过 100 行,我会主动怀疑是不是设计出了问题,该拆函数就拆函数。
最后,排查多线程问题不要靠猜,一定要借助工具把"谁在什么时间访问了哪块内存、持有什么锁"还原出来。gdb 的线程栈、valgrind 的 helgrind、TSan 这三板斧,基本上能覆盖绝大多数并发 bug 的定位。真遇到了极其隐蔽的死锁或数据竞争,先把日志打全,再缩小异常输入范围,会比盯着代码干看好得多。
线程这部分内容其实不难,难的是把概念和 API 真正对应到自己的代码里。你把上面这些坑挨个踩过一遍,或者看别人踩过一遍,以后写多线程就会自然形成肌肉记忆。