子进程 stdout 管道写满 64KB,整个脚本就卡死了:一次没有报错的死锁
先说现象。一个 Node 脚本循环调用外部命令跑批处理,跑到第几十个任务时突然不动了。进程没退出、CPU 占用接近 0、日志停在「开始执行任务 37」之后再无下文。杀掉重跑,可能卡在任务 40,也可能卡在任务 12,没有规律。第一反应会怀疑外部命令自己 hang 住了,或者网络请求超时没设。
真相是:外部命令没 hang,它只是想写日志,写不进去。
为什么管道会「写不进去」
spawn 一个子进程并把 stdio 设成 'pipe' 时,操作系统会在父子进程之间建一根管道。这根管道在内核里有个固定容量的缓冲区,Linux 上默认 64KB(早期内核 4KB,老系统上更小;Windows 的匿名管道缓冲区通常是 4KB~64KB 之间)。
关键在于:这个缓冲区不是队列,写满之后写操作会阻塞。子进程调 write() 往 stdout 写,缓冲区满了,write() 就不返回,子进程被挂起在写系统调用上,直到缓冲区里有空间被读走。
而父进程这边,如果你 spawn 时把 stdout 设成 'pipe',却从来没去读 child.stdout(或者读了但读得太慢、被同步逻辑挡住),缓冲区永远不会被取空。于是:子进程等父进程读,父进程在等子进程 exit —— 标准死锁,谁都不报错。
这就能解释那些「诡异」的特征:CPU 为 0(大家都在等),没有异常(没有任何一方出错),卡住的位置不确定(取决于子进程输出多快、什么时候攒满 64KB)。输出少的命令永远不卡,输出多的命令偶尔卡。
为什么容易踩
最常见的写法是这样:
const child = spawn(cmd, args, { stdio: 'pipe' });
child.on('close', resolve);
本意是「我只关心它退没退出」。但既然要了 pipe,就有责任把管道读空。不读,就是上面那个死锁。
另一个变体是同步读取时读串了:用 spawnSync 但没设 maxBuffer,输出超过 Node 默认的 1MB 就直接抛 ERR_CHILD_PROCESS_STDIO_MAXBUFFER。这个至少还报错,比静默挂死好一点,但同样让人措手不及——一个用来列目录的命令,输出几万个文件就超了。
怎么确认是这个原因
第一步,看子进程卡在哪个系统调用上。Linux 下:
cat /proc/<子进程pid>/wchan
如果输出是 pipe_write 或类似名字,基本就锁定了。有条件的话 strace -p <pid> 看最后一帧是不是 write(1, ...) 没返回。
第二步,直接在 shell 里复现:把同一个命令的输出重定向给一个不读的接收端,看它是否也卡住。或者更简单——量一下输出大小:
cmd args | wc -c
如果输出量在几十 KB 到几 MB 这个量级,就非常可疑了。小于 64KB 的命令基本不可能触发。
第三步,排除「真的是命令自己慢」。给子进程的 stdout 挂个监听,打印每收到一段数据的时间戳。如果卡住时最后一段数据之后再也没有数据、而且子进程仍然活着,那就是写阻塞;如果子进程已经退出但 close 事件没来,那是别的问题(比如孙进程还占着文件描述符)。
三种修法
1)如果不需要输出,别要 pipe
spawn(cmd, args, { stdio: 'ignore' });
直接把子进程的输出丢到 /dev/null。缓冲区根本不存在,自然没有写满的问题。这是最省事也最快的方案,缺点是出问题时没有日志可看。折中做法是用 'inherit',让子进程直接继承父进程的 stdout,走终端或日志系统,同样不经过管道。
2)需要输出就一定要读,而且要边跑边读
const child = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'] });
const chunks = [];
child.stdout.on('data', d => chunks.push(d));
child.stderr.on('data', d => chunks.push(d));
要点是监听器要在子进程开始大量输出之前就挂上。如果先 await 一个耗时操作再挂监听,中间那段时间的输出会堆在缓冲区里。
如果输出量可能很大,别用数组攒着(内存会涨),改成边读边写文件或者边解析。
3)要同步执行就显式给足 maxBuffer
spawnSync(cmd, args, { maxBuffer: 64 * 1024 * 1024 });
execSync 同理。默认 1MB 对大多数命令够用,但对「列出全部文件」「导出全部日志」这类命令远远不够,而且超限是抛错而不是截断,很容易把正常的命令判成失败。
两个补充细节
一是 stderr 也要读。很多人只读了 stdout,忘了 stderr 也是 pipe、也有 64KB 上限。一个往 stderr 打大量警告的命令,同样会卡死,而且因为 stdout 是空的,更容易误判成「命令没输出所以卡住」。
二是 close 和 exit 的区别。exit 在子进程本身结束时触发,close 要等所有 stdio 流都关闭。如果子进程 fork 了孙进程且孙进程继承了 stdout,exit 会先到,close 要等孙进程也退出。用 close 更安全,但也更容易出现「看起来卡住」——这时候要查的是孙进程,不是管道。
小结
子进程卡住、CPU 为 0、没有异常,先别怀疑命令本身。看一下 stdio 是怎么配的:要了 pipe 就必须读;不读就设成 ignore 或 inherit;用 spawnSync 就把 maxBuffer 给够。这三条能挡掉绝大多数「跑着跑着不动了」的问题。
- 点赞
- 收藏
- 关注作者
评论(0)