使用Semgrep检测Go协程泄漏
背景
在Java或C++中编写多线程代码可能让计算机科学专业的学生重新考虑职业选择,而在Go语言中异步调用函数只需在函数调用前添加go关键字。然而,编写并发Go代码也存在风险,因为恶意的并发错误可能悄悄潜入应用程序。不知不觉中,可能有数千个挂起的协程拖慢应用程序,最终导致崩溃。
本文提供了一个可用于错误排查的Semgrep规则,并包含一个我们在审计中使用的专业Semgrep规则库的链接。同时解释了如何使用其中一个规则来发现Go中特别棘手的错误类型:协程泄漏。
本文描述的技术灵感来自GCatch,这是一个使用过程间分析和Z3求解器来检测可能导致挂起协程的通道误用错误的工具。该工具的技术和开发特别令人兴奋,因为对于由通道等Go特定结构的不正确使用引起的并发错误缺乏研究。
协程泄漏详解
Go中最著名的并发错误可能是竞态条件,这通常是由于在循环中使用协程时内存别名不当导致的。另一方面,协程泄漏也是常见的并发错误,但很少被讨论。部分原因是协程泄漏的后果只有在多次发生后才变得明显;泄漏开始以明显的方式影响性能和可靠性。
协程泄漏通常是由于不正确使用通道来同步协程之间传递的消息而导致的。当应该使用缓冲通道的情况下使用无缓冲通道进行逻辑处理时,经常会出现这个问题。这种类型的错误可能导致协程挂起在内存中,最终耗尽系统资源,导致系统崩溃或拒绝服务状况。
实际示例
|
|
在上述代码中,第21行的通道写操作阻塞了包含它的匿名协程。第19行声明的协程将被阻塞,直到在dataChan上发生读操作。这是因为在使用无缓冲通道时,读写操作会阻塞协程,每个写操作都必须有相应的读操作。
导致匿名协程泄漏的两种场景:
- 如果在第24行的读操作之前发生第二个case(
case <- time.After(timeout)),requestData函数将退出,其中的匿名协程将被泄漏 - 如果两个case同时触发,调度器将随机选择其中一个case。如果选择了第二个case,匿名协程将被泄漏
运行代码时,您将得到以下输出:
|
|
挂起的协程是第19行的匿名协程。
修复方案
使用缓冲通道可以修复上述问题。虽然读写无缓冲通道会导致协程阻塞,但向缓冲通道执行发送(写)操作只有在通道缓冲区满时才会导致阻塞。同样,接收操作只有在通道缓冲区为空时才会导致阻塞。
为了防止协程泄漏,我们只需要在第17行的通道上添加长度:
|
|
运行更新后的程序,我们可以确认不再有挂起的协程:
|
|
这个错误可能看起来很小,但在某些情况下,它可能导致协程泄漏。有关协程泄漏的示例,请参阅Kubernetes存储库中的此PR。在运行1,496个协程时,补丁的作者经历了由于协程泄漏导致的API服务器崩溃。
发现错误
调试并发问题的过程非常复杂,以至于像Semgrep这样的工具可能看起来不适合。然而,当我们仔细检查在野外发现的常见Go并发错误时,我们确定了可以轻松利用来创建Semgrep规则的模式。这些规则使我们能够找到甚至这种类型的复杂错误,主要是因为Go并发错误通常可以用几组简单模式来描述。
在使用Semgrep之前,重要的是要认识到它可以解决的问题类型的限制。在搜索并发错误时,最重要的限制是Semgrep无法进行过程间分析。这意味着我们需要针对包含在单个函数中的错误。在Go中这是一个可管理的问题,不会阻止我们使用Semgrep,因为Go程序员通常依赖于在单个函数内定义的匿名协程。
现在我们可以开始构建我们的Semgrep规则,基于以下典型的协程泄漏表现:
- 声明了一个类型为T的无缓冲通道C
- 在匿名协程G中执行对通道C的写/发送操作
- 在select块(或G之外的另一个位置)中读取/接收C
- 程序遵循一个执行路径,其中C的读操作在封闭函数终止之前不发生
通常是最后一步导致协程泄漏。
由上述条件导致的错误往往会在代码中产生模式,我们可以使用Semgrep检测这些模式。无论这些模式采取什么形式,程序中都会声明一个无缓冲通道,我们需要分析:
|
|
我们还需要排除通道被声明为缓冲通道的情况:
|
|
要检测我们示例中的协程泄漏,我们可以使用以下模式。