Skip to content
Go
os/exec 与 os/signal — 外部命令与优雅关停

os/exec 与 os/signal — 外部命令与优雅关停

os/exec 负责一件事:从 Go 进程里拉起另一个进程,喂给它输入,拿回输出,并如实告诉你它是怎么死的。它是 Go 世界对 C 的 exec 系列函数的包装,不是 system()。os/signal 负责另一件事:让内核或终端管理员在外部要求你的程序退出时,程序有机会把话说完——把 SIGTERM/Ctrl-C 转成 context.Context 的取消,接进优雅关停。

两者经常一起出现:服务进程要么被 os/exec 包装成更大系统的执行单元(CI runner、CLI 包装器、git 胶水层),要么被外部信号要求收尾。本文所有实测均在 Windows 11 + go1.27.0 上完成,涉及 Unix 专属行为的地方会明确标注"按文档/POSIX 约定,未在本地复现"。

为什么不经过 shell:一个安全红利和一份不便

包文档原文说得很直白:

Unlike the “system” library call from C and other languages, the os/exec package intentionally does not invoke the system shell and does not expand any glob patterns or handle other expansions, pipelines, or redirections typically done by shells. The package behaves more like C’s “exec” family of functions.

于是出现两个后果。安全上,注入面基本关闭:你的命令和参数以 argv 数组的形式直达 CreateProcess/execve,没有 shell 二次解析,; rm -rf、$(...) 这类 payload 不会被解释。体验上,*.log、$HOME、| 也不会被解释——它们是字面参数。实测把 *.md、$PROBE_MARK、a|b 当参数传给一个打印 os.Args 的子进程(父进程同时设置了环境变量 PROBE_MARK):

argc=5
arg[0]="E:\\tmp\\probe-osexec\\probe.exe"
arg[1]="args"
arg[2]="*.md"
arg[3]="$PROBE_MARK"
arg[4]="a|b"

三个参数一个字符都没变。所以安全审计报告里凡是出现 exec.Command("sh", "-c", cmdline) 且 cmdline 拼了外部输入的地方,都会被标为高危——因为你手工把关闭的注入面重新打开了。

片段:直接执行 vs 过 shell

// 直接执行:grep 会把 "*.go" 当字面文件名,报 No such file or directory
cmd := exec.Command("grep", "-l", "TODO", "*.go")

// 需要 glob 时先问 filepath.Glob,不要为此请出 shell
files, _ := filepath.Glob("*.go")
cmd = exec.Command("grep", "-l", "TODO", files...)

// 需要展开环境变量时用 os.ExpandEnv
name := os.ExpandEnv("$USER-report.txt")

环境变量展开用 os.ExpandEnv,glob 用 path/filepath.Glob——包文档推荐的这两条出路都在标准库里。

什么时候确实要过 shell,怎么安全地把用户输入塞进去

管道、复合命令、重定向、find -exec 这类确实离不开 shell。此时唯一的原则是:shell 脚本部分只能是常量,外部输入一律走位置参数。sh -c 的用法是 sh -c '脚本' $0的名字 $1 $2 ...——脚本后面第一个参数绑定给 $0,第二个才是 $1。实测(Windows 上解析到 E:\GIT\Git\usr\bin\bash.exe):

userInput := "*.exe ; echo INJECTED"

// 安全:输入经 "$1" 传入,脚本里 "$1" 加引号
safe := exec.Command(bp, "-c", `printf 'a1=[%s] a2=[%s]\n' "$1" "$2"`, "argv0-is-$0", userInput)

// 危险:输入拼进脚本文本
unsafe := exec.Command(bp, "-c", "echo unquoted: "+userInput)

实测输出:

SAFE output: a1=[*.exe ; echo INJECTED] a2=[]
UNSAFE output:
unquoted: probe.exe
INJECTED

UNSAFE 那两行就是教科书级的现场:; 后的注入命令被执行了,*.exe 被 glob 展开成了目录里的 probe.exe。同一段输入走 "$1" 传参则原样落到字符串里。结论:过 shell 不可避免时,输入当参数,脚本当常量,引用不裸写。

Command、CommandContext、LookPath 的边界

