Understanding Real-World Concurrency Bugs in Go
目录
最近看了一篇关于golang 并发bug研究的paper,有挺多参考价值的,顺便做个笔记~
Introduction
Go的一个主要设计目标是改善传统的多线程编程语言,使得并发编程更加容易且更少出错,其主要通过两方面:
- 让线程(goroutine)更加轻量化和容易创建
- 在线程间使用特殊的消息通讯(channel)
论文中主要研究了六个开源软件,分别是:Docker,Kubernetes,etcd,gRPC,CockroachDB,BoltDB,总共包含171个并发Bug。通过分析bug的根源,重现,以及修复来研究这些bug。
研究问题主要关注:消息传递和共享内存哪种进程间通讯机制更不易于出错。相比共享内存,Go则更推崇消息传递的方式。
对于Bug,主要按照两个维度进行分类:
- 按bug的原因分为:共享内存bug和消息传递bug;
- 按bug的影响分为:阻塞bug和非阻塞bug;
Bug示例:
bug1 | fix |
// message passing, blocking bug func finishReq(timeout time.Duration) ob { ch := make(chan ob) go func() { result := fn() ch <- result // block }() select { case result = <-ch: return result case <-time.After(timeout): return nil } } | // message passing, blocking bug func finishReq(timeout time.Duration) ob { ch := make(chan ob, 1) go func() { result := fn() ch <- result // block }() select { case result = <-ch: return result case <-time.After(timeout): return nil } } |
说明:如果timeout了,则goroutine无法正常结束 |
Background and Applications
Goroutine
以goroutine作为并发单元,按 M-N 的方式绑定到内核线程。Go支持使用匿名函数来创建线程,在匿名函数前定义的所有本地变量都可以被子goroutine访问到,然而这很容易造成竞态。
传统的方式 Mutex(lock/unlock), RWMutex(read/write lock), Cond(条件变量), atomic(read/write);新的方式 Once, WaitGroup。不过误用 waitgroup 很容易引发bug。
Message Passing
新的方式 channel,包含 buffered 和 unbuffered。使用 channel 具有一些潜规则(必须初始化、从nil channel读写导致阻塞、往closed channel发送数据或关闭一个closed channel将导致panic)。
select 带有随机性,而这种随机性也有可能引入并发bug。
Go Concurrency Usage Patterns
Goroutine Usages
在实际开发环境中:
- 写的代码是否包含很多goroutine(静态);
- Go程序在运行时是否创建很多goroutine(动态);
论文中统计了6个研究对象每千行代码创建的goroutine数量在0.18~0.83之间,同时除了Kubernetes和BoltDB它们也大量使用匿名函数。
对比 gRPC-Go 和 gRPC-C,在处理相同的请求数量的情况下(因为Go与C性能不同,不能以时间为参考),可以看到:
- gRPC-Go goroutine的数量是 gRPC-C thread的数倍之多;
- gRPC-Go goroutine的运行时间是整个程序运行时间的60%~90%, gRPC-C thread则基本是100%;
观察结果1:相比C,Goroutines生命周期更短但创建得更加频繁。
Concurrency Primitive Usages
统计结果显示,共享内存用得比消息传递更多。
观察结果2:虽然传统的共享内存线程间通讯还保留着重度使用,Go程序员也使用了大量的消息传递方式。
Bug Study Methodology
收集Bug的方式:从github上搜索带有 race、deadlock、 concurrency、mutex、atomic等关键字的提交,然后进行随机抽样和过滤,再人工处理,最后总共包含171个bug。
Bug分类:
- 按bug的行为:
- 如果一个或多个goroutine被卡住,无法继续往前执行,则称之为阻塞bug;
- 如果goroutine可以正常结束,但是他们的目的没有达到,则称之为非阻塞bug;
- 按bug的原因:shared memory or passing messages;
Blocking Bugs
Root Causes of Blocking Bugs
观察结果3:与普遍认知(消息传递不易出错)相反,更多的阻塞性bug是因为消息传递引发的。
Mutex:重复加锁,获取锁的顺序冲突,忘记解锁;
RWMutex:进程A重复获取读锁,进程B获取写锁,导致死锁;
WaitGroup:
bug2 | fix |
var group sync.WaitGroup group.Add(len(pm.plugins)) for _, p := range pm.plugins { go func(p *plugin) { defer group.Done() }(p) group.Wait() } | var group sync.WaitGroup group.Add(len(pm.plugins)) for _, p := range pm.plugins { go func(p *plugin) { defer group.Done() }(p) } group.Wait() |
Misuse of Message Passing
常规的一些bug 如前面提到的(bug 1);另外,当使用 Go 一些特定的库时,也需要额外注意,比如:
bug3 | fix |
ctx := context.Background() hctx, hcancel := context.WithCancel(ctx) if timeout > 0 { hctx, hcancel = context.WithTimeout(ctx, timeout) } | ctx := context.Background() var hctx context.Context var hcancel context.CancelFunc if timeout > 0 { hctx, hcancel = context.WithTimeout(ctx, timeout) } else { hctx, hcancel = context.WithCancel(ctx) } |
说明:WithCancel 创建了一个goroutine,而后如果timeout > 0,hcancel 指向新对象,导致goroutine无法正常结束。 |
观察结果5:所有因消息传递导致的阻塞bug都跟Go新的消息传递语义如channel有关,它们都难以检测特别是跟其它同步机制混用的时候。
Detection of Blocking Bugs
Go提供了内置的死锁监测,然而对于前面的bug,只有少部分能被检测出来。原因是:
- 只要还有goroutine在执行,则不会认为是死锁;
- 只检测了Go并发基元阻塞的goroutine,因等待系统资源而阻塞的goroutine并不考虑;
Non-Blocking Bugs
Root Causes of Non-blocking Bugs
虽然与传统语言相似,有些bug却是因为对Go的新特性理解不透彻导致:
bug4 | fix |
for i := 0; i <= 10; i++ { go func() { fmt.Println(i) }() } | for i := 0; i <= 10; i++ { go func(i int) { fmt.Println(i) }(i) } |
说明:匿名函数导致的竞态 |
另外也有 WaitGroup 的误用导致的bug:
bug5 | fix |
func (p *peer) send(){ p.mu.Lock() defer p.mu.Unlock() switch p.status { case idle: go func(){ p.wg.Add(1) // .. p.wg.Done() }() case stopped: } } func (p *peer) stop(){ p.mu.Lock() p.status = stopped p.mu.Unlock() p.wg.Wait() } | func (p *peer) send(){ p.mu.Lock() defer p.mu.Unlock() switch p.status { case idle: p.wg.Add(1) go func(){ // .. p.wg.Done() }() case stopped: } } func (p *peer) stop(){ p.mu.Lock() p.status = stopped p.mu.Unlock() p.wg.Wait() } |
说明:无法保证goroutine 比 p.wg.Wait() 更早执行 |
channel重复关闭导致的Bug:
bug6 | fix |
select { case <- c.closed: default: close(c.closed) } | Once.Do(func(){ close(c.closed) }) |
说明:当多个goroutine同时执行该代码时,有可能使channel被关闭多次,导致panic |
select 随机性导致的Bug:
bug7 | fix |
ticker := time.NewTicker() for { f() select { case <- stopChn: return case <- ticker: } } | ticker := time.NewTicker() for { select { case <- stopChn: return default: } f() select { case <-stopChn: return case <-ticker: } } |
说明:由于select的随机性,可能导致 stopChn 后 f() 被执行多一次 |
Timer误用导致的Bug:
bug8 | fix |
timer := time.NewTimer(0) if dur > 0 { timer := time.NewTimer(dur) } select { case <-timer.C: case <-ctx.Done(): return nil } | var timeout <-chan time.Time if dur > 0 { timeout = time.NewTimer(dur).C } select { case <-timeout: case <-ctx.Done(): return nil } |
说明:当dur=0时,则select总是直接返回,ctx.Done() 不起作用 |
Detection of Non-Blocking Bugs
Go 提供了数据竞态检测器,可以在 build 的时候加入 -race 标志。
参考:https://songlh.github.io/paper/go-study.pdf
评论