线程模型:Linux 里的线程其实还是进程

难度 ★★★★☆ · 前置:IPC 选型:四把锤子,什么时候该抽哪把 | 一句话结论:Linux 内核里根本没有「线程」这个类型,只有一堆共享了某些资源(地址空间、fd 表、信号处理)的进程;pthread_create 底下调的就是 clone。

起了 128 个线程,CPU 用了 5%

我刚开始写并发程序的时候有个特别朴素的判断标准,线程开得比核多机器就该更忙,nproc 打出 32,我盯着那个数字看了一会儿,然后起了 128 个线程,每个线程从队列里取一件活干完再取下一件,自认为这就是把 32 个核心榨到极限的写法,现在回头看,这个想法里藏着一个很具体的误解,就是把线程当成了某种免费的、可以随便加的东西,加得越多机器就越忙,而真实情况刚好反着来,加得越多它们互相之间就越挤,挤到最后谁也没比谁快多少。

1
nproc
1
32

跑起来之后我打开 top,第一行就把我噎住了。

1
%Cpu(s): 3.2 us,  1.1 sy,  0.0 ni, 95.0 id

CPU 用了不到 5%。

我第一反应是这些线程根本没跑起来,于是换成按线程的视角又看了一遍,128 行,几乎每一行的 %CPU 都是 0.0,偶尔蹦出一个 0.1,这下更像是我代码写错了,我回去加日志,循环开头打一行、结尾打一行,再跑一遍,日志每秒刷几万行,说明每个线程都在老老实实干活,那问题就不在线程有没有跑,在于它们跑得太快,快到 32 个核转一圈回来又轮到它们,剩下的 96 个只能在运行队列里排队,当时我还顺手看了眼 /proc/loadavg,三个数都在 100 上下,比 CPU 使用率吓人得多,后来才想明白它统计的是可运行的 task 数,排队等 CPU 的那些也全算在里面。

(这件事我当时没敢问,因为看起来像是应该自己知道的,问出来大概会被笑。)

1
2
3
4
  PID    USER   PR  NI    VIRT    RES   %CPU  COMMAND
12844 ark 20 0 8421344 82104 0.1 worker
12845 ark 20 0 8421344 82104 0.0 worker
12846 ark 20 0 8421344 82104 0.0 worker

真正让我想通的是一个很笨的命令,ls /proc/12844/task | wc -l,输出 128,128 个执行流都在,一个没少,可内核的调度器并不把它们当成什么特殊的东西,它眼里那 128 项就是 128 个可调度的实体,跟我平时说的进程没有本质区别,这个视角一转,后面很多事就都好讲了,包括为什么 ps 能一个进程号列出好几行。

内核里没有线程这个东西

内核里只有一个结构叫 task_struct,所谓进程和线程都是它,区别只在于它跟别人共享了哪些资源,而这件事根本不用猜,clone 的参数把答案写得明明白白。

1
clone(CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND | CLONE_THREAD)
标志 共享什么 不共享时
CLONE_VM 地址空间(同一张页表) 复制一份页表(COW)
CLONE_FILES 文件描述符表 复制 fd 表
CLONE_FS 工作目录、根目录、umask 各自独立
CLONE_SIGHAND 信号处理函数 各自独立
CLONE_THREAD 线程组(tgid 相同) 不同进程
CLONE_PARENT 父进程设为调用者的父 父是调用者
CLONE_NEWNS 新的 mount namespace 共享挂载表

排在第一行的 CLONE_VM 最要紧,它意味着新 task 和调用者用同一张页表、同一片地址空间,你在一个线程里 malloc 出来的指针,另一个线程拿到就能直接解引用,中间没有任何转换或者代理,这也是为什么多线程程序里一个野指针能炸到谁都想不到的地方,因为它踩的那块内存从名字上看属于别人,从地址上看又完全合法,编译器不会拦你,内核也不会,顺带说一句,glibc 的 malloc 在多线程下会给每个线程配一块 arena 来减少锁竞争,可 arena 之间并不隔离,你 double free 了一样可能踩到隔壁线程刚申请的对象。

(我第一次读到这里的时候以为 clone 是什么内部函数,后来才知道它跟 fork 一样是个正经系统调用。)

