2. 线程的诞生与消亡
说实话,线程这东西,很多人用了好几年,真问起底层怎么创建的,往往就卡住了。我当年也是这样——直到有一次线上服务莫名其妙地OOM,排查到最后发现是线程栈分配出了问题。从那以后,我就下定决心把这块彻底啃透。
今天咱们就一起扒一扒pthread_create的底裤。从glibc源码到内核的clone系统调用,再到线程栈的分配策略,一步不落。
pthread_create:用户态的入口
你写个简单的pthread_create调用,glibc背后干了多少事?我数了数,核心流程至少有七八步。但别怕,咱们抓住主线就行。
先看个最简示例:
#include <pthread.h>
#include <stdio.h>
void* worker(void* arg) {
printf("Hello from thread!\n");
return NULL;
}
int main() {
pthread_t tid;
pthread_create(&tid, NULL, worker, NULL);
pthread_join(tid, NULL);
return 0;
}
就这么几行代码,glibc内部却是一场大戏。pthread_create的源码在glibc的nptl/pthread_create.c里。核心函数是__pthread_create_2_1,它做了这几件关键事:
- 分配线程栈——调用
allocate_stack,这是个大活儿,后面细说 - 设置线程属性——比如栈大小、调度策略、分离状态等
- 创建线程描述符——就是那个
struct pthread结构体,管理线程生命周期 - 调用clone系统调用——这才是真正创建内核线程的那一步
核心要点:pthread_create本质上是个包装函数。它把用户态的参数整理好,最后通过clone系统调用让内核创建一个新的执行上下文。
clone系统调用的参数奥秘
说到clone,很多人以为它和fork差不多。其实差别大了去了。fork创建的是完整进程,clone创建的是轻量级进程——也就是我们常说的线程。
clone的原型长这样:
long clone(unsigned long flags, void *child_stack,
int *parent_tid, int *child_tid,
unsigned long tls);
关键就在flags参数上。glibc创建线程时传的flags,我直接给你列出来:
| 标志位 | 含义 | 为什么需要 |
|---|---|---|
| CLONE_VM | 共享地址空间 | 线程共享进程的虚拟内存 |
| CLONE_FS | 共享文件系统信息 | 线程共享当前目录、umask等 |
| CLONE_FILES | 共享文件描述符表 | 线程共享打开的文件 |
| CLONE_SIGHAND | 共享信号处理函数 | 线程共享信号处理逻辑 |
| CLONE_THREAD | 加入同一线程组 | 让新线程属于同一个进程 |
| CLONE_SETTLS | 设置线程本地存储 | 每个线程有自己的TLS |
| CLONE_PARENT_SETTID | 在父线程中设置tid | 用于pthread_t的管理 |
| CLONE_CHILD_CLEARTID | 子线程退出时清除tid | 用于pthread_join的等待机制 |
你看,这一串标志位组合起来,就定义了「线程」的本质——和父进程共享几乎所有资源,唯独栈是独立的。
我的经验:曾经有个同事问,为什么线程里用fork会有问题?你看看CLONE_VM这个标志就明白了——fork出来的子进程如果和线程共享地址空间,那mutex这些锁的状态就全乱套了。
线程栈的分配策略
嗯,这里要重点说说。线程栈的分配,是pthread_create里最容易被忽视但又最容易出问题的地方。
glibc的allocate_stack函数在nptl/allocatestack.c里。它的策略是这样的:
- 优先使用用户指定的栈大小——如果你通过pthread_attr_setstacksize设置了大小,那就用你的
- 否则使用默认大小——通常是8MB(x86_64 Linux上)
- 分配方式——通过mmap匿名映射,不是malloc
为什么用mmap而不是malloc?我解释一下:
- mmap可以指定映射地址,方便栈底对齐
- mmap分配的内存可以带PROT_NONE的守护页,防止栈溢出
- 线程退出时,mmap可以立即释放,而malloc归还后可能被其他线程复用
默认8MB这个数字,很多人觉得太大了。其实不然。你想想看,一个线程的栈里要放什么?
- 函数调用链的栈帧
- 局部变量(尤其是大数组)
- 线程本地存储(TLS)
- 信号处理时的备用栈
我见过一个案例:某团队写了个图像处理程序,每个线程里声明了一个4MB的局部数组做缓冲区。结果8MB的栈被吃掉一半,再加上函数调用链,稍微深一点就栈溢出了。后来改成动态分配,问题解决。
避坑指南:我曾经在嵌入式Linux上吃过亏。默认栈大小是8MB,但嵌入式设备总共才256MB内存,开32个线程就把内存吃光了。解决方案是显式设置栈大小为256KB或512KB,够用就行。
线程栈的守护页机制
glibc在分配线程栈时,会在栈底(高地址)和栈顶(低地址)之间加一个守护页。这个守护页是PROT_NONE的,任何读写都会触发SIGSEGV。
为什么要这么做?
说白了,就是为了检测栈溢出。当线程的栈指针向下增长,碰到守护页时,内核会立即发送段错误信号。这比等到栈把其他内存区域踩坏了再发现要好得多。
守护页的大小通常是4KB(一页)。但要注意,这个机制不是万能的:
- 如果栈增长太快,一次跳过了守护页,那就检测不到了
- 如果栈溢出是向上增长(比如往高地址写),守护页也防不住
线程的消亡:pthread_exit与资源回收
线程退出时,glibc会做清理工作。核心流程在__pthread_unwind和__do_cancel里。
主要干这几件事:
- 调用线程清理函数(通过pthread_cleanup_push注册的)
- 释放线程本地存储(TLS)数据
- 如果线程是分离状态(detached),直接释放栈和线程描述符
- 如果是joinable状态,设置线程状态为已退出,并唤醒等待的pthread_join
这里有个关键点:线程栈什么时候释放?
如果是分离线程,线程退出时立即释放。如果是joinable线程,要等到pthread_join返回后才释放。这就是为什么你必须在合适的时候调用pthread_join或pthread_detach,否则会有内存泄漏。
记住:每个joinable线程都必须被join一次。不join的线程,它的栈和描述符会一直占用内存。这就是所谓的「线程僵尸」。
知识体系总览
下面这张图,把线程创建到消亡的完整流程画出来了。我建议你多看几遍,把每个环节串起来。
这张图把用户态和内核态的分界画得很清楚。你注意看,pthread_create在用户态做完栈分配和属性设置后,通过clone系统调用进入内核。内核创建task_struct,设置好栈指针,然后调度执行。线程跑完用户函数后,再走清理流程。
我的建议:如果你在排查线程相关的问题,先确认栈大小是否合理,再看线程是否被join了。这两个问题占了线程相关bug的八成以上。
好了,线程的诞生与消亡,从glibc到内核,从栈分配到资源回收,咱们都过了一遍。下次你写pthread_create的时候,脑子里应该能浮现出背后那一整套流程了。