Golang Go语言中理解现实中并发漏洞
Golang Go语言中理解现实中并发漏洞
对于编程(数据)模型的设计不仅会使得一些问题变得易于(或更难)解决,也会导致某些类型的漏洞更容易(或更难)产生、侦测和修复。本文的研究对象就是 Go 语言的并发机制。在深入研究之前先思考一下会更有意思,你现在对 Go 语言可能会有以下几点认识:
- Go 语言明显是被设计为服务于并发编程的,想要使其变得更简单且不易出错
- Go 语言确实让并发编程变更简单且不易出错了
- Go 程序大量使用 channel 的消息传递机制,相较于共享内存的同步机制,它更不容易出错
- Go 程序的并发漏洞更少
- Go 语言内置的对于死锁和数据竞争的侦测能捕获所有(绝大多数)的代码漏洞
这些陈述中的第一点是毋庸置疑的。至于其它几点,你可以先参考本文的研究数据,再重新评估一下你还会有多坚决地继续持有这些观点...
我们使用真实的 Go 程序应用来进行对于并发漏洞的第一个系统性研究。我们研究了六个 Go 的软件(项目),包括:Docker,Kubernetes 和 gRPC。我们一共分析了 171 个并发相关的漏洞,其中超过一半是非传统的、Go 语言特有的问题。除了造成这些漏洞的根本原因外,我们还研究了它们的修复方式,通过实验重现这些漏洞,并使用两个公开的 Go 漏洞侦测工具进行了漏洞扫描。
这六个用于研究的应用是:Docker,Kubernetes,etcd,CockroachDB,gRPC 以及 BoltDB,显然这些都是现实世界中重量级的 Go 代码。
在继续研究它们的并发漏洞之前,我们先从研究这些应用实际是如何使用 Go 的并发原语开始。这些漏洞可以从两个主要的维度进行分类:表现行为(阻塞或非阻塞),以及造成问题的并发原语的机制(共享内存或消息传递)。我们先快速回顾一下 Go 语言的主要并发机制。
Go 的并发机制
Go 语言的一个主要设计目的,就是改进传统的多线程编程方式,简化并发编程使其不易出错。为了达到这个目的,Go 语言将它的多线程设计汇聚在了两点原则上:1 )使线程(称之为 goroutines,go 协程)变得轻量且易于创建; 2 )使用显式的消息传送(通过 channels 实现)进行线程通信。
Go 协程是轻量的用户态线程(「绿色」线程)。在函数调用(包括匿名函数)前面加上 go 关键字,就能创建一个协程。匿名函数可以访问到在其之前申明的本地变量,而且它们是被共享使用的。Channels 用于在协程之间传送数据和状态,而且可以使用缓冲或不使用缓冲。当使用无缓冲 channel 的时候,一个协程在发送(或者接收)时会被阻塞,直到其它的协程进行了数据接收(或者发送)。select 语句可以让一个 go 协程同时监听多个 channel,如果多于一个 channel 可用的时候,Go 会随机选择一个分支执行。Go 语言还支持传统的同步机制原语包括互斥,条件变量和原子变量。
Go 并发原语在实践中的应用
这六个应用都大量使用了 Go 协程,尤其是用于匿名方法。
在研究 gRPC 的时候,由于它既有 C 的实现也有 Go 的实现,比较起来结果就很有趣。下面的表格展示了处理相同数量的请求时,使用 gRPC-Go 和 gRPC-C 创建的协程数量比率。
在对比表格中,go 协程相比 C 版本创建的线程有更短的生命周期,但是创建的频度更高。这种高频繁使用协程的行为是 Go 语言所推崇的。
如果我们审视所有这些应用对并发原语的使用统计,会有一个更加令人惊讶的发现,共享内存的同步操作仍然比消息传递使用得多。
最常出现的消息传递原语是 chan,它的使用量中在所有应用中占比 18.5% 到 43%。所以,现在的情形是传统的共享内存方式的通信还是被大量使用,与大量的消息传递原语同时并存。从漏洞的角度来看,我们拥有了不漏洞类型发生的可能性数据:共享内存通信造成的,消息传递造成的以及两者共同作用造成的!
Go 并发漏洞
作者搜索了这些应用的 Github 提交历史,从中找到了修复并发漏洞的提交(共 3211 个)。从这些漏洞中随机选取了 171 个用于研究。
这些漏洞被分为阻塞漏洞和非阻塞漏洞。当一个或多个协程在执行中意外卡主无法推进时,阻塞漏洞就产生了。这个定义比死锁更宽泛,包含了循环等待以外的情况,但是不包括对其它非协程提供资源的等待。其中包含 85 个阻塞漏洞和 86 个非阻塞漏洞。
我们还从另一个维度对漏洞进行了划分,看它们是否与共享内存保护相关( 105 个)还是和消息传递相关( 66 个)。
阻塞型漏洞
首先让我们来看看阻塞漏洞,其中的 42% 都与共享内存相关,另外 58% 与消息传递相关。上文提到过共享内存原语实际上比消息传递原语使用得更多。
与普遍认为的消息传递不易犯错相比,我们的研究显示更多的阻塞漏洞是由错误的消息传递造成的,而不是由错误的共享内存保护造成的。
共享内存相关的漏洞包括一般的常见情况,和因 Go 语言中 RWMutext 和 Wait 的实现而产生的新情况。
对于消息传送相关的漏洞,许多都是因为 channel 丢失了发送或接受方,或者忘记了关闭 channel。
所有消息传递引起的阻塞漏洞都与 Go 特有的消息传递语法相关,比如 channel。这些漏洞很难发现,尤其是当消息传递和其它的同步原语一起使用的时候。与一般的认识不同,消息传递会比共享内存方式造成更多的阻塞漏洞。
在调查了这些漏洞的修复之后,会发现弄明白漏洞产生的原因之后,它们修复起来都相当简单,而且修复的类型都与造成漏洞的起因相关。这说明在 Go 语言中,使用全自动或半自动的工具修复阻塞型漏洞是很有前景的一个方向。
Go 的内置死锁探测器只能检测到此次研究中 21 个阻塞漏洞中的两个。
非阻塞型漏洞
与消息传递相比,非阻塞型漏洞更多是由于共享内存的错误使用造成的。有一半的非阻塞漏洞符合「传统」的内存共享漏洞模式。还有一些漏洞是由于缺乏对 Go 语言特性的理解,尤其是前置申明的本地变量,在协程中被调用的匿名函数共享使用,以及 WaitGroup 的语法。
Go 语言为简化多线程编程而引入的新编程模型和新工具库,可能造成更多的并发漏洞。
消息传送型的非阻塞漏洞则相对不那么常见,“编程语言中这些复杂的消息传递机制,与其它的语言特性组合起来,可能造成这些漏洞很难被发现”。
有趣的是,修复共享内存漏洞的程序员,更喜欢使用消息传送机制来修复这些问题。
Go 语言的数据竞争探测器可以探测出此次研究中一半的非阻塞漏洞。
更多关于Golang Go语言中理解现实中并发漏洞的实战系列教程也可以访问 https://www.itying.com/category-94-b0.html
在Golang(Go语言)中,理解现实中的并发漏洞对于编写高效且安全的并发程序至关重要。并发漏洞通常源于对Go语言的并发原语(如goroutines和channels)的误用,以及对共享资源访问控制不当。
Go语言通过goroutines和channels提供了强大的并发处理能力。然而,如果开发者不正确地管理这些并发原语,就可能导致数据竞争、死锁和资源泄露等并发漏洞。
数据竞争是并发编程中最常见的问题之一,它发生在多个goroutines同时访问和修改共享数据时,且没有适当的同步机制来保护这些访问。这可能导致程序行为不可预测,甚至崩溃。
死锁则是另一种常见的并发漏洞,它发生在两个或多个goroutines相互等待对方释放资源,从而导致程序无法继续执行。这通常是由于不恰当的锁使用或channel操作引起的。
为了避免这些并发漏洞,开发者需要遵循一些最佳实践。首先,应尽量避免在多个goroutines之间共享可变数据。如果必须共享,应使用适当的同步机制(如互斥锁或channel)来保护对这些数据的访问。其次,应仔细设计goroutines和channels之间的交互,以避免死锁的发生。
此外,Go语言还提供了race detector等工具,用于在开发阶段检测和诊断并发漏洞。开发者应充分利用这些工具来确保并发程序的正确性和稳定性。
总之,理解并正确应对Go语言中的并发漏洞是编写高效且安全并发程序的关键。