第 02 章 从源码到进程:一个 hello 的诞生之旅

墨:第 1 章的地图一,第一级写着”源码→ELF→进程”。这一章我们就把这一级放大,用一个真实的小程序 hello 的视角讲完。我给你讲个故事——hello 从你敲下回车那一刻,到它在屏幕上印出 “hello world”,中间经历了什么。你别小看这行 printf,它背后跨了构建期、加载期、运行期三个时代。

你:一行 printf 还能有三个时代?我还以为就是”编译完跑起来”五秒钟的事。

墨:五秒钟是给你的体感。对内核和硬件来说,这五秒里发生了几百次跨越特权级的往返、几千次页表查找、好几次缺页中断。今天先讲”构建期”和”加载期”——也就是它怎么从一个文本文件,变成 CPU 能跑的东西。


2.1 构建期:从人类文字到机器语言

hello.c 躺在你磁盘上,本质就是一串 UTF-8 字节,CPU 一个字都不认识。要让它跑起来,得先”翻译”。翻译分四步:

hello.c  ──预处理──▶ hello.i  ──编译──▶ hello.s  ──汇编──▶ hello.o  ──链接──▶ hello(ELF)
 (C源码)    (宏展开)     (汇编)     (机器码.o)      (拼成可执行)

2.1.1 预处理:把”缩写”展开

#include <stdio.h> 不是”链接库”,是把 stdio.h 那一整坨头文件文本,原样粘贴到你的 .c 里;#define MAX 100 把后面所有 MAX 替换成 100。产出 hello.i 是个巨大的纯文本(几十万行,因为标准库头文件被展开)。这步不检查语法对不对,只做文本替换。

墨:你有没有想过,为什么改一个头文件,所有 include 它的 .c 都要重编?

你:因为预处理是”文本粘贴”,头文件变了,粘贴出来的内容就变了,编译出的机器码自然可能不同。头文件是”契约”,契约变了,下游都得重来。

墨:对,这是为什么大项目编译慢——依赖图庞大。这也是为什么有人用”前向声明”和”Pimpl”去缩减头文件依赖,本质是缩小预处理的粘贴面。你写 Go 可能没这痛感(Go 没有头文件预处理),但 C/C++ 的老兵都被这个坑过。

2.1.2 编译:从汇编到机器码(两半)

hello.i 交给编译器(gcc/clang),它做两件事:

  • 前端:词法/语法/语义分析,生成中间表示(IR),做类型检查、优化(inline、常量折叠)。
  • 后端:把 IR 翻译成目标平台的汇编 hello.s,再做指令级优化(寄存器分配、指令调度、向量化)。

注意:这步产出的是汇编,还不是最终机器码。汇编是人类可读的助记符(mov rax, 1)。

2.1.3 汇编:助记符 → 字节

as 把 hello.s 翻译成真正的机器码 hello.o——一串二进制字节,每条指令对应 CPU 能 decode 的 opcode。此时 hello.o 是可重定位目标文件(relocatable):它假设自己从地址 0 开始,所有跨函数/跨文件的引用都是”占位符”(因为还不知道最终地址)。

2.1.4 链接:把碎片拼成一体

如果你的程序只用一个 hello.o,链接器(ld)把它和标准库(如 libc)拼起来,解决所有”占位符”:

  • 静态链接:把 libc 的代码直接拷进你的 hello,产物自包含但巨大。
  • 动态链接:不拷 libc 代码,只在 hello 里记一笔”我需要 libc 的 printf 符号”,运行时再找。这是绝大多数 Linux 程序的方式(你 ldd ./hello 能看到它依赖 libc.so.6)。

墨:动态链接省了空间,但有代价吗?

你:有。运行时才解析符号,多了”加载时找库、 relocation、GOT/PLT 跳一下”的开销。而且依赖的 so 版本要对得上,否则”符号版本不匹配”直接起不来。

墨:对,而且动态链接的”运行时找库”这一步,正好引出下一章要讲的加载器(loader)——它才是 ./hello 真正起跑前的最后一棒。还有个微观细节你记一下:动态链接靠 GOT(全局偏移表)和 PLT(过程链接表) 实现”延迟绑定”——第一次调 printf 才去解析它的真实地址,写进 GOT,之后直接跳。所以第一次调用某个库函数会稍慢(要解析),后面就快。这也是为什么微基准测试经常”先 warmup 几次再计时”——冷热的差异可能就藏在 GOT 解析里。


2.2 ELF:磁盘上的”程序说明书”

链接产物 hello 是 ELF(Executable and Linkable Format) 格式。你可以把它想成”一张给加载器的说明书”,告诉内核:代码放哪、数据放哪、入口在哪、要多少内存。关键结构:

  • ELF 头:魔数、架构(x86-64)、入口地址 _start、程序头表偏移。
  • 程序头表(Program Headers):给加载器看的——“把文件的哪段(offset+size)映射到进程的哪个虚拟地址(vaddr)、什么权限(r/w/x)“。这是 execve 真正用的。
  • 节区(Sections):给链接器/调试器看的(.text 代码、.data 已初始化数据、.bss 未初始化数据、.symtab 符号表、.debug 调试信息)。运行时不需要。
  • 段(Segments) vs 节(Sections):段是”加载视角”(连续、按权限合并),节是”链接视角”(细碎、按用途分)。一个 LOAD 段可能包含多个节。

📦 案例:readelf -l ./hello 你会看到两个 LOAD 段:一个 R-X(代码,可读可执行)、一个 RW-(数据,可读写但不可执行)。注意数据段没有 X,代码段没有 W——这是”W^X”安全底线(数据不能执行、代码不能写),防止 shellcode 注入。后面第 9 章讲安全时还会遇到这条线。


2.3 加载期:execve 把”说明书”变成”活进程”

现在你敲 ./hello。shell 调 fork() 复制自己,再在子进程里调 execve("./hello", ...)。这一刻,内核登场。

execve 系统调用做的事(微观):

  1. 读 ELF 头,校验魔数,确认架构匹配当前进程。
  2. 释放旧地址空间(fork 出来的那份被整个拆掉)。
  3. 按程序头表,逐段 mmap:
    • 代码段 mmap 成 R-X,映射到文件(文件支撑映射,file-backed——只读时不需要真的把文件读进内存,靠缺页按需读);
    • 数据段 mmap 成 RW-,.bss 部分匿名映射清零;
    • 栈、堆、vsyscall/vDSO 区域也建好。
  4. 设好栈:把 argc/argv/envp、以及 auxiliary vector(auxv) 压栈——auxv 里有关键信息:页大小、入口地址、AT_PHDR(程序头表位置)。加载器要靠 auxv 才能找到程序头。
  5. 动态链接? 若有 PT_INTERP 段(指向 /lib64/ld-linux-x86-64.so.2),先把动态链接器映射进来,把控制权先交给 ld.so,由它去加载 libc.so、做符号解析(GOT/PLT 绑定),最后才跳到你的 _start。
  6. 跳入口:寄存器设好,PC 指向 ELF 头的 e_entry(对动态链接是 ld.so 的入口),CPU 切回用户态,开始跑。

墨:注意第 3 步——代码段 mmap 了,但文件的字节真的被读进内存了吗?

你:没有!mmap 只是建了”虚拟地址→文件偏移”的映射,页表里这些页标成”不在内存”。真正读文件,要等 CPU 第一次执行到那条指令、触发缺页中断,内核才去把那一页从磁盘读进来。

墨:这就是”按需分页(demand paging)“——程序启动时几乎不读磁盘代码,只建映射;跑哪读哪。所以一个大程序启动快,不是因为全读进来了,是因为它大概率只跑其中一小部分路径。这条微观机制,后面第 3 章讲缺页时会再放大。它和”优化”的关系你先记着:冷启动慢常常不是 CPU,是缺页把代码/数据从磁盘/页缓存按需调入的 IO 成本——尤其 page cache 没热的时候。


2.4 运行期起跑:第一个 printf 发生了什么(预告)

hello 终于跑到 main,执行 printf("hello world\n")。这一行,跨进了”用户态→内核态”的门槛:

  • printf 是 libc 封装,先写到 stdio 缓冲区(默认行缓冲/全缓冲);
  • 遇到 \n 或缓冲满,libc 调 write(1, buf, n) —— 这是系统调用;
  • CPU 从用户态切到内核态(第 4 章细讲切换成本),内核 VFS 层找到 fd=1 对应的 struct file(通常是当前终端/pty),走 write 路径(第 7 章文件系统);
  • 数据从用户缓冲区拷贝到内核缓冲区,最终由终端驱动/PTY 送到你的屏幕;
  • 系统调用返回,切回用户态,main 继续,return 0 → exit → 进程被回收。

墨:你看,一行 printf,跨了用户态/内核态、走了 VFS、动了缓冲区拷贝、还可能动了终端驱动。这还只是”往屏幕打个字”。

你:所以后面每章都是把”其中一级”放大——这一行的 write 系统调用,第 4 章讲”怎么进内核”,第 7 章讲”内核怎么写”,第 8 章讲”如果 fd 是文件怎么落盘”。一环扣一环。

墨:对。这一章你拿到了”构建期+加载期”的全貌,下一章我们钻进”进程活着之后的内存世界”——虚拟地址、MMU、页表、缺页,那是和程序运行关系最密切、也最常被调参影响(swappiness、overcommit、THP)的一级。


2.5 这级和”系统参数”有什么瓜葛(承上启下)

你可能会问:构建期和加载期,好像没用到 /proc/sys 那些参数?对,这级几乎不受 sysctl 直接控制——它是编译器和链接器的产物、内核加载逻辑固定的。但有两处间接相关,先点一下,后面细章会展开:

  • vm.overcommit_memory:mmap/brk 申请虚拟地址时,内核要不要”真检查物理内存够不够”。设为 2(严格)时,大 mmap 可能被拒(OOM 前就 ENOMEM);设为 0/1(宽松)时,先给虚拟地址、物理页等缺页时再要。这直接影响”加载/分配能不能成功”,第 6、12 章细讲。
  • vm.drop_caches:排障时手动清 page cache,会让”冷启动”重新变慢(因为代码/数据要从磁盘读,而非从热的 page cache 读)。它是观测工具,不是调优项。
  • /sys/kernel/randomize_va_space(ASLR):加载时随机化基址,安全特性,影响”每次进程地址布局不同”。属 /proc/sys/kernel 或 sysfs 等价项。

