今天在写一个Go语言程序时,它包含了一个使用exec.Command
对外部 shell 脚本的调用,这段脚本代码中包含了一个后台任务语句。
结果发生了一个问题:exec.Command
始终不会返回,一直挂起。导致我的Go程序也挂起了。
过程还原
Go代码(a.go),等待sh a.sh
执行结束,并打印输出。
1 2 3 4 5 6 7 8 9 10 11 12 |
|
Bash脚本代码(a.sh),仅一句后台任务语句。
1 2 3 4 |
|
如果此时尝试运行Go代码:go run a.go
,会发现cmd.Output()
始终不会返回。
而如果直接在终端里面执行sh a.sh
,立马就退出了。
所以问题是:这两种执行方式有什么区别呢?
我首先考虑到ping localhost
会一直运行并打印输出,所以导致exec.Command
也不会退出。
于是我换了一个会立即结束的程序:
1 2 3 |
|
改成上面这样后,exec.Command
马上就返回了。
看起来真是上面所说的原因?但是让我费解的是:末尾的&
不是会让进程在后台执行的吗?为什么会影响exec.Command
呢?
问题的原因
在经过在 StackOverflow 上提问,以及查看exec.Command的源代码之后。找到了问题的原因。
首先看一个例子
这个例子直接在终端里面执行一个后台任务:
1 2 3 4 |
|
很显示的可以看到,虽然date
已经被放到后台执行了,但是,它的输出仍然在前台中。
终端是如何把一个后台任务的输出显示在自己的输出中的?
这就是借助管道实现的重定向了。
管道重定向的基本原理
几年前我在 Windows 上实现一个简单的 HTTP 服务器的时候,为了重定向 CGI 应用程序(Lua/PHP等)的输出,我还专门写过这样的一个函数来实现管道重定向的功能,其中就多处涉及到管道的传递与处理。今天再次遇到这个类似的问题,竟然懵圈了!
简而言之,父进程(本文的终端)在创建一个子进程(本文的后台任务)时,为了传递标准输入(stdin)给子进程以及接收子进程的标准输出(stdout)/标准错误(stderr),会先创建三个管道(每个管道都有一个读端和写端),并把它们作为启动参数传递给子进程。
上一节的命令中,终端在启动date
时,把终端自己的标准输出(stdout)和date
的标准输出连接起来,就可以做到把date
的输出显示在自己的标准输出中。
源代码解读
Go的exec
的等待进程退出的代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
|
结合这段代码再回到开始的ping localhost &
语句(它在被sh
执行的脚本中):
其中state, err := c.Process.Wait()
等待的是sh
进程的结束,没问题,sh
很快就退出了
再看这段代码:
1 2 3 4 5 |
|
这段代码会等待所有的 goroutines 退出,这些 goroutines 在干啥呢?可以再看下面这段代码:
1 2 3 4 5 6 7 8 9 |
|
也就是说,开了一个 goroutine 去把数据从子进程那里把数据读回来。
结论是什么?
exec.Command()
会等待直接启动的那个进程的退出,并等待所有的连接标准输入/标准输出/标准错误管道全部关闭之后才会返回。只要这三者之一任意一个没有被关闭(而不是写没写/读没读),等待就会持续。
标准输入:由于本文中的ping
是后台任务,sh
根本就没把标准输入传递给它,sh
退出时就把exec.Command
的标准输入管道(的读端)给关闭了。所以这儿的goroutine早就结束了。
标准输出/标准错误:仍然和ping
连接在一起呢!
所以应该怎么写?
以后台任务执行时,同时重定向后台任务的标准输出(stdout)及标准错误(stderr)。
手动重定向后,sh
就不会再把来自exec.Command
的管道和后台任务的标准输出/标准错误连接起来了。
1 2 3 4 5 |
|
2>&1
会把错误输出到标准输出,如果不需要的话:2>/dev/null
。
注意>
两边的空格,有些可以省,有些不可以。
不随终端退出
通常,终端在登出的时候,会发送SIGHUP
信号给所有的后台进程,目的是通知它们需要退出了。
nohup
命令会设置忽略这个信号,终端退出时它不会退出。所以,由它启动的子进程更不会退出了。
1 2 3 4 5 |
|