img

这只地鼠又来了,说明今天我又来讲 Go 语言并发中常见的一些bug,看代码咯。

引用传递

1for i := 17; i <= 21; i++ {  // write
2    go func() { /* Create a new goroutine */ }   // before modify
3    go func(i int) {  // modified
4        apiVersion := fmt.Sprintf("v1.%d", i) // read
5    }()   // before modify
6    }(i)  // modified
7}

这里说的是,更改代码之前,我们启动一个 goroutine,这个 goroutine 用的是一个闭包,闭包捕获了外面的变量 i,而这个 i 用的还是地址。而迭代器 for 用的也是同一个 i 的地址,所以到 goroutine 执行的时候,最后 Print 出来的 i 就是最后一个了。

这里的修改方式比较简单,就是把 i 当作参数传过去,因为是值传递,也就解决问题了。

WaitGroup

 1func (p *peer) send() {
 2    p.mu.Lock()
 3    defer p.mu.Unlock()
 4    switch p.status {
 5    case idle:
 6        p.wg.Add(1)   // modified
 7        go func() {
 8            p.wg.Add(1)   // before modify
 9            ......
10            p.wg.Done()
11        }()
12        case stopped:
13    }
14}
15
16func (p * peer) stop() {
17    p.mu.Lock()
18    p.status = stopped
19    p.mu.Unlock()
20    p.wg.Wait()
21}

这里代码还是涉及到 WaitGroup 的用法,在修改代码之前,Add 是放在 go func 中,有可能 WaitGroup 依旧是 0,WaitGroup 的 Wait 就不需要等待任何 goroutine 就能执行完成,整个程序也就执行结束了。

因此,在启动 goroutine 前要保证 Add 完成,将 Add 放在 go func 之前就能使得整个逻辑在不同条件下正常执行。

重复关闭 Channel

1select {   // before modify
2    case <- c.closed:   // before modify
3    default:   // before modify
4        Once.Do(func() {   // modified
5            close(c.closed)
6    })   // modified
7}   // before modify

这代码在执行并发操作 channel 时,多次关闭同一个 channel。这种情况也是我们平时开发中最常见的问题,重复关闭 channel。

为了解决代码逻辑有误的情况,又得额外去打一些补丁。比如这里的 Once.Do 都是之后修改的代码,说明之前的 select 可能会进入多次,因此就会对这个 channel close 关闭多次。编译器就会抛出 channel panic 的错误。

 1ticker := time.NewTicker()
 2for {
 3    select {   // modified
 4        case <- stopCh:   // modified
 5            return   // modified
 6        default:   // modified
 7    }   // modified
 8    f()
 9    select {
10        case <- stopCh:
11            return
12        case <- ticker:
13    }
14}

最后个代码例子,可能不是很直观。这段代码是中,是另一段 goroutine 代码来向 stopCh 的 channel 发送通知数据,在这段中接受的。

在修改之前,这里的意思其实是程序有可能在执行 f 函数,而这个 f 函数内部逻辑比较复杂,时间复杂度比较高,需要计算半个小时之类的。当外部已经通过 stopCh 通知需要停止 f 函数的逻辑了,也就需要退出整个循环,而不是再回到循环,然后进入到 f 函数中。也就是说 Fn 耗时很久,但进入之前没有判断外部给的 stopCh 中的通知而浪费了算力。

修改后的代码就在 f 函数之前,有一次判断,能够提前退出。不用再等 f 函数执行完成,再来接受和判定终止的通知。

小结

到目前为止,通过三期的文章,已经将 Go 语言中并发部分的一些常见的 bug 都梳理了一遍,其中一些代码例子还是挺经典的。作为 Go 开发者,这些问题虽然我们没有全部都遇到,其中一两个发生在自己身上也是很正常不过的。

以上的例子其实都来自于一篇 Go 语言的学术型论文当中。没想到学术界也会写一些工程界关于 bug 的论文,还挺神奇的。所以经过我三篇文章的介绍,给我们的启示是,去研读最新的科技论文,其中不仅有较为前沿的科技理论,也可能会有偏工程性的案例研究。学术无涯,研究无界,保持一颗热爱技术的心,无论是理论还是工程都是有研究的意义和价值的,感谢这样的研究者。我们专注于工程中的工程师也应该向他们多多学习和借鉴,不拘泥于做好自己手头上的事情,也可以适当探讨更好的理论解决方案,为科技世界一同贡献一份力。