墨:所以”构建加载”这级是地基,参数主要在它之上的运行期才发力。但地基不稳(比如链接错版本 so),上面怎么调都白搭。


2.6 演化线小结

  • 问题:人类写的文本 CPU 不认识;且不同文件里引用的函数地址在编译期未知,必须把”碎片拼成一体、且能映射到内存”。
  • 设计:四步翻译(预处理→编译→汇编→链接)把文本变机器码;ELF 用”程序头表”给加载器、用”节”给链接器,分离加载视角与链接视角;execve 用 mmap + 按需分页,只建映射不读盘,跑哪读哪;动态链接用 GOT/PLT 延迟绑定省空间。
  • 演化规律:从静态链接(自包含但肥)演化到动态链接(省空间但引入运行时解析),再演化出 ASLR(安全但地址随机)、PIE(位置无关可执行,配合 ASLR)。每一步都是”新目标(省空间/安全)逼出结构变化”,但保持 ELF 格式兼容——又是”兼容优先、渐进加特性”。你今天 ./hello 能跑,靠的是三十年 ELF 格式的向后兼容。

2.7 思考题

🔧 思考题 1(按需分页):为什么说”大程序启动快,不是因为全读进内存了”?这和系统参数里哪个排障旋钮能直接验证?

【参考答案】因为 execve 只 mmap 各段、建虚拟地址→文件的映射,页表标”不在内存”;真正读磁盘代码/数据要等 CPU 执行到、触发缺页中断才按需调入(demand paging)。所以启动只读了”马上要跑的那一小部分路径”的页。验证旋钮:vm.drop_caches=1|2|3 清掉 page cache 后,再启动同一程序会明显变慢——因为这次缺页要从磁盘(而非热的 page cache)读代码/数据页。这正说明”启动快”依赖 page cache 已热,而非程序真的全在内存。冷启动慢常是缺页 IO 成本,不是 CPU。

🔧 思考题 2(GOT/PLT):动态链接的”延迟绑定”为什么会让”第一次调用某库函数稍慢、之后快”?和微基准测试的 warmup 有什么关系?

【参考答案】动态链接用 PLT(过程链接表)+ GOT(全局偏移表)做延迟绑定:第一次调 printf 时,PLT 发现 GOT 里还没填真实地址,触发”解析符号→找到 libc 里 printf 地址→写入 GOT”;之后调用直接跳 GOT 里已填的地址,不再解析。所以首次调用多一次符号解析开销,之后零开销。微基准测试里若只测一次,可能测到”含首次解析”的慢值;warmup 几次让 GOT 填满再计时,得到稳态值。这也是为什么基准测试要 discard 前若干次、取稳态——冷热的差异藏在动态链接绑定里。

🔧 思考题 3(W^X):为什么 ELF 代码段是 R-X、数据段是 RW-,而不是 R W X 都开?这和系统安全(呼应第 9 章)有什么关系?

【参考答案】W^X(Writable XOR eXecutable):一段内存不能同时可写又可执行。若数据段(RWX)可写又可执行,攻击者写入 shellcode 后直接跳转执行即代码注入成功。代码段 R-X 不可写,防止运行时篡改代码;数据段 RW- 不可执行,防止数据当代码跑。这是防代码注入的基础硬件级防线(配合 NX bit)。加载器按程序头表的权限位 mmap,内核/CPU 强制执行。后面讲容器逃逸、缓冲区溢出时,这条线是”数据不能变代码”的底层护栏。

🔧 思考题 4(进阶·总账):你 ldd ./hello 看到它依赖 libc.so.6,但运行时报 “version ‘GLIBC_2.34’ not found”。请沿”构建期→链接→加载”的链推断:这错误出在哪一阶段(编译/链接/加载)、为什么、和动态链接的哪条机制相关?它属于”参数能解决”还是”环境/编译问题”?

【参考答案】① 出错阶段:加载期(运行时),由动态链接器 ld.so 报,不是编译/链接期(那期能过,说明本机编译时链接的是本机 libc)。② 原因:动态链接在 ELF 里记了”我需要 libc 的某符号版本 GLIBC_2.34”。加载时 ld.so 去本机 libc.so.6 找该版本符号,本机 glibc 太老、没有 2.34 版本,于是绑定失败、进程起不来。这是”符号版本契约不匹配”。③ 机制:动态链接的版本化符号(versioned symbol)——ELF 不仅记符号名,还记所需版本,加载时校验。④ 能不能靠 /proc/sys 参数解决:不能。这不是内核参数问题,是”运行环境的 glibc 版本低于编译时依赖”。解法要么升级运行机 glibc、要么在老 glibc 环境重新编译(或静态链接、或用兼容的构建环境)。这正好印证 0.4 节:参数只调”内核权衡”,解决不了”依赖/版本契约”类问题——定位错了家族,调参白费。


墨:第 2 章把”程序怎么来的”讲完了。下一章我们进内存世界——虚拟地址、MMU、四级页表、TLB、缺页中断。那是和程序运行、和 vm.swappiness/overcommit/min_free_kbytes 这些参数关系最密切的一级。系好,要钻硬件了。

2.5 静态链接 vs 动态链接:一个被忽视的运维炸弹

墨:第 2 章讲了动态链接”运行时再绑符号”的机制。这一节把静态/动态的选择后果讲透,因为它直接影响你”把一个二进制部署到新机器”的成败。

动态链接的隐性成本:

  1. 启动时要 ld.so 找、加载、重定位所有 .so——大型 C++ 程序启动慢,且依赖目标机的 .so 版本(第 2 章 glibc 案例就是栽在这);
  2. 运行期每个符号第一次调用走 PLT 延迟绑定(2.3.3 节 GOT/PLT),有一次性开销;
  3. 安全面:多个程序共享同一 .so,.so 一升级全升级(也一崩全崩)。

静态链接的反面:

  1. 二进制自包含,拷到任何同架构机器都能跑,不受目标机 glibc 版本绑架——容器/部署的福音;
  2. 代价:体积大(把 libc 也打进去)、且无法共享 libc 的代码页缓存(每个进程一份 libc 代码页,内存略多);
  3. 代价:libc 有安全补丁时,你得重新静态编译发布,不能只更新系统 .so。

你:那 Go 这种静态编译的语言,不就没这些事了?

墨:对,Go 默认静态链接(把 runtime 和依赖全打进二进制),所以”拷到哪都能跑”,没有 glibc 版本契约问题——这也是 Go 在容器里这么流行的原因之一。 但代价是二进制大、且 Go 的 runtime 自己实现了调度/GC/网络,不依赖 glibc 的那套。理解了”静态/动态”的取舍,你才明白”为什么有些语言部署简单、有些语言在新机器上跑不起来”,以及为什么容器镜像里常选 scratch/静态二进制而非动态链接的发行版基础镜像。

(第 02 章 完)


2.8 深度现场:一个被 -O2 优化”吃掉”的并发可见性 bug——从 C 源码到机器码的断层

墨:第 02 章讲的是”程序怎么从源码变成进程”。这一节给一个只有懂”源码≠机器码”才会中招的真实 bug——它完美说明:你写的字,和 CPU 跑的字,中间隔着编译器的整个世界观。

2.8.1 事故:一个”只在发布版复现”的卡死

📦 案例:某 C 写的网络库,有个主线程和 worker 通过标志位通信:主线程设 g_stop=1 让 worker 退出循环。开发版(-O0)好好的,一发发布版(-O2),worker 永远不退出,服务停不下来。

2.8.2 现象与推错

你:逻辑很简单——while(!g_stop) { do_work(); },主线程 g_stop=1 后 worker 就该退出。开发版确实如此。但 -O2 发布版,worker 死循环不退出。 第一反应:是不是主线程没真的写到?加日志发现主线程确实把 g_stop 设成 1 了。懵。

2.8.3 命中:编译器把”读”优化掉了

墨:根因:g_stop 没声明 volatile,编译器在 -O2 下认定”这个循环里 g_stop 不会被本线程改”,于是把 while(!g_stop) 优化成”只从寄存器读一次 g_stop,之后不再回内存取”——甚至直接变成 while(true) 的死循环。主线程改的是内存里的 g_stop,但 worker 的 CPU 寄存器里那份”副本”永远不变。这是源码到机器码的断层:你以为每次循环都读内存,编译器为了快,只读一次缓存进寄存器。开发版(-O0)不做这种优化,所以正常;发布版(-O2)一优化就炸。

验证:用 objdump -d 反汇编 worker 的循环,看汇编里有没有每次都 mov 从内存读 g_stop——-O2 版本里你会发现它把 g_stop 读进寄存器后,循环体里再没有访存指令,纯 jmp 回自己。读反汇编,真相大白。

2.8.4 治本与引申

墨:两剂药:① g_stop 声明为 volatile(告诉编译器”这变量可能被别的线程改,每次都从内存读”)——但注意 volatile 不保证线程安全/原子性,只是修这个特定可见性 bug;正经做法是用 std::atomic(C++)/恰当的内存屏障。② 更重要的是:这事故只有”懂程序从源码到机器码经历了什么”的人才会往 -O2 上想;不懂的人会疯狂查锁、查信号、查业务代码,永远查不到编译器头上。

2.8.5 同源坑:strip 把符号表删了,core dump 变成天书

墨:顺带一个发布版常踩的:为了”减小二进制体积/防逆向”,发布构建 strip 掉符号表。结果某天 core dump 了,gdb 加载后全是 ?? 和十六进制地址,函数名/行号全无,你连”崩在哪个函数”都不知道。教训:发布版至少保留带符号的副本(或 debuginfo 包),core 分析时 gdb 二进制 核心文件 配合 symbol-file 指向带符号版本。否则第 04 章讲的”core dump 是排障最后一道光”就废了。

墨:这一节把第 02 章”构建期→机器码”的抽象,焊成了一个你亲手会踩、踩了不会查的真实坑。记住:你写的 C,不等于 CPU 跑的汇编;中间的编译器,有自己的小算盘。 这也是为什么本书反复强调”懂机制”——因为 bug 常常藏在”你以为的和实际跑的”之间的缝里。

