更新时间:

#不要忽略 goroutine 的启动时间, for range 陷阱

原文地址 mp.weixin.qq.com

小菜刀的项目中需要将数据推给多个服务器,大致如下

 1package main
 2
 3import (
 4    "fmt"
 5    "sync"
 6)
 7
 8func mockSendToServer(url string) {
 9    fmt.Printf("server url: %s\n", url)
10}
11
12func main() {
13    urls := []string{"0.0.0.0:5000", "0.0.0.0:6000", "0.0.0.0:7000"}
14    wg := sync.WaitGroup{}
15    for _, url := range urls {
16        wg.Add(1)
17        go func() {
18            defer wg.Done()
19            mockSendToServer(url)
20        }()
21    }
22    wg.Wait()
23}

请读者停来下思考一下,以上代码会得到什么样的输出。

1$ go run main.go 
2server url: 0.0.0.0:7000
3server url: 0.0.0.0:7000
4server url: 0.0.0.0:7000

这个结果,是不是和你想的一样呢。那么,问题出在了哪里?为什么从 for 循环中传递的 url 变得相同了,且为数组中的最后一项 url。

原因分析

在 Go 中提供了非常好的程序分析工具,trace 和 pprof。trace 侧重于分析 goroutine 的调度,pprof 则侧重于程序的运行性能。为了更好地排查上述 bug 产生的原因,代码中新增了 trace 代码段。

 1package main
 2
 3import (
 4    "fmt"
 5    "os"
 6    "runtime/trace"
 7    "sync"
 8)
 9
10func mockSendToServer(url string) {
11    fmt.Printf("server url: %s\n", url)
12}
13
14func main() {
15    f, err := os.Create("trace.out")
16    if err != nil {
17        panic(err)
18    }
19    defer f.Close()
20
21    err = trace.Start(f)
22    if err != nil {
23        panic(err)
24    }
25    defer trace.Stop()
26    urls := []string{"0.0.0.0:5000", "0.0.0.0:6000", "0.0.0.0:7000"}
27    wg := sync.WaitGroup{}
28    for _, url := range urls {
29        wg.Add(1)
30        go func() {
31            defer wg.Done()
32            mockSendToServer(url)
33        }()
34    }
35    wg.Wait()
36}

运行命令 go run main.go  ,将在同级目录生成 trace.out 文件。此时,执行 go tool 命令。

1go tool trace trace.out
22020/08/15 16:35:58 Parsing trace...
32020/08/15 16:35:58 Splitting trace...
42020/08/15 16:35:58 Opening browser. Trace viewer is listening on http://127.0.0.1:61272

在 web 浏览器(推荐使用 Chrome)打开 http://127.0.0.1:61272

在这里,我们只关心两项指标。第一行 View trace(可视化整个程序的调度流程)和第二行 Groutine analysis。相信读者对 trace 和 pprof 的基本玩法都有了解,这里就不做过多介绍。后续小菜刀也会尽力出一些关于它们使用的详细文章。

进入 Goroutine analysis 项。

可以看到,程序一共有 5 个 goroutine,分别是三个 for 循环里启动的匿名 go func()、一个 trace.Start.func1 和 runtime.main。

进入 main.main.func1

这三个代表的就是 for 循环结构体里面启动的三个 goroutine。这里,请记住它们的编号 19、20、21。

此时,退回到 http://127.0.0.1:61272 页面,进入 View trace 项。

点击 G1(G1 代表的是 runtime.main)所在的绿色方框,得到如下信息

上图是重点!我们可以看到 G18、G19、G20、G21 都是通过 G1 衍生出来的。同时可以看到,G1 运行阻塞于第 35 行代码处,即 wg.Wait()。

但是,通过上图中 goroutine 的运行时序,此时 for 循环中的 3 个 goroutine 均还未成功启动运行(虽然已经在主 goroutine 即 main 中通过 go 关键字进行了 goroutine 调用声明)。

那么,到这里读者应该清楚上文中 bug 产生的原因了吧。

1for _, url := range urls {
2    wg.Add(1)
3    go func() {
4        defer wg.Done()
5        mockSendToServer(url)
6    }()
7}
8wg.Wait()

原因

当主 goroutine 中的 for 循环逻辑已经走完并阻塞于 wg.Wait()一段时间后,go func 的 goroutine 才启动准备(准备资源,挂载 M 线程等)完毕。那么,此时三个 goroutine 中获取的 url 都是指向的最后一次 for 循环的 url,因此就造成了上文中的 bug。

**goroutine 的启动时间是多少
**

通过在小菜刀电脑上的多次运行观察,一次 goroutine 的启动准备时间在数十微秒左右。当然该值在不同的操作系统和硬件设备上肯定会存在一些差异。为此,小菜刀在程序以下部分做了些小改动,以做测试

 1for i, url := range urls {
 2    wg.Add(1)
 3    go func() {
 4        defer wg.Done()
 5        mockSendToServer(url)
 6    }()
 7    if i == 1 {
 8        //在读取url为"0.0.0.0:6000"时,睡50微秒
 9        time.Sleep(time.Microsecond * 50)
10    }
11}
12wg.Wait()

程序多次运行会产生不同的结果

 1 $ go run main.go 
 2server url: 0.0.0.0:6000
 3server url: 0.0.0.0:7000
 4server url: 0.0.0.0:6000
 5
 6 $ go run main.go 
 7server url: 0.0.0.0:6000
 8server url: 0.0.0.0:6000
 9server url: 0.0.0.0:7000
10
11 $ go run main.go 
12server url: 0.0.0.0:6000
13server url: 0.0.0.0:7000
14server url: 0.0.0.0:7000

那么,我们就选取 6000、7000、7000 的结果来分析下当时 goroutine 的启动和调度情况。

如上,可以得知:由于在第二次 for 循环中让主 goroutine 睡了 50 微秒,使得首次被主 goroutine 调起的 go func(上图表现为 G20)已经得到了充足的时间来准备启动。但是首次调起的 go func,其获取 url 的时间片是在第二次循环的睡眠阶段,因此它得到的 url 是 "0.0.0.0:6000",而其他两个 go func(G21 和 G19)最终运行时获取的值还是 "0.0.0.0:7000" 的 url。

之所以睡眠 50 微秒会造成不同的结果,是由于 goroutine 的启动时间并不固定,会存在一定范围的波动。

因此,解决上文的 bug 的方案应为如下

1for _, url := range urls {
2    wg.Add(1)
3    go func(url string) {
4        defer wg.Done()
5        mockSendToServer(url)
6    }(url)
7}
8wg.Wait()

将每次遍历的 url 所指向值,通过函数入参,作为数据资源赋予给 go func, 这样不管 goroutine 启动会有多耗时,其 url 已经作为 goroutine 的私有数据保存,后续运行就用上了正确的 url,那么,上文 bug 也相应解除。

后记

小菜刀在线上遇到该 bug 时,虽然已经知道通过 url 入参的方式进行修改,但当时没有过多思考,以为问题是出在了 for...range 的值拷贝上面。通过后续和同事的讨论与自己多次不同尝试之后,才意识到原来是 goroutine 的启动时间在捣鬼。

最后,希望读者们看完此文,不会再写出此 bug。

往期精彩推荐

Golang 技术分享

图片