来自:指月 https://www.lixueduan.com

原文:https://www.lixueduan.com/post/go/exex-cmd-timeout/

本文主要从源码层面分析了 Go exec 包执行命令超时失效问题,找出具体原因并给出相关解决方案。

现象

使用 os/exec 执行 shell 脚本并设置超时时间,然后到超时时间之后程序并未超时退出,反而一直阻塞。

具体代码如下:

func main() {ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)defer cancel()// 二者都可以触发cmd := exec.CommandContext(ctx, "bash","/root/sleep.sh")// cmd := exec.CommandContext(ctx, "bash","-c","echo hello && sleep 1200")out, err := cmd.CombinedOutput()fmt.Printf("ctx.Err : [%v]\n", ctx.Err())fmt.Printf("error   : [%v]\n", err)fmt.Printf("out     : [%s]\n", string(out))
}

/root/sleep.sh:

#!/bin/bash
sleep 1200

运行上述代码

[root@kc ~]# go run main.go

会创建一个 bash 进程,bash 进程又会创建一个 sleep 子进程:

[root@kc ~]# ps -ef|grep sleep
root     15485 15479  0 11:38 pts/1    00:00:00 bash /root/sleep.sh
root     15486 15485  0 11:38 pts/1    00:00:00 sleep 1200
root     15491 15239  0 11:38 pts/2    00:00:00 grep --color=auto sleep

等 context 超时之后,bash 进程被 kill 掉,进而 sleep 进程被 1 号进程托管,并且此时程序并未退出

[root@kc ~]# ps -ef|grep sleep
root     15486     1  0 11:38 pts/1    00:00:00 sleep 1200
root     15499 15239  0 11:38 pts/2    00:00:00 grep --color=auto sleep

手动 kill 掉 sleep 进程

kill 15486

此时程序退出

[root@kc ~]# go run main.go
ctx.Err : [context deadline exceeded]
error   : [signal: killed]
out     : []

原因分析

执行流程

exec.cmd 执行流程如下:

图源: PureLife

首先 go 中调用 fork 创建子进程,在子进程中执行具体命令,并通过管道和子进程进行连接,子进程将结果输出到管道,go 从管道中读取。

go 与 /bin/bash 之间通过两个管道进行连接,分别用于捕获 stderr 和 stdout 输出,/bin/bash 程序退出后,管道写入端被关闭,从而 go 可以感知到子进程退出,从而立刻返回。

猜想:根据现象可知,创建了两个进程,超时后 bash 进程退出,但是 sleep 进程还在,如果 sleep 进程继续占有管道,那么就可能导致阻塞。后续手动 kill 掉 sleep 进程后程序退出也能印证这一点。

相关源码

带着这个猜想去查看一下源码,相关源码均在 os/exec/exec.go 中。

CombinedOutput

func (c *Cmd) CombinedOutput() ([]byte, error) {if c.Stdout != nil {return nil, errors.New("exec: Stdout already set")}if c.Stderr != nil {return nil, errors.New("exec: Stderr already set")}var b bytes.Bufferc.Stdout = &bc.Stderr = &berr := c.Run()return b.Bytes(), err
}func (c *Cmd) Run() error {if err := c.Start(); err != nil {return err}return c.Wait()
}

CombinedOutput 逻辑很简单,和方法名一样,将 Stdout 和 Stderr 设置为同一个 writer。

Run 方法中则调用了 Start 和 Wait 方法:

  • Start 方法用于启动子进程,启动后立即返回

  • Wait 方法则阻塞,等待子进程结束并回收资源。

阻塞大概率出现在 Wait 方法中,因此先看 Wait 方法。

Wait

Wait 方法具体如下

func (c *Cmd) Wait() error {if c.Process == nil {return errors.New("exec: not started")}if c.finished {return errors.New("exec: Wait was already called")}c.finished = truestate, err := c.Process.Wait()if c.waitDone != nil {close(c.waitDone)}c.ProcessState = statevar copyError errorfor range c.goroutine {if err := <-c.errch; err != nil && copyError == nil {copyError = err}}c.closeDescriptors(c.closeAfterWait)if err != nil {return err} else if !state.Success() {return &ExitError{ProcessState: state}}return copyError
}

根据 debug 得知阻塞点就是 err := <-c.errch 这句。从 errch 中读取错误信息并最终返回给调用者。而 <-ch 命令阻塞的原因只有发送方未准备好,那么 errch 对应的发送方是谁呢,就在 Start 方法中:

Start

func (c *Cmd) Start() error {// ...if len(c.goroutine) > 0 {c.errch = make(chan error, len(c.goroutine))for _, fn := range c.goroutine {go func(fn func() error) {c.errch <- fn()}(fn)}}if c.ctx != nil {c.waitDone = make(chan struct{})go func() {select {case <-c.ctx.Done():c.Process.Kill()case <-c.waitDone:}}()}//...

第一部分,通过启动后台 goroutine 执行 c.goroutine 中的方法并将错误写入 c.errch,可以猜测一下应该是这里的产生了阻塞,需要继续追踪 c.goroutine 是哪儿来的。

第二部分则是开启了另一个 goroutine,用来监听 context,在超时之后会 kill 掉子进程。

这也符合现象中看到的,超时后 bash 进程被 kill 掉了。

接下来继续追踪 c.goroutine 是哪儿赋值的,同样是在 Start 方法中,前面提到了 go 通过管道来连接子进程以收集结果,具体逻辑就在这里:

 func (c *Cmd) Start() error {// ...type F func(*Cmd) (*os.File, error)for _, setupFd := range []F{(*Cmd).stdin, (*Cmd).stdout, (*Cmd).stderr} {fd, err := setupFd(c)if err != nil {c.closeDescriptors(c.closeAfterStart)c.closeDescriptors(c.closeAfterWait)return err}c.childFiles = append(c.childFiles, fd)}}

通过 (*Cmd).stdin, (*Cmd).stdout, (*Cmd).stderr 三个方法来分别处理 stdin、stdout、stderr。

这里先忽略掉 stdin,只看 stdout、stderr

具体 stdout、stderr 方法如下:

func (c *Cmd) stdout() (f *os.File, err error) {return c.writerDescriptor(c.Stdout)
}func (c *Cmd) stderr() (f *os.File, err error) {// 如果 stderr 和 stdout 一样的就不重复处理了if c.Stderr != nil && interfaceEqual(c.Stderr, c.Stdout) {return c.childFiles[1], nil}return c.writerDescriptor(c.Stderr)
}

二者都是调用的 writerDescriptor,不过 stderr 中简单判断了一下避免重复处理。

writerDescriptor 方法如下:

func (c *Cmd) writerDescriptor(w io.Writer) (f *os.File, err error) {// case1if w == nil {f, err = os.OpenFile(os.DevNull, os.O_WRONLY, 0)if err != nil {return}c.closeAfterStart = append(c.closeAfterStart, f)return}// case2if f, ok := w.(*os.File); ok {return f, nil}// case3pr, pw, err := os.Pipe()if err != nil {return}c.closeAfterStart = append(c.closeAfterStart, pw)c.closeAfterWait = append(c.closeAfterWait, pr)c.goroutine = append(c.goroutine, func() error {_, err := io.Copy(w, pr)pr.Close() // in case io.Copy stopped due to write errorreturn err})return pw, nil
}

有三个分支逻辑:

  • case1:如果没有指定 stderr 或者 stdout 就直接写入 os.DevNull

  • case2:如果指定的 stderr 或者 stdout 是 *os.File 类型也直接返回,后续直接写入该文件

  • case3:如果前两种情况都不是就进行最后一种情况,也即是最终的阻塞点。创建管道,子进程写入管道写端点,go 中启动一个 goroutine 从管道读端点读取并写入到指定的 stderr 或者 stdout 中。

这里只分析 case3,首先 io.Copy 方法会一直阻塞到 reader 被关闭才会返回,这也就是为什么这里会产生阻塞。

正常情况下 context 超时后,子进程会被 kill 掉,那么管道的写端点自然会被关闭, io.Copy 则在 copy 完成后正常返回,给 c.errch 中发送一个 nil,Wait 方法则从 c.errch 中读取到 error 就返回了,一切正常

Go exec 包执行命令超时失效问题分析及解决方案相关推荐

  1. linux expect 自动交互 执行命令 超时 不完整 中断 解决方法

    使用 expec t自动交互执行命令时,默认超时timeout为30s 手动添加set timeout -1设置 超时时间为无穷大 就可以执行完命令了 通过expect执行scp,传输文件不完整 写了 ...

  2. jar包执行命令脚本

    最近需要部署jar包,编写了执行脚本.不完美,停止的时候直接使用kill -9 pid,比较暴力,所以不适合在生产环境使用. 查询相关进程列表 [root@ /]# ps -ef | grep jar ...

  3. golang exec.Command 执行命令 返回详细错误信息

    当我运行下面的代码: cmd := exec.Command("find","/","-maxdepth","1",&q ...

  4. linux 多个会话同时执行命令后history记录不全的解决方案

    基本认识 linux默认配置是当打开一个shell终端后,执行的所有命令均不会写入到~/.bash_history文件中,只有当前用户退出后才会写入,这期间发生的所有命令其它终端是感知不到的. 问题场 ...

  5. linux 终端必须退出 history才会记录吗,Linux随笔 - linux 多个会话同时执行命令后history记录不全的解决方案【转载】...

    基本认识 linux默认配置是当打开一个shell终端后,执行的所有命令均不会写入到~/.bash_history文件中,只有当前用户退出后才会写入,这期间发生的所有命令其它终端是感知不到的. 问题场 ...

  6. Go 学习笔记(43)— Go 标准库之 os/exec(执行外部命令、非阻塞等待、阻塞等待、命令输出)

    1. 概述 golang 下的 os/exec 包执行外部命令包执行外部命令.它包装了 os.StartProcess 函数以便更容易的修正输入和输出,使用管道连接I/O,以及作其它的一些调整. 与 ...

  7. 在Linux使用exec执行命令时报的哪些错

    问题1:find: paths must precede expression [root@localhost data]# find /oracle/backup/exp/data -name ex ...

  8. linux怎么用两个进程传值,linux下的C开发14,可执行程序如何传递参数?模拟shell执行命令...

    上一节介绍了 linux 中的文件类型,并在文章最后使用 C语言编写了程序,该程序能够接受一个文件名参数,并打印出该文件的类型.不知道大家如何,反正我当初学编程时,发现(编译后的)可执行程序居然也能像 ...

  9. linux下service+命令和直接去执行命令的区别,怎么自己建立一个service启动

    启动一些程序服务的时候,有时候直接去程序的bin目录下去执行命令,有时候利用service启动. 比如启动mysql服务时,大部分喜欢执行service mysqld start.当然也可以去mysq ...

最新文章

  1. 科大讯飞全新1024:3大计划,200项A.I.能力,全链路驱动应用场景创新!
  2. 第十二周项目一-实现复数类中的运算符重载(1)
  3. linux怎么命令设置网络连接,Linux网络操作命令
  4. Java 面试题 —— 老田的蚂蚁金服面试经历
  5. web.config配置数据库连接
  6. 在git上push代码时缺少Change-Id
  7. python中copytree的用法_python复制文件的方法实例详解
  8. oracle 复制数据 insert into、as select
  9. 小程序向webview传参_微信小程序(1)——web-view和小程序间传递参数、发送消息...
  10. linux e1000e 网卡驱动,Ubuntu安装Intel e1000e千兆网卡
  11. rdcman汉化_Remote Desktop Organizer – 管理组织远程桌面 - 小众软件
  12. win下apache2.4 支持php8.0
  13. 商业方向的大数据专业_工业大数据应用的三大挑战和五大商业趋势
  14. 关于Servlet的两种配置Web.xml文件配置或者使用(@WebServlet(name = ,urlPatterns = ))配置问题——WebServlet注解
  15. 墙里秋千墙外道。墙外行人,墙里佳人笑。笑渐不闻声渐悄。多情却被无情恼。
  16. sinon.stub_JavaScript测试工具对决:Sinon.js vs testdouble.js
  17. 【算法学习】笨拙的奶牛
  18. 【若依vue框架学习】3.通过Excel导入数据/通过反射读取注解
  19. TextCNN文本分类实现(主要是CNN模型的使用)
  20. Python爬虫-爬取常用IP代理

热门文章

  1. WordPress商业模板avada使用方法
  2. mysql学生选课系统的关系模型_数据库系统原理ER模型与关系模型
  3. wifi 定位 java_android 的wifi定位
  4. mysql 表 rowid_如何查一张表最大的ROWID
  5. c语言编程排球队员站位问题,【排球课堂】一文看懂排球常识 解说总提的“卡轮”原来如此...
  6. 03:JAVA网络通信篇(5)
  7. 第四届传智杯练习赛题解(c++)
  8. 小米运动睡眠数据导出并分析(python)
  9. iOS14+中广告标识(idfa)获取方式
  10. 5 个维度深度剖析「主从架构」原理