2.8.6 一道送你的思考题

思考题(进阶): 除了 volatile 缺失,还有哪种常见的”源码到机器码”断层会让并发程序在 -O2 下行为突变?提示:想想”内存访问重排”和”编译器把两个独立变量塞进同一个寄存器/缓存行”会怎样。(参考答案藏在” false sharing(伪共享)“里:两个线程各写一个相邻变量,编译器/CPU 可能把它们放进同一缓存行,一核写导致另一核的整行失效——这又是”你以为独立、实际共享”的断层,和第 03 章缓存一致性、第 09 章 CPU 缓存一脉相承。)


实战补遗一:文件描述符在 fork 后”偷偷继承”,把连接池拖爆

墨:你起一个子进程干脏活(fork+exec),没关它继承的文件描述符。你觉得这最多是”子进程多开几个 fd”,对吧?

你:对啊,子进程退出 fd 就释放了,能有多大影响?

墨:如果子进程是长活的(比如一个常驻 sidecar),它继承的每一个 fd 都一直占着——而且它会继承你整个连接池的所有 fd。连接池 1000 个连接 → 这个 helper 默默持着 1000 个 fd 不关;你以为是连接池的 fd,其实是被 helper 偷占了,连接池想扩到 2000 却 EMFILE。这正好呼应第 2 章”进程起跑”和”fd 是进程级资源”。

现象

Go 服务用 exec.Command 起一个常驻 sidecar 做日志转发。运行一段时间后 sidecar 的 fd 数随时间线性涨;主服务某天扩连接池报 EMFILE(明明 ulimit -n 给到 10 万),lsof 看 sidecar 持着上千个本属于主服务的 socket fd。

推断(可能推错):先查 sidecar 自己

第一推断:「sidecar 自己 fd 泄漏。」去看 sidecar 代码,没发现问题——因为它根本”没打开”那些 fd,是被继承的。推错。

真相:fork 默认复制整个 fd 表

fork 会复制父进程整个 fd 表;除非 fd 设了 FD_CLOEXEC(close-on-exec),否则 exec 后新程序照样持有。Go 的 os/exec 对新建的管道 fd 默认设了 CLOEXEC,但主服务 net.Dial 出来的连接池 fd 在 fork 前就开着,且 Go 的 net.Conn 底层 fd 不保证 CLOEXEC——于是被常驻 sidecar 继承、永不关(直到 sidecar 退出,而它常驻)。

调整:别让不该继承的 fd 继承

  • 起子进程前用 syscall.CloseOnExec(fd) 给连接 fd 打 CLOEXEC;或 cmd.ExtraFiles 只传必要 fd,不继承全部;
  • 更稳:用受控通道(cmd.StdoutPipe 等)而非继承整个 fd 表;
  • 监控 sidecar 的 fd 数,异常上涨即告警。

再观察

给连接 fd 设 CLOEXEC 后,sidecar fd 数恒定,主服务连接池扩到 5000 不再 EMFILE。

改进(权衡:拿什么换什么)

动作拿什么换什么
CLOEXEC 显式打标记需改代码、继承 fd 要显式传不被偷偷占满
只传 ExtraFiles改造启动逻辑fd 面最小

🔧 思考题:为什么 ulimit -n 够大,还是报 EMFILE?

【参考答案】

EMFILE 是”进程级 fd 数超 RLIMIT_NOFILE”。sidecar 继承了主服务的上千连接 fd + 自己正常用的 fd,总数超了它的 ulimit -n,于是它在想新开 fd 时报 EMFILE——根因不是 limit 小,是”本不该持有的 fd 被继承占着”。调大 limit 只是延后爆,治本是 CLOEXEC 不继承。这也呼应第 3/17 章:ulimit 是进程级限制,fd 泄漏要从”谁持有了”查起,而不是无脑 ulimit -n 1048576。


实战补遗二:失控 fork 把 kernel.pid_max 打满,新连接全 EAGAIN

墨:你服务有个 bug:每个请求 fork 一个子进程干脏活,但忘了 wait,子进程变僵尸也不回收。运行一天后,新 SSH 连不上、accept 报 EAGAIN。你查 ulimit、查内存,都没满。我说:去看 pid_max。

你:fork 子进程和 pid_max 有关系?而且 EAGAIN 不是”资源临时不可用”吗?

墨:有关系,而且很直接。fork 一个进程就要消耗一个 pid;僵尸不回收,pid 就被占着不释放。pid 耗尽 → 任何新 fork(包括 sshd 接收新连接时 fork 登录进程)都失败 → EAGAIN(或 EAGAIN 在 accept 的变体)。这正好接第 2 章”进程起跑”和”fd/pid 都是有限资源”。

现象:运行一天后”进不去、连不上”

某老 CGI 服务,每请求 fork 一个 sh 跑脚本、处理完 exit 但父进程没 wait。初看正常;一天后:新 SSH 连接被拒、sshd 日志 fork: Resource temporarily unavailable;应用 accept 偶发 EAGAIN;但内存、CPU、ulimit -n 都正常。ps 一看僵尸进程(Z 状态)好几万。

