使用Semgrep检测Goroutine泄漏:Go并发编程的隐形杀手

本文深入探讨Go语言中常见的goroutine泄漏问题,通过具体代码示例展示无缓冲通道导致的并发缺陷,并详细介绍如何使用Semgrep静态分析工具构建检测规则来发现这类难以察觉的并发漏洞。

使用Semgrep发现Goroutine泄漏 - Trail of Bits博客

Alex Useche, 安全工程师 2021年11月8日发布

虽然在Java或C++中编写多线程代码可能让计算机科学专业学生重新考虑职业选择,但在Go中异步调用函数只需在函数调用前加上go关键字。然而,编写并发Go代码也存在风险,因为恶性的并发错误可能悄悄潜入应用程序。不知不觉中,可能有数千个挂起的goroutine正在拖慢应用程序,最终导致崩溃。

本文提供了一个可用于错误搜寻的Semgrep规则,并包含一个指向我们在审计中使用的专业Semgrep规则库的链接。它还解释了如何使用其中一条规则来发现Go中特别棘手的错误类型:goroutine泄漏。

Goroutine泄漏详解

Go中最著名的并发错误可能是竞态条件,这通常是由于在循环内使用goroutine时内存别名使用不当造成的。另一方面,goroutine泄漏也是常见的并发错误,但很少被讨论。这部分是因为goroutine泄漏的后果只有在多次发生后才会变得明显;泄漏开始以明显的方式影响性能和可靠性。

goroutine泄漏通常是由于不正确使用通道来同步goroutine之间传递的消息而导致的。当应该使用缓冲通道的情况下使用了无缓冲通道时,经常会出现这个问题。这类错误可能导致goroutine挂起在内存中,最终耗尽系统资源,导致系统崩溃或拒绝服务状况。

让我们看一个实际例子:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import (
  "fmt"
  "runtime"
  "time"
)

func main() {
  requestData(1)
  time.Sleep(time.Second * 1)
  fmt.Printf("挂起的goroutine数量: %d", runtime.NumGoroutine() - 1)
}

func requestData(timeout time.Duration) string {
 dataChan := make(chan string)

go func() {
     newData := requestFromSlowServer()
     dataChan <- newData // 阻塞
 }()
 select {
 case result := <- dataChan:
     fmt.Printf("[+] 请求返回: %s", result)
     return result
 case <- time.After(timeout):
     fmt.Println("[!] 请求超时!")
         return ""
 }
}

func requestFromSlowServer() string {
 time.Sleep(time.Second * 1)
 return "非常重要的数据"
}

在上述代码中,第21行的通道写操作阻塞了包含它的匿名goroutine。第19行声明的goroutine将被阻塞,直到在dataChan上发生读操作。这是因为使用无缓冲通道时,读写操作会阻塞goroutine,每个写操作都必须有相应的读操作。

有两种情况会导致匿名goroutine泄漏:

  1. 如果在第24行的读操作之前发生第二种情况(case <- time.After(timeout)),requestData函数将退出,其中的匿名goroutine将被泄漏
  2. 如果两种情况同时触发,调度器将随机选择两种情况之一。如果选择了第二种情况,匿名goroutine将被泄漏

运行代码时,您将得到以下输出:

1
2
3
[!] 请求超时!
挂起的goroutine数量: 1
程序退出。

挂起的goroutine是第19行的匿名goroutine。

使用缓冲通道可以解决上述问题。虽然读写无缓冲通道会导致goroutine阻塞,但向缓冲通道执行发送(写入)操作仅在通道缓冲区满时才会导致阻塞。类似地,接收操作仅在通道缓冲区为空时才会导致阻塞。

为了防止goroutine泄漏,我们只需要在第17行的通道中添加长度,得到以下代码:

1
2
3
4
5
6
7
func requestData(timeout time.Duration) string {
 dataChan := make(chan string, 1)

go func() {
     newData := requestFromSlowServer()
     dataChan <- newData // 阻塞
 }()

运行更新后的程序后,我们可以确认不再有挂起的goroutine。

1
2
3
[!] 请求超时!
挂起的goroutine数量: 0
程序退出。

这个错误可能看起来很小,但在某些情况下,它可能导致goroutine泄漏。有关goroutine泄漏的示例,请参阅Kubernetes存储库中的这个PR。在运行1,496个goroutine时,补丁作者经历了由于goroutine泄漏导致的API服务器崩溃。

发现错误

调试并发问题的过程非常复杂,以至于像Semgrep这样的工具可能看起来不太适合。然而,当我们仔细检查在野外发现的常见Go并发错误时,我们确定了可以轻松利用来创建Semgrep规则的模式。这些规则使我们能够找到甚至这种复杂的错误,主要是因为Go并发错误通常可以用几组简单模式来描述。

在使用Semgrep之前,重要的是要认识到它可以解决的问题类型的限制。在搜索并发错误时,最重要的限制是Semgrep无法进行过程间分析。这意味着我们需要针对包含在单个函数中的错误。在Go中工作时这是一个可管理的问题,不会阻止我们使用Semgrep,因为Go程序员通常依赖在单个函数内定义的匿名goroutine。

现在我们可以开始构建我们的Semgrep规则,基于以下goroutine泄漏的典型表现:

  1. 声明了类型为T的无缓冲通道C
  2. 在匿名goroutine G中执行对通道C的写入/发送操作
  3. 在select块(或G之外的其他位置)读取/接收C
  4. 程序遵循一个执行路径,其中C的读操作在封闭函数终止之前不发生

正是最后一步通常导致goroutine泄漏。

由上述条件导致的错误往往会在代码中产生模式,我们可以使用Semgrep检测这些模式。无论这些模式采取什么形式,程序中都会声明一个无缓冲通道,我们需要分析:

1
2
3
- pattern-inside: |
       $CHANNEL := make(...)
       ...

我们还需要排除通道声明为缓冲通道的情况:

1
2
3
- pattern-not-inside: |
       $CHANNEL := make(..., $T)
       ...

要检测我们示例中的goroutine泄漏,可以使用以下模式…

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计