论文笔记《Understanding_Real-World_Concurrency_Bugs_in_Go》
论文会议:ASPLOS
年份:2019
参考文献:Tu T, Liu X, Song L, et al. Understanding real-world concurrency bugs in Go[C]//Proceedings of the Twenty-Fourth International Conference on Architectural Support for Programming Languages and Operating Systems. 2019: 865-878.
该博客中文字部分为个人总结,图片和代码部分均基本出自文献,如有侵权,请指出
1. 论文概述
该论文主要针对 Go 语言中的并发编程,对开源市场上的 6 款主流程序包括 Docker、Kubernetes 和 gRPC 等做了 bug 分析和重现,并分析了他们的 bug fix commit。通过对这些 bug 进行分类和源码分析与修复,得出未来Go 语言可进行工作的方向和需要注意的编程点。
2. 重点
2.1 bug 分类
论文将 bug 从两个正交维度进行分析:
- bug 起因
- 共享内存(锁)
- 消息传递(channel)
- bug 行为
- 阻塞bug(死锁)
- 非阻塞Bug(数据竞态)
2.2 bug 分析指标
论文里提出 统计指标
l i f t ( A , B ) = P ( A B ) P ( A ) P ( B ) lift(A,B) = \frac{P(AB)}{P(A)P(B)} lift(A,B)=P(A)P(B)P(AB)
A 指 bug 的起因类别
B 指修复 bug 策略类别
P(AB)指该 bug 是因 A 引起且被 B 修复的可能性
为1时则表示 AB 无关,大于 1 时则表示正相关
3. 并发编程 分类
为了更好地分析,也总结了Go语言对两种并发编程的支持度:
- 共享内存
- lock/unlock (Mutex)
- read/write lock (RWMutex)
- 条件变量 (Cond)
- 原子操作 (atomic)
- WaitGroup
Go 语言的
RWMutex
与 C 语言的
pthread_rwlock_t
不同,写锁比读锁具有更高的优先级
- 消息传递
- channel: buffered/unbuffered
- select: random
- context: 跨goroutine携带特定于请求的数据或元数据
向
nil channel
发送和接收数据都将永久阻塞,但是向已关闭的
channel
发送数据和再次关闭将引发
panic
4. 对 Go 内部实现的分析
4.1 死锁检测器
go 内置的 死锁检测器 只能检测很少的并发 bug,为了占用最少的系统资源时间
检测能力弱的原因有二:
- 当有少数几个 goroutine 在跑时并不会得出阻塞的结果
- 仅检测 goroutine 是否因 go 并发原语而阻塞,而不检测其是否正在等待系统资源
4.2 数据竞态检测器
go run -race xxx.go
:用于检测数据竞态
在程序执行期间,竞争检测器为每个内存对象最多创建四个指向,以存储对象的历史访问。 它将每个新访问与存储的影子值进行比较,以检测可能的竞争。
当其数据竞态检测器只能检测小部分的非阻塞 bug。原因有三:
- 并非所有非阻塞bug都是数据竞争。竞态检测器并非旨在检测其他类型
- 底层先发生算法的有效性取决于并发goroutine的交织
- 每个存储对象只有四个拷贝指向,检测器无法保留很长的历史记录,并且可能会丢失数据竞争
4.3 并发原语
Go的一些并发原语违反了以往的规则,故这也是造成 bug 的原因之一
急需新技术去和消息传递导致的非阻塞bug 做斗争和检测
5. 源码 bug 分析
本人对其中的主流软件所产生的bug颇感兴趣。
bug 1: 因 channel
引起的阻塞 bug
channel
作为示范,其介绍了 Kubernetes 中的一个消息传递的阻塞 Bug,伪代码如下:
func finishReq(timeout time.Duration) r ob {
// ch := make(chan ob) bug
ch := make(chan ob, 1) // fix
go func() {
result := fn()
ch <- result // block
}
select {
case result = <- ch:
return result
case <- time.After(timeout):
return nil
}
}
finishReq
函数使用匿名函数创建了一个子
goroutine
;子
goroutine
执行函数
fn()
并通过
channel
发送结果给父
goroutine
;而子
goroutine
将会因非缓存
channel
被阻塞,同时,父
goroutine
也会在
select
中被阻塞直到子协程发送
result
给
channel
或
timeout
发生。若
timeout
发生或两者都可选,父函数将可能返回
nil
,并且将没有其他
goroutine
能接收
result
,子
goroutine
将永久阻塞,造成
goroutine
泄露。
解决办法是将无缓冲
channel
声明为有一个缓冲的
channel
这样就可以保证子
goroutine
一定能发送
result
到
channel
了,即不会被阻塞
bug 2:错用 WaitGroup
WaitGroup
var group sync.WaitGroup
group.Add(len(pm.plugins))
for _,p := range pm.plugins {
go func(p *plugin) {
defer group.Done()
}(p)
// group.Wait() bug
}
group.Wait() // fix
Wait()
在循环内,该循环将会被永久阻塞,bug 修复是将
Wait()
移到循环体之外,让
goroutine
全部构建完,确保
Done()
函数能全部执行完
bug 3:因 context
引起的阻塞 bug
context
// —————— bug ——————
htcx, hcancel := context.WithCancel(ctx)
if timeout > 0 {
hctx, hcancel = context.WithTimeout(ctx, timeout)
}
// —————— fix ——————
var htcx context.Context
var hcancel context.CancelFunc
if timeout > 0 {
hctx, hcancel = context.WithTimeout(ctx, timeout)
} else {
hctx, hcancel = context.WithCancel(ctx)
}
在 bug 的版本中,针对
context
初始化了两次,一次是
WithCancel
,一次是
WitchTimeout
。而在 Go 1.14 源码中,两次都会在源码中,进行
goroutine
的创建,创建的源码如下:
// propagateCancel arranges for child to be canceled when parent is.
func propagateCancel(parent Context, child canceler) {
...
if p, ok := parentCancelCtx(parent); ok {
...
} else {
atomic.AddInt32(&goroutines, +1)
// 创建了 goroutine 并且会阻塞直到父子协程 done
go func() {
select {
case <-parent.Done():
child.cancel(false, parent.Err())
case <-child.Done():
}
}()
}
}
这将导致没有办法去发送消息把
WithCancel
创建的
goroutine
关闭掉。
解决办法是避免创建额外的
context
,初始化只一次就够,不要改变指向
bug 4:因 channel
和 lock
的混合使用导致的阻塞 bug
channel
lock
func goroutine1() {
m.Lock()
// ch <- request bug-block
// fix
select {
case ch <- request:
default:
}
m.UnLock()
}
func goroutine2() {
for {
m.Lock() // block
m.UnLock()
request <- ch
}
}
在 bug 版本中,当
goroutine1
执行到
ch <- request
的时候,其将阻塞;当
goroutine2
执行到
m.Lock()
的时候,也会阻塞,这就导致了循环等待而死锁。
解决办法之一是使用
select
加
default
使
goroutine1
释放锁资源
bug5:因匿名函数导致的数据竞态
for i := 17;i <= 21;i++ {
// bug
go func() {
apiVersion := fmt.Sprintf("v1.%d", i)
...
}()
// fix
go func(i int) {
apiVersion := fmt.Sprintf("v1.%d", i)
...
}(i)
}
在
go
关键字后进行协程创建时,创建需要一定资源和运行时间,此时外部循环 i 已经运行到 21,在 bug 版本中,将只能得到 1.21
bug6:因滥用 WaiGroup
而导致的非阻塞bug
WaiGroup
func (p *peer) send() {
p.mu.Lock()
defer p.mu.UnLock()
switch p.status {
case idle:
// fix
p.wg.Add(1)
go func() {
// p.wg.Add(1) bug
...
p.wg.Done()
}()
case stopped:
}
}
func(p *peer) stop() {
p.mu.Lock()
p.status = stopped
p.mu.UnLock()
p.wg.Wait()
}
在 bug 版本中,无法保证在
goroutine
中的
Add()
一定在
Wait()
前执行,有可能直接执行了
Wait()
解决办法是将
Add()
方法从
goroutine
创建中移出,与
Wait()
处于同一级别中。
bug7:因关闭 channel
两次而导致的bug
channel
// ———— bug ————
select {
case <- c.closed:
default:
close(c.closed)
}
// ———— fix ————
Once.Do(func() {
close(c.closed)
})
当有多个
goroutine
运行时,将有可能同时运行到
default
并进行多次关闭而导致抛出
panic
解决办法是使用
Once
进行一次关闭
bug8:因 select
和 channel
导致的非阻塞 bug
select
channel
ticker := time.NewTicker()
for {
// fix add begin
select {
case <- stopCh:
return
default:
}
// fix add end
f() // heavy function
select {
case <- stopCh:
return
case <- ticker:
}
}
在没有添加
fix
的版本中,当执行到 select 的时候,若从
stopCh
中接收到消息的同时,
ticker
也同样触发了,则无法保证哪项会被选择,如果选择了
case2
,则
f()
将会无用功地执行了一遍
解决办法是在
f()
之前,每次循环前去捕获
stopCh
的信号
bug9:因 Timer
导致的非阻塞 bug
Timer
// bug
timer := time.NewTimer(0)
if dur > 0 {
timer = time.NewTimer(dur)
}
select {
case <-timer.C:
case <- ctx.Done():
return nil
}
// fix
var timeout <-chan time.Time
if dur > 0 {
timeout = time.NewTImer(dur).C
}
select {
case <-timeout:
case <-ctx.Done():
return nil
}
与 bug3 类似,但是,当dur不大于0时,库内部
goroutine
将在创建计时器后立即向
timer.C
通道发送信号,从而导致函数过早返回(第8行)。 解决方法是避免在第1行创建计时器。
补充
在 Go 1.14 中,若传入
NewTimer
的整数
d <= 0
,将会抛出
panic
6. 分析总结
观察结论1
通过分析几款主流软件和分析Go语言和其他语言的对比,发现
goroutine
的执行时间比 C 语言短,并且创建更为频繁,静态和运行时皆如此。
观察结论2
分析几款主流软件源码后发现,共享内存并发编程还是广泛主要使用的,但消息通信也有占用一部分的代码量
意义1
随着goroutines的大量使用和新型并发原语的出现,Go程序可能会引入更多的并发bug。
观察结论3
与普遍认为消息传递不太容易出错相反,在研究Go应用程序中,更多的阻塞错误是由错误的消息传递引起的,而不是由错误的共享内存保护引起的。
观察结论4
尽管有的错误是传统并发错误,但也可以合理推测有的错误是因为 Go 的新型的内部实现方式或语义造成的 bug
由共享内存同步引起的大多数阻塞错误都具有与传统语言相同的原因和修复。 但是,由于Go对现有原语的新实现或新的编程语义,其中一些 bug 又与传统语言有所不同。
观察结论5
由消息传递引起的所有阻塞错误都与Go的新语法(例如 channel)有关。 它们可能很难检测到,尤其是当消息传递操作与其他同步机制混用时。
意义2
与通常的看法相反,消息传递比共享内存可能导致更多的阻塞错误。 论文呼吁
注意消息传递编程中的潜在危险,并提出该领域中的错误检测的研究问题。
观察结论6
论文研究中的大多数阻塞错误(传统的共享内存错误和消息传递错误)都可以通过简单的解决方案来解决,并且许多修复都与错误原因直接相关。
意义3
Go阻塞bug产生的原因和修复之间的高度相关性及其修复的简单性表明,有希望开发出全自动或半自动化的工具来修复Go的阻塞bug。
意义4
简单的运行时死锁检测器对检测 Go阻塞bug 无效。 未来的研究应侧重于构建新颖的阻止错误检测技术,例如,结合使用静态和动态阻塞模式检测。
观察结论7
大约三分之二的 共享内存 非阻塞bug 是由传统原因引起的。 Go的新多线程语义和新库贡献了其余的三分之一。
意义5
Go引入的用于简化多线程编程的新编程模型和新库本身可能是更多并发性错误的原因。
观察结论8
与共享内存访问相比,消息传递引起的非阻塞bug要少得多。 通道规则以及将通道与其他特定于Go的语义和库一起使用的复杂性是这些无阻塞bug发生的原因。
意义6
如果使用正确,则消息传递比共享内存访问不太容易发生非阻塞bug。 但是,消息传递的复杂设计可能会导致在与其他特定于语言的功能结合时很难发现这些错误。
观察结论9
传统的共享内存同步技术仍然是Go中非阻塞错误的主要修复方法,而channel不仅被广泛用于修复与通道相关的错误,而且还用于修复共享内存的错误。
意义7
尽管Go程序员继续使用传统的共享内存保护机制来修复非阻塞性错误,但在某些情况下,他们更喜欢使用消息传递修复,这可能是因为他们将消息传递视为更安全的跨线程通信方式。
意义8
简单的传统数据竞赛检测器无法有效检测所有类型的Go非阻塞错误。 未来的研究可以利用我们的错误分析来开发更具信息性,特定于Go的非阻塞错误检测器。