推断(可能推错):先查 fd 和内存

第一推断:「fd 泄漏 / ulimit -n 满。」——lsof 看 fd 没满,ulimit 够。EAGAIN 在 accept 上常被误读成 fd 满,但这次不是。 第二推断:「内存满,fork 分配不出。」——free 看内存够。推错。

真相:pid 是有限资源,僵尸占着不还

kernel.pid_max(默认 32768 或更大,看配置)是系统级 pid 上限。僵尸进程(defunct)已退出但父未 wait,仍占一个 pid 槽。几万僵尸 → pid 池见底 → 新 fork 拿不到 pid → EAGAIN。sshd 接收新连接要 fork login,也拿不到 pid → 连不上。这和业务内存、fd 完全无关,是进程表满了。

调整:回收僵尸 + 别在热路径 fork

  • 根因:父进程 wait()/waitpid() 回收,或改用 signalfd/SIGCHLD 自动回收;或干脆别 fork(用线程/协程/连接池,Go 更不该用 exec 每请求);
  • 紧急:kill -9 <父pid> 让僵尸被 init 接管回收(治标);
  • 兜底:适当调大 kernel.pid_max(只延后,不治本;且过大 pid 表遍历略慢);
  • 监控:僵尸进程数(ps -e | grep Z / procps 的 tasks 统计)、pid_max 用量。

再观察

改”每请求复用子进程 + 父 wait 回收”后,僵尸归零,pid_max 用量常驻 2000 内,SSH/accept 不再 EAGAIN。

改进(权衡:拿什么换什么)

动作拿什么换什么
不用每请求 fork改架构pid 不被耗尽
wait 回收改动僵尸归零
pid_max 调大延后爆发临时缓冲

🔧 思考题:EAGAIN 在 accept 上到底意味着什么?它和 EMFILE 怎么区分?

【参考答案】

accept 返回 EAGAIN(在 non-blocking 套接字上)通常表示”当前没有已完成连接可取”——但本例是**fork 失败传导**:sshd 接受连接后 fork login 失败(EAGAIN 来自 fork 拿不到 pid),表现为”连不上”。EMFILE 则是”进程 fd 数超 RLIMIT_NOFILE”(第 3/17 章)。区分:看 dmesg/日志是 fork 失败还是 open 失败、看 ps 僵尸数 vs lsof fd 数。本例僵尸几万 → 是 pid 不是 fd。一句话:EAGAIN 是”资源临时不可用”的统称,具体哪种资源得看上下文和配套计数,不能见 EAGAIN 就调 fd——这正呼应第 2 章那句”进程起跑时,pid 和 fd 都是有限的、会被耗尽的资源”,排障要定位”哪种资源”,不是见错误码就动手。


实战补遗三:写时复制(COW)翻车——fork 之后内存”翻倍”的假象

墨:fork() 创建子进程,Linux 用的是写时复制(COW),对吧?那理论上 fork 不复制内存,只复制页表,瞬间完成。你知道 COW 在哪翻车吗?

你:是不是 fork 之后父子都写同一块内存,触发大量缺页,反而比直接复制还慢?

墨:方向对,但翻车方式更阴。讲个真实的:一个 C++ 服务,父进程占 8G 堆,平时没事。某次加了个”fork 子进程做快照”的逻辑(类似 fork 后子进程把内存 dump 出去再退出)。上线后监控发现:机器内存瞬间从 8G 飙到 16G,OOM 把别的进程杀了。

你:不是 COW 吗?子进程没写,怎么会涨到 16G?

墨:问题就在”子进程 dump 内存”这个动作。dump = 把父进程那 8G 堆读一遍写进文件。读本身不触发 COW,但……你用什么读?如果逐字节遍历那 8G,且遍历过程中父进程恰好也在并发修改这块内存(哪怕只改一个字节),那一个字节就触发 COW,复制一整页(4K)。更致命的是:很多序列化库为了”方便”,会先把内存 memcpy 到一个临时 buffer 再序列化——这一 memcpy 就是全量写,瞬间把 8G 堆每一页都 COW 复制一份,父子各 8G = 16G,直接翻倍。

推断过程:① dmesg 看 OOM 日志,被杀的是”无辜邻座”,不是这个服务本身——说明是整机内存被这个 fork 撑爆;② cat /proc/<子pid>/smaps 看子进程的匿名页 Private_Dirty 巨大(8G),而正常 COW 子进程 Private_Dirty 应该很小(只有真正被写的页);③ 看 dump 代码,发现用了 memcpy 全量拷贝——罪魁。

调整:两条路。① 改用 fork + 子进程只读 mmap 原内存、直接序列化不 memcpy(读不触发 COW,只要父进程在此期间不写这块,子进程就不复制);② 更彻底:别 fork 了,用 vmsplice/splice 或应用层自己维护可快照的数据结构,绕开 COW 语义。再观察:去掉 memcpy 后,fork 子进程期间整机内存只多涨了几百 MB(仅父进程并发修改触发的零星 COW 页),OOM 不再。

你:vfork() 是不是更省?它连页表都不复制。

