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

都会瞬间通透。

相关推荐
北i28 分钟前
Dubbo 默认超时重试:把下游打雪崩的隐形放大器
开发语言·qt·dubbo
10mAh39 分钟前
【Java】ArrayList 遍历删除为什么报错?三种写法与漏删复现
java·开发语言·windows
wewowilling1 小时前
AI大模型开发--01Python基础(无废话)
开发语言·python·函数
小羊没烦恼!1 小时前
OS X 上使用.NET开发应用程序
开发语言·前端·数据库·人工智能·c#·策略模式
棉猴1 小时前
玩游戏学Python5-函数
开发语言·python·函数·玩游戏·游戏编程
谢亮_vipxieliang2 小时前
JavaScript this 指向完全指南:调用位置决定一切
开发语言·javascript·ecmascript
denlaku2 小时前
JDK 13 新特性详解
java·开发语言
念越2 小时前
题库、练习与错题本代码阅读
开发语言·数据结构
l1t2 小时前
几种数据库软件count(distinct)和count()from group by子查询性能测试
开发语言·数据库
熊猫钓鱼>_>2 小时前
大学图论第 6 讲:网络流与二分图匹配——对偶性让瓶颈自己现形
开发语言·算法·面试·职场和发展·图论·校招·面经