理解真实世界中 Go 的并发 BUG( 三 )


最后 , 关于阻塞性bug , 文章认为消息传递机制更容易造成更多类型的bug 。
2、非阻塞性bug统计如下:
理解真实世界中 Go 的并发 BUG文章插图
img
(1)对共享内存的保护失败
已有很多研究发现 , 未保护共享内存或保护错误是造成数据竞争或其他非阻塞性bug的主要原因 。 本文也发现80%非阻塞性bug都归因于未保护或错误地保护共享内存 。 但go中的情况和传统编程语言的情况也并非完全相同 。
传统bug:超过一半非阻塞性bug都是由于传统问题造成的 , 就跟在Java、C这些编程语言中一样 , 如原子操作的破坏、顺序混乱、数据竞争 。 有几个bug是对go新特性的不够理解造成的 , 如:Docker#22985 和 CockroachDB#6111 是由于将一个变量的引用通过Channel在不同协程间传递 , 从而造成了共享变量的竞争状态 。
匿名函数:Go语言中在一个函数前加go关键字就可以启动协程 , 这个函数是可以没有名字的(匿名) 。 在匿名函数之前定义的所有局部变量 , 在匿名函数中都是可见的 。 不幸的是 , 由于开发者可能不够注意对这些在不同协程中的共享变量做保护 , 从而可能容易导致数据竞争的bug 。 有11个bug就是这种类型 , 其中9个是父协程和子协程之间的数据竞争 , 2个是两个子协程之间的数据竞争 。 如下图的一个例子 , 含bug的版本中 , 变量i在父协程和子协程之间共享了 , 开发者想要得到不同的i值所生成的apiVersion , 但是如果在父协程的for循环结束后子协程才运行起来 , 那所有的apiVersion都将等于”v1.21” 。 解决方案就是将i作为参数传递到子协程中 , 此时传递的是i的拷贝 。
理解真实世界中 Go 的并发 BUG文章插图
img
WaitGroup的误用:使用WaitGroup的一个基本准则是 , Add必须在Wait之前执行 。 有6个bug是因为违反了这条准则 。 如下图所示 , 这是etcd中的一个bug , 这里是无法保证func1中行8的Add一定在func2中行5的Wait之前执行的 。 解决方案就是将Add操作遇到行6的位置 , 保证要么Add在Wait之前执行 , 要么根本不会执行到idle这个case 。
理解真实世界中 Go 的并发 BUG文章插图
img
特定库函数:go中有些类库的变量是隐式在多协程中共享的 。 如context就被设计为可以被多个关联协程访问 。 etcd#7816就是因为在多个协程中竞争使用一个context对象的一个字符串字段导致的 。
另一个例子是testing包 。 测试函数只有一个testing.T类型的变量 , 这个变量用于传递测试状态如error何日志 。 有3个bug就是在测试函数以及测试函数内启动的子协程之间竞争使用testing.T变量导致 。
(2)消息传递中的错误
channel的误用:前面也提到过 , channel的使用需要遵循一定的规则 , 否则就会引起一些bug 。 如下图所示(Docker#24007) , 可能有多个协程会运行到这段代码 , 其中可能有多个跑到了select的default分支 , 导致对channel的多次关闭 , 从而引发panic 。 这种情况 , 可以使用Once.Do将关闭channel的语句包起来 , 保证它只会执行一次 。
理解真实世界中 Go 的并发 BUG文章插图
img
还有一种类型是将channel和select一起使用 , 当select收到多个case的消息时 , 是没办法保证会执行哪一个的 , 这种非确定性的选择 , 导致了3个bug 。 下图是一个例子 , 其中f函数执行耗时操作 , 当它执行完之后 , stopCh的消息和ticker有可能同时到达 , 此时并不一定会执行到11行return语句 , 也有可能执行到case <- ticker 这里 , 从而继续循环 , f()没必要地多执行了一次 。 这种情况下 , 应该在f()执行的前后都判断一下是否该退出循环 。