功能说明
调用外部命令时如果没有超时,进程卡住会拖住整个请求;只设置时间而不结束进程,还可能留下孤儿进程持续占用资源。exec.CommandContext 会在上下文取消时结束子进程,配合 context.WithTimeout 就能得到可控的执行上限。
示例把标准输出和标准错误写到同一个缓冲区,出错时可以把完整输出带回给调用方。主程序通过命令行参数把自身再次启动为慢子进程,睡两秒后打印完成,父进程只等待三百毫秒,因此必然超时,便于演示而无需依赖外部命令。
关键代码
RunWithTimeout 先派生带超时的上下文,函数返回前调用 cancel 释放计时器。命令执行后先检查上下文错误是否等于 DeadlineExceeded,如果是就返回自定义超时错误而不是原始的 signal killed,便于日志区分。其他错误会包装退出状态并附带输出内容。子进程分支通过 flag 判断,避免运行时递归启动。
使用注意
默认情况下只结束直接子进程,子进程再派生的进程可能存活;Unix 上可以设置进程组并发送组信号,Windows 行为不同需单独处理。缓冲区会占用内存,输出很大的命令应改为流式写入文件或限制大小。超时时间应覆盖正常最坏耗时,太短会造成误杀。命令名和参数分开传入,不要拼接字符串交给 shell,以免注入。
验证方式
运行后应打印超时错误,提示 timed out after 300ms,输出为空,说明子进程在打印之前就被结束。把超时改为三秒,子进程会睡满两秒并输出 child finished,err 为 nil。传一个不存在的命令名,应返回 failed 错误并包含启动失败的原始信息。
代码示例
package main
import (
"bytes"
"context"
"errors"
"flag"
"fmt"
"os"
"os/exec"
"time"
)
func RunWithTimeout(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
runCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
command := exec.CommandContext(runCtx, name, args...)
var output bytes.Buffer
command.Stdout = &output
command.Stderr = &output
err := command.Run()
if errors.Is(runCtx.Err(), context.DeadlineExceeded) {
return output.String(), fmt.Errorf("%s timed out after %s", name, timeout)
}
if err != nil {
return output.String(), fmt.Errorf("%s failed: %w: %s", name, err, output.String())
}
return output.String(), nil
}
func main() {
child := flag.Bool("child", false, "run as the slow child process")
flag.Parse()
if *child {
time.Sleep(2 * time.Second)
fmt.Println("child finished")
return
}
output, err := RunWithTimeout(context.Background(), 300*time.Millisecond, os.Args[0], "-child")
fmt.Printf("output=%q err=%v\n", output, err)
}