fork() 其实就是 clone(SIGCHLD),一个标志都不给,什么都复制一份,而 pthread_create() 走的是另一条路,它把 CLONE_VM、CLONE_FS、CLONE_FILES、CLONE_SIGHAND、CLONE_THREAD、CLONE_SYSVSEM、CLONE_SETTLS、CLONE_PARENT_SETTID 一串全部打开,共享一大堆,所以线程这个词在 Linux 上只是用户态的一个叫法,不是内核里的类型。

pid 和 tgid 是两个东西

task_struct 里挂着两个 ID,一个是 pid,这个 task 自己的编号,全局唯一,另一个是 tgid,也叫线程组 ID,等于主线程的 pid。

1
ps -eLf | head -5
1
2
3
4
UID        PID  PPID   LWP  C NLWP    STIME TTY      TIME CMD
ark 12844 12800 12844 0 128 20:12 pts/0 00:00:00 ./worker
ark 12844 12800 12845 0 128 20:12 pts/0 00:00:00 ./worker
ark 12844 12800 12846 0 128 20:12 pts/0 00:00:00 ./worker

盯 PID 和 LWP 两列就够了,PID 三行全是 12844,因为它们是同一个线程组,LWP 一列是 12844、12845、12846,那才是各自的 pid,lightweight process 这个叫法是早年留下的,说的就是一个可调度的 task,反映到 /proc 上,线程有自己独立的目录,两套访问路径都通。

1
2
ls -d /proc/12845
ls /proc/12844/task

同一个线程既能从 /proc/tgid/task/pid 进去,也能直接走 /proc/pid。

我第一次撞上这件事是在排查一个很怪的现场,lsof 列出来的文件挂在好几个不同的 PID 上,可 ps 里那个服务明明只有一个进程号,我盯着屏幕看了半天,怀疑是不是有隐藏进程,还去 ps aux 里翻了一遍,最后才想明白那些多出来的号就是它的线程,是 lsof 按 task 在列而不是按进程在列,那天之后我才真正接受线程和进程在内核里是一回事。

为什么要这么设计

这个设计不是后来打补丁改出来的,是从第一天就这样。

1991 年 Linus 写内核的时候,POSIX 线程标准还没有,那是 1995 年才定下来的东西。他后来在邮件列表里的说法大意是:

Linux 是一个多进程操作系统……我做 clone 的时候,是把它设计成「让两个 task 共享一部分状态」,而不是「设计一个线程模型」。

这个选择带来两个后果,一好一坏,好的一面是进程和线程走同一套代码,调度、信号、/proc、cgroup 全部统一,一个 task 就是一切,内核里不需要为线程单开一条路径,坏的一面在早期暴露得很彻底,LinuxThreads 那个实现里每个线程在 /proc 下都是独立的进程,getpid() 返回不同的值,信号投递乱成一团。

2003 年 NPTL 才把这个收掉。

NPTL 的关键动作就是引入 tgid 和 CLONE_THREAD,内核 2.6 加 glibc 2.3.2,从此所有线程共享一个 tgid,getpid() 返回 tgid 而不是自己的 pid,gettid() 才返回自己的 pid,信号那部分也跟着改了,发给 tgid 的信号是 process-directed,内核从组里挑一个没阻塞它的线程投递,不保证是哪一个,想指定线程得用 tgkill 或者 pthread_kill。

这一条我踩过一次,一个 SIGTERM 落在了压根没写清理逻辑的线程上,进程退得很干净,日志一个字没留(我至今觉得这个设计有点讽刺,最该稳的地方最不稳)。

还有个细节,gettid() 在 glibc 2.30 之前没有包装函数,只能写 syscall(SYS_gettid),那句难看的调用现在还在很多老代码里躺着,2019 年才补上。

1:1 模型,以及它的账单

现代 Linux 的线程模型是 1:1,一个用户态线程对应一个内核调度实体。

模型 用户线程 : 内核线程 例子
1:1 1 : 1 Linux NPTL、Java 21 之前的平台线程
N:1 N : 1 Go 早期、Java 21 的虚拟线程
M:N M : N Erlang、Java 21 虚拟线程实际是 M:N 的变体

