os/exec.Cmd 停子进程需设进程组隔离并三段式发信号:Linux/macOS设Setpgid:true,Windows设CREATE_NEW_PROCESS_GROUP;先SIGTERM+5秒超时等待,未退出则SIGKILL;必须调Wait()防僵尸;非root运行需CAP_SETUIDS权限。

os/exec.Cmd 是 Go 启动子进程最常用也最稳妥的方式,但它本身不管理生命周期——启动容易,停干净难。多数线上问题(孤儿进程、僵尸进程、信号发错对象、内存泄漏)都源于没处理好“停”这个环节。
为什么 cmd.Process.Kill() 杀不干净子进程
根本不是 Kill 方法有问题,而是默认没开启进程组隔离。比如执行 exec.Command("sh", "-c", "sleep 100"),实际启动的是 sh 进程,它再 fork 出 sleep;cmd.Process.Kill() 只向 sh 发 SIGKILL,sleep 立刻变成孤儿继续跑。
- Linux/macOS 必须设:
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} - Windows 必须设:
cmd.SysProcAttr = &syscall.SysProcAttr{CreationFlags: syscall.CREATE_NEW_PROCESS_GROUP} - 设完之后,才能用
syscall.Kill(-pgid, syscall.SIGTERM)(注意负号)向整个组发信号 - 没设 Setpgid 时调
syscall.Getpgid(cmd.Process.Pid)会返回错误,别跳过这步校验
怎么发 SIGTERM 才算“优雅停止”
只调 cmd.Process.Signal(syscall.SIGTERM) 是半截操作:它发了信号,但不等退出,也不兜底。真正可用的流程是三段式:
- 先发
SIGTERM,给子进程机会做清理(关闭 listener、刷缓冲、释放锁) - 用
select+time.After(5 * time.Second)等最多 5 秒 - 超时未退出,再发
SIGKILL强杀 - Windows 下
SIGTERM无效且会 panic,必须分支:if runtime.GOOS == "windows"时改用taskkill /PID %d /T /F
Wait() 不调,几分钟就堆满僵尸进程
Go 不自动回收子进程状态。只要 cmd.Start() 启动了,不管后续是否出错、是否被 kill,都必须调 cmd.Wait() 或 cmd.Process.Wait(),否则内核保留 exit status,进程变 [sleep] <defunct>。
立即学习“go语言免费学习笔记(深入)”;
- 别用
cmd.Run()—— 它阻塞,且出错时cmd.Process可能为 nil,后续无法发信号 - 推荐异步 Wait:
go func() { cmd.Wait(); close(done) }(),配合 channel 控制生命周期 - 如果用了
StdoutPipe()或StderrPipe(),务必在Wait()前close()对应 pipe,否则可能死锁 - 循环启停场景下,漏一次
Wait(),几轮后ps aux | grep defunct就能看见一堆
以指定用户/组启动子进程的硬限制
想让子进程以非 root 用户运行(比如 www-data),不能靠 shell 切换(如 su -c),得用底层凭证控制。但这有强约束:
- 必须通过
cmd.SysProcAttr.Credential = &syscall.Credential{Uid: uid, Gid: gid}设置 - 父进程需有
CAP_SETUIDS能力(Linux),普通用户进程基本做不到,除非用sudo启动或 setuid 二进制 - Windows 下该字段无效,
Uid/Gid会被忽略并返回 -1 - 设置失败时错误信息通常是
operation not permitted,不是配置写错了,是权限不够
进程管理真正的复杂点不在启动,而在“停得干净”——信号语义、进程树结构、平台差异、资源释放顺序,每个环节断一环,都会留下孤儿或僵尸。别省那几行判断和分支。


