墨:vfork() 是另一个坑——它暂停父进程、和父进程共享地址空间,子进程一旦修改任何东西(或没 exec 就返回)会直接破坏父进程。而且现代 COW fork 已经足够快,vfork 的”快”换来的”共享地址空间”风险在实际工程里基本不值得。glibc 的 posix_spawn 内部会根据情况在 fork+exec 和 vfork+exec 间择优,你自己的代码别手撸 vfork。真正要省内存的 fork 场景,记住一条:子进程 fork 后尽快 exec(exec 会整体替换地址空间,COW 页直接丢弃,零复制代价),或只读不写、别 memcpy。

🔧 思考题:Go 语言的 os/exec 起子进程用的是 fork+exec(实际是 clone+execve)。Go 程序堆动辄几百 MB,为什么 Go 起子进程一般不担心 COW 翻倍?

【参考答案】

因为 Go 的 exec.Command 走的是 fork(实际 clone 带 CLONE_VM 等标志)后立即 execve 新程序。execve 会用新程序的映像整体替换进程地址空间,父子原本共享的那些 COW 页在 exec 瞬间被丢弃——根本没机会被”写”触发复制。所以 Go 起子进程的内存代价几乎只是”fork 瞬间复制页表 + 子进程加载新程序”,不会把父进程几百 MB 堆 COW 复制一遍。

翻车只发生在:你自己写 fork 后不立刻 exec、而是先在子进程里读/写父进程大内存(比如子进程先处理数据再 exec,或像事故里那样 memcpy dump)。只要遵守”fork 后尽快 exec、或只读不写”,COW 就安全。这也解释了为什么 shell 管道 cmd1 | cmd2 起一堆子进程从不担心内存翻倍——每个子进程都是 fork 后秒 exec 新程序。



实战补遗四:Go 的 goroutine 栈、mmap 与进程虚拟地址——为什么 top 里 RES 比你算的大

墨:老哥,你用 Go 写服务,top 里看 RES 占了 1.2G,你按”堆 + 栈”心算了一下才 600M,剩下那 600M 去哪了?你说得清吗?

你:……说不清。可能缓存?

墨:对一半。但 Go 进程里有一块特别容易被忽略的东西——goroutine 栈 + runtime 自己的内存,加上 mmap 映射,全算在进程的虚拟地址空间里,RES 把它们都算上。我把这层账给你拆开。

现象:一个中等并发(5 万 goroutine)的 Go 网关,RES 1.2G,但你 pprof 看 inuse_space(堆)才 400M,goroutine 数 5 万 × 平均栈 8K ≈ 400M,加起来 800M,还差 400M。

推断:差的 400M 可能是 page cache(读了大文件)。

可能推错:这台机器没碰大文件,page cache 不该有这么多。再看 cat /proc/<pid>/smaps,发现有一堆 [anon] 段,单个 64M,标注 mmap,总共接近 400M——那是 Go runtime 的 heap arena:Go 从操作系统按 64M 一块 mmap(MAP_ANONYMOUS) 预先圈地,即使没用完也占着虚拟和常驻。

调整:用 /proc/<pid>/smaps 的 Rss 字段逐段加总,确认那 400M 确实来自 mmap 的 anon 段(heap arena + mmap 的调度器/GC 元数据),不是泄漏。

再观察:更坑的是 goroutine 栈——Go 的栈是可增长的,初始 2K(老版本 8K),增长时 runtime 会 mmap 新栈、复制、再 munmap 旧栈。高并发下频繁伸缩栈,会留下不少 mmap 碎片,虽然最终会被复用,但瞬时 RES 会虚高。

改进:别用 top 的 RES 当”真实内存占用”,要结合 pprof heap + smaps。对内存敏感的服务,用 GOGC 调高、或 debug.FreeOSMemory() 在低谷时把 arena 还给 OS(注意这有 STW 代价)。

🔧 思考题:mmap 圈的”地”(heap arena)就算你 free 了 Go 对象,RES 也不一定会掉——为什么?要它真的掉,需要触发什么机制?

【参考答案】

Go runtime 向 OS 要内存是用 mmap 大块 arena,对象 free 只是 runtime 内部把它标为空闲、放回 mcache/mcentral,arena 这块 mmap 映射不会立刻 munmap。所以 RES 不随 Go 对象释放而下降是正常的。

要让 RES 真的掉,得让 runtime 把空闲 arena 还给 OS,触发条件有两个:

  1. runtime 检测到某 arena 长时间全空闲,会在 GC 后 munmap 它(Go 1.12+ 有这套机制,但有阈值和延迟);
  2. 你主动调 debug.FreeOSMemory(),它会强制做一次 GC 并尽量归还——代价是一次 STW 停顿,生产别随便调。

另外 GODEBUG=madvdontneed=1(Go 1.16+ 默认行为改过)控制 runtime 用 MADV_DONTNEED 还是 MADV_FREE 归还页:前者立刻让 RSS 下降(但下次访问要重新缺页),后者只是标记可回收、RSS 表面上不降。smaps 里看 Rss vs Pss 才是定位”是不是真用了这么多物理页”的正确姿势。


实战补遗五:fork 炸弹与 pid_max——僵尸进程把 PID 表占满的真实事故

