bigywater.tech
Go · 94

os/exec:管道、僵尸进程与 Wait 的必要性

用 os/exec 调外部命令,最容易出问题的不是启动,而是收尾:不 Wait 会留下僵尸进程,管道没读完就 Wait 会丢数据或卡死,子进程的子进程会让 Output 迟迟不返回,Context 超时也杀不干净。这篇把这些情形一个个跑出来,并验证 WaitDelay 能不能兜底。

Z
不调用 Wait 时,已退出的子进程在 ps 里的状态
1009 → 206 ms
孙进程抱着 stdout 时,cmd.Output() 不设与设 WaitDelay = 200 ms 的返回用时
200 ms → 约 2 秒
CommandContext 设了 200 ms 超时,实际返回的时间(孙进程仍抱着管道)

僵尸进程

实测 · 本机执行输出
  Start 之后不调用 Wait,子进程已经退出 200 ms:ps 显示 54033 Z    <defunct>
  调用 cmd.Wait() 之后:ps 显示 (进程表里已经没有这个 pid)

子进程退出后,内核保留它的退出状态,等父进程来取。不调用 Wait,ps 里就是状态 Z(<defunct>);调用后进程表项消失。长期运行的服务里反复 Start 而不 Wait,僵尸会越积越多。

StdoutPipe 的顺序

实测 · 本机执行输出
  先 Wait 再读 StdoutPipe:读到 "",err = read |0: file already closed
  先读完 StdoutPipe 再 Wait:读到 "hello\nworld\n",读 err = <nil>,Wait err = <nil>

文档写明:Wait 会在看到命令退出后关闭管道,所以必须先读完再 Wait。先 Wait 的实验读到空和 “file already closed”;使用 StdoutPipe 时也不能调用 Run。

管道写满

实测 · 本机执行输出
  子进程写 200000 字节,父进程没人读管道:1.5 秒后 Wait 仍然没有返回(子进程卡在 write 上:54052 S    head)

管道有缓冲上限,子进程写 200000 字节而父进程不读,子进程阻塞,Wait 也就一直不返回。要么及时读,要么把输出交给 cmd.Stdout(由 os/exec 起 goroutine 去读)。

孙进程抱着管道

实测 · 本机执行输出
  sh 立刻退出,但留下一个后台 sleep 1 抱着 stdout:cmd.Output() 用了 1009 ms 才返回,输出 "hi\n",err = <nil>
  同样的命令设 cmd.WaitDelay = 200 ms:206 ms 返回,输出 "hi\n",err = exec: WaitDelay expired before I/O complete(是 ErrWaitDelay:true)

sh 已经退出,但它留下的后台 sleep 1 还持有 stdout 的写端,Output 要等到 EOF 才返回(1009 ms)。给 cmd.WaitDelay 设 200 ms 后 206 ms 返回,同时返回 ErrWaitDelay(输出仍是 hi)。

CommandContext 杀不干净

实测 · 本机执行输出
  CommandContext(超时 200 ms),sh -c 'sleep 2; echo done':2017 ms 后返回,输出 "",err = signal: killed
  CommandContext(超时 200 ms),sh -c 'sleep 2 & wait':2015 ms 后返回,输出 "",err = signal: killed(sh 被杀了,孙进程 sleep 还抱着 stdout)

200 ms 超时到了,CommandContext 只给直接的子进程(这里是 sh)发 Kill,sh 的孙进程 sleep 2 仍然活着、抱着管道,所以 Output 一直等到约 2 秒(2017 ms)才返回,错误是 signal: killed。配合 WaitDelay,或者让子进程自成进程组再整体杀掉,才能真正按超时返回(进程组的做法本文没有实验)。

成本

实测 · 本机执行输出
  exec.Command("/usr/bin/true").Run() 50 次:最小 1.5 ms / 中位 2.1 ms / 最大 5.8 ms

启动并等待一个什么都不做的进程,中位数 2.1 ms。批量调用外部命令时,这个固定成本要算进去。

文档

实测 · 本机执行输出
  StdoutPipe returns a pipe that will be connected to the command's standard output when the command starts. Cmd.Wait will close the pipe after seeing the command exit, so most callers need not close the pipe themselves. It is thus incorrect to call Wait before all reads from the pipe have completed. For the same reason, it is incorrect to call Cmd.Run when using StdoutPipe. See the example for idiomatic usage.
  If WaitDelay is non-zero, it bounds the time spent waiting on two sources of unexpected delay in Wait: a child process that fails to exit after the associated Context is canceled, and a child process that exits but leaves its I/O pipes unclosed.
  If WaitDelay is zero (the default), I/O pipes will be read until EOF, which might not occur until orphaned subprocesses of the command have also closed their descriptors for the pipes.

文档对 WaitDelay 的描述和实验吻合:默认零值会一直读到所有持有管道的进程关闭它们的描述符。

分步演示

os/exec
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64 / Go 1.27.1(brew);用 go run / go build / go test 直接编译运行,需要第三方库时经代理从 proxy.golang.org 下载;计时用 time.Now / testing.B,数字每次运行会有小幅抖动。僵尸状态用 ps -o pid=,stat=,comm= 观察;孙进程场景用 sh -c 启动后台 sleep;计时用 time.Since;启动成本 50 次;读过的文档:go doc os/exec.Cmd.StdoutPipe / Cmd 的 WaitDelay 注释
    • 没有验证:Linux 上的行为差异、进程组(Setpgid)杀整组的写法、Cmd.Cancel 自定义、僵尸进程在容器 PID 1 下的收养问题、stderr 管道的同类问题、Windows 行为
    京ICP备2026067502号-1京公网安备11010502063487号