exec.Command(name, arg...) 的几条容易被忽略的语义,都来自包文档并经实测印证:

  • name 含路径分隔符(./x、bin/tool)时直接用作 Path,不查 PATH;否则先 LookPath。
  • Args[0] 永远是传入的 name,不是解析后的绝对路径——子进程看到的 argv[0] 由此决定,想伪造或改名就改这里。
  • LookPath 失败不会让 Command 返回错误(它的签名里就没有 error),错误被存进 Cmd.Err 字段延迟爆发:直到 Start/Run/Output 才原样返回。实测构造不存在的程序:
cmd.Err at creation = exec: "definitely-not-a-real-prog-xyz": executable file not found in %PATH%
Output err  = "exec: \"definitely-not-a-real-prog-xyz\": executable file not found in %PATH%"
Start err   = "exec: \"definitely-not-a-real-prog-xyz\": executable file not found in %PATH%"

Output 返回的错误与 cmd.Err 是同一个对象,类型为 *exec.Error(字段 Name 存程序名,Unwrap 出底层原因),并且 errors.Is(err, exec.ErrNotFound) 为 true——实测确认。所以区分"没找到可执行文件"和"跑了但失败"要靠错误类型,不是靠退出码:

cmd := exec.Command("maybe-not-installed")
if err := cmd.Run(); err != nil {
    var execErr *exec.Error
    switch {
    case errors.As(err, &execErr):
        log.Printf("根本没起来: %v", err) // LookPath 阶段失败
    default:
        log.Printf("起来了但没跑好: %v", err) // 大概率 *ExitError
    }
}
  • Go 1.19 起,本包不会解析相对当前目录的可执行文件:即使 PATH 里有 .,LookPath("prog") 也找不到 ./prog,而是返回满足 errors.Is(err, exec.ErrDot) 的错误。想跑当前目录的程序就写明 ./prog。这是防"当前目录劫持"的加固,细节见包文档与 https://go.dev/blog/path-security。
  • Cmd.Dir 是子进程的工作目录;Path 若是相对路径,按文档"evaluated relative to Dir",实测在 Windows 上确认 &exec.Cmd{Path: "probe-sub.exe", Dir: "E:\\tmp\\probe-osexec\\sub"} 能正常跑起来。
  • CommandContext(ctx, ...)(Go 1.7 引入)与 Command 的区别只多一个 ctx:ctx 到期时调用 cmd.Cancel——默认实现就是对进程 Kill。文档原文:“CommandContext sets the command’s Cancel function to invoke the Kill method on its Process.”

Windows 还有一条隐蔽语义:进程收到的永远是整条命令行字符串,由 CreateProcess 自己再拆。Command 按 CommandLineToArgvW 的兼容规则拼接并转义 Args,但 msiexec.exe 和 cmd.exe(因此也包括所有批处理)是例外——它们的解引用算法不同。文档的指引是:遇到这类目标自己拼 SysProcAttr.CmdLine 并留空 Args。

拿输出的三种方式与经典死锁

Output / CombinedOutput:安全默认项

out, err := cmd.Output()          // 只要 stdout
all, err := cmd.CombinedOutput()  // stdout + stderr 混在一起

这两个方法内部自己接管 stdout/stderr 的拷贝并等拷贝完成,不存在管道误用,日常 90% 的场景应该用它们。一个细节:Output 在失败且你没有自设 Stderr 时,会把 stderr 的前若干字节填进返回的 *ExitError.Stderr(太长时只保留首尾,中间替换成省略说明——见 ExitError 文档),调试时能直接印出来:

errors.As ok=true ExitCode=3 Stderr="something went wrong on stderr\n"
full error text = "exit status 3"
CombinedOutput err=exit status 3 output="something went wrong on stderr\n"

(实测自一个"向 stderr 打一行、exit 3"的子进程。)

StdoutPipe:必须读完再 Wait,反过来就丢数据或死锁

StdoutPipe 文档的关键句(go1.27 措辞):“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.” 即 Wait 会替你把管道关掉,而"先 Wait 再读"是错的。错到什么程度?实测一个输出很少的子进程:

Wait err=<nil>
read after Wait: err=read |0: file already closed data=""

