3. Go并发使用模式
在研究Go并发bugs之前,首先了解现实世界的Go并发程序是怎么样的。本节将介绍我们选择的六个应用程序中的基本程序用法和Go并发原语用法的静态和动态分析结果。
3.1 Goruntine 用途
要理解Go中的并发性,我们首先应该了解goroutine在真实世界的Go程序中是如何使用的。Go中的一个设计理念是使goroutine轻量级且易于使用。因此,我们会问“真正的Go程序员是否倾向于使用许多goroutine(静态)编写代码?”和“真正的Go应用程序是否在运行时(动态)创建了大量goroutine?”
为了回答第一个问题,我们收集了goroutine创建站点的数量(即创建goroutine的源代码行)。表2总结了结果。总体而言,这六个应用程序使用了大量的goroutine。每千条源代码的平均创建位置范围在0.18到0.83之间。我们进一步将创建站点分为使用普通函数创建goroutine的站点和使用匿名函数的站点。除了Kubernetes和BoltDB之外,所有应用程序都使用更多的匿名函数。
表格2 协程/线程创建站点的数量。使用普通函数和匿名函数的goroutine/线程创建站点的数量,创建站点的总数,以及每千行代码的创建站点。
应用程序 | 普通函数 | 匿名函数 | 站点总数 | 每千行代码的创建站点 |
---|---|---|---|---|
Docker | 33 | 112 | 145 | 0.18 |
Kubernetes | 301 | 233 | 534 | 0.23 |
etcd | 86 | 211 | 297 | 0.67 |
CockroachDB | 27 | 125 | 152 | 0.29 |
BoltDB | 2 | 0 | 2 | 0.22 |
gRPC-C | 5 | - | 5 | 0.03 |
gRPC-Go | 14 | 30 | 44 | 0.83 |
为了理解Go和传统语言之间的区别,我们还分析了gRPC的另一种实现,即gRPC-C,它是在C/C++中实现的。gRPC-C包含140K行代码,也由Google的gRPC团队维护。与gRPC-Go相比,gRPC-C具有惊人的很少创建线程(每个KLOC只有五个创建站点和0.03个站点)。
我们进一步研究了goroutine的运行时创建。我们运行了gRPCGo和gRPC-C来处理三个性能基准,这些基准旨在比较用不同编程语言编写的多个gRPC版本的性能。这些基准测试使用不同的消息格式、不同数量的连接以及同步和异步RPC请求来配置gRPC。由于gRPC-C比gRPC-Go更快,因此我们运行gRPC-C和gRPC-Go来处理相同数量的RPC请求,而不是相同的总时间量。
表3显示了运行这三个工作负载时,gRPC-Go中创建的goroutine数与gRPC-C中创建的线程数的比率。
在客户端和服务器端的不同工作负载上创建了更多的goroutine。表3还展示了我们对goroutine运行时持续时间的研究结果,并将其与gRPC-C的线程运行时持续进行了比较。由于gRPC-Go和gRPC-C的总执行时间不同,比较绝对goroutine/线程持续时间没有意义,因此我们报告并比较相对于gRPC-Go与gRPC-C总运行时间的goroutine/thread持续时间。具体来说,我们计算所有goroutine/线程的平均执行时间,并使用程序的总执行时间将其标准化。我们发现gRPC-C中的所有线程都从整个程序的开始到结束执行(即100%),因此表3中只包含了gRPC-Go的结果。
对于所有工作负载,goroutine的标准化执行时间都比线程短。
工作量 | 协程/线程 | 平均执行时间 | ||
---|---|---|---|---|
客户端 | 服务端 | 客户端-Go | 服务端-Go | |
g_sync_ping_pong | 7.33 | 2.67 | 63.65% | 76.97% |
sync_ping_pong | 7.33 | 4 | 63.23% | 76.57% |
qps_unconstrained | 201.46 | 6.36 | 91.05% | 92.73% |
发现1: Goroutine的执行时间比C更短,但创建频率更高(无论在静态还是运行时)。
3.2 并发原语用法
在对现实世界Go程序中的goroutine用法有了基本了解之后,我们接下来将研究goroutine如何在这些程序中进行通信和同步。具体来说,我们计算了六个应用程序中不同类型并发原语的使用情况。表4显示了总的(原语使用的绝对数量)以及每种类型的原语占总原语的比例。共享内存同步操作的使用频率高于消息传递,Mutex是所有应用程序中使用最广泛的原语。对于消息传递原语,chan是使用频率最高的一种,从18.48%到42.99%不等。
应用程序 | 共享内存 | 消息传递 | 总和 | |||||
---|---|---|---|---|---|---|---|---|
锁(读写、互斥锁) | 原子操作 | Once操作 | WaitGroup | Cond | chan | Misc. | ||
Docker | 62.62% | 1.06% | 4.75% | 1.70% | 0.99% | 27.87% | 0.99% | 1410 |
Kubernetes | 70.34% | 1.21% | 6.13% | 2.68% | 0.96% | 18.48% | 0.20% | 3951 |
etcd | 45.01% | 0.63% | 7.18% | 3.95% | 0.24% | 42.99% | 0 | 2075 |
CockroachDB | 55.90% | 0.49% | 3.76% | 8.57% | 1.48% | 28.23% | 1.57 | 3245 |
gRPC-GO | 61.20% | 1.15% | 4.20% | 7.00% | 1.65% | 23.03% | 1.78% | 786 |
BoltDB | 70.21% | 2.13% | 0 | 0 | 0 | 23.40% | 4.26% | 47 |
我们进一步比较了gRPC-C和gRPC-Go中并发原语的用法。gRPC-C只使用锁,它在746个地方使用(每个KLOC使用5.3个原语)。gRPC Go在786个位置使用八种不同类型的原语(每个KLOC使用14.8个原语)。显然,与gRPC-C相比,gRPCGo使用的并发原语数量更大,种类也更多。
接下来,我们将研究并发原语的用法如何随时间变化。图2和图3显示了2015年2月至2018年5月六个应用程序中共享内存和消息传递原语的使用情况。总的来说,随着时间的推移,这些用法趋于稳定,这也意味着我们的研究结果将对未来的Go程序员有价值。
发现2:尽管传统的共享内存线程通信和同步仍然被大量使用,但Go程序员也使用大量的消息传递原语。
启发1:随着goroutine和新类型的并发原语的大量使用,Go程序可能会引入更多的并发bug。
4 Bug的研究方法
本节将讨论我们如何在本研究中收集、分类和复制并发错误。
收集并发错误。为了收集并发错误,我们首先过滤了六个应用的GitHub提交历史,
通过在提交日志中搜索与并发相关的关键字,包括“竞争”、“死锁”、“同步”、“并发”、“锁”、“互斥锁”、“原子操作”、“competite”、“上下文”、“once”和“goroutine 泄漏”。其中一些关键字在以前的工作中用于收集并发性其他语言的错误。其中一些与Go引入的新并发原语或库有关,例如“once”和“context”。其中之一,”goruntine 泄漏“,与Go中的一个特殊问题有关。总共,我们发现3211个不同的提交符合我们的搜索条件。
然后,我们对过滤后的提交进行随机抽样,确定修复并发错误的提交,并手动研究它们。许多与bug相关的提交日志也提到了相应的bug报告,我们还研究这些报告进行bug分析。我们总共研究了171个并发漏洞。
Bug 分类。我们提出了一种根据两个正交维度对Go并发错误进行分类的新方法。第一个维度是基于bug的行为。如果一个或多个goroutine在执行过程中被无意地卡住而无法前进,我们称这种并发问题为阻塞错误。相反,如果所有的goroutine都可以完成任务,但它们的行为不被期望,我们称这一类为非阻塞的。大多数先前的并发bug研究将bug分为死锁bug和非死锁bug, 其中死锁包括跨多个线程循环等待的情况。我们对阻塞的定义比死锁更广泛,包括没有循环等待但一个(或多个)goroutine等待其他goroutine无法提供的资源的情况。正如我们将在第5节中所展示的,相当多的Go并发bug都属于这种类型。我们认为,随着像Go这样的新语言的新编程习惯和语义,我们应该更加关注这些非死锁阻塞错误,并扩展传统的并发错误分类机制。
第二个维度是并发bugs的原因。当多个线程尝试通信时,并发错误就会发生,并且在通信过程中会发生错误。因此,我们的想法是通过不同的goroutine如何通信来分类并发错误的原因:通过访问共享内存或消息传递。这种分类可以帮助程序员和研究人员选择更好的方式来执行线程间通信,并在执行此类通信时检测和避免潜在错误。
根据我们的分类方法,共有85个阻塞错误和86个非阻塞错误,共有105个错误是由错误的共享内存保护引起的,66个错误是由于错误的消息传递引起的。表5显示了每个应用程序中错误类别的详细细分。
应用程序 | 行为 | 原因 | ||
---|---|---|---|---|
阻塞 | 非阻塞 | 共享内存 | 消息传递 | |
Docker | 21 | 23 | 28 | 16 |
Kubernetes | 17 | 17 | 20 | 14 |
etcd | 21 | 16 | 18 | 19 |
CockroachDB | 12 | 16 | 23 | 5 |
gRPC | 11 | 12 | 12 | 11 |
BoltDB | 3 | 2 | 4 | 1 |
总计 | 85 | 86 | 105 | 66 |
我们进一步分析了我们研究的bug的生存时间,即从将bug代码添加(提交)到软件到在软件中修复(提交bug修复补丁)的时间。如图4所示,我们研究的大多数bug(共享内存和消息传递)都有很长的生存时间。我们还发现,报告这些错误的时间接近修复它们的时间。这些结果表明,我们研究的大多数错误都不容易被触发或检测到,但一旦被触发,它们很快就被修复了。因此,我们认为这些bug是非常重要的,值得仔细研究。
复制并发错误。为了评估内置的死锁和数据竞争检测技术,我们复制了21个阻塞错误和20个非阻塞错误。为了重现bug,我们将应用程序回滚到bug版本,构建bug版本,并使用bug报告中描述的bug触发输入运行构建的程序。我们利用错误报告中提到的症状来决定是否成功复制了错误。由于其非确定性,并发错误很难再现。有时,我们需要多次运行一个有问题的程序,或者手动为一个有错误的程序添加睡眠。对于未再现的bug,这要么是因为我们没有找到一些依赖库,要么是因为没有观察到所描述的症状。
有效性的威胁。对我们研究有效性的威胁可能来自多个方面。我们选择了六个具有代表性的Go应用程序。在Go中实现了许多其他应用程序,它们可能没有相同的并发问题。我们只研究了已修复的并发错误。可能有其他并发bug很少被复制,开发人员也从未修复过。对于一些固定的并发错误,提供的信息太少,难以理解。我们的研究中没有包含这些错误。尽管有这些限制,我们还是尽了最大努力收集真实世界的Go并发错误,并进行了全面而公正的研究。我们相信,我们的研究结果足够普遍,足以启发和指导未来Go并发bug的研究。