1:1 的好处是内核调度器能直接看见每个线程,多核并行天然成立,一个线程阻塞了也不会拖累别的线程,代价是每个线程都是一个完整的内核对象,这个对象的分量拆开算,task_struct 在 64 位内核上大约 8 到 10KB,再加一份内核栈,通常 8KB 或者 16KB,再加页表相关的记账,所以线程不能开太多这句话背后是有具体数字撑着的,不是线程很重这种含糊说法,而是每个线程固定吃掉几十 KB 内核内存,换个角度算,机器上 10000 个线程光内核那部分就要占掉一百多 MB,这部分还是不可换出的,free 里的 used 涨得比你想的快。

sudo slabtop -o | grep task_struct 能看见 objsize 那一列。

为什么大多数程序不该手写线程

至于日常写业务代码,我的建议是能别手写就别手写。

pthread 这套接口是 1995 年的设计,坑多到有点不体面,忘记 pthread_join 会泄漏线程资源,忘记 pthread_mutex_unlock 会死锁而且没有 RAII 这种东西兜底,pthread_cancel 更是个灾难,异步取消点、清理函数、pthread_cleanup_push 必须严格配对,信号和线程的交互同样复杂,信号到底落在哪个线程上、屏蔽字是线程私有还是进程共享,这些问题没有一个是查一遍 man 就能记住的,而 Go 的 goroutine、Java 的虚拟线程、Rust 的 async,本质上都在绕开这套接口。

但底层还是那套 clone。

一次 pthread_create 做了什么

图里第 2 步值得单独拎出来,线程栈是 mmap 出来的,默认 8MB 一块,这就是 /proc/PID/maps 里那些没有路径的匿名段,每个线程一段 8MB,彼此之间留一个 guard page 挡着,栈溢出的时候先撞到它,进程直接吃一个 SIGSEGV,这比悄悄踩坏隔壁线程的数据强多了,后者查起来是真正的地狱,128 个线程就是 128 段 8MB 的匿名映射,加起来 1GB 的 VIRT。

开篇那个 VIRT 8421344 折合 8GB,其中 1GB 是这 128 段栈,剩下的大头是堆和动态库。

共享什么,不共享什么

资源 共享? 说明
地址空间 是 全局变量、堆,所有线程都能改
fd 表 是 一个线程 close,其他线程也受影响
工作目录 是 一个线程 chdir,全都变了
信号处理函数 是 signal() 设的是整个进程的
信号屏蔽字 否 每个线程独立
栈 否 每个线程一份
寄存器 / PC 否 当然
errno 否 每个线程独立(__thread)
线程局部存储 否 __thread / pthread_key_create
信号待处理队列 否 但有进程级共享的部分

这张表里最容易被忽视的是 fd 表那一条,一个线程把 socket 关了,另一个线程拿着同一个 fd 往里写,拿到的是 EBADF,而且这个错误出现的时间点取决于两个线程谁先跑,你复现的时候往往已经过去几个小时,中间还夹着一次重启,工作目录共享同样阴险,一个线程 chdir("/tmp"),所有线程的相对路径全变了,而报错信息大概率是文件不存在,跟线程八竿子打不着,排查的时候你会去翻那个路径的权限,会去查 mount 表,会怀疑是不是跑在容器里,一圈转完才想到去看线程,而这时候距离事故发生可能已经过去两小时了。

这就是异步编程模型拼命避免共享可变状态的深层原因,不是洁癖。

线程数上不去的三层原因

线程数上不去这件事,得分三层看,混在一起讲永远是笔糊涂账。

第一层是内核对象本身,每个线程的 task_struct 8 到 10KB,加内核栈 8 或 16KB,再加一些记账结构,128 个线程大概 3MB,这一层其实还好,第二层是线程栈占的虚拟地址,默认每线程 8MB VIRT,10000 个线程就是 80GB,而真实 RSS 只按访问到的页算,10000 个线程可能只用掉几百 MB,所以栈大小和实际内存是两回事,前者吓人,后者才算数,地址空间有 128TB,理论上还远,但 VIRT 到 80GB 这个量级,很多监控工具会先叫起来,Ops 半夜给你打电话的时候解释这个也解释不清。

栈可以调小。