墨:老哥,你见过一台机器”进程数没多少,但 fork 直接报 resource temporarily unavailable”吗?

你:可能是 fd 满了?或者内存满了?

墨:都不是,是 PID 用光了——kernel.pid_max 这张表被僵尸进程/失控 fork 占满。这节把 PID 命名空间和僵尸的账算清,和第 2 章的 fd 继承是”进程资源”的两条不同线。

现象:一台机器 ps aux 看进程才 3000 个,但某 Go 服务调 exec.Command 启子进程时 fork 失败:fork/exec ...: resource temporarily unavailable。dmesg 无 OOM,内存也够,fd 也没满。

推断:是内存或 fd 不够。

可能推错:第一,fork 失败但内存足、fd 足,说明受限的是PID 表——kernel.pid_max(默认常 32768 或 4194304 视内核/发行版)是系统能分配的最大 PID 号。第二,看 ps -e | wc -l 才 3000,但 ps -e -o stat | grep Z | wc -l(僵尸态 Z 进程)有 29000 个——大量僵尸占着 PID 没回收,把 pid_max 的号段吃光,新 fork 拿不到 PID 号就失败(报 EAGAIN/resource temporarily unavailable,不是 ENOMEM)。

调整:僵尸进程是”父进程没 wait() 回收的子进程”——子进程退出后,内核保留它的 task_struct(含退出码)等父进程来收尸。父进程如果 exec.Command 后没读 cmd.Wait()/stdout,或父进程自己卡死忘了 wait,子进程就变僵尸,PID 一直占着。定位:哪个父进程养了海量僵尸——ps -eo pid,ppid,stat,comm | awk '$3=="Z"' | awk '{print $2}' | sort | uniq -c | sort -rn 看僵尸的 ppid 集中在哪个进程。

再观察:那次根因是 Go 服务里 exec.Command("sh","-c",...) 启子进程,没 cmd.Wait(),且子进程输出没读(pipe 满了子进程阻塞,父进程也不 wait),僵尸越积越多直到 pid_max 满。修法:defer cmd.Wait() 必写 + 用 cmd.Output() 而非手动 pipe 避免阻塞 + 或在 unit 里 KillMode=process 让 systemd 清理。

延伸:kernel.pid_max 太小(老默认值 32768)在容器密集的节点也容易满(每容器一堆短命进程)。可调 sysctl -w kernel.pid_max=4194304,但根因是僵尸没回收,调大只是拖延,该修代码/父进程。

改进:

  • fork 报 resource temporarily unavailable 先查僵尸:ps 看 Z 态数量 + pid_max 使用率。
  • Go 里 exec.Command 必须 defer cmd.Wait(),且用 cmd.Output()/CombinedOutput() 读输出避免子进程因 pipe 满阻塞变僵尸。
  • 监控 PID 余量:cat /proc/sys/kernel/pid_max vs ps -e | wc -l;容器用 pids.current/pids.max(cgroup v2 的 pids 控制器,超了直接拒绝 fork)。
  • 别只调大 pid_max 掩盖僵尸泄露,那会养出更多僵尸直到别处爆。

🔧 思考题:僵尸进程”占着 PID 但几乎不占内存/CPU”,为什么它对系统是有害的?init/PID 1 在容器里不回收僵尸,会导致什么?

【参考答案】

僵尸进程的本质:子进程退出后,内核不能立刻删它的 task_struct,因为要保留退出码给父进程 wait() 读取。所以僵尸进程确实不占 CPU、几乎不占内存(只留一个极小的 task_struct + PID 号),但它占着一个 PID 号。危害就在这:PID 号是有限资源(受 kernel.pid_max 约束),僵尸越多,可用 PID 越少,直到新 fork 拿不到号 → 整个系统/容器无法创建任何新进程(连 ls、sh 都起不来),这是”静默瘫痪”——机器看着内存 CPU 都闲,但啥新进程都起不了。

init/PID 1 的特殊职责:在 Unix 里,PID 1 进程自动收养所有孤儿进程(父进程先死、子进程被 reparent 到 init),并负责 wait() 回收它们。所以正常系统里僵尸不会堆积——父进程死了,子进程变孤儿被 init 收养,init 的 wait() 顺手清掉。但容器里 PID 1 是你的应用进程(如 Go 服务),它若没实现”收养+wait 孤儿”的逻辑(大多数应用不实现),那么容器内产生的僵尸(尤其应用自己 exec 的子进程 + 孤儿)没人回收,PID 一直占着,直到 pids.max 满。这也是为什么容器里僵尸问题比宿主更常见——宿主有完整 init(systemd)兜底,容器里 PID 1 往往是裸应用。

缓解:docker run --init 注入一个 tini 作 PID 1 专门回收僵尸;或应用代码 exec.Command 必 Wait()。cgroup v2 的 pids 控制器(pids.max)是硬墙——超了直接拒绝 fork,比 kernel.pid_max 更早暴露、且按容器隔离(不污染宿主)。监控 pids.current/pids.max 比看全局 pid_max 更准(容器场景)。

一句话:僵尸不占资源但占号,号满则系统失能;宿主靠 init 兜底,容器靠 --init 或代码 Wait(),别只调 pid_max 掩盖泄露。