Go os.exec

golang基础的执行命令操作如下

func TestSingleCommand(t *testing.T) {
	stdout, err := exec.Command("uname", "-a").CombinedOutput()
	if err != nil {
		t.Error(err)
		return
	}
	t.Log(stdout)
}
func TestSingleTimeoutCommand(t *testing.T) {
	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()

	stdout, err := exec.CommandContext(ctx, "ping", "-c 2", "-i 1", "www.baidu.com").CombinedOutput()
	if err != nil {
		t.Error(err)
		return
	}
	t.Log(string(stdout))
}
// can not get stdout
//
// === RUN   TestSingleTimeoutCommand
//    exec_test.go:27: signal: killed
func TestLongTimeoutCommand(t *testing.T) {
	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()

	stdout, err := exec.CommandContext(ctx, "ping", "www.baidu.com").CombinedOutput()
	if err != nil {
		t.Error(err)
		return
	}
	t.Log(stdout)
}

注意,这样的操作,进行会被kill掉,无法获取stdout信息

基于上面的代码我们做一下改造,把ping www.baidu.com放在/tmp/a.sh当中,然后使用/bin/bash去执行

// can not cancel when timeout
// cat /tmp/a.sh
// 		ping www.baidu.com
//
// === RUN   TestSingleTimeoutCommand
//    exec_test.go:27: signal: killed
func TestTimeoutCancelFailureCommand(t *testing.T) {
	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()

	stdout, err := exec.CommandContext(ctx, "/bin/bash", "/tmp/a.sh").CombinedOutput()
	if err != nil {
		t.Error(err)
		return
	}
	t.Log(stdout)
}

这个原因是为什么呢?两个有什么区别?使用pstree看一下进程信息如下:

/bin/bash /tmp/a.sh

pstree
 |-+= 05392 pgy tmux
 | |-+= 05393 pgy -zsh
 | | \-+= 70797 pgy /bin/bash /tmp/a.sh
 | |   \--- 70798 pgy ping www.baidu.com
 
 
 ping www.baidu.com
 
 pstree
 |-+= 05392 pgy tmux
 | |-+= 05393 pgy -zsh
 | | \--= 70841 pgy ping www.baidu.com

由于我本地使用了tmuxzsh,所有执行信息都是从这两个里面fork出来,可以发现使用bash执行和直接命令执行的区别在于,bash会认为是多条命令在执行,会fork一个进程出来,而使用ping命令直接执行并不会fork

这个区别对golang有什么影响呢?翻翻官网的issues:

  • windows平台:https://github.com/golang/go/issues/22381#issuecomment-368114949

  • 一个比较详细的解释:https://github.com/golang/go/issues/18874#issuecomment-277272067

  • 一位博主的图

golang cmd

由图中可以看到,当golangexec执行fork类型的任务时,会将stdoutstderr放至pipe当中;而timeout context执行完后,无法做到回收子进程,所以整个程序被hang住;那如何做到优雅退出,并拿回stdoutstderr呢,需要手工从pipe当中获取无法使用CombinedOutput方法,因为此方法只会获取父进程的pipe

所以整体的代码如下:

// cancel command && get stdout\ stderr
// cat /tmp/a.sh
// 		ping www.baidu.com
//
// === RUN   TestSingleTimeoutCommand
//    exec_test.go:27: signal: killed
func TestTimeoutCancelCommand(t *testing.T) {
	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()

	cmd := exec.CommandContext(ctx, "/bin/bash", "/tmp/a.sh")

	stdoutPipe, _ := cmd.StdoutPipe()
	stderrPipe, _ := cmd.StderrPipe()

	outReader := bufio.NewReader(stdoutPipe)
	errReader := bufio.NewReader(stderrPipe)

	stdoutChan := make(chan string, 0)
	stderrChan := make(chan string, 0)

	err := cmd.Start()
	if err != nil {
		fmt.Println(err.Error())
		return
	}

	go func() {
		for {
			line, err := outReader.ReadString('\n')

			if line != "" {
				stdoutChan <- line
			}

			if err != nil {
				stderrChan <- err.Error()
				return
			}

			if line == "" {
				break
			}
		}
	}()
	go func() {
		for {
			line, err := errReader.ReadString('\n')

			if line != "" {
				stderrChan <- line
			}

			if err != nil {
				stderrChan <- err.Error()
				return
			}

			if line == "" {
				break
			}
		}
	}()

	var stdoutStr string
	var stderrStr string
LoopBreak:
	for {
		select {
		case <-ctx.Done():
			break LoopBreak
		case str := <-stdoutChan:
			stdoutStr += str
		case str := <-stderrChan:
			stderrStr += str
		}
	}

	err = cmd.Wait()
	if err != nil {
		exitErr := err.(*exec.ExitError)
		status := exitErr.Sys().(syscall.WaitStatus)
		if status.ExitStatus() == 0 {
			fmt.Printf("wrong exit status: %v", status.ExitStatus())
		}
	}

	fmt.Println(stdoutStr)
	fmt.Println(stderrStr)
	fmt.Println("exec done")
}

关于管道操作

管道操作不要使用CombinedOutput,会将stderr重定向至stdout当中;看如下几个测试用例

参考