本文共 2.4k 字,预计阅读时间 10 分钟。
最近有一个需求,用 Golang 执行一条命令,并且需要为该命令设置一定时间的 timeout。由此展开,我们从执行一条基本的 shell 命令开始,逐渐研究黑魔法。
0x00 前言
本文我们假设没有任何前置知识,可放心食用。
正好昨天面试问到了 Go-routine 的相关原理问题,以及 CSP 相关概念问题。开篇先 diss 一波这位面试同学,感觉非常学院派,不是很懂问一个安全算法岗 Engineer 而不是 Researcher 这些问题的意义,一个初阶 user 是肯定不知道 CSP 并发模型 (Concurrent mode) 的定义的,并且还问了”诸如你既然喜欢 Golang 为什么不深入研究“之类引起不适的问题(我当时就说了刚接触 ML 没研究语言特性)这里就简单学习一下。
然而,文章还是以此作为标题,末尾会附上开头的需求之外,关于这部分知识的一些面试小技巧,工作面前都是舔狗(x
0x01 首先跑一个 os/exec
我们用官方文档的样例代码运行一条命令,获取输出。
1 | func RunCommand(command string, args ...string) (stdout string) { |
输出了2018/11/21 22:27:00 fuck
。
0x02 os/exec 黑魔法
os/exev 有一些黑魔法,
我们新建了一个名为 echo_loop 的 bash 脚本:
1 | while true; do date; sleep 1; done |
当我们执行RunCommand("bash", "echo_loop.sh")
,程序卡住了。显然这个脚本在不断地输出,我们希望不断取得的中间输出。但是我们不能让他卡住,不然怎么检测 timeout 呢。我们有一些针对的 command 对象的操作,cmd.Run()
是要等待命令运行完成的,而cmd.Start()
就可以让命令不要 wait:
1 | func RunCommand(command string, args ...string) (stdout string) { |
然而,此时要想获得输出,可以通过cmd.StdoutPipe()
(需要注意的是,这个需要在Start()
之前完成,不然会输出的内容会是exec: StdoutPipe after process started
)。加上这个之后我们又卡住了:
1 | func RunCommand(command string, args ...string) (stdout string) { |
当然,还有一个cmd.Wait()
,返回一个 error 类型,可以这样获得输出。不幸的是也会卡住:
1 | func RunCommand(command string, args ...string) (stdout string) { |
我们很自然的想到一个操作,我们需要在一个命令不在前台执行(为了控制 timeout),并获得它的输出。很幸运,Golang 实现了这一套机制。
0x03 Channel & Goroutine
我们首先看一段代码:
1 | func sum(s []int, c chan int) { |
Channel
Channel 是 Go 中的一个核心类型。你可以把它看成一个管道,通过它并发核心单元就可以发送或者接收数据进行通讯。基本操作方式如下:
1 | ch := make(chan int) // 新建一个 Channel |
一个 Channel 是可以设置单双向,收发类型,容量的。关于 Channel 的一些知识,可以参考官方文档或这个文章。值得注意的一个问题是,在进行一次 receive 操作时,如果 Channel 中还没有数据,会使得程序在此阻塞,此时应从一个 goroutine 向其中写数据。存取道理一样,都是阻塞的,需要一个 goroutine 来读取才行。
Goroutine
关于 Routine,其实没有什么复杂的调用方式,调用函数前加一个标识符go
就可以了,所以我们常叫做 goroutine。可以看做是我们熟知的线程,但是在实现上更轻。Golang 实现的是一个线程池。具体实现会在后面【0x05 面试小技巧】部分说到。
Goroutine 和 Channel 也是相辅相成的。由于没有类似 Python 中thread.join()
的概念。正如之前所说,Channel 的存取是阻塞的。默认的 Channel 大小是 0,我们把这叫做无缓冲。无缓冲的 Channel 在取消息和存消息的时候都会挂起当前的 goroutine。因此,我们可以完成 goroutine 向一个 Channel 写数据,写完之后该 Channel 可以读到数据,继续向下执行剩余部分。
默认的 Channel 大小是 0,叫做无缓冲。我们不用默认值,给一个大小,就可缓冲了。试图让无缓冲 Channel 承载数据是死锁的重要原因之一,给 Channel 一个大小可以避免。
一些问题
Q1:写入数据一直没读会怎样?没有数据一直在读会怎样?
都是死锁,错误信息为all goroutines are asleep - deadlock
我们打印 Done 之前增加一句c <- 0
,就会出现一个死锁。这个数据没有人读。
我们打印 Done 之前增加一句_ = <- c
,也会出现一个死锁。没有数据一直在读。
Q2:假设同时加上这两句话呢?
也是死锁。
划重点:单一的 goroutine 中操作数据一定死锁。在传入 0 到 c 的时候,此时需要一个线程池中,已经 join 的一个线程来读,此时除了 main 这个线程,没有别的 goroutine,正如错误提示所说,all goroutines are asleep。
Q3:还能怎么死锁?
好问题。我们要知道死锁的原理,是出现了循环读取、循环等待,使得一个无缓冲的 Channel 承载数据。这里给一个典型的循环等待的例子:
1 | c, quit := make(chan int), make(chan int) |
通俗点说,依次存入 Channel 的数据,需要依次取出。如果真的需要不能依次取出,正如之前索索,可以给 Channel 一个大小。这里写成c, quit := make(chan int, 1), make(chan int, 1)
,上面的程序就不会死锁了。
当然,bug 千千万,我们在这里也给一个不死锁的不是 bug 的反例。其原因是,这个 goroutine 在运行前,主程序就退出了:
1 | c := make(chan int) |
这一段问题参考了这篇文章。更加详细的解释,可以参见这篇文章。
0x04 带 timeout 执行命令
我们好像已经解决了最初的问题:需要在一个命令不在前台执行(将cmd.Wait()
启动成一个 goroutine),并获得它的输出(通过 Channel)在原来的代码上修改有这些:
- 为了避免在发送 SigInt 和 Kill 线程时管道之间关闭顺序的其妙操作(StdoutPipe 在结束后无法获取内容,未结束时会阻塞等待),参考了这篇文章中这段的处理方式,通过 bytes buffer 来接受。
- 通过 select 来处理正常运行完成和 Kill 结束的命令的操作。
- 在发送 SigInt 信号后,有些命令会输出一些信息(比如 ping),在 Kill 前添加了 10ms 延时。
代码如下,可直接复制测试运行:
1 | package main |
0x05 面试小技巧
哎好晚了。先放两个链接吧,如果有机会再来更新。
关于 goroutine:
关于 CSP: