文章目录
- [36 - Go exec 执行命令(重点🔥)](#36 - Go exec 执行命令(重点🔥))
- [什么是 exec?](#什么是 exec?)
- [exec 解决什么问题?](#exec 解决什么问题?)
- [exec 的本质是什么?](#exec 的本质是什么?)
- [exec 的核心结构](#exec 的核心结构)
- 最简单示例
-
- [执行 ls 命令](#执行 ls 命令)
- [Output() 做了什么?](#Output() 做了什么?)
- 小结
- 基础使用
-
- 获取错误输出
- [为什么用 CombinedOutput?](#为什么用 CombinedOutput?)
- 进阶实战
-
- 实时输出命令日志(重点🔥)
- [进阶示例:执行 Shell](#进阶示例:执行 Shell)
- 进阶示例:向命令写入数据
- 常见错误与坑(重点🔥)
-
- [坑一:Output 导致大输出卡死](#坑一:Output 导致大输出卡死)
- [坑二:Start 后忘记 Wait(高危🔥)](#坑二:Start 后忘记 Wait(高危🔥))
- [坑三:Shell 注入(非常危险🔥)](#坑三:Shell 注入(非常危险🔥))
- 底层原理解析(核心🔥)
-
- [exec.Command 到底做了什么?](#exec.Command 到底做了什么?)
- [为什么设计成 Cmd 对象?](#为什么设计成 Cmd 对象?)
- [为什么 Go 不默认走 Shell?](#为什么 Go 不默认走 Shell?)
- [exec 与 Pipe 的关系(重点🔥)](#exec 与 Pipe 的关系(重点🔥))
- 为什么容易死锁?
- exec.CommandContext(重点🔥)
- 对比与扩展
-
- [Run vs Start vs Output](#Run vs Start vs Output)
- [exec vs syscall](#exec vs syscall)
- [exec vs Shell 脚本](#exec vs Shell 脚本)
- 最佳实践
- [stderr 必须处理](#stderr 必须处理)
- 点睛总结
- 思考与升华
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
都会瞬间通透。