今天在写一个Go语言程序时,它包含了一个使用`exec.Command`对外部 shell 脚本的调用,这段脚本代码中包含了一个后台任务语句。 结果发生了一个问题:`exec.Command`始终不会返回,一直挂起。导致我的Go程序也挂起了。 ## 过程还原 Go代码(a.go),等待`sh a.sh`执行结束,并打印输出。 ```go package main import ( "fmt" "os/exec" ) func main() { cmd := exec.Command("sh", "a.sh") out, err := cmd.Output() fmt.Println(string(out), err) } ``` Bash脚本代码(a.sh),仅一句后台任务语句。 ```bash #!/bin/bash # 放到后台去执行 ping localhost & ``` 如果此时尝试运行Go代码:`go run a.go`,会发现`cmd.Output()`始终不会返回。 而如果直接在终端里面执行`sh a.sh`,立马就退出了。 所以问题是:这两种执行方式有什么区别呢? 我首先考虑到`ping localhost`会一直运行并打印输出,所以导致`exec.Command`也不会退出。 于是我换了一个会立即结束的程序: ```bash #!/bin/bash ifconfig & ``` 改成上面这样后,`exec.Command`马上就返回了。 看起来真是上面所说的原因?但是让我费解的是:末尾的`&`不是会**让进程在后台执行**的吗?为什么会影响`exec.Command`呢? ## 问题的原因 在经过[在 StackOverflow 上提问](https://stackoverflow.com/q/57643867/3628322),以及查看[exec.Command](https://github.com/golang/go/blob/1a7c15fa6d5ce2d78d0f9f5050ee9dd1e29485df/src/os/exec/exec.go#L258)的源代码之后。找到了问题的原因。 ### 首先看一个例子 这个例子直接在终端里面执行一个后台任务: ```bash $ date & [1] 36756 Mon Aug 25 22:24:31 CST 2019 [1]+ Done date ``` 很显示的可以看到,虽然`date`已经被放到后台执行了,但是,它的输出仍然在前台中。 **终端是如何把一个后台任务的输出显示在自己的输出中的?** 这就是借助管道实现的重定向了。 ### 管道重定向的基本原理 几年前我在 Windows 上实现一个简单的 HTTP 服务器的时候,为了重定向 CGI 应用程序(Lua/PHP等)的输出,我还专门写过[这样的一个函数来实现管道重定向](https://github.com/movsb/taoweb/blob/a56153c140db0225b31fd8f8c99721ffcc17e8c0/src/http_handler.cpp#L102-L226)的功能,其中就多处涉及到管道的传递与处理。今天再次遇到这个类似的问题,竟然懵圈了! **简而言之,父进程(本文的终端)在创建一个子进程(本文的后台任务)时,为了传递标准输入(stdin)给子进程以及接收子进程的标准输出(stdout)/标准错误(stderr),会先创建三个管道(每个管道都有一个读端和写端),并把它们作为启动参数传递给子进程。** 上一节的命令中,终端在启动`date`时,把终端自己的标准输出(stdout)和`date`的标准输出连接起来,就可以做到把`date`的输出显示在自己的标准输出中。 ### 源代码解读 Go的`exec`的[等待进程退出的代码](https://github.com/golang/go/blob/1a7c15fa6d5ce2d78d0f9f5050ee9dd1e29485df/src/os/exec/exec.go#L492-L523): ```go func (c *Cmd) Wait() error { // ... state, err := c.Process.Wait() if c.waitDone != nil { close(c.waitDone) } c.ProcessState = state var copyError error for range c.goroutine { if err := <-c.errch; err != nil && copyError == nil { copyError = err } } } ``` 结合这段代码再回到开始的`ping localhost &`语句(它在被`sh`执行的脚本中): 其中`state, err := c.Process.Wait()`等待的是`sh`进程的结束,没问题,`sh`很快就退出了 再看这段代码: ```go for range c.goroutine { if err := <-c.errch; err != nil && copyError == nil { copyError = err } } ``` 这段代码会等待所有的 goroutines 退出,这些 goroutines 在干啥呢?可以再看下面这段代码: ```go func (c *Cmd) writerDescriptor(w io.Writer) (f *os.File, err error) { // ... c.goroutine = append(c.goroutine, func() error { _, err := io.Copy(w, pr) pr.Close() // in case io.Copy stopped due to write error return err }) return pw, nil } ``` 也就是说,开了一个 goroutine 去把数据从子进程那里把数据读回来。 ### 结论是什么? **`exec.Command()`会等待直接启动的那个进程的退出,并等待所有的连接标准输入/标准输出/标准错误管道全部关闭之后才会返回。只要这三者之一任意一个没有被关闭(而不是写没写/读没读),等待就会持续。** 标准输入:由于本文中的`ping`是后台任务,`sh`根本就没把标准输入传递给它,`sh`退出时就把`exec.Command`的标准输入管道(的读端)给关闭了。所以[这儿的goroutine](https://github.com/golang/go/blob/1a7c15fa6d5ce2d78d0f9f5050ee9dd1e29485df/src/os/exec/exec.go#L266)早就结束了。 标准输出/标准错误:仍然和`ping`连接在一起呢! ## 所以应该怎么写? **以后台任务执行时,同时重定向后台任务的标准输出(stdout)及标准错误(stderr)。** **手动重定向后,`sh`就不会再把来自`exec.Command`的管道和后台任务的标准输出/标准错误连接起来了。** ```bash # 如果输出无用,直接丢弃 $ ping localhost > /dev/null 2>&1 & # 如果要保留输出,可以输出到文件 $ ping localhost > ping.out 2>&1 & ``` `2>&1`会把错误输出到标准输出,如果不需要的话:`2>/dev/null`。 注意`>`两边的空格,有些可以省,有些不可以。 ## 不随终端退出 通常,终端在登出的时候,会发送`SIGHUP`信号给所有的后台进程,目的是通知它们需要退出了。 `nohup`命令会设置忽略这个信号,终端退出时它不会退出。所以,由它启动的子进程更不会退出了。 ```bash # 如果输出无用,直接丢弃 $ nohup ping localhost > /dev/null 2>&1 & # 如果要保留输出,可以输出到文件 $ nohup ping localhost > ping.out 2>&1 & ```