36 - Go exec 执行命令

文章目录


36 - Go exec 执行命令(重点🔥)

在 Go 的日常开发中:

  • 调用 Shell 脚本
  • 执行 Linux 命令
  • 调用 ffmpeg / git / kubectl 等工具
  • 实现自动化运维
  • 管理子进程

这些场景几乎都离不开:

go 复制代码
os/exec

很多人以为:

exec 就是"执行一下命令"。

但实际上:

exec 本质上是 Go 对"进程控制"的封装。

它背后涉及:

  • 进程创建
  • 标准输入输出
  • Pipe 管道
  • Shell 行为
  • 阻塞与异步
  • 僵尸进程
  • IO 死锁
  • 上下文取消

这些才是真正的重点。


什么是 exec?

Go 中执行命令主要使用:

go 复制代码
os/exec

核心入口:

go 复制代码
exec.Command() // 创建命令

例如:

go 复制代码
exec.Command("ls", "-l") // 创建命令

本质上:

Go 帮你创建一个子进程,并管理其生命周期。

它不是"调用 shell"。

这一点非常重要。


exec 解决什么问题?

Go 程序本身只能执行 Go 代码。

但现实中:

  • 系统命令
  • Python 脚本
  • Shell 脚本
  • 运维工具
  • 第三方二进制

都已经存在。

所以:

exec 的核心价值是:让 Go 成为"系统调度器"。

例如:

text 复制代码
Go -> 调用 ffmpeg -> 转码视频
Go -> 调用 git -> 自动发布
Go -> 调用 kubectl -> 部署服务
Go -> 调用 bash -> 执行运维脚本

exec 的本质是什么?

从操作系统角度看:

text 复制代码
父进程(Go)
    ↓ fork
子进程
    ↓ execve
加载新程序

Go 的 exec.Command()

本质是在帮你完成:

text 复制代码
fork + exec

Linux 底层最终调用:

text 复制代码
execve()

Windows 则对应:

text 复制代码
CreateProcess()

exec 的核心结构

Go 中最核心的是:

go 复制代码
type Cmd struct

源码中:

go 复制代码
type Cmd struct {
	Path string   // 命令路径
	Args []string // 命令参数
	Env  []string // 环境变量
	Dir  string   // 工作目录

	Stdin  io.Reader // 标准输入
	Stdout io.Writer // 标准输出
	Stderr io.Writer // 标准错误
}

它描述的是:

"一个待启动的进程"。

注意:

text 复制代码
Command() 只是创建对象
Start()    才真正启动进程

很多人第一次都会误解。


最简单示例

执行 ls 命令

go 复制代码
package main

import (
	"fmt"
	"os/exec"
)

func main() {
	// 创建命令对象
	cmd := exec.Command("ls", "-l") // 这里执行的是ls命令,列出当前目录下的文件和文件夹

	// 执行命令并获取输出
	output, err := cmd.Output() // 这里会将命令的输出结果转换为字节切片
	if err != nil {
		fmt.Println("执行失败:", err)
		return
	}

	// 输出结果
	fmt.Println(string(output)) // 将字节切片转换为字符串并输出
}

运行结果:

text 复制代码
total 8
-rw-r--r-- 1 root root 123 main.go

Output() 做了什么?

很多人以为:

go 复制代码
cmd.Output()

只是"获取输出"。

实际上:

它内部做了:

text 复制代码
Start()
Wait()
读取 stdout

等价于:

go 复制代码
cmd.Start() // 启动命令
cmd.Wait()  // 等待命令执行完成

所以:

Output() 是同步阻塞的。


小结

exec 的核心不是命令。

而是:

text 复制代码
Go 对子进程生命周期的控制。

这是后面所有高级玩法的基础。


基础使用

获取错误输出

很多命令:

错误信息在 stderr。

例如:

go 复制代码
package main

import (
	"fmt"
	"os/exec"
)

func main() {
	cmd := exec.Command("ls", "/notefound") // 这里故意写错路径
	output, err := cmd.CombinedOutput()     // 执行命令并获取输出和错误信息
	fmt.Println(string(output))             // 打印输出信息
	fmt.Println(err)                        // 打印错误信息
}

输出:

text 复制代码
ls: cannot access '/notfound': No such file or directory
exit status 2

为什么用 CombinedOutput?

因为:

go 复制代码
Output()

只读取:

text 复制代码
stdout

而:

text 复制代码
stderr 会丢失

真实开发中:

建议优先:

go 复制代码
CombinedOutput() // 同时读取 stdout 和 stderr

尤其是:

  • shell 调试
  • 运维工具
  • kubectl
  • docker

