# Go 执行命令行

Go 语言中执行外部命令主要的方法是使用包 os/exec

此包的详细文档见 exec package - os/exec - pkg.go.dev (opens new window),这里只介绍几种常用操作。

执行命令也分几种情况:

  1. 仅执行命令;
  2. 执行命令,获取结果,不区分 stdout 和 stderr;
  3. 执行命令,获取结果,区分 stdout 和 stderr。

另外,默认的命令执行是在 go 进程当前的目录下执行的,我们可能还需要通过 cmd.Dir 指定命令执行目录。

# 1. 仅执行命令

执行命令,首先要拼接一下命令和参数,然后运行命令。

  • 拼接命令与参数使用 exec.Command(),其会返回一个 *Cmd
func Command(name string, arg ...string) *Cmd

执行命令使用 *Cmd 中的 Run() 方法,Run() 返回的只有 error。

func (c *Cmd) Run() error

我们直接看代码:

func ExecCommand(name string, args ...string) {
    cmd := exec.Command(name, args...) // 拼接参数与命令
    if err := cmd.Run(); err != nil {  // 执行命令,若命令出错则打印错误到 stderr
        log.Println(err)
    }
}

func main() {
    ExecCommand("ls", "-l")
}

执行代码,没有任何输出。

上面的代码中,我们执行了命令 ls -l,但是没有得到任何东西。

# 2. 不区分 stdout 和 stderr

要组合 stdout 和 stderr 输出,,Cmd 中有方法:

func (c *Cmd) CombinedOutput() ([]byte, error)

用这个方法来执行命令(即这个方法是已有 Run() 方法的作用的,无需再执行 Run())。

我们修改上述代码:

func ExecCommand(name string, args ...string) {
    cmd := exec.Command(name, args...) // 拼接参数与命令

    var output []byte
    var err error

    if output, err = cmd.CombinedOutput(); err != nil {
        log.Println(err)
    }
    fmt.Print(string(output)) // output 是 []byte 类型,这里最好转换成 string
}

func main() {
    ExecCommand("ls", "-l")
}

我们得到了 ls -l 这条命令的输出.

# 3. 区分 stdout 和 stderr

区分 stdout 和 stderr,可以分别获取 StdoutPipeStderrPipe,然后分别对其进行读取。可以参考一下实现方式。

func main() {

	cmd := exec.Command("ls", "-l")

	wg := sync.WaitGroup{}
	wg.Add(2)

	stdoutPipe, err := cmd.StdoutPipe()
	if err != nil {
		panic(err)
	}
	stderrPipe, err := cmd.StderrPipe()
	if err != nil {
		panic(err)
	}

	go func() {
		defer wg.Done()
		getOutput(stdoutPipe)
	}()

	go func() {
		defer wg.Done()
		getOutput(stderrPipe)
	}()

	cmd.Run()

	wg.Wait()
}

func getOutput(reader io.Reader) {
	r := bufio.NewReader(reader)
	for true {
		line, _, err := r.ReadLine()
		if err != nil {
			if err == io.EOF {
				break
			}
			fmt.Println(err)
			return
		}
		fmt.Println(string(line))
	}
}

# 4. 如何避免 Go 命令行执行产生孤儿进程

当我们使用 Go 程序执行其他程序的时候,如果其他程序也开启了其他进程,那么在 kill 的时候可能会把这些进程变成孤儿进程,一直执行并滞留在内存中。当然,如果我们程序非法退出,或者被 kill 调用,也会导致我们执行的进程变成孤儿进程,那么为了解决这个问题,可以参考一下思路:

  • 给要执行的程序创建新的进程组,并调用 syscall.Kill,传递负值 pid 来关闭这个进程组中所有的进程。

参考下面代码:

func main() {
  cmd := exec.Command("/bin/bash", "-c", "watch top > top.log")
  // 创建新的进程组,进程组 ID 就是当前主进程 ID
  cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid:true}
  // 杀掉该进程组的所有进程,注意这里的 pid 要传负值,才会杀掉该进程组的所有进程
  defer syscall.Kill{-cmd.Process.Pid, sysycall.SIGKILL}
  ...
}

僵尸进程 vs 孤儿进程

概念:

  • 孤儿进程:父进程结束了,而它的一个或多个子进程还在运行,那么这些子进程就成为孤儿进程。子进程的资源由 init 进程回收(PPID=1)。
  • 僵尸进程:子进程退出了,但是父进程没有用 wait 或 waitpid 去获取子进程的状态信息,那么子进程的进程描述符(包括进程号 PID,退出状态 the termination status of the process,运行时间 the amount of CPU time taken by the process 等)仍然保存在系统中,这种进程称为僵尸进程。

危害:

  • 孤儿进程是没有父进程的进程,它由 init 进程循环的 wait() 回收资源,init 进程充当父进程。因此孤儿进程并没有什么危害。

  • unix 提供了一种机制保证父进程知道子进程结束时的状态信息。

    这种机制是:在每个进程退出的时候,内核会释放所有的资源,包括打开的文件,占用的内存等。但是仍保留一部分信息(进程号 PID,退出状态,运行时间等)。直到父进程通过 wait 或 waitpid 来取时才释放。

    但是这样就会产生问题:如果父进程不调用 wait 或 waitpid 的话,那么保留的信息就不会被释放,其进程号就会被一直占用,但是系统所能使用的进程号是有限的,如果大量产生僵死进程,将因没有可用的进程号而导致系统无法产生新的进程,这就是僵尸进程的危害。

解决:僵尸进程

  • kill 杀死元凶父进程(一般不用)。

  • 父进程用 wait 或者 waitpid 去回收资源(方案不好):

    父进程通过 wait 或 waitpid 等函数去等待子进程结束,但是不好,会导致父进程一直等待被挂起,相当于一个进程在干活,没有起到多进程的作用。

  • 信号机制,在处理函数中调用 wait 回收资源(推荐):

    通过信号机制,子进程退出时向父进程发送 SIGCHLD 信号,父进程调用 signal(SIGCHLD,sig_child) 去处理 SIGCHLD 信号,在信号处理函数 sig_child() 中调用 wait 进行处理僵尸进程。什么时候得到子进程信号,什么时候进行信号处理,父进程可以继续干其他活,不用去阻塞等待。

上次更新: 9/19/2022, 11:49:47 PM