子进程正常退出,Wait 返回 nil,随后读管道拿到 file already closed,数据彻底丢失。数据多时问题升级为死锁:操作系统管道缓冲写满后,子进程阻塞在 write,父进程阻塞在"等子进程退出"。实测让子进程写 8MB 且父进程持管不读:

after 2s: Wait still blocked (child stuck writing)
drained 8388628 bytes from pipe
now Wait returns: <nil>

把管道读干净的那一刻,Wait 才返回。这就是 StdoutPipe 的铁律:Start → 把 pipe 读到 EOF → Wait。需要边输出边处理时写完整的消费循环:

package main

import (
	"bufio"
	"fmt"
	"log"
	"os/exec"
)

func main() {
	cmd := exec.Command("git", "log", "--oneline", "-n", "3")
	stdout, err := cmd.StdoutPipe()
	if err != nil {
		log.Fatal(err)
	}
	if err := cmd.Start(); err != nil {
		log.Fatal(err)
	}
	// 先把管道读干净,再 Wait:顺序不能反
	sc := bufio.NewScanner(stdout)
	for sc.Scan() {
		fmt.Println("line:", sc.Text())
	}
	if err := sc.Err(); err != nil {
		log.Fatalf("读 stdout 失败: %v", err)
	}
	if err := cmd.Wait(); err != nil {
		log.Fatalf("git log 退出: %v", err)
	}
}

(完整可运行。在一个含两个提交的小仓库里实测输出 line: d3eb5cb second、line: b38f51e first。)不需要逐行处理就别用 StdoutPipe:Stdout 字段挂上 io.Writer,Wait 会替你把拷贝 goroutine 一起等完,不存在顺序问题。

StdinPipe:子进程等 EOF 时你必须 Close

StdinPipe 文档:“The pipe will be closed automatically after Cmd.Wait sees the command exit. A caller need only call Close to force the pipe to close sooner.” 很多 CLI(gpg、openssl、mysql)读到 stdin 的 EOF 才肯开工,你不 Close,Wait 就永远等不到。习惯写法是 defer stdin.Close(),或者用 cmd.Stdin = strings.NewReader(...)——后者实测同样顺畅(read 11 bytes from stdin: "hello stdin")。注意 Stdin 为 nil 时子进程连的是 os.DevNull,读出来立刻 EOF:实测 read 0 bytes from stdin: ""。

超时与防挂死:CommandContext、Cancel、WaitDelay

只设 ctx 超时是不够的,因为有两类挂死 ctx 管不了:

  1. 子进程无视/没收到杀。CommandContext 的默认 Cancel 是 Process.Kill,Unix 下 SIGKILL 不可捕获,这条基本安全;但若你自定义了 Cancel(比如发 SIGTERM),子进程抓住不退出,Wait 就一直等。
  2. 孙进程继承管道。子进程自己退出了,但它 spawn 的孙进程继承了 stdout 写端,Output/Wait 要等 EOF——EOF 要等所有持有写端的进程退出。经典触发方式:daemon 型工具启动时 fork 一个后台常驻进程。

最小复现(完整可运行,单二进制三角色):

// 孙进程占住 stdout 导致 Wait 挂死的复现与 WaitDelay 解法。
package main

import (
	"errors"
	"fmt"
	"log"
	"os"
	"os/exec"
	"time"
)

func main() {
	self, err := os.Executable()
	if err != nil {
		log.Fatal(err)
	}
	role := ""
	if len(os.Args) > 1 {
		role = os.Args[1]
	}
	if role == "child" {
		// 子进程:起一个孙进程共享 stdout,然后立刻退出。
		son := exec.Command(self, "grandchild")
		son.Stdout = os.Stdout
		if err := son.Start(); err != nil {
			log.Fatal(err)
		}
		os.Exit(0)
	}
	if role == "grandchild" {
		// 孙进程:什么都不做,只是活着,握着继承来的 stdout 写端。
		time.Sleep(30 * time.Second)
		return
	}

	// 祖父进程:跑子进程并拿输出。子进程毫秒级就退出了,
	// 但 Output 要等 stdout 的 EOF,而 EOF 要等孙进程也闭嘴。
	c := exec.Command(self, "child")
	c.WaitDelay = 500 * time.Millisecond
	start := time.Now()
	out, err := c.Output()
	fmt.Printf("elapsed=%v out=%q\n", time.Since(start).Round(time.Millisecond), string(out))
	fmt.Printf("err=%v IsErrWaitDelay=%v\n", err, errors.Is(err, exec.ErrWaitDelay))
}

不设 WaitDelay 时实测 2 秒观察点 Output 纹丝不动(“after 2s: Output still blocked”,按管道语义它会一直挂到孙进程 30 秒自然退出)。加上 WaitDelay 后实测:

elapsed=521ms out=""
err=exec: WaitDelay expired before I/O complete IsErrWaitDelay=true

Cmd.WaitDelay 是 Go 1.20 新增字段(官方发布说明原文:“The new Cmd fields Cancel and WaitDelay specify the behavior of the Cmd when its associated Context is canceled or its process exits with I/O pipes still held open by a child process.")。语义按文档:计时从"ctx 到期"或”Wait 发现子进程已退出"两者较早的时刻开始;到点后先 Kill 没退的子进程,再强关 I/O 管道,从而叫醒所有阻塞的拷贝 goroutine。若此时命令本身是成功退出的、只是管道没关,Wait 返回 ErrWaitDelay(“exec: WaitDelay expired before I/O complete”)而不是 nil——用 errors.Is(err, exec.ErrWaitDelay) 判定,实测确认。

超时链路整体长这样:CommandContext(ctx) 默认 Cancel = Process.Kill;想要"先礼后兵"(Unix)就自定义 Cancel。注意 os/exec 没有 Cmd.Signal 方法(实测 go doc os/exec.Cmd.Signal 直接报 no method),发信号要用底层的 os.Process.Signal:

// 片段:Unix 下的温和取消,Go 1.20+ 的惯用写法
cmd := exec.CommandContext(ctx, "some-daemon-cli")
cmd.Cancel = func() error {
	// 先 SIGTERM;不退出则由 WaitDelay 兜底升级成 Kill
	return cmd.Process.Signal(syscall.SIGTERM)
}
cmd.WaitDelay = 5 * time.Second

文档对 Cancel 返回值的处理也值得知道:Cancel 调用后命令若以成功状态退出,Wait 仍返回非 nil——要么包装 Cancel 的返回值,要么 ctx 的 context.Canceled/DeadlineExceeded;命令以失败状态退出则照常返回退出错误。Cancel 置 nil 则 ctx 到期时什么都不做,只剩 WaitDelay 兜底——文档明说这适合"不支持 shutdown 信号但总会自己跑完"的命令。

实测超时本身(Windows):300ms ctx 到期后 Output 于 elapsed=302ms 返回,err 为 "exit status 1",且 errors.Is(err, context.DeadlineExceeded) 为 false——Windows 的 Kill 走 TerminateProcess,退出状态里只有数字 1,没有"死于信号"的概念,所以 ctx 错误没有包装进来(命令以非成功状态退出时走"照常返回退出错误"分支)。Unix 下 Process.Kill 即 SIGKILL,Wait 返回文本为 "signal: killed" 的 *ExitError、ExitCode() 为 -1——这一段按文档与 POSIX 约定描述,未在本地复现。

退出码与错误判定:ExitError 全解

命令"跑完了但失败"在 Go 里不是 nil,而是 *exec.ExitError,它内嵌 *os.ProcessState,所以退出码从它身上取:

err := cmd.Run()
var ee *exec.ExitError
if errors.As(err, &ee) {
	log.Printf("exit=%d, state=%q", ee.ExitCode(), ee.ProcessState.String())
}

对同一个"向 stderr 写一行再 os.Exit(3)“的子进程,实测两种拿法:

errors.As ok=true ExitCode=3 Stderr="something went wrong on stderr\n"
full error text = "exit status 3"
CombinedOutput err=exit status 3 output="something went wrong on stderr\n"

Output() 返回的 *ExitError.Stderr 被自动填上 stderr 内容(前提是没自设 cmd.Stderr);CombinedOutput() 则把两个流混进返回的字节里,错误本身不再单独携带 stderr。ExitError.Error() 的文本就一个来源——ProcessState.String(),实测正常退出 3 时是 exit status 3,Windows 上 Kill 后是 exit status 1。ExitCode() 的语义要按文档背下来:进程还没退出或被信号杀死时返回 -1。Windows 侧没有信号致死这回事,实测 Process.Kill 后 ExitCode() 返回 1(TerminateProcess 的退出码),不是 -1;写跨平台判定逻辑时不要把 -1 当成"被杀"的唯一标志,也别拿 1 当"成功”。想区分"被我们自己的超时机制杀掉"和"程序自己失败退出",在调用处记录 ctx 状态,比在退出码里猜可靠:

// 片段:超时归因的标准姿势
if err := cmd.Run(); err != nil {
	if ctx.Err() != nil {
		return fmt.Errorf("命令被取消: %w", ctx.Err())
	}
	return fmt.Errorf("命令失败: %w", err)
}

errors.Is/errors.As 一路可用:*exec.Error(没起来)、*exec.ExitError(起来没跑好)、exec.ErrWaitDelay(WaitDelay 兜底)、context.DeadlineExceeded(Cancel 返回成功且命令以成功状态退出时才会包装进来)。ProcessState 还提供 ExitTime() 与 Sys()(Unix 上可转 syscall.WaitStatus 拿信号号,Windows 上语义不同——以 go doc os.ProcessState 为准)。

cmd.Env、Stdin 与并发

cmd.Env 的三档语义实测如下(父进程设置了 PROBE_MARK=from-parent,子进程打印 len(os.Environ()) 与若干 Getenv):

cmd.Env 的取值 子进程环境实测结果
nil(默认) 完全继承父进程:envcount=105,PROBE_MARK="from-parent"
[]string{}(非 nil 空切片) 空环境:envcount=1,只剩 Windows 自动补的 SYSTEMROOT;PATH=""
append(os.Environ(), "PROBE_MARK=override") 继承 + 覆盖:envcount=105,PROBE_MARK="override"

要点按 Cmd 文档逐条对应:Env 为 nil 时"the new process uses the current process’s environment";重复 key 时切片里最后一个生效;Windows 上 SYSTEMROOT 缺失时永远会被补上(除非显式设成空串)——这也是为什么在 Windows 上给子进程清空环境后它还能跑:Go 自己保证了这条隐含依赖。给子进程加一个变量必须 append(os.Environ(), "K=V"),只写 []string{"K=V"} 等于删光 PATH/SYSTEMROOT,子进程十有八九起不来。Dir 字段在 Unix 上还顺带决定子进程的 PWD 环境变量(文档语义;Windows 上 PWD 是 Git Bash 自己带的,非本机制)。

Stdin 除 strings.Reader 外可以接任何东西:io.MultiReader 拼"先喂一行命令、后面接真输入",io.Pipe 做交互。多进程并发方面,Cmd 之间彼此独立,Start + 各自 Wait 即可并发跑一批;但同一个 Cmd 不能复用,实测第二次 Run 直接返回 "exec: already started"(文档同样声明 “A Cmd cannot be reused after calling its Cmd.Start, Cmd.Run, Cmd.Output, or Cmd.CombinedOutput methods”)。Wait 也不可并发调用——go doc os/exec.Cmd.Wait 明说 “Wait must not be called concurrently from multiple goroutines”。

os/signal 与优雅关停

signal.Notify 最重要的语义常被忽略:它从不为了投递而阻塞。文档原文:“Package signal will not block sending to c: the caller must ensure that c has sufficient buffer space to keep up with the expected signal rate. For a channel used for notification of just one signal value, a buffer of size 1 is sufficient.” GOROOT 源码(src/os/signal/signal.go 的 process)里投递动作是 select { case c <- sig: default: }——注释就写着 “send but do not block for it”。也就是说:通道缓冲满了,信号直接丢弃,慢消费者会漏信号而毫无线索。给 SIGTERM/Interrupt 这类"来了就该关"的信号,make(chan os.Signal, 1) 是标准写法;要按信号计数做精细逻辑,就别依赖通道不丢。

注册与反注册语义(均按 go doc os/signal):同一通道可以多次 Notify,效果是并集扩大,只有 Stop(c) 能摘掉(Stop 返回后保证该通道不再收到信号);Reset(sig...) 按信号粒度恢复默认行为;Ignore 则让信号彻底无声。实测在 Windows 上:Notify 两次同通道无错误,Stop/Reset 正常返回。不注册时 Go 程序的默认行为:SIGINT/SIGTERM/SIGHUP 直接退出,SIGQUIT 等带栈转储退出(包文档"Default behavior of signals in Go programs"一节)——所以没人 Notify 的 Go 程序收到 SIGTERM 就是猝死,不会优雅。

现代写法是 Go 1.16 引入的 signal.NotifyContext(1.16 发布说明:“The new NotifyContext function allows creating contexts that are canceled upon arrival of specific signals."):信号到达 = ctx 取消,和 HTTP 服务的关停天然拼接。它底层就是 Notify,同样遵守"缓冲满丢信号"的规则;返回的 stop 注销信号行为并取消 ctx(文档:“The stop function unregisters the signal behavior, which, like signal.Reset, may restore the default behavior for a given signal”)。实测:父 ctx 取消时 ctx.Err() 与 context.Cause(ctx) 均为 context canceled;调用 stop() 后同样如此。因信号导致取消时,context.Cause 返回描述该信号的错误(文档语义,未实测真信号)。

完整可运行的优雅关停模板(HTTP 侧的更多配置见 ../03-网络编程/net-http.md,ctx 语义见 ../02-并发与同步/context.md):

package main

import (
	"context"
	"errors"
	"fmt"
	"log"
	"net/http"
	"os"
	"os/signal"
	"syscall"
	"time"
)

func main() {
	mux := http.NewServeMux()
	mux.HandleFunc("/hello", func(w http.ResponseWriter, r *http.Request) {
		time.Sleep(2 * time.Second) // 模拟一个慢请求
		fmt.Fprintln(w, "hi")
	})
	srv := &http.Server{Addr: ":8080", Handler: mux}

	go func() {
		if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
			log.Fatalf("listen: %v", err)
		}
	}()
	log.Println("serving on :8080")

	// Ctrl-C(os.Interrupt)或 kill(SIGTERM,Unix)都会取消这个 ctx
	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer stop()
	<-ctx.Done()
	stop() // 恢复默认行为:此后再来一个 Ctrl-C 会直接杀掉进程

	shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()
	if err := srv.Shutdown(shutdownCtx); err != nil {
		log.Printf("shutdown 超时或失败: %v", err)
	}
	log.Println("bye")
}

第二个 stop() 是刻意的:defer stop 在函数返回才执行,而关停窗口里应该让"第二次 Ctrl-C"直接砸死进程,否则卡死的 Shutdown 会把 Ctrl-C 全部吞成 no-op。

Windows 上的可用性差异严格按包文档的 Windows 小节表述:"os.Interrupt is the only signal that can be used on Windows”——^C/^BREAK 会转成 os.Interrupt 送进通道;另外若注册了 Notify,Windows 的 CTRL_CLOSE_EVENT/CTRL_LOGOFF_EVENT/CTRL_SHUTDOWN_EVENT 会以 syscall.SIGTERM 的形式出现在通道里,但进程不因此获救——文档原话是这些事件下 “the process will still get terminated unless it exits”,只是给了一个清理窗口。代码里 syscall.SIGTERM 在 Windows 上合法可编译、NotifyContext 注册无害(实测),但真正向别的进程 Signal(syscall.SIGTERM) 在 Windows 上行不通。Linux 下用 kill -TERM <pid> 走完整链路的行为,未在本地复现。

陷阱汇总

陷阱 后果 对策
sh -c 拼接用户输入 命令注入(实测 ; echo INJECTED 被执行) 输入走 "$1" 位置参数,脚本只当常量
期待 *.go/$VAR 被展开 grep 找不到文件,变量名原样传入(实测) filepath.Glob、os.ExpandEnv
LookPath 失败在 Command 处不报错 以为构造即成功 检查 cmd.Err;运行期用 errors.As 认 *exec.Error
先 Wait 再读 StdoutPipe 数据丢失,读报 file already closed(实测) Start → 读到 EOF → Wait
子进程输出大且没人读管道 管道满,子进程阻塞、Wait 死等(实测 8MB) 流式消费,或干脆用 Output/Stdout 字段
孙进程继承 stdout 写端 子进程早退了,Output 仍挂到孙进程死 设 Cmd.WaitDelay(Go 1.20+)
只设 ctx 超时以为万能 自定义 Cancel 发 SIGTERM 被无视照样挂 WaitDelay 兜底升级成 Kill
cmd.Env = []string{"K=V"} PATH 等全清空,子进程只剩 SYSTEMROOT(实测) append(os.Environ(), "K=V")
用 -1 判定"被信号杀" Windows 上 Kill 实测返回 1,不是 -1 归因看调用处 ctx.Err(),退出码跨平台不可靠
复用同一个 Cmd 第二次 Run 返回 exec: already started(实测) 每次重新 Command
信号通道无缓冲/消费太慢 信号被静默丢弃(文档 + GOROOT 源码 select/default) make(chan os.Signal, 1)
NotifyContext 后不显式 stop 关停窗口里第二次 Ctrl-C 被吞 收到信号后立即 stop()
StdinPipe 忘了 Close 等 EOF 的 CLI 永不退出 显式 Close 或用 cmd.Stdin
找 Cmd.Signal 方法 不存在(go doc 实测报 no method) 自定义 Cancel 里用 cmd.Process.Signal

实战建议

什么时候值得用 os/exec 而不是引第三方库:包装成熟 CLI(git、kubectl、ffmpeg、openssl)做胶水层时,标准库几乎总是正解——零依赖、行为可审计,构建产物不用背额外供应链。反过来,如果需求是"和某个服务做结构化交互",优先找它的原生 Go 客户端(数据库、消息队列都有),把 CLI 当协议用是脆弱设计:输出格式随版本漂移,编码随环境变脸。判断标准很简单——你是在"执行程序拿退出码",还是在"借程序说协议"。

生产里 CLI 包装器有个值得抄的骨架:CommandContext 起步(ctx 从请求/任务继承),Cancel 保持默认或写成"先 SIGTERM 后 Kill"的升级逻辑,WaitDelay 永远设一个有限值(哪怕只有 10 秒)——它是孤儿进程占管道这条老路的唯一系统性解法。输出默认走 CombinedOutput 或显式分挂 Stdout/Stderr 到日志,只有需要逐行解析协议式输出(git 的 porcelain 格式)才动用 StdoutPipe,并严格守住"读完再 Wait"。错误判定用 errors.As 分三档:*exec.Error(没装)、*ExitError(跑砸,带 stderr 尾巴)、其余(管道/取消),每一档给不同的用户文案和不同的重试策略——“没装"重试一万次也不会装上。

CI 里跑外部命令的三件套是超时、日志、退出码。超时用 CommandContext + WaitDelay 双保险,别让一个卡死的 lint 工具吃掉整条流水线;日志把 stdout/stderr 分开流式转发(StdoutPipe、StderrPipe 各起一个 goroutine 消费,主 goroutine 只管 Wait),比事后翻混合输出好排障;退出码永远透传或映射成构建状态,并且不要把非零退出码咽掉——Output() 的错误丢给 _ 是 CI 胶水层最常见的撒谎方式。测试外部命令的注意点见 ../06-系统与测试/testing.md:exec.LookPath 先行、测不到就 t.Skip,别把"本机没装 jq"变成红灯。

优雅关停的最后一条经验:signal.NotifyContext 解决的是"知道该关了”,解决不了"关得完"。Shutdown 的超时 ctx、在途请求的等待上限、http.Server 自身的 ReadHeaderTimeout(见 ../03-网络编程/net-http.md)要成套配置;如果进程还管着 os/exec 起的子任务,关停路径里同样要取消它们的父 ctx,否则你优雅了 HTTP,留下了满世界的孤儿进程。线上诊断场景(../06-系统与测试/pprof.md)里这套信号骨架也是标配——pprof 的 HTTP 端点同样需要能干净地收摊。