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里。它的策略是这样的:

  1. 优先使用用户指定的栈大小——如果你通过pthread_attr_setstacksize设置了大小,那就用你的
  2. 否则使用默认大小——通常是8MB(x86_64 Linux上)
  3. 分配方式——通过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里。

主要干这几件事:

  1. 调用线程清理函数(通过pthread_cleanup_push注册的)
  2. 释放线程本地存储(TLS)数据
  3. 如果线程是分离状态(detached),直接释放栈和线程描述符
  4. 如果是joinable状态,设置线程状态为已退出,并唤醒等待的pthread_join

这里有个关键点:线程栈什么时候释放?

如果是分离线程,线程退出时立即释放。如果是joinable线程,要等到pthread_join返回后才释放。这就是为什么你必须在合适的时候调用pthread_join或pthread_detach,否则会有内存泄漏。

记住:每个joinable线程都必须被join一次。不join的线程,它的栈和描述符会一直占用内存。这就是所谓的「线程僵尸」。

知识体系总览

下面这张图,把线程创建到消亡的完整流程画出来了。我建议你多看几遍,把每个环节串起来。

线程创建与消亡核心流程 用户态 (glibc) pthread_create 入口 __pthread_create_2_1 分配线程栈 allocate_stack (mmap) 设置线程属性 栈大小、调度、TLS clone 系统调用 flags = CLONE_VM | CLONE_THREAD | ... 内核态 (Linux Kernel) 创建 task_struct 复制/共享资源 设置用户态栈指针 child_stack 参数 调度执行 wake_up_new_task 线程开始执行用户函数 (start_routine) 从 clone 返回后,跳转到 start_routine 线程退出:pthread_exit / return → 清理 → 唤醒 pthread_join

这张图把用户态和内核态的分界画得很清楚。你注意看,pthread_create在用户态做完栈分配和属性设置后,通过clone系统调用进入内核。内核创建task_struct,设置好栈指针,然后调度执行。线程跑完用户函数后,再走清理流程。

我的建议:如果你在排查线程相关的问题,先确认栈大小是否合理,再看线程是否被join了。这两个问题占了线程相关bug的八成以上。

好了,线程的诞生与消亡,从glibc到内核,从栈分配到资源回收,咱们都过了一遍。下次你写pthread_create的时候,脑子里应该能浮现出背后那一整套流程了。