1
2
3
pthread_attr_t attr;
pthread_attr_init(&attr);
pthread_attr_setstacksize(&attr, 128 * 1024); /* 128KB */

但 pthread_attr_setstacksize 不能小于 PTHREAD_STACK_MIN,通常 16KB。

我压过一次,把栈设成 128KB,压测三天都正常,第四天一个递归解析 JSON 的请求把栈打穿了,core 落在一个跟线程毫无关系的函数里,查了一整天才绕回 pthread_attr_setstacksize 那一行(这大概是我交过最贵的一次学费,而且它不是线程知识,是常识)。

第三层是调度开销,每个线程都要被调度、要上下文切换、要抢缓存,超过物理核心数之后收益递减,这条曲线在 CPU 密集型任务上掉得特别快,CPU 密集型约等于核心数是最优的,IO 密集型的可以多开一些,比例取决于阻塞时间占多少,回到开头那个场景,128 个线程在 32 核上跑 CPU 密集任务,多出来的 96 个只是在互相抢 CPU。

上下文切换到底多贵

1
vmstat 1
1
2
3
procs -----------memory---------- ---swap-- -----io---- -system-- ------cpu-----
r b swpd free buff cache si so bi bo in cs us sy id wa st
4 0 0 82104 20488 344120 0 0 0 12 812 1024 3 1 96 0 0

cs 那一列就是每秒上下文切换次数,这个数字自己是有成本的。

一次切换大约 1 到 3 微秒,取决于 CPU 型号和当时的缓存状态,里面包括保存恢复寄存器、切栈、可能的 TLB 刷新,这个数量级可以用 perf bench sched pipe 测,可以自己写个两个进程拿 pipe 来回传字节的基准,1000 次每秒的切换只是 3 毫秒的 CPU 时间,微不足道,100 万次每秒就是 3 秒,那已经开始影响性能了,而且这还只是纯粹的切换成本,没算缓存被冲掉之后重新填的那些开销。

top 里 sy 高、us 低,通常就是切太多的表现。

单看某个进程的话,可以直接读这个文件。

1
grep -E 'voluntary|nonvoluntary' /proc/PID/status
1
2
voluntary_ctxt_switches:     142
nonvoluntary_ctxt_switches: 38

主动切换是进程自己让出 CPU,等 IO、等锁、sleep,被动切换是被抢占,时间片用完或者来了更高优先级的进程,主动多说明它在等,被动多说明 CPU 抢不过来,这两个数字分开看比一个总数有用得多。

我调过一个服务,一测发现被动切换是主动的二十倍,那问题就不在 IO 上。

把线程和进程摆到同一张表里

看线程和进程的关系

1
2
ps -eLf | head -1
ps -eLf | awk '$4 != $2' | head -5

第二行筛出来的是 LWP 和 PID 不一样的行,也就是线程。

1
ls /proc/$$/task

只有一个,因为 bash 是单线程的。

想看多线程的样子,随便起一个 Python 的。

1
2
3
4
5
6
7
8
9
10
11
12
python3 -c "
import threading, time, os
for i in range(4):
threading.Thread(target=lambda: time.sleep(30), daemon=True).start()
print('started, pid =', os.getpid())
time.sleep(30)
" &
sleep 1
PID=$(pgrep -n -f 'threading')
echo "tgid=$PID children:"
ls /proc/$PID/task
ps -eLf | awk -v p=$PID '$2 == p {print $2, $4}'

你会看到 task 目录里有 5 个,主线程加 4 个工作线程,它们的 LWP 各不相同但 PID 全是 $PID。

看 thread name

1
cat /proc/$PID/task/*/comm

默认都是 python3,可以自己改。

1
2
3
import ctypes
libc = ctypes.CDLL('libc.so.6')
libc.prctl(15, b'worker-1', 0, 0, 0) # PR_SET_NAME = 15

改完之后 /proc/.../comm 就是 worker-1,top -H 里也能看到,多线程服务起手就该设线程名,这是我最想塞给新人的一条习惯。

我第一次遇到线上 CPU 打满,top -H 打出来 64 个线程全叫同一个名字,跟没看一样,最后靠 perf top 反查调用栈才定位到是哪个。

测线程数上限

这个别在生产上跑。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
python3 -c "
import threading, time
def f(): time.sleep(60)
n = 0
try:
while True:
threading.Thread(target=f, daemon=True).start()
n += 1
if n % 500 == 0: print(n, flush=True)
except Exception as e:
print('failed at', n, ':', e)
" &
sleep 10
kill %1 2>/dev/null

在默认 ulimit -u 和内存下,通常几万到十几万就撑不住了。

1
RuntimeError: can't start new thread

背后可能是 pthread_create 返回 EAGAIN,内核资源不够,也可能是 ENOMEM,栈空间不够,这两种原因的处理方式完全不一样,一个是调限制,一个是减栈,ulimit -u 是进程数上限,多线程也算在里面。

1
ulimit -u
1
63488

对比 fork 和 clone 的成本

1
2
3
4
5
6
7
time python3 -c "
import os
for _ in range(1000):
pid = os.fork()
if pid == 0: os._exit(0)
os.waitpid(pid, 0)
"

再对比线程。

1
2
3
4
5
6
time python3 -c "
import threading
ts = [threading.Thread(target=lambda: None) for _ in range(1000)]
for t in ts: t.start()
for t in ts: t.join()
"

线程明显快,因为 fork 要复制页表,虽然是 COW,但页表本身得复制一份,task_struct 的一堆字段也得复制,新的地址空间还得建起来,fork 的成本和进程地址空间大小正相关,pthread_create 的成本基本固定,这就是大内存进程要避免频繁 fork 的原因,一次 fork 可能比算一整天还贵。

顺便看看 CPU 亲和性

1
taskset -pc $$
1
pid 12844's current affinity list: 0-31

可以绑。

1
taskset -c 0-3 ./worker

对延迟敏感的服务常这么干,把线程钉在固定的核上,避免在核心之间跳来跳去把缓存全作废。

线程不是轻量级进程

线程是轻量级进程这句反了,在 Linux 里进程和线程是同一个东西,都是 task_struct,线程只是共享了更多资源的进程,轻量这个词是历史包袱,Linux 内核里有线程调度器也没有,只有一个调度器,CFS 现在是 EEVDF,它调度的是 task_struct,不区分你是进程还是线程,gettid() 和 pthread_self() 更不是一回事,前者是内核给的整数 ID 会复用,后者是 glibc 的指针,类型是 pthread_t。

线程之间 fd 是独立的错,共享的,一个线程 close(3),其他线程手里的 3 号 fd 同时失效,线程数越多并发越高也不对,CPU 密集型超过核心数就是浪费,IO 密集型要看实际阻塞比例,开篇那个场景就是反例,pthread_cancel 能安全停止线程这个更危险,除非你完整实现了清理函数链,否则大多数项目应该直接禁用它,__thread 和 thread_local 只差一个编译器也不全是,__thread 是 GCC 扩展,thread_local 是 C++11 的标准,语义上有差别,动态初始化的支持就不一样,底层都是 %fs 段寄存器。

信号发给进程随便哪个线程处理不对,内核会挑一个没屏蔽该信号的线程,想指定得用 pthread_kill(tid, sig),ps -L 里的 NLWP 不是线程数上限,它是当前线程数,上限由 ulimit -u、内核参数和内存共同决定。

内核眼里没有线程

内核里没有线程这个类型,只有共享了不同资源集合的 task_struct,pthread_create 底下是 clone 加一堆 CLONE_* 标志,fork 是同一个调用但一个都不共享,tgid 是线程组 ID 等于主线程的 pid,pid 和 LWP 是各自的 ID。

线程共享地址空间、fd 表、工作目录、信号处理函数,不共享栈、寄存器、errno、信号屏蔽字,1:1 模型下每个线程都有内核对象和 8MB 的虚拟栈,所以线程数有真实上限,超过核心数之后收益递减,CPU 密集型约等于核心数就够了。

排查线程问题就三件事,ps -eLf 看 LWP,top -H 看各线程 CPU,/proc/PID/task/*/comm 看名字。

线程本身到这里算是讲清了,麻烦的地方才刚开始,两个线程同时改一个变量为什么会出错,答案比加个锁要深,它牵着 CPU 缓存、编译器重排和内存屏障三样东西。