阅读 45
理解真实项目中Go并发的Bug(Understanding Real-World Concurrency Bugs in Go)

理解真实项目中Go并发的Bug(Understanding Real-World Concurrency Bugs in Go)

本文内容源于论文《Understanding Real-World Concurrency Bugs in Go》,从6个非常流行的开源项目中,收集了171个并发bug,从传统的共享内存访问、Go语言新的并发原语的特性方面入手,研究了并发bug产生的原因以及修复的方法,以便使Go研发人员更好的理解Go并发模型以及使用Go语言编写出更稳定、健壮的软件系统。

table1-对开源项目的研究列表.jpeg 表1中列出了选择的6个开源项目包括数据中心容器系统(Docker、Kubernetes)、分布式key-value存储系统(etcd)、数据库系统(CockroachDB、BoltDB)和gRPC。从星级(starts)看都是流行的开源项目。研发的年份至少3年以上。项目规模从几千行代码到百万行代码不等。 可以看出,选择的项目非常具有代表性。

table2-创建的协程数.jpeg

表2表明各项目中都大量的使用了协程。和最后一行的gRPC-C(用C语言实现的)线程相比可知,gRPC-C的每千行代码平均创建0.03个线程,而用Go实现的项目,平均从千行代码平均0.18个协程,到0.83个协程。

table4-各项目使用的并发原语.jpeg 表4中显示的是各项目使用的并发原语的占比统计。其中传统的共享内存访问中主要集中在Mutex原语上,而消息传递原语的使用则主要集中在Channel的使用上。由此可以看出,Go虽然推荐在协程之间“使用通信来共享内存,而不是通过共享内存来通信”,但由该表可知,Go同时支持共享内存和通道通信两种并发模式。而且,在实际项目中,使用共享内存相关原语还多于通道通信的并发模式。

table5-并发分类行为和原因.jpeg

该研究基于这6个开源项目,共收集了171个并发bug,并将这171个并发bug分为两个维度:引起bug的原因和bug的表现行为(阻塞bug和非阻塞bug)。

阻塞bug

table6-阻塞bug引起的原因.png 表格6显示了阻塞bug的原因统计。根据该表显示,在收集到的82个bug中共计36个bug是因为对共享内存访问的保护错误导致的,有46个是因为误用消息传递导致的。

  • 对共享内存访问导致的bug进一步细化分析:
    • 有28个是因为Mutex的使用不正确,包括重复获取锁,获取锁的顺序存在冲突,忘记释放锁等操作。
    • 5个在RWMutex上。在Go中写锁比读锁有更高的优先级。如果一个协程A先执行一次读锁即sync.RWMutex.RLock(),然后一个协程B进行获取写锁操作sync.RWMutex.Lock(),然后协程A再进行获取读锁操作,sync.RWMutex.RLock()。 这样就会形成一个死锁。因为A第一读锁可以获取成功,然后协程B获取写锁时,会被阻塞。然后协程A再次获取读锁时,也会被B的写锁堵塞住。
    • 3个在Wait上。一般是一个进程使用了Cond.Wait(),但没有其他协程调用Cond.Signal()来解除等待。
  • 对消息传递导致的bug进一步细化分析:
    • 有29个是因为误用Channel。一般和通道相关的阻塞bug是因为没有向通道发送消息(或从通道接收消息)或关闭通道,而导致正在等待从通道接收消息(或等待往通道发送消息)的协程阻塞。
    • 有16个bug是因为通道和其他阻塞原语一起使用造成的。比如一个协程因为通道阻塞,另一个协程因为锁或wait操作阻塞。
    • 有4个bug是因为误用Go中的消息库造成的。

根据以上的阻塞bug的原因,那么对应的修复bug的方法一般如下:

  • 通过添加缺少的解锁操作
  • 移动lock或unlock操作到合适的未知
  • 移除多余的锁操作
  • 在select语句中增加default分支或在一个不同通道上的case操作
  • 将unbuffered channel替换成buffered chanel

如图表7中,展示了对阻塞bug的修复策略的总结。从对并发原语的添加、移动位置、改变、移除或混合使用共享内存和消息通讯的并发原语来解决阻塞的并发bug。

table7-阻塞bug修复方法统计.jpeg

由此可见,在该研究中(传统的共享内存的方式和消息传递的方式)的大部分阻塞bug都可以通过简单的方案修复,并且很多修复都是跟bug引起原因相关的。

也就是说,阻塞bug引起的原因一般是由对共享内存的原语和消息传递到原语使用不当造成的。同时在Go中,错误的使用消息传递的方式导致的阻塞bug多余错误的使用共享内存原语,高达58%。然而在解决阻塞bug时的方法也很简单,一般通过移动、删除、添加对应解锁原语即可解决

非阻塞bug

非阻塞bug一般是表现为协程之间产生数据竞争,而引起数据竞争的主要原因还是因为没有对共享内存进行保护或错误的保护了共享内存访问。

table9-非阻塞bug引起的原因.png

表9统计了非阻塞bug引起的原因。在收集的bug中,大概有80%的是因为没有保护共享内存访问或保护错误。

  • 对共享内存访问导致的bug进一步细化分析:
    • 传统的bug:大部分是因为类似原子性,顺序冲突或数据竞争造成的。
    • 匿名函数:在Go中可以通过匿名函数来启动协程,这样匿名函数就可以访问本地的变量,如果使用不当,就加大了数据竞争的机会。
    • 误用WaitGroup。这是Go中的新特性,由于对WaitGroup使用的理解不足,造成在调用Wait和Add的时候顺序不一致,造成非阻塞bug。
    • 对Go提供的库函数理解不足。Go中提供了很多库函数,这些库函数可能会隐式的存在变量共享,如果使用不正确,则会非常容易造成非阻塞bug。
  • 对消息传递导致的bug进一步细化分析
    • 误用通道: 在Go中使用通道需要遵循一些基本原则,比如通道只能关闭一次,select的case语句中都准备好时,是随机选择case分支的
    • Go中提供的特殊库的使用:Go中有些库使用了通道,研发人员在使用该库时如果对其内部不了解,也容易因为误用而造成非阻塞bug。
    针对以上问题,我们看下对非阻塞bug的修复策略,如表10所示。

table10-修复非阻塞bug的策略.jpeg

表10展示了非阻塞bug的修复策略。根据表10可知:

  • 69%的非阻塞bug可以通过严格的时间顺序进行修复,或者通过增加像Mutex这样的同步原语,或移动已有的同步原语到合适的未知,类似于Add。
  • 通过对共享变量进行私有化
  • 通过移除共享变量访问的指令。

并发Bug示例展示

  • 示例1:该示例节选自Docker项目,是由WaitGroup引起的阻塞Bug
1 var group sync.WaitGroup
2 group.Add(len(pm.plugins))
3 for _, p := range pm.plugins {
4	go func(p *plugin) {
5		defer group.Done()
6	}
7 -	group.Wait()
8 }
9 +group.Wait()
复制代码

该示例中的bug是因为WaitGroup类型的共享变量group引起的。因为在第2行,len(pm.plugins)被用做了Add的参数,所有只有当第5行的group.Done()被调用len(pm.plugins)次时,第7行的group.Wait()才会被解除阻塞。因为Wait的调用放在了for循环的内部,所以,它会阻塞for循环在第4行后续的协程的创建,并且也阻塞了每个被创建协程的Done函数的调用。那么修复方法就是将Wait方法移动到for循环外,如示例中的第9行。

  • 示例2:由channel和lock的错误使用导致的阻塞bug
1 func goroutine1() {
2 	m.Lock()
3 	ch <- request //blocks
4 	select {
5 		case ch <- request
6 		default:
7 	}
8 	m.Unlock()
9 }

10 func goroutine2() {
11 	for {
12		m.Lock() //blocks
13		m.Unlock()
14		request <- ch
15	}
16 }
复制代码

该示例中,goroutine1和goroutine2两个协程,同时共享父协程的非缓冲通道ch。因为在第3中的ch输入,只有在第14行goroutine2从ch读取request之后才能写入成功,所以goroutine1在第3行将request发送到channel中时被阻塞,同时第12行goroutine2在m.Lock()的位置被阻塞,因为第2行goroutine1中已经进行了m.Lock()。所以就造成了死锁。

修复办法就是将第3行去掉,增加4-7行的select-case-default分支。

  • 示例3:由匿名函数引起的数据竞争的非阻塞bug,该bug也是来源于Docker项目
1 for i := 17; i <= 21; i++ {// write
2-	 go func() { /*Create a new goroutine*/
3+	 go func(i int) {
4			 apiVersion := fmt.Sprintf("v1.%d", i) //read
5			 ...
6-		}()
7+		}(i)
8}
复制代码

父进程和第2行的子协程共享变量i,研发者的意图是每个子协程都用不同的i值初始化apiVersion变量。然而,在这个程序中apiVersion的值是不确定的。这跟go中子协程的调度时机有关系。例如,子协程开始执行的时间是在整个for循环之后,那么apiVersion值就会是"v1.21"。只有当每个子协程在创建字符串apiVersion变量之后且在变量i被分配新值之前就立即初始化apiVersion变量,那么该程序才能得到期望的结果。Docker研发者就通过每次创建协程的时候就拷贝一个i值来修复了此bug。

  • 示例4:该示例展示了一个由Timer导致的非阻塞bug
 1 - timer := time.NewTimer(0)
 2 + var timeout <-chan time.Time
 3 	 if dur > 0 {
 4 -		timer = time.NewTimer(dur)
 5 +		timeout = time.NewTimer(dur).C
 6		}
 7		select {
 8 -		case <- timer.C:
 9 +		case <- timeout:
10			case <- ctx.Done()
11				return nil
12		}
复制代码

上面示例中,程序的意图是设计一个计时器。在第1行,创建了一个timer对象,超时时间是0。在创建Timer对象的同时,Go运行时环境就会隐式的开启一个内部的协程,以供倒计时用。在第4行timer的超时时间被设置为dur。开发者意图是仅当dur大于0或当ctx.Done()的时候从当前函数中返回。然而,当dur小于等于0时,Go运行时创建的倒计时的协程将会在timer创建的时候就会给timer.C通道发送信号量,在第8行导致函数过早的返回。

  • 示例5:该bug来自etcd项目,由于误用WaitGroup导致的非阻塞bug
1 func(p *peer) send() {
2		p.mu.Lock()
3		defer p.mu.Unlock()
4		switch p.status {
5			case idle:
6 +			p.wg.Add(1)
7				go func() {
8 -				p.wg.Add(1)
9					...
10				p.wg.Done()
11			}()
12		case stopped:
13	}
14 }

15 func (p *peer) stop() {
16		p.mu.Lock()
17		p.status = stopped
18		p.mu.Unlock()
19		p.wg.Wait()
20 }
复制代码

该bug中,在第8行的Add函数不一定能够保证在第19行的Wait语句之前执行。修复方法是将第8行的Wait函数移动到第6行,这样就能保证Add函数一定能在Wait函数之前运行。

文章分类
后端
文章标签