
先摆现象。一个很普通的工具调用:父进程拉起子进程,只关心它的 stdout 和退出码,stderr 接了管道但没人去读。子进程往 stderr 写 1 MiB 日志,然后退出。按直觉,写完就该退,实际上 3000 毫秒过去它还活着,父进程只能 SIGKILL 收场。这不是偶发,是操作系统管道的确定性行为。
先查规范:pipe 缓冲区有多大,满了会怎样
POSIX 对 pipe 的定义里有两句话值得抄下来。第一句,管道容量至少 PIPE_BUF(Linux 上 4096 字节,macOS 上 16384 字节)。第二句更关键,如果写端写入时缓冲区已满,write 会阻塞,直到有读端取走数据。
再看 Node 的 spawn:stdio 数组里写 "pipe",就是建一条这样的 OS 管道。子进程拿到的 fd 2 指向管道写端,父进程这边对应一个可读流。Node 不会自动消费这条流。spawn 的文档写明,除非监听数据事件或者 resume,数据就堆在内核缓冲区里。
两边规范拼起来,结论就出来了:缓冲区容量是有限的(macOS 实测到 64 KiB 上下,内核分配的不是理论值),写满后子进程的 write 调用永远睡在内核里,除非父进程开始读。
四个场景,把话说死
环境:macOS 26.2、node v26.8.1、系统 python3。四个用例,子进程都往 stderr 写 1 MiB,区别只在父进程怎么处理 stderr。脚本在 tools/verify-tool-stderr.mjs,可以直接复现。
E1 python 子进程写 1 MiB stderr,父进程 pipe 且从不读
-> 3000 ms 仍没退出,父进程只能 SIGKILL(卡死)
E2 node 子进程写 1 MiB stderr,父进程 pipe 且从不读
-> 3000 ms 仍没退出,父进程只能 SIGKILL(卡死)
E3 python 子进程写 1 MiB stderr,父进程 stdio ignore
-> 32 ms 正常退出
E4 python 子进程写 1 MiB stderr,父进程持续读走
-> 20 ms 正常退出
E1 和 E2 说明语言无关,python 写的子进程和 node 写的子进程一样卡。E3 和 E4 是两条出路:要么 stderr 直接 ignore(子进程的写落到 /dev/null,永远不会满),要么 pipe 但父进程持续把数据读走。两种写法都在 35 毫秒以内正常退出,和卡死组差了两个数量级。
1 MiB 也不是个特殊数字。缓冲区上限在 64 KiB 附近,所以哪怕只写 70 KiB 的报错日志,一样触发。真实的工具子进程跑久了输出几 MB stderr 很常见,这不是构造出来的极端场景。第四组数据我是后来才补的:起初只想对比「读与不读」,写完才发现 ignore 这条路同样要实测才算数。
为什么偏偏是 agent 场景重灾
agent 跑工具,通常一个工具一个子进程,几十个并发。每个进程的 stderr 都用 pipe 接,图的是代码写起来最顺手,stdio: ["ignore", "pipe", "pipe"] 一行。工具正常时什么都不输出,安静得像没问题;工具一旦狂打日志,整批子进程一起挂在 write 系统调用上,父进程还在傻等退出码。
更麻烦的是这类卡死没有报错。进程没崩溃、没异常,只是不再前进。定时任务超时把它杀掉,日志里只有一行超时,根因一行都看不见。排查过这类问题的都知道,从超时倒推到「stderr 没人读」这一步有多费劲。
顺带一提 stdout 同理,只是 stdout 常有人读,少踩一些。stdin 也有对应坑:子进程读 stdin 而父进程不写不关,等的是另一个方向。
结论
写出会往 stderr 打日志的子进程时,父进程只有三个合法选择:
- 不关心就
ignore,落到/dev/null,永不阻塞,最省心。 - 关心就接住,监听
stderr的 data 事件,或至少resume()。想要日志进文件,用stdio: ["ignore", "inherit", fs.openSync("app.log", "a")],让内核直接写到文件描述符上,不经过父进程。 - 绝不留「pipe 了但不读」这个中间态,它比 ignore 和全读都危险,因为坏在暗处。
这条的验证脚本和输出都在 tools/ 下留档。第四种方案(stdio ignore 加 2>&1 合并重定向)没单独测,它本质是 E3 的 shell 版,原理相同,不重复占篇幅。