理解真实世界的并发 Bug

Go带来了新的并发原语和并发模式(其实也不太新),如果没有深入了解这些特性,一样会写出并发bug。

Understanding Real-World Concurrency Bugs in Go 这篇论文里,作者系统地分析了6个流行的Go项目(Docker、Kubernetes、gRPC-go、etcd、CockroachDB、 BoltD)和其中171个并发bug,通过这些分析我们可以加深对Go的并发模型的理解,从而产出更好、更可靠的代码。

Our study shows that it is as easy to make concurrency bugs with message passing as with shared memory,sometimes even more.
我们的研究表明,消息传递和共享内存一样、有时甚至更容易写出并发错误。

例如下面是k8s的一个bug,finishReq创建了一个 goroutine 来执行fn 然后通过 select 等待 goroutine 完成或超时:

func finishReq(timeout time.Duration) r ob {
    ch :=make(chan ob)
    // ch :=make(chan ob, 1) // 修复方案
    go func() {
        result := fn()
        ch <- result // 阻塞
    }

    select {
    case result := <- ch:
        return result
    case <- time.After(timeout)
        return nil  // 如果执行到这里,不会再从 ch 读数据,上面 goroutine 永远阻塞
    }
}

如果超时先发生,或者 goroutine 写 channel 和超时同时发生但go运行时选择了超时分支,goroutine 会永远阻塞导致 goroutine 泄漏。

Go并发模式使用情况

这一节分析了6个项目里 goroutine 和并发原语的使用情况。

匿名函数的 goroutine 使用比普通函数要多,基本每 2~5 千行代码创建一个 goroutine:

Goroutine

虽然 Go 鼓励消息传递,但是在这些大项目里,共享内存的使用比消息传递要多,Mutex 基本在 channel 的两倍以上:

并发原语

Bug分类

这篇论文里,按两个维度对 bug 进行分类:

  1. 行为:阻塞和非阻塞,阻塞 bug 指 goroutine 意外地阻塞无法继续执行的情况(例如死锁),非阻塞 bug 通常是数据冲突(例如并发读写)
  2. 原因:共享内存和消息传递,因为用了这两种技术之一导致的bug

file

数量上,共享内存有的更多 bug,但是考虑到共享内存的使用比消息传递多(见上一节的统计),用的多自然 bug 也多,所以平均下相差不大。

阻塞bug

file

消息传递产生了更多的阻塞 bug,特保是和共享内存一起使用的时候,产生的 bug 很不好发现。

共享内存阻塞 bug 实例,Docker 错误使用 WaitGroup

var group sync.WaitGroup
group.Add(len(pm.plugins))
for _, p := range pm.plugins {
    go func(p *plugin) {
        defer group.Done()
    }
    group.Wait() // 阻塞
}
// 应该在这里group.Wait()

错误使用 channel 和 mutex 导致阻塞:

func goroutine1() {
    m.Lock()
    ch <- request // 阻塞
    m.Unlock()
}

func goroutine2() {
    for {
        m.Lock()    // 阻塞
        m.Unlock()
        request <- ch
    }
}

非阻塞bug

file

共享内存导致更多的非阻塞 bug,几乎是消息传递的8倍。

例如在下面这段代码里,每当ticker触发时执行一次f(),通过stopCh退出循环:

ticker := time.NewTicker()
for {
    f()
    select {
    case <- stopCh:
        return
    case <- ticker:
    }
}

但是select是非确定性的,stopChticker同时发生时,不一定会执行stopChan的分支,正确做法是先检查一次stopCh

ticker := time.NewTicker()
for {
    select { // 先检查一次
    case <- stopCh:
        return
    default:
    }
    f()
    select {
    case <- stopCh:
        return
    case <- ticker:
    }
}

参考

本作品采用《CC 协议》,转载必须注明作者和本文链接
本帖由系统于 2年前 自动加精
Oraoto
讨论数量: 0
(= ̄ω ̄=)··· 暂无内容!

讨论应以学习和精进为目的。请勿发布不友善或者负能量的内容,与人为善,比聪明更重要!