否则排错非常痛苦。


进阶实战

实时输出命令日志(重点🔥)

很多人这样写:

go 复制代码
output, _ := cmd.Output()

问题:

text 复制代码
命令结束前,看不到日志。

例如:

  • docker pull
  • ffmpeg
  • rsync
  • kubectl apply

可能执行几分钟。

这时候:

必须实时读取 stdout。


正确写法

go 复制代码
package main

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

func main() {

	cmd := exec.Command("ping", "127.0.0.1") // 创建命令

	// 获取标准输出管道
	stdout, err := cmd.StdoutPipe()
	if err != nil {
		panic(err)
	}

	// 启动进程
	if err := cmd.Start(); err != nil {
		panic(err)
	}

	// 实时读取输出
	scanner := bufio.NewScanner(stdout)
	// 实时输出
	for scanner.Scan() {
		fmt.Println(scanner.Text())
	}

	// 等待进程结束
	cmd.Wait()
}

执行流程图

text 复制代码
Go程序
   ↓
创建 Pipe
   ↓
Start()
   ↓
子进程写 stdout
   ↓
Go 实时读取 Pipe

为什么必须先 Start?

因为:

text 复制代码
Pipe 是进程间通信。

只有子进程启动:

Pipe 才真正有数据。

所以:

go 复制代码
StdoutPipe()
→ Start()
→ 读取
→ Wait()

顺序不能错。


小结

实时日志的本质:

text 复制代码
不是 exec。
而是 Pipe 管道通信。

进阶示例:执行 Shell

很多人第一次会这样:

go 复制代码
exec.Command("cd", "/tmp")

直接报错。

因为:

text 复制代码
cd 不是可执行程序。
它是 shell 内建命令。

正确方式

go 复制代码
package main

import (
	"fmt"
	"os/exec"
)

func main() {

	cmd := exec.Command(
		"bash",
		"-c",
		"cd /tmp && ls",
	)

	output, err := cmd.CombinedOutput()
	if err != nil {
		fmt.Println(err)
		return
	}

	fmt.Println(string(output))
}

为什么必须 bash -c?

因为:

go 复制代码
exec.Command()

默认:

text 复制代码
不经过 shell。 直接执行。

这是一个极其重要的设计。

它避免了:

  • shell 注入
  • 转义问题
  • 环境不一致

所以:

go 复制代码
exec.Command("ls *.log")

实际上不会展开:

text 复制代码
*.log

因为:

text 复制代码
通配符展开属于 shell 行为。

思考点

这也是 Go 比 Python subprocess 更"安全"的地方之一。

Go 默认:

text 复制代码
拒绝隐式 shell。

进阶示例:向命令写入数据

例如:

text 复制代码
Go -> grep

示例

go 复制代码
package main

import (
	"fmt"
	"os/exec"
)

func main() {

	cmd := exec.Command("grep", "hello")

	// 获取 stdin
	stdin, err := cmd.StdinPipe()
	if err != nil {
		panic(err)
	}

	// 获取输出
	output, err := cmd.StdoutPipe()
	if err != nil {
		panic(err)
	}

	// 启动进程
	if err := cmd.Start(); err != nil {
		panic(err)
	}

	// 写入数据
	stdin.Write([]byte("hello world\n"))
	stdin.Write([]byte("golang\n"))

	// 必须关闭
	stdin.Close()

	buf := make([]byte, 1024)

	n, _ := output.Read(buf)

	fmt.Println(string(buf[:n]))

	cmd.Wait()
}

输出:

text 复制代码
hello world

为什么必须 Close?

因为:

text 复制代码
grep 一直等待 EOF。

如果不关闭:

text 复制代码
子进程永远认为还有输入。

程序卡死。

这是经典坑。


常见错误与坑(重点🔥)

坑一:Output 导致大输出卡死

很多人:

go 复制代码
cmd.Output()

然后执行:

text 复制代码
docker logs
ffmpeg
find /

结果:

text 复制代码
程序卡住

错误代码

go 复制代码
package main

import (
	"fmt"
	"os/exec"
)

func main() {
	cmd := exec.Command("yes") // yes命令会一直输出y 直到被杀死

	output, _ := cmd.Output() // 执行命令并获取输出

	fmt.Println(string(output)) // 打印输出
}

输出:

text 复制代码
signal: killed

为什么会错?

因为:

text 复制代码
yes 命令无限输出。

而:

go 复制代码
Output()

会:

text 复制代码
持续缓存 stdout 到内存。

最终:

  • 内存暴涨
  • Pipe 堵塞
  • 进程阻塞

底层原因

Pipe 是有限缓冲区:

Linux 默认:

text 复制代码
64KB

如果:

text 复制代码
子进程写满 Pipe
父进程不读取

子进程会阻塞。

这就是:

text 复制代码
经典 Pipe 死锁。

正确写法

实时消费:

go 复制代码
package main

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

func main() {
	start := time.Now()
	cmd := exec.Command("yes") // yes命令会一直输出 直到被杀死

	output, _ := cmd.StdoutPipe()       // 获取输出 管道 的引用 // 这里的输出是空的 因为我们没有读取 它 所以它会一直阻塞
	if err := cmd.Start(); err != nil { // 启动命令
		fmt.Println(err) // 启动命令
	}
	fmt.Println(output) // 输出管道的引用 但不是管道的内容 而是管道的引用
	fmt.Println(time.Since(start))
}

边读边处理。

而不是:

text 复制代码
一次性读取全部输出。

坑二:Start 后忘记 Wait(高危🔥)

很多人:

go 复制代码
cmd.Start()

然后结束。


错误代码

go 复制代码
package main

import (
	"fmt"
	"os/exec"
)

func main() {
	cmd := exec.Command("sleep", "10") // 创建一个sleep命令

	cmd.Start() // 执行命令

	fmt.Println("done") // 打印输出
}

为什么危险?

因为:

text 复制代码
子进程退出后
父进程没有回收。

Linux 中会变成:

text 复制代码
僵尸进程(Zombie)

底层原理

OS 需要父进程:

text 复制代码
waitpid()

来回收:

  • PID
  • exit code
  • process table

而:

go 复制代码
cmd.Wait() // 等待命令执行完成

本质上就是:

text 复制代码
waitpid() // 等待进程退出 并回收资源

正确写法

go 复制代码
package main

import (
	"fmt"
	"os/exec"
)

func main() {
	cmd := exec.Command("sleep", "10") // 创建一个sleep命令

	cmd.Start()       // 执行命令
	err := cmd.Wait() // 等待命令执行完成
	if err != nil {
		panic(err)
	}

	fmt.Println("done") // 打印输出
}

或者:

go 复制代码
cmd.Run()

因为:

text 复制代码
Run = Start + Wait

小结

这是很多线上系统:

text 复制代码
僵尸进程爆炸

的根源。

尤其:

  • 运维系统
  • CI/CD
  • agent

特别容易踩坑。


坑三:Shell 注入(非常危险🔥)


错误代码

go 复制代码
userInput := "test; mkdir /tmp/evil"

cmd := exec.Command(
	"bash",
	"-c",
	"grep "+userInput,
)

为什么危险?

因为:

text 复制代码
bash -c 会解析 shell 特殊字符。

用户输入:

text 复制代码
;
&&
|
$
`

都可能执行恶意命令。


正确写法

不要拼接 shell。

直接传参数:

go 复制代码
cmd := exec.Command(
	"grep",
	userInput,
)

因为:

go 复制代码
exec.Command()

不会经过 shell。

参数不会被解析。

这是最安全的方式。


底层原理解析(核心🔥)

exec.Command 到底做了什么?

调用:

go 复制代码
exec.Command("ls", "-l")

实际流程:

text 复制代码
构建 Cmd
    ↓
创建 Pipe
    ↓
fork 子进程
    ↓
dup2 重定向 stdin/stdout/stderr
    ↓
execve 执行程序
    ↓
父进程 Wait

为什么设计成 Cmd 对象?

因为:

text 复制代码
进程启动前
需要配置大量参数。

例如:

  • 环境变量
  • 工作目录
  • stdin
  • stdout
  • stderr
  • ExtraFiles
  • SysProcAttr

所以:

text 复制代码
Cmd 是"进程配置对象"。

不是简单函数。


为什么 Go 不默认走 Shell?

因为 Shell:

虽然方便。

但有问题:

  • shell 注入
  • 转义复杂
  • 跨平台不一致
  • 性能损耗

所以:

Go 默认:

text 复制代码
直接 execve。

这是典型:

text 复制代码
安全优先设计。

exec 与 Pipe 的关系(重点🔥)

很多人以为:

text 复制代码
exec 是执行命令。

实际上:

exec 真正的核心是"进程间通信"。

因为:

text 复制代码
stdout
stderr
stdin

全部都是:

text 复制代码
Pipe 文件描述符。

例如:

go 复制代码
cmd.StdoutPipe()

本质:

text 复制代码
os.Pipe()

为什么容易死锁?

因为:

Pipe 不是无限的。

当:

text 复制代码
子进程疯狂写
父进程不读

Pipe 满:

text 复制代码
write 阻塞

整个程序卡死。

这就是:

text 复制代码
exec 最经典问题。

exec.CommandContext(重点🔥)

生产环境必须掌握。


超时控制

go 复制代码
package main

import (
	"context"
	"fmt"
	"os/exec"
	"time"
)

func main() {
	// 超时时间设置为3秒
	ctx, cancel := context.WithTimeout(
		context.Background(),
		3*time.Second,
	)
	// 延迟取消,确保主goroutine退出时能够释放资源
	defer cancel()
	// 使用context.Background()创建的上下文,
	cmd := exec.CommandContext(
		ctx,
		"sleep",
		"10", // 这里的10秒是错误的,只是为了演示
	)
	// 执行命令,并等待其完成
	err := cmd.Run()

	fmt.Println(err)
}

输出:

text 复制代码
signal: killed

为什么重要?

因为:

很多命令可能:

  • 死循环
  • 卡网络
  • 阻塞 IO
  • 永不退出

所以:

exec 一定要有超时控制。

否则:

text 复制代码
goroutine 泄漏
进程泄漏
资源耗尽

对比与扩展

Run vs Start vs Output

方法 是否等待 是否获取输出 适合场景
Run 简单执行
Output stdout 获取结果
CombinedOutput stdout+stderr 调试推荐
Start 异步执行

exec vs syscall

对比 exec syscall
易用性 极低
抽象级别 高级封装 OS底层
跨平台
适合业务
适合内核级控制

exec vs Shell 脚本

对比 exec Shell
并发控制
错误处理 一般
类型系统
可维护性
复杂调度 一般

最佳实践

优先使用参数模式

推荐:

go 复制代码
exec.Command("grep", "hello") // 参数模式

不要:

go 复制代码
bash -c "grep hello" // 字符串模式 不推荐

必须设置超时

推荐:

go 复制代码
exec.CommandContext() // 必须超时

不要:

text 复制代码
无限等待子进程。

大输出必须流式读取

推荐:

go 复制代码
StdoutPipe() // 流式读取

不要:

go 复制代码
Output() // 一次性读取,不适合大输出

读取超大日志。


Start 必须配 Wait

否则:

text 复制代码
僵尸进程。

stderr 必须处理

否则:

text 复制代码
很多错误根本看不到。

点睛总结

exec 的本质:

text 复制代码
不是"执行命令"。

而是:
Go 对操作系统进程模型的封装。

真正难的:

从来不是:

text 复制代码
如何执行命令。

而是:

text 复制代码
如何正确管理:
进程
Pipe
IO
生命周期
超时
资源回收

这也是为什么:

很多人会 exec。

但真正能写稳定进程调度系统的人并不多。


思考与升华

如果让你自己实现一个"迷你 exec":

你会发现核心只需要:

text 复制代码
fork
execve
pipe
dup2
waitpid

伪代码:

text 复制代码
创建 pipe
fork 子进程

子进程:
    dup2(pipe)
    execve()

父进程:
    read(pipe)
    waitpid()

你会突然发现:

exec 本质上只是:

"进程 + 文件描述符"的组合。

而 Linux 世界里:

text 复制代码
一切皆文件。

这也是:

text 复制代码
stdin/stdout/stderr
都能被重定向
都能 Pipe
都能网络化

的根本原因。

理解这一点。

你对:

  • shell
  • docker
  • kubernetes
  • ssh
  • systemd

都会瞬间通透。

相关推荐
寻道码路2 小时前
LangChain4j Java AI 应用开发实战(二):大模型参数调优实战:Temperature、TopP、MaxTokens 深度解析
java·开发语言·人工智能·aigc
lolo大魔王2 小时前
Go 语言 HTTP 协议与 RESTful API 实训全解(理论 + 实战 + 规范)
http·golang·restful
吃好睡好便好2 小时前
在Matlab中绘制饼状图
开发语言·学习·matlab·3d·信息可视化
weixin_6682 小时前
DGX-spark上成功部署Voxtral-Mini-4B-Realtime-2602支持realtime ws接口
开发语言·python
一只小逸白3 小时前
LeetCode Go 常用函数速查表
linux·leetcode·golang
上弦月-编程3 小时前
Java类与对象:编程核心解密
java·开发语言·jvm
大大杰哥3 小时前
从 Volatile 到 ThreadLocal:Java 线程安全机制备忘
java·开发语言·jvm
崇山峻岭之间3 小时前
matlab绘制复杂曲线
开发语言·matlab
skywalk81633 小时前
中文编程语言的开创性语法,言律:一门以汉语为思维内核的原生中文编程语言
开发语言·编程