等待带有后台任务的Shell脚本的外部进程可能会被挂起

陪她去流浪 桃子 2019年08月26日 阅读次数:3804

今天在写一个Go语言程序时,它包含了一个使用exec.Command对外部 shell 脚本的调用,这段脚本代码中包含了一个后台任务语句。 结果发生了一个问题:exec.Command始终不会返回,一直挂起。导致我的Go程序也挂起了。

过程还原

Go代码(a.go),等待sh a.sh执行结束,并打印输出。

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),仅一句后台任务语句。

#!/bin/bash

# 放到后台去执行
ping localhost &

如果此时尝试运行Go代码:go run a.go,会发现cmd.Output()始终不会返回。 而如果直接在终端里面执行sh a.sh,立马就退出了。 所以问题是:这两种执行方式有什么区别呢?

我首先考虑到ping localhost会一直运行并打印输出,所以导致exec.Command也不会退出。 于是我换了一个会立即结束的程序:

#!/bin/bash

ifconfig &

改成上面这样后,exec.Command马上就返回了。

看起来真是上面所说的原因?但是让我费解的是:末尾的&不是会让进程在后台执行的吗?为什么会影响exec.Command呢?

问题的原因

在经过在 StackOverflow 上提问,以及查看exec.Command的源代码之后。找到了问题的原因。

首先看一个例子

这个例子直接在终端里面执行一个后台任务:

$ date &
[1] 36756
Mon Aug 25 22:24:31 CST 2019
[1]+  Done                    date

很显示的可以看到,虽然date已经被放到后台执行了,但是,它的输出仍然在前台中。

终端是如何把一个后台任务的输出显示在自己的输出中的?

这就是借助管道实现的重定向了。

管道重定向的基本原理

几年前我在 Windows 上实现一个简单的 HTTP 服务器的时候,为了重定向 CGI 应用程序(Lua/PHP等)的输出,我还专门写过这样的一个函数来实现管道重定向的功能,其中就多处涉及到管道的传递与处理。今天再次遇到这个类似的问题,竟然懵圈了!

简而言之,父进程(本文的终端)在创建一个子进程(本文的后台任务)时,为了传递标准输入(stdin)给子进程以及接收子进程的标准输出(stdout)/标准错误(stderr),会先创建三个管道(每个管道都有一个读端和写端),并把它们作为启动参数传递给子进程。

上一节的命令中,终端在启动date时,把终端自己的标准输出(stdout)和date的标准输出连接起来,就可以做到把date的输出显示在自己的标准输出中。

源代码解读

Go的exec等待进程退出的代码

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很快就退出了

再看这段代码:

for range c.goroutine {
	if err := <-c.errch; err != nil && copyError == nil {
		copyError = err
	}
}

这段代码会等待所有的 goroutines 退出,这些 goroutines 在干啥呢?可以再看下面这段代码:

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早就结束了。

标准输出/标准错误:仍然和ping连接在一起呢!

所以应该怎么写?

以后台任务执行时,同时重定向后台任务的标准输出(stdout)及标准错误(stderr)。 手动重定向后,sh就不会再把来自exec.Command的管道和后台任务的标准输出/标准错误连接起来了。

# 如果输出无用,直接丢弃
$ ping localhost > /dev/null 2>&1 &

# 如果要保留输出,可以输出到文件
$ ping localhost > ping.out 2>&1 &

2>&1会把错误输出到标准输出,如果不需要的话:2>/dev/null

注意>两边的空格,有些可以省,有些不可以。

不随终端退出

通常,终端在登出的时候,会发送SIGHUP信号给所有的后台进程,目的是通知它们需要退出了。

nohup命令会设置忽略这个信号,终端退出时它不会退出。所以,由它启动的子进程更不会退出了。

# 如果输出无用,直接丢弃
$ nohup ping localhost > /dev/null 2>&1 &

# 如果要保留输出,可以输出到文件
$ nohup ping localhost > ping.out 2>&1 &

标签:Bash · Go · 重定向