对exec.Cmd.CombinedOutput的一点研究

陪她去流浪 桃子 阅读次数:77

CombinedOutput会自动设置命令的stdoutstderr为内存内的缓冲区,然后等待进程运行结束。其相关代码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// CombinedOutput runs the command and returns its combined standard
// output and standard error.
func (c *Cmd) CombinedOutput() ([]byte, error) {
	// ...省略部分不相关代码...
	var b bytes.Buffer
	c.Stdout = &b
	c.Stderr = &b
	err := c.Run()
	return b.Bytes(), err
}

这段代码虽然看起来十分简单,但是实际上极其容易造成一种误解:stdoutstderr设置成“同一个Writer”是安全的

所以我也在我自己的代码中写出了类似下面这样看起来很像又不完全像的代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
func main() {
	cmd := exec.Command(`echo`, `123`)
	b := bytes.Buffer{}
	cmd.Stdout = io.MultiWriter(os.Stdout, &b)
	cmd.Stderr = &b
	cmd.Run()
	output := b.String()
	fmt.Println("output:", output)
	if output == `` {
		panic(`should not be empty`)
	}
}

在一台全新的服务器上,给出了如下的运行结果:时而正常、时而崩溃。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
root@iv-yeh9qbzz7kh2cbeuhzh4:~# ./go1.26.1/bin/go run a.go
123
output: 123

root@iv-yeh9qbzz7kh2cbeuhzh4:~# ./go1.26.1/bin/go run a.go
123
output:
panic: should not be empty

goroutine 1 [running]:
main.main()
	/root/a.go:20 +0x415
exit status 2

这让我很费解。按照正常理解,stdoutstderr在进程内是两个不同的描述符,对应到两个文件,也就意味着:它们可以被多线程同时写。所以,上述写法一定是线程不安全的,会造成数据竞态。但是Go语言本身就能把它们设置为同一个Write呢?而我,只是模仿Go的标准库写了差不多的代码。

如果再去看看Cmd.Stdout/Stderr的文档,有下面的说明:

1
2
3
4
5
6
type Cmd struct {
	// If Stdout and Stderr are the same writer, and have a type that can
	// be compared with ==, at most one goroutine at a time will call Write.
	Stdout io.Writer
	Stderr io.Writer
}

这里的文档正好解释了上面的问题:如果是同一个Writer且==Write同一时刻只会被一个goroutine调用。这就保证了数据的安全。但这是如何保证的呢?

当Stdout和Stderr设置到同一个Writer时,下面的Go语言代码保证了只会创建唯一一个共享的os.File

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
func (c *Cmd) childStdout() (*os.File, error) {
	return c.writerDescriptor(c.Stdout)
}

func (c *Cmd) childStderr(childStdout *os.File) (*os.File, error) {
	if c.Stderr != nil && interfaceEqual(c.Stderr, c.Stdout) {
		return childStdout, nil
	}
	return c.writerDescriptor(c.Stderr)
}

// interfaceEqual protects against panics from doing equality tests on
// two interfaces with non-comparable underlying types.
func interfaceEqual(a, b any) bool {
	defer func() {
		recover()
	}()
	return a == b
}

os.FileWrite方法又调用了poll.FD.Write方法:

1
2
3
4
5
6
7
// write writes len(b) bytes to the File.
// It returns the number of bytes written and an error, if any.
func (f *File) write(b []byte) (n int, err error) {
	n, err = f.pfd.Write(b)
	runtime.KeepAlive(f)
	return n, err
}

poll.FD内部一进来就对写操作进行了加锁:

1
2
3
4
5
6
7
// Write implements io.Writer.
func (fd *FD) Write(p []byte) (int, error) {
	if err := fd.writeLock(); err != nil {
		return 0, err
	}
	defer fd.writeUnlock()
	// ...

所以综合各种以上措施后,把stdout和stderr设置成完全相同的同一个Writer是安全的。否则,应该自行加锁。