Cyrus Blog

FLAG{S0_H4PPY_C_U_H3R3} (>.<)

Channel, goroutine 和 os/exec

本文共 2.4k 字,预计阅读时间 10 分钟。

最近有一个需求,用 Golang 执行一条命令,并且需要为该命令设置一定时间的 timeout。由此展开,我们从执行一条基本的 shell 命令开始,逐渐研究黑魔法。

0x00 前言

本文我们假设没有任何前置知识,可放心食用。

正好昨天面试问到了 Go-routine 的相关原理问题,以及 CSP 相关概念问题。开篇先 diss 一波这位面试同学,感觉非常学院派,不是很懂问一个安全算法岗 Engineer 而不是 Researcher 这些问题的意义,一个初阶 user 是肯定不知道 CSP 并发模型 (Concurrent mode) 的定义的,并且还问了”诸如你既然喜欢 Golang 为什么不深入研究“之类引起不适的问题(我当时就说了刚接触 ML 没研究语言特性)这里就简单学习一下。

然而,文章还是以此作为标题,末尾会附上开头的需求之外,关于这部分知识的一些面试小技巧,工作面前都是舔狗(x

0x01 首先跑一个 os/exec

我们用官方文档的样例代码运行一条命令,获取输出。

1
2
3
4
5
6
7
8
9
10
11
12
13
func RunCommand(command string, args ...string) (stdout string) {
cmd := exec.Command(command, args...)
outBytes, err := cmd.Output()
if err != nil {
log.Fatal(err)
}
stdout = string(outBytes)
return
}

func main() {
log.Print(RunCommand("echo", "fuck"))
}

输出了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
2
3
4
5
6
7
8
9
func RunCommand(command string, args ...string) (stdout string) {
cmd := exec.Command(command, args...)
err := cmd.Start()
if err != nil {
log.Fatal(err)
}
// stdout = string(outBytes)
return
}

然而,此时要想获得输出,可以通过cmd.StdoutPipe()(需要注意的是,这个需要在Start()之前完成,不然会输出的内容会是exec: StdoutPipe after process started)。加上这个之后我们又卡住了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
func RunCommand(command string, args ...string) (stdout string) {
cmd := exec.Command(command, args...)
err := cmd.Start()
if err != nil {
log.Fatal(err)
}
out, err := cmd.StdoutPipe()
if err != nil {
log.Fatal(err)
}
defer out.Close()
outBytes, err := ioutil.ReadAll(out)
if err != nil {
log.Fatal(err)
}
stdout = string(outBytes)
return
}

当然,还有一个cmd.Wait(),返回一个 error 类型,可以这样获得输出。不幸的是也会卡住:

1
2
3
4
5
6
7
8
9
10
func RunCommand(command string, args ...string) (stdout string) {
cmd := exec.Command(command, args...)
err := cmd.Start()
if err != nil {
log.Fatal(err)
}
outBytes := cmd.Wait()
stdout = outBytes.Error()
return
}

我们很自然的想到一个操作,我们需要在一个命令不在前台执行(为了控制 timeout),并获得它的输出。很幸运,Golang 实现了这一套机制。

0x03 Channel & Goroutine

我们首先看一段代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
func sum(s []int, c chan int) {
sum := 0
for _, v := range s {
sum += v
}
time.Sleep(2 * time.Second)
c <- sum // 发送数据到 Channel
}

func main() {
s := []int{1, 2, 3, 4, 5, 6}
c := make(chan int)
log.Print("Waiting")
go sum(s[len(s)/2:], c) // 前半段
go sum(s[:len(s)/2], c) // 后半段
x, y := <-c, <-c // 分别阻塞地接收数据
log.Print("Done")
log.Print(x, y)
}

// Output:
// 2018/11/21 23:51:54 Waiting
// 2018/11/21 23:51:56 Done
// 2018/11/21 23:51:56 15 6

Channel

Channel 是 Go 中的一个核心类型。你可以把它看成一个管道,通过它并发核心单元就可以发送或者接收数据进行通讯。基本操作方式如下:

1
2
3
ch := make(chan int)	// 新建一个 Channel
ch <- v // 发送值 v 到 Channel ch中
v := <-ch // 从 Channel ch 中接收数据,并将数据赋值给 v

一个 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
2
3
4
5
6
7
8
9
c, quit := make(chan int), make(chan int)

go func() {
c <- 1 // c 正在等待数据被取走,才能执行 quit
quit <- 0 // 楼上等待期间这里也在等
}()

x := <- quit // quit 在等待 goroutine 有人写
y := <- c // 由于一直没有 quit,c 中数据不可能被取走

通俗点说,依次存入 Channel 的数据,需要依次取出。如果真的需要不能依次取出,正如之前索索,可以给 Channel 一个大小。这里写成c, quit := make(chan int, 1), make(chan int, 1),上面的程序就不会死锁了。

当然,bug 千千万,我们在这里也给一个不死锁的不是 bug 的反例。其原因是,这个 goroutine 在运行前,主程序就退出了:

1
2
3
4
5
c := make(chan int)

go func() {
c <- 1
}()

这一段问题参考了这篇文章。更加详细的解释,可以参见这篇文章

0x04 带 timeout 执行命令

我们好像已经解决了最初的问题:需要在一个命令不在前台执行(将cmd.Wait()启动成一个 goroutine),并获得它的输出(通过 Channel)在原来的代码上修改有这些:

  1. 为了避免在发送 SigInt 和 Kill 线程时管道之间关闭顺序的其妙操作(StdoutPipe 在结束后无法获取内容,未结束时会阻塞等待),参考了这篇文章中这段的处理方式,通过 bytes buffer 来接受。
  2. 通过 select 来处理正常运行完成和 Kill 结束的命令的操作。
  3. 在发送 SigInt 信号后,有些命令会输出一些信息(比如 ping),在 Kill 前添加了 10ms 延时。

代码如下,可直接复制测试运行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
package main

import (
"syscall"
"time"
"bytes"
"log"
"os/exec"
)

func RunCommandWithTimeout(timeout int, command string, args ...string) (stdout, stderr string, isKilled bool) {
var stdoutBuf, stderrBuf bytes.Buffer
cmd := exec.Command(command, args...)
cmd.Stdout = &stdoutBuf
cmd.Stderr = &stderrBuf
cmd.Start()
done := make(chan error)
go func() {
done <- cmd.Wait()
}()
after := time.After(time.Duration(timeout) * time.Millisecond)
select {
case <-after:
cmd.Process.Signal(syscall.SIGINT)
time.Sleep(10*time.Millisecond)
cmd.Process.Kill()
isKilled = true
case <-done:
isKilled = false
}
stdout = string(bytes.TrimSpace(stdoutBuf.Bytes())) // Remove \n
stderr = string(bytes.TrimSpace(stderrBuf.Bytes())) // Remove \n
return
}

func main() {
resultOut, resultErr, resultStat := RunCommandWithTimeout(3000, "ping", "baidu.com")

log.Print("Is Killed: ", resultStat)
log.Print("Res: \n===\n", resultOut, "\n===")
log.Print("Err: \n===\n", resultErr, "\n===")
}

// Output:
// 2018/11/22 01:47:44 Is Killed: true
// 2018/11/22 01:47:44 Res:
// ===
// PING baidu.com (220.181.57.216): 56 data bytes
// 64 bytes from 220.181.57.216: icmp_seq=0 ttl=53 time=44.607 ms
// 64 bytes from 220.181.57.216: icmp_seq=1 ttl=53 time=44.476 ms
// 64 bytes from 220.181.57.216: icmp_seq=2 ttl=53 time=45.519 ms
//
// --- baidu.com ping statistics ---
// 3 packets transmitted, 3 packets received, 0.0% packet loss
// round-trip min/avg/max/stddev = 44.476/44.867/45.519/0.464 ms
// ===
// 2018/11/22 01:47:44 Err:
// ===
//
// ===

0x05 面试小技巧

哎好晚了。先放两个链接吧,如果有机会再来更新。

关于 goroutine:

关于 CSP: