跳到主要内容

用 strace 盯住 clone():进程和线程只差一个 flag,坑却在共享内存上

abanana
abanana

· 阅读约 5 分钟

今天想确认一件事:glibc 把 fork() 和 pthread_create() 分别翻译成了什么。最省事的办法还是老一套,把 strace 挂上去,只看 clone 这一族调用:

strace -f -e trace=clone,clone3 ./your_app

在 Ubuntu 24.04 上跑一遍,两行输出并排摆着,差别特别扎眼。fork() 那条干干净净,flags 里只有一个 SIGCHLD:

clone(child_stack=NULL, flags=SIGCHLD) = 12345

pthread_create() 走的是 clone3,flags 一长串:

clone3({flags=CLONE_VM|CLONE_FS|CLONE_FILES|CLONE_SIGHAND|
        CLONE_THREAD|CLONE_SYSVSEM|CLONE_SETTLS, ...}, 88) = 12346

那一长串我看着像一张装箱单:哪几样东西要跟别人共用,全写在这里了。这篇就围绕这一长一短讲两件事——内核眼里到底有没有“线程”这个东西,以及共享地址空间这笔交易真正让你付出的是什么。读完你能自己把这两条复现出来,也能明白我后面为什么对“多线程就是快”这句话越来越警惕。

内核眼里只有 task

调度器调度的是 task,不是进程也不是线程。所谓“进程”和“线程”,在创建那一刻就由 flag 组合决定了:传 CLONE_VM,新任务指向已有地址空间;不传,就另起一套。CLONE_FILES 共享文件描述符表,CLONE_SIGHAND 共享信号处理器,CLONE_THREAD 让新任务加入同一个线程组→同组任务 getpid() 返回相同的值,gettid() 各不相同。

再往外延一层,容器用的也是 clone(),只是 flag 换成 CLONE_NEW* 去开命名空间。进程、线程、容器,在我看来就是同一个任务创建函数的三套 flag 组合,不是什么三种不同物种。这个认知一旦立住,后面所有的坑都好解释了——它们全都来自同一件事:谁和谁共享了哪块内存。

线程私有的部分其实很少:栈、寄存器、线程局部存储。堆、全局变量、打开的文件,同进程的线程全都共用。共用就意味着共享命运,而共享命运有两种坏运气。

第一种:会崩的

任一线程触发段错误,默认动作是终止整个进程,退出码 139。之前看过一个实验挺直观:一个干活的线程正常跑着,另一个线程往空指针写了一下,稳定的那个也停了——它不是被谁通知的,是整个进程没了。

这种我反而没那么怕。它会崩,崩了有 exit code、有 core dump、有栈,有东西可以查。真正让我难受的是第二种。

第二种:不报错的

两个线程同时把某个像素值从 50 加 10,各读一次、各写一次:

线程 A:读到 50
线程 B:读到 50
线程 A:写 60
线程 B:写 60      ← 期望 70,实际 60

程序不报错、不崩、日志干干净净,只是结果少了。四线程各对共享计数器递增 1,000,000 次那个实验更狠:期望 4,000,000,无锁跑出 3,966,879,加互斥锁才回到 4,000,000。关键是第二次运行丢的数量还不一样——不确定性这条最要命,你没法靠“跑一遍看看对不对”来验证,因为对的次数可能刚好够你签收。

我的判断很偏:宁可要一个每次必崩的东西,也不要一个“大部分时候对”的东西。崩溃给你信号,竞态不给你信号,它只在你上线之后某个流量高峰给你一个数字,然后你去猜。

还有一个,藏在 fork 里

这块是我看评论区才注意到的,之前 fork() 用得相当随意。多线程进程调 fork(),子进程只保留调用 fork() 的那个线程,其他线程连同它们的栈一起消失了——但它们持有过的锁,在内存里还是“已加锁”的状态,被原样复制进子进程。子进程再去拿同一把锁:

pthread_mutex_lock(&mtx);
pid_t pid = fork();
if (pid == 0) {
    pthread_mutex_lock(&mtx);   // 这把锁的持有者已经不存在了
}                               // 于是它就永远等下去

不崩、不报错、死锁检测报不报警看运气,进程就挂在那儿。POSIX 对此的规定是 exec 之前子进程只能调 async-signal-safe 函数,所以正规做法要么 posix_spawn,要么 fork() 完立刻 exec,中间别碰任何带锁的东西。💡 小技巧:在 multithreaded 进程里看到 fork(),先找它后面有没有 exec,没有就得多看两眼。

我一开始以为这是个理论上的边角情况,把示意码写出来摆在那儿看,才发现触发条件有多普通——只要 fork 的那一刻有别的线程正好攥着一把锁,就够了。后来才想明白,这其实就是“不报错的坑”里最典型的一类:状态被复制走了,能改状态的人却没跟过来。

顺手划一下边界

CPython 标准构建的 GIL 让字节码没法靠线程并行,所以很多人改多进程——这是语言层面的事,不是内核层面的事,两者别混着骂。多进程也不是白拿的:每个进程一份独立地址空间,照片要复制四份,四份结果还得靠管道、套接字或共享内存拼回来。Chrome 给每个站点开独立进程、PostgreSQL 给每个连接开进程,都是主动选了“崩一个不影响别人”这条路的代价。要拿多核啃同一份大数据集,线程仍然是顺手的那把刀。

划重点:第一,进程和线程在内核里的区别就是一个 CLONE_VM,别把它们想成两种东西;第二,共享地址空间的真正代价不是“可能崩溃”,是“可能错得悄无声息”,竞态和 fork 后的锁状态都属于后者;第三,multithreaded 进程里的 fork() 要当成危险动作对待,posix_spawn 或 fork 后立刻 exec 是更稳的写法。你可以挑个自己项目里的小程序,照着跑一遍 strace,把两行 clone 的输出并排看一眼,比读十篇讲进程线程区别的文章都实在。

abanana
abanana

把自己踩过的坑整理成一篇能复现的笔记,写给三个月前的自己看。

查看主页 →