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

// 执行系统命令
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/

记一次 Go 调用系统命令出现的问题分析的更多相关文章

  1. 【Java】如何调用系统命令

    如何通过Java调用系统命令,如ping 127.0.0.1.java -version等? > 简单的例子 package com.nicchagil.callpython.No001无参数调 ...

  2. python调用系统命令 shell命令

    使用python调用系统命令,基本有3种选择: 1. 使用os模块的system方法 import os os.system('ls') 2. 使用os模块的popen方法 import os os. ...

  3. python笔记之调用系统命令

    python笔记之调用系统命令 目前我使用到的python中执行cmd的方式有三种 使用os.system("cmd") 该方法在调用完shell脚本后,返回一个16位的二进制数, ...

  4. 调用系统命令之subprocess模块

    除了常见的os.system和os.popen方法,官方强烈推荐使用subprocess来调用系统命令. 这个库用起来其实很简单,按照惯例先贴一下官文关键点: The subprocess modul ...

  5. python常用模块-调用系统命令模块(subprocess)

    python常用模块-调用系统命令模块(subprocess) 作者:尹正杰 版权声明:原创作品,谢绝转载!否则将追究法律责任. subproces基本上就是为了取代os.system和os.spaw ...

  6. JAVA调用系统命令:python、shell等

    实际项目开发场景中,可能会用到java项目调用系统命令的需求,如调用python或者shell脚本 可以参考如下例子,例子来源于ambari源码: \ambari\ambari-server\src\ ...

  7. Python 调用系统命令的模块 Subprocess

    Python 调用系统命令的模块 Subprocess 有些时候需要调用系统内部的一些命令,或者给某个应用命令传不定参数时可以使用该模块. 初识 Subprocess 模块 Subprocess 模块 ...

  8. php调用系统命令的函数的比较

    了解命令的区别并进行直观的选择 这是一篇翻译文章,原作者通过表格的形式更加直观的展现出差异并进行选择 前言 PHP有众多调用系统命令的函数,大致如下: system() exec() passthru ...

  9. Nodejs中调用系统命令、Shell脚本的方法和实例

    每种语言都有自己的优势,互相结合起来各取所长程序执行起来效率更高或者说哪种实现方式较简单就用哪个,nodejs是利用子进程来调用系统命令或者文件,文档见http://nodejs.org/api/ch ...

  10. nohup 程序在后台运营 避免 xshell 卡死 通过 nohup.out分析调取系统命令时的异常分析

    nohup  程序在后台运营 避免 xshell  卡死 [root@admin1 after_fc_distributed]# nohup /root/anaconda3/bin/python da ...

随机推荐

  1. Elasticsearch 如何保证写入过程中不丢失数据的

    丢失数据的本质 在本文开始前,首先明白一个点,平时我们说的组件数据不丢失究竟是在指什么,如果你往ES写入数据,ES返回给你写入错误,这个不算数据丢失.如果你往ES写入数据,ES返回给你成功,但是后续因 ...

  2. 基于python的每日固定时间执行实例解析

    一 概念 datetime模块属性如下所示:   二 源码解析 该源码中,对时间库datetime应用十分到位,可以看作如下所示: import datetime import threading d ...

  3. Android Studio安装插件重启插件消失

    问题 安装插件后,已经提示让重启IDE,但是重启后发现插件是安装失败了 解决方法 原因是自己改了配置,如果下载的插件是jar包,则可以安装,如果是zip压缩文件的插件,则是要我们手动解压一下 我上面的 ...

  4. 回顾redis底层数据结构

    参考,欢迎点击原文:https://blog.csdn.net/qq_38286618/article/details/102530020 https://www.cnblogs.com/jaycek ...

  5. 15_AAC编码实战

    本文将分别通过命令行.编程2种方式进行AAC编码实战,使用的编码库是libfdk_aac. 要求 fdk-aac对输入的PCM数据是有参数要求的,如果参数不对,就会出现以下错误: [libfdk_aa ...

  6. 2024-03-16:用go语言,给你一个正整数数组 nums, 每一次操作中,你可以从 nums 中选择 任意 一个数并将它减小到 恰好 一半。 (注意,在后续操作中你可以对减半过的数继续执行操作)

    2024-03-16:用go语言,给你一个正整数数组 nums, 每一次操作中,你可以从 nums 中选择 任意 一个数并将它减小到 恰好 一半. (注意,在后续操作中你可以对减半过的数继续执行操作) ...

  7. 实时3D渲染它是如何工作的?可以在哪些行业应用?

    随着新兴技术--3D渲染的发展,交互应用的质量有了极大的提高.用实时三维渲染软件创建的沉浸式数字体验,几乎与现实没有区别了.随着技术的逐步改进,在价格较低的个人工作站上渲染3D图像变得更加容易,设计师 ...

  8. 洞元智维产品上线:江浙沪PV访问量飙升背后的深层逻辑与未来发展趋势

    洞元智维产品自上线以来,凭借其卓越的智能化产品性能和出色的用户体验,迅速在市场上崭露头角,特别是在上海.江苏.浙江等经济发达地区,其PV访问量持续飙升.这不仅反映了用户对智能化产品的强烈需求,更揭示了 ...

  9. 笔记:vite 的启动链路以及背后的部分原理

    这里分享一篇找到的文章 原文地址:https://blog.csdn.net/frontend_frank/article/details/106632197 NO.1 vite 是什么 vite - ...

  10. hdfs开启回收站(废纸篓)

    1.背景 我们知道,在mac系统上删除文件,一般情况下是可以进入 废纸篓里的,如果此时我们误删除了,还可以从 废纸篓中恢复过来.那么在hdfs中是否存在类似mac上的废纸篓这个功能呢?答案是存在的. ...