首页 > 其他分享 >记一次 Go 调用系统命令出现的问题分析

记一次 Go 调用系统命令出现的问题分析

时间:2023-12-21 21:44:35浏览次数:28  
标签:err 调用 return 系统命令 nil cmd file error Go

首先在程序中封装了下面一个函数用来执行系统命令:

// 执行系统命令
func executeCommand(command string, output, outerr io.Writer) error {
	cmd := exec.Command("/bin/bash", "-c", command)
	stdout, err := cmd.StdoutPipe()
	if err != nil {
		return err
	}
	defer stdout.Close()
	stderr, err := cmd.StderrPipe()
	if err != nil {
		return err
	}
	defer stderr.Close()

	if err := cmd.Start(); err != nil {
		return err
	}

	errRet := make(chan error, 2)

	go func() {
		if _, err := io.Copy(output, stdout); err != nil {
			errRet <- fmt.Errorf("[executeCommand] copy cmd stdout error: %s", err)
		} else {
			errRet <- nil
		}
	}()

	go func() {
		if _, err := io.Copy(outerr, stderr); err != nil {
			errRet <- fmt.Errorf("[executeCommand] copy cmd stderr error: %s", err)
		} else {
			errRet <- nil
		}
	}()

	if err := cmd.Wait(); err != nil {
		return err
	}

	for i := 0; i < 2; i++ {
		if err := <-errRet; err != nil {
			return err
		}
	}

	return nil
}

我们使用的是 os/exec 包来执行命令,我们这里选择使用异步的方式来执行命令,所以需要通过创建管道来拿到命令的标准输出和错误。上面 cmd.Start 不会阻塞当前的执行,所以我们后面开启两个协程来异步收集错误,最后通过 cmd.Wait 来等待命令执行完成,最后通过信道等待收集错误的协程执行完毕。函数接收两个 io.Writer 参数,用来写入标准输出和标准错误,外部可以读取其中的内容。

这整个函数看似没有任何问题,我们在外部来调用一下:

	err := executeCommand("ls", os.Stdout, os.Stderr)
	if err != nil {
		fmt.Println(err)
	}
	err = executeCommand("cat /dev/null", os.Stdout, os.Stderr)
	if err != nil {
		fmt.Println(err)
	}
	err = executeCommand("cat /dev/null", os.Stdout, os.Stderr)
	if err != nil {
		fmt.Println(err)
	}

我们这里直接使用 os.Stdoutos.Stderr 接受命令的输出和错误,这会直接输出到控制台。当我们执行时会发现有时候运行会出现下面的报错:

read |0: file already closed
write /dev/stdout: file already closed

这两种报错都有一定的几率出现,而且不一定同时出现,有些时候甚至不出现,由于我们的方法中存在协程,所以这种存在概率的问题大多是因为并发情况下没有前后固定顺序造成的。

我们首先来看第一个错误:read |0: file already closed 这个错误字面上看是读取文件的时候文件已经关闭了,我们可以看 cmd.StdoutPipe() 这部分的源码:

func (c *Cmd) StdoutPipe() (io.ReadCloser, error) {
	if c.Stdout != nil {
		return nil, errors.New("exec: Stdout already set")
	}
	if c.Process != nil {
		return nil, errors.New("exec: StdoutPipe after process started")
	}
	pr, pw, err := os.Pipe()
	if err != nil {
		return nil, err
	}
	c.Stdout = pw
	c.childIOFiles = append(c.childIOFiles, pw)
	c.parentIOPipes = append(c.parentIOPipes, pr)
	return pr, nil
}

其中会调用 os.Pipe 源码如下:

func Pipe() (r *File, w *File, err error) {
	var p [2]int

	e := syscall.Pipe2(p[0:], syscall.O_CLOEXEC)
	if e != nil {
		return nil, nil, NewSyscallError("pipe2", e)
	}

	return newFile(p[0], "|0", kindPipe), newFile(p[1], "|1", kindPipe), nil
}

可以看到这里会创建两个文件分别是 |0 还有 |1 ,然后把 |1 给到 Stdout,同时放到子进程的 IO 中,|0 是作为父进程的 IO 管道,同时返回到外部,也就是说是使用 |0 作为子进程和父进程通信的管道,根据上面的报错,很明显就是 |0 文件已经关闭,所以我们无法读取。

那么什么时候会关闭呢,实际上是在执行 Wait 时,进程执行完毕后回收资源阶段会关闭:

func (c *Cmd) Wait() error {
	if c.Process == nil {
		return errors.New("exec: not started")
	}
	if c.ProcessState != nil {
		return errors.New("exec: Wait was already called")
	}

	state, err := c.Process.Wait()
	if err == nil && !state.Success() {
		err = &ExitError{ProcessState: state}
	}
	c.ProcessState = state

	// other
    // ...

	closeDescriptors(c.parentIOPipes)
	c.parentIOPipes = nil

	return err
}

上面是 Wait 的一部分代码,可以看到最后 closeDescriptors(c.parentIOPipes) 关闭了向父进程通信的管道。

原因就是我们开启协程收集输出时,进程这个时候已经执行完毕,所以就直接调用了 Wait,这个时候输出管道被关闭,而我们还没有读取完,就会出现这个报错,我们可以用简单的代码来验证一下:

// 在 Wait 后面加两行代码
n, err := stdout.Read(make([]byte, 10))
fmt.Println(n, err)

果然我们执行后会输出同样的错误,到这里第一个错误的原因就找到了,然后来看第二个错误:write /dev/stdout: file already closed ,这和错误看上去是往 /dev/stdout 写入的时候文件已经关闭了,但是 /dev/stdout 是标准输出,也不太可能关闭,而且这个错误是 io.Copy 返回的,我们同样在 Wait 后面加两行代码来验证下:

n, err := io.Copy(output, stdout)
fmt.Println(n, err)

然后执行果然出现了这个错误,那么这又是什么原因呢?我们可以借助于工具开启断点调试来定位问题,定位过程如下:

首先进入 io.Copy

func Copy(dst Writer, src Reader) (written int64, err error) {
	return copyBuffer(dst, src, nil)
}

这里直接进入 copyBuffer

func copyBuffer(dst Writer, src Reader, buf []byte) (written int64, err error) {
	// If the reader has a WriteTo method, use it to do the copy.
	// Avoids an allocation and a copy.
	if wt, ok := src.(WriterTo); ok {
		return wt.WriteTo(dst)
	}
	// Similarly, if the writer has a ReadFrom method, use it to do the copy.
	if rt, ok := dst.(ReaderFrom); ok {
		return rt.ReadFrom(src)
	}
	// other...
	return written, err
}

这个函数后面的代码省略了,我们只关注前面这几行,首先是 src.(WriterTo) 这个断言是失败的,也就是说管道文件没有这个方法,然后继续走到 dst.(ReaderFrom) 这个是断言成功的,于是就进入 rt.ReadFrom 中,这个 ReadFrom 是接口方法,具体的实现在 os/file 包中:

// ReadFrom implements io.ReaderFrom.
func (f *File) ReadFrom(r io.Reader) (n int64, err error) {
	if err := f.checkValid("write"); err != nil {
		return 0, err
	}
	n, handled, e := f.readFrom(r)
	if !handled {
		return genericReadFrom(f, r) // without wrapping
	}
	return n, f.wrapErr("write", e)
}

进去之后调用 f.checkValid 这个是正常通过了,然后就来到 f.readFrom(r) 中,接着会来到 copyFileRange 这个过程调用层级比较深,我们就省略直接来到错误发生的地方:

func copyFileRange(dst, src *FD, max int) (written int64, err error) {
	// The signature of copy_file_range(2) is:
	//
	// ssize_t copy_file_range(int fd_in, loff_t *off_in,
	//                         int fd_out, loff_t *off_out,
	//                         size_t len, unsigned int flags);
	//
	// Note that in the call to unix.CopyFileRange below, we use nil
	// values for off_in and off_out. For the system call, this means
	// "use and update the file offsets". That is why we must acquire
	// locks for both file descriptors (and why this whole machinery is
	// in the internal/poll package to begin with).
	if err := dst.writeLock(); err != nil {
		return 0, err
	}
	defer dst.writeUnlock()
	if err := src.readLock(); err != nil {
		return 0, err
	}
	defer src.readUnlock()
	var n int
	for {
		n, err = unix.CopyFileRange(src.Sysfd, nil, dst.Sysfd, nil, max, 0)
		if err != syscall.EINTR {
			break
		}
	}
	return int64(n), err
}

上面会先对 dst 加写锁,然后对 src 加读锁,结果是执行 readLock 时出现了问题:

// readLock adds a reference to fd and locks fd for reading.
// It returns an error when fd cannot be used for reading.
func (fd *FD) readLock() error {
	if !fd.fdmu.rwlock(true) {
		return errClosing(fd.isFile)
	}
	return nil
}

这里由于文件已经关闭,所以会走到 errClosing 这里:

// ErrFileClosing is returned when a file descriptor is used after it
// has been closed.
var ErrFileClosing = errors.New("use of closed file")
// Return the appropriate closing error based on isFile.
func errClosing(isFile bool) error {
	if isFile {
		return ErrFileClosing
	}
	return ErrNetClosing
}

这里会返回错误:use of closed file 然后一层一层返回,最终回到 ReadFrom 方法中走到 f.wrapErr("write", e) 这个地方:

// wrapErr wraps an error that occurred during an operation on an open file.
// It passes io.EOF through unchanged, otherwise converts
// poll.ErrFileClosing to ErrClosed and wraps the error in a PathError.
func (f *File) wrapErr(op string, err error) error {
	if err == nil || err == io.EOF {
		return err
	}
	if err == poll.ErrFileClosing {
		err = ErrClosed
	} else if checkWrapErr && errors.Is(err, poll.ErrFileClosing) {
		panic("unexpected error wrapping poll.ErrFileClosing: " + err.Error())
	}
	return &PathError{Op: op, Path: f.name, Err: err}
}

wrapErrerr 重新设置为 ErrClosed 然后包装到 PathError 中返回,结果中 Op 就是 writePath 就是目标文件名称,即 /dev/stdouterr 就是 ErrClosed 也就是 file already closed ,也就得到了最后的结果:write /dev/stdout: file already closed ,这个错误确实具有一定的迷惑性,当写入文件加锁失败时表示的确实是写入的这个文件已关闭,但是当读取文件加锁失败时,这里虽然是 write /dev/stdout 但是表示的是从来源读取失败,前面的标识无论什么情况下都是目标文件而已,这样第二个错误的原因就清楚了。

那么这两个报错有哪些不同呢?

read |0: file already closed 这个报错是正在读取文件时,文件被关闭而出现的报错,也就是说这个是在执行 unix.CopyFileRange 过程中文件被关闭导致的错误。

write /dev/stdout: file already closed 这个错误是先关闭文件后,再进行读取时在 readLock 加锁时就检测出来已关闭而导致的错误。

所以这两个错误都是文件被关闭导致的,关键的区别是读在关闭后还是在关闭过程中。

那么原因清楚了,最原始的代码应该如何修改呢?

首先我们直接让获取输出一定在 Wait 调用之前就可以保证读取没问题,所以将遍历结果信道的操作放在 Wait 前面,这样保证一定读取完成再执行 Wait 操作回收资源:

func executeCommand(command string, output, outerr io.Writer) error {
	cmd := exec.Command("/bin/bash", "-c", command)
	stdout, err := cmd.StdoutPipe()
	if err != nil {
		return err
	}

	stderr, err := cmd.StderrPipe()
	if err != nil {
		return err
	}

	if err := cmd.Start(); err != nil {
		return err
	}

	errRet := make(chan error, 2)

	go func() {
		if _, err := io.Copy(output, stdout); err != nil {
			errRet <- fmt.Errorf("[executeCommand] copy cmd stdout error: %s", err)
		} else {
			errRet <- nil
		}
	}()

	go func() {
		if _, err := io.Copy(outerr, stderr); err != nil {
			errRet <- fmt.Errorf("[executeCommand] copy cmd stderr error: %s", err)
		} else {
			errRet <- nil
		}
	}()

	for i := 0; i < 2; i++ {
		if err := <-errRet; err != nil {
			return err
		}
	}

	if err := cmd.Wait(); err != nil {
		return err
	}

	return nil
}

这样就不会出现问题了,而且管道我们也不需要在外面代码中关闭,Wait 回收资源时会自动关闭。

其实上面写法还是太复杂了,对于我们这个场景其实就是同步执行命令,不需要这么麻烦,我们只需要手动设置 cmd 实例的 StdoutStderr 即可:

func executeCommand(command string, output, outerr io.Writer) error {
	cmd := exec.Command("/bin/bash", "-c", command)

	cmd.Stdout = output
	cmd.Stderr = outerr

	if err := cmd.Start(); err != nil {
		return err
	}

	if err := cmd.Wait(); err != nil {
		return err
	}

	return nil
}

这样代码就简单多了,注意设置 cmd.Stdoutcmd.Stderr 必须放在 cmd.Start() 之前,之后可能会丢失输出。

既然是同步的,我们可以再进一步,将 cmd.Start()cmd.Wait() 合并成 cmd.Run() ,这样更简单:

func executeCommand(command string, output, outerr io.Writer) error {
	cmd := exec.Command("/bin/bash", "-c", command)

	cmd.Stdout = output
	cmd.Stderr = outerr

	if err := cmd.Run(); err != nil {
		return err
	}

	return nil
}

这样代码就更简洁了,而且我们需要的功能也完全可以实现。不过今天这个例子能告诉我们在异步情况下执行命令需要注意什么,以及如何定位问题,如果我们需要在不阻塞当前线程的情况下执行命令,那么也必须使用管道获取执行结果,这时候我们就要注意避免这些报错的情况。

Reference:

  1. https://www.cnblogs.com/panlq/p/17267345.html
  2. https://www.lixueduan.com/posts/go/exex-cmd-timeout/

标签:err,调用,return,系统命令,nil,cmd,file,error,Go
From: https://www.cnblogs.com/freeweb/p/17920179.html

相关文章

  • keto ory 团队开源的google zanzibar 实现
    ory公司在认证以及授权方面开源了不少东西,keto就是一个googlezanzibar的开源实现代码基于golang开发,同时也是提供了restapi以及grpc能力,同时还支持一个OPL的权限模型语言说明类似的开源实现有不少,permify也是一个,还有openfga,都是值得研究学习的参考资料https://gith......
  • mongo如何使用脚本更新数据
    前言数据更新是我们日常操作数据库必不可少的一部分,下面这篇文章就给大家分享了操作MongoDB数据更新的一些干货,对大家具有一定的参考学习价值,一起来学习学习吧。常用的函数update(,,,),其中表示筛选的条件,是要更新的数据updateMany()更新所有匹配到的数据upsertupsert是一个布......
  • [转载]使用GoEasy在uniapp下实现实时音视频通话附关键代码
    GRTC(GoEasyReal-TimeCommunication)是GoEasy推出的新功能,用于协助开发者在uniapp下轻松实现一对一和多人场景下的实时音视频通话功能。集成步骤1.配置云厂商音视频服务GRTC功能依赖于云厂商的音视频服务,目前已集成七牛云音视频服务(每月免费5000分钟),并计划未来支持更多云厂......
  • django+vue实现文件夹上传
    最近学django的文件上下传,网上的文件夹上下传压根没有,找了好几个,报错一大堆,没有一个能用,花里胡哨,可气!!!下面这个方法是我刚刚用过的,分享给大家。前端vue非常简单,template部分<inputtype="file"id="twos"webkitdirectory/><el-buttontype="primary"@click="sumfolder">文件夹......
  • 无涯教程-Go - 函数指针
    Go编程语言使您可以将指针传递给函数,只需将函数参数声明为指针类型。在下面的示例中,我们将两个指针传递给一个函数,并更改该函数内部的值,该值会反映在调用函数中-packagemainimport"fmt"funcmain(){/*局部变量定义*/varaint=100varbint=200fmt.P......
  • http调用接口
    importjava.io.BufferedReader;importjava.io.IOException;importjava.io.InputStreamReader;importjava.net.HttpURLConnection;importjava.net.URL;publicstaticStringget(Stringurl,Stringcookie)throwsIOException{HttpURLConnectionconnection=(H......
  • golang简单判断22-65535开发情况
    packagemainimport( "fmt" "net" "sync" "time")funcmain(){ server:="42.51.129.175"//要检查的服务器地址 ports:=make([]int,65535)//要检查的端口范围,从22到65535 fori:=22;i<=65535;i++{ ports......
  • MongoDB限定条件的查询语句
    在MongoDB里面查询语句使用如下:--限定条件进行查询db.getCollection('source_news').find("_id":{$in:[28829497251611,28829497251535,28829497251452,28829497251359,28829497251276,28829497251238,28829497251130,28829497250977,28829497250914,28829497......
  • 无涯教程-Go - 多维数组函数
    Go编程语言允许多维数组,这是多维数组声明的一般形式-varvariable_name[SIZE1][SIZE2]...[SIZEN]variable_type如,以下声明创建了三维5、10、4个整数数组-varthreedim[5][10][4]int二维数组二维数组是多维数组的最简单形式,本质上,二维数组是一维数组的列表,要声明大小为[x......
  • 【业务安全实战演练】业务接口调用模块测试9
    业务接口调用模块1,接口调用重放测试测试方法:接口调用重放测试可以理解成重放测试,接口也就是数据请求,功能很多,例如发布文章,发布评论,下订单,也可以理解成只要请求有新的数据生成,能重复请求并成功,都可以算请求重放,也就是接口重放测试。修复方法:对生成订单缓解可以使用验证码,防止生......