线程创建的详细过程 —— 从 clone() 到"主线程什么时候返回"
上一篇 process-creation 讲了进程怎么"复制"出来(
fork+ 少量 clone flags)。本篇聚焦线程:它和进程本质上都是内核里的"调度实体(task)",走的都是clone(),区别只在共享多少。本篇讲透线程创建的全过程,并重点回答一个常见困惑:"创建一个线程,主线程什么时候返回?" 全篇呼应 demo 程序../../demos/cpu-demo/main.cpp(std::thread+detach())。
一、线程 vs 进程的本质区别
一句话:进程是"资源的容器",线程是"调度的单位"。 同一进程里的多个线程共享绝大部分资源,只保留极少数"每个执行流必须独占"的东西。
| 资源 | 进程间(fork) | 同进程的线程间 |
|---|---|---|
| 虚拟地址空间(代码段/数据段/堆) | 各自独立(copy-on-write 复制) | 共享(同一份) |
| 全局变量、静态变量 | 各自一份 | 共享(改一个都看得见) |
| 打开的文件描述符表(fd) | fork 时复制一份(之后独立) | 共享(一个线程 open 的 fd,别的线程能用) |
| 当前工作目录、文件系统信息 | 独立 | 共享 |
| 信号处理函数(handler) | 独立 | 共享 |
| 栈(stack) | 独立 | 各自独立(每线程一个栈) |
| 寄存器 / 程序计数器(PC) | 独立 | 各自独立(各跑各的指令流) |
| TLS(线程本地存储) | —— | 各自独立(errno、__thread 变量) |
| 内核调度实体 / tid | 独立 | 各自独立(每线程一个 tid,可被独立调度到不同核) |
看得出规律:"要一起看到同一份数据"的东西共享(地址空间、全局、fd);"每个执行流跑指令必须独占"的东西独立(栈、寄存器、TLS、tid)。
二、pthread_create / std::thread 底层是 clone()
std::thread(C++)→ 底层调用 pthread_create(glibc / NPTL)→ 最终发起 clone() 系统调用。线程和进程创建用的是同一个系统调用,差别全在传给 clone() 的 flags:flags 里每一位表示"这个资源要不要和父任务共享"。
- fork(造进程):只带很少的共享位,绝大部分资源独立(因为父子进程之间基本不共享什么信息)。
- pthread_create(造线程):带一大堆
CLONE_*共享位,几乎什么都共享。
线程创建典型 flags:
| flag | 含义(打开=共享) |
|---|---|
CLONE_VM | 共享虚拟地址空间(同一份内存,最关键) |
CLONE_FILES | 共享打开文件描述符表 |
CLONE_FS | 共享文件系统信息(cwd、umask、root) |
CLONE_SIGHAND | 共享信号处理函数表 |
CLONE_THREAD | 归入同一个线程组(同一 PID,各自不同 tid) |
CLONE_SETTLS | 设置新线程的 TLS 寄存器 |
CLONE_PARENT_SETTID / CLONE_CHILD_CLEARTID | 配合 glibc 管理 tid、线程退出时清理 |
对比记忆(呼应 process-creation 的 clone 表):
fork≈ clone(少量 flags),几乎啥都不共享;pthread_create≈ clone(CLONE_VM|CLONE_FILES|CLONE_FS|CLONE_SIGHAND|CLONE_THREAD|...),几乎啥都共享。 同一个 clone,flags 不同,造出来的就是"进程"还是"线程"。
在发起 clone() 之前,glibc/NPTL 还替我们干了两件苦活:
- 分配线程栈:默认约 8MB(可通过
pthread_attr_setstacksize调整),用mmap从地址空间里划一块,作为新线程的独立栈。 - 设置 TLS:为新线程准备线程本地存储区,并通过
CLONE_SETTLS让内核把 TLS 寄存器(x86-64 上的fs)指过去。

三、【重点】"创建一个线程,主线程什么时候返回?"
这是最容易搞错的点,拆开讲清楚。
3.1 pthread_create/clone 是异步的 —— 调用一返回就继续
pthread_create(底层 clone)是异步的:
- 返回时机 =
clone系统调用完成、内核已经创建好一个可被调度的新线程(分配了 tid、栈指针、入口 PC)。 - 返回不代表新线程已经开始执行,更不代表新线程跑完。甚至新线程可能一条指令都还没跑,主线程就已经拿到返回值、继续往下走了。
所以答案是:主线程在"内核把新线程建好并可调度"的那一刻就立刻返回,继续执行 pthread_create 之后的代码——它不等新线程开始跑,也不等它跑完。
3.2 此后两者并发,谁先跑不确定
pthread_create 返回后,主线程和新线程就是两个独立的可调度实体,由 CPU 调度器决定:
- 谁先跑(可能主线程先跑一大段,也可能新线程立刻被调度);
- 跑在哪个核(多核上可能真正并行);
- 怎么交替。
这个顺序不确定、不可依赖。 想要确定的先后,必须自己用 join() / 条件变量 / 信号量等同步手段。

3.3 想"等"新线程:join() vs detach()
主线程若要等新线程,必须显式调用 join();否则默认就是不等。
| 操作 | 语义 | 主线程行为 | 资源回收 |
|---|---|---|---|
join() | 汇合 | 阻塞等待目标线程结束后才继续 | join 时回收线程资源 |
detach() | 分离 | 不等,各跑各的,放手不管 | 线程结束时自动回收 |
| 都不调用 | —— | std::thread 析构时若仍 joinable,直接 std::terminate() 崩溃 | —— |
demo 里用的是 detach()(../../demos/cpu-demo/main.cpp 第 126 行 work_thread.detach();):主线程把工作线程"放手",然后自己进入每秒采样打印的循环,两者并发跑——工作线程狂消耗 CPU,主线程负责观测。
3.4 detach 的关键后果:主线程 return,整个进程就没了
这是 demo 结构的核心原因,必须讲透:
- 进程的生命周期 ≠ 主线程 return 就等新线程。 一旦
main()里的主线程return(或调用exit()),整个进程连同它所有线程(包括还在跑的 detached 线程)会被一起销毁——因为进程地址空间被拆掉了,寄居其中的线程也就没了。 - 所以,如果 demo 主线程在
detach()之后直接return,工作线程可能一条业务指令都还没来得及跑,进程就退出了,什么现象都观测不到。 - 这正是
../../demos/cpu-demo/main.cpp里主线程detach()后进入while(true){ sleep(1); ... }无限循环、而不是return的原因:用主线程"不退出"来撑住整个进程存活,让 detached 工作线程有机会一直跑下去,供 top/pidstat 观测。
对照:如果改用
join(),主线程会阻塞在 join 处直到工作线程结束——但 demo 的工作线程本身是while(true)死循环,join 会永远阻塞,采样循环也就跑不起来了。所以 demo 选detach()+ 主线程自己循环,是刻意设计。
四、线程栈、TLS、线程 ID
- 线程栈:每线程一个独立栈,Linux 上默认约 8MB(
ulimit -s查看主线程栈上限;子线程栈由 pthread 分配)。可用pthread_attr_setstacksize或std::thread之外的属性调整。栈是线程独立的根本原因——各自的函数调用链、局部变量互不干扰。 - TLS(线程本地存储):像
errno就是 TLS,每个线程有自己的一份,A 线程的系统调用出错不会污染 B 线程的errno;C/C++ 里__thread/thread_local变量也是 TLS。 - 线程 ID 的两套概念:
gettid():返回内核视角的线程 ID(tid),全系统唯一,就是/proc/$PID/task/下看到的那个号,也是top -H、ps -T显示的号。主线程的 tid 恰好等于进程的 PID。pthread_self():返回 glibc 用户态视角的 pthread_t 句柄,只在本进程内有意义,用于pthread_join/pthread_cancel等,不等于 tid。- 一句话:排查/观测看
gettid(tid),代码里管理线程用pthread_self(pthread_t)。
五、观测:怎么看到线程被创建
| 手段 | 命令 | 看什么 |
|---|---|---|
| 跟踪系统调用 | strace -f ./cpu_demo | 抓到 clone(...) 且 flags 里带 CLONE_THREAD|CLONE_VM|...(对比 fork 的 clone flags 少得多) |
| 看进程内的线程 | ps -T -p <PID> | 每个线程一行,SPID 列就是 tid |
| top 展开线程 | top -H -p <PID> | 按线程显示 CPU,能看到工作线程吃满一个核、主线程几乎不耗 CPU |
| /proc | ls /proc/<PID>/task/ | 每个子目录名就是一个线程的 tid |
跑 demo 时用 strace -f 能清楚看到主线程发起带一大堆 CLONE_* 的 clone() 造出工作线程,再用 top -H / ps -T 看到进程里有两个线程、工作线程在狂吃 CPU。
延伸阅读:top(含 -H 线程视图)、pidstat(-t 按线程统计)、strace(-f 跟踪 clone)、fork-and-threads(fork 与线程的取舍对比)、thread-affinity(把线程钉到指定 CPU 核)。
总结
线程创建走的是带一大堆 CLONE_* 共享 flags 的 clone(),与 fork 只是 flags 多少之别;pthread_create 是异步的——内核一把新线程建成可调度,主线程就立刻返回继续跑,绝不等新线程开始或结束,想等就 join()、放手就 detach(),而一旦主线程 return/exit 整个进程连同 detached 线程一起销毁(这正是 ../../demos/cpu-demo/main.cpp 主线程 detach 后进死循环 sleep 而不 return 的原因)。