Uber LeakProf
May 12, 2023 23:45 · 3560 words · 8 minute read
原文:https://www.uber.com/blog/leakprof-featherlight-in-production-goroutine-leak-detection
在微服务开发中使用 Go 作为编程语言非常流行,其主要特点是并发作为一等公民。因其与日俱增的受欢迎程度,Uber 采用 Go 也就不足为奇了:Uber 代码库中相当多的关键业务逻辑、支持库或关键基础设施组件都是用它开发的。
Go 的并发模型是建立在轻量级线程——goroutine 上的。任何以 go
关键字为前缀的函数调用都会异步地启动该函数。由于 goroutine 语法开销和资源需求很低,因此在 Go 代码库中被广泛使用,程序通常同时运行几十个、几百个或几千个 goroutine。两个以上 goroutine 可以通过通道(channel)传递消息来互相通信,这种模式受到了 Hoare 的 Communicating Sequential Processes 的启发。虽然传统的共享内存通信也是一种选择,但 Go 开发团队鼓励用户优先选用通道,正确使用时可以更好地避免数据竞争。
Goroutine 泄漏
goroutine 一个不幸的副作用是 groutine 泄漏。通道语义的关键是阻塞,通道操作会使得 goroutine 执行停滞,直到找到通信对端。具体来说,对于无缓冲通道,发送方将被阻塞直到接收方到达该通道,反之亦然。一个 goroutine 可能永远阻塞在尝试发送或接收数据,这种情况就是 goroutine 泄漏。当太多的 goroutine 泄漏时后果可能很严重。泄漏的 goroutine 消耗资源(例如内存),它们不能被释放或回收。注意,缓冲通道满了也会导致 goroutine 泄漏。
编程错误(例如复杂的控制流程、过早返回、超时)会导致 goroutine 之间通信的不匹配,其中一个或多个 goroutine 可能会阻塞但没有其他 goroutine 来解除阻塞。goroutine 泄漏会防止垃圾回收器回收相关通道、goroutine 栈和永久阻塞的 goroutine 的所有可接触的对象。在长时间运行的服务中小泄漏日积月累加剧了问题。
Go 发行版没有提供开箱即用的在编译或运行时任何检测 goroutine 泄漏方案。检测 goroutine 泄漏并不容易,因为它们可能依赖于 goroutine 之间的复杂交互或其他罕见的运行时条件。一些已提出的静态分析技术不精确,或多或少都会误判。其他提案如 goleak 在测试期间使用动态分析,可以揭露阻塞错误,但其效果取决于代码路径和线程调度的全面覆盖。对大项目进行完全覆盖是不可能的:例如在生产环境中变更代码路径的某些配置未必经过了单元测试。
检测 goroutine 泄漏很复杂,尤其是使用了大量三方库和成百上千个 goroutine 的复杂生产代码中还涉及到许多通道。在生产代码中检测泄漏的需求非常强烈,此类工具需要满足:
- 开销要低:因为它会被用于生产工作负载,开销过高会浪费计算资源。
- 误报率要低:假泄漏浪费开发者的时间
一种轻量级的解决生产环境中 goroutine 泄漏的方案
我们采用了一种实用的方法来检测生产环境中长时间运行的程序中的 goroutine 泄漏以满足上述标准:
- 如果程序存在大量的 goroutine 泄漏,最终现象是某些通道操作上阻塞的 goroutine 数量增加。
- 只有少数源码位置(涉及通道操作)产生了大多数 goroutine 泄漏。
- 罕见的 goroutine 泄漏产生的开销很低,可以忽略不计。
第一点是由于泄漏的程序中 goroutine 数量激增而得出的结论。第二点简单说明了并非所有通道操作都会导致泄漏。由于所有泄漏的 goroutine 将持续存在于服务的生命周期内,反复遇到触发泄漏的场景造成大量阻塞的 goroutine,尤其是许多不同的执行路径和循环中的并发操作。最后,第三点是一个实际考虑因素。如果很少遇到引发泄漏的操作,它对内存不太可能造成严重的影响。基于这些实际的观测,我们设计了 LeakProf,这是一种低误报率且运行时开销最小化的泄漏指示器。
LeakProf 的实现
LeakProf 定期地对当前正在运行的 goroutine(用 pprof 获取)进行调用栈分析。检查特定配置的调用栈可以指示一个 goroutine 是否阻塞在像通道发送、通道接收和 select 这样的操作上。这些都是 Go 运行时中众所周知的阻塞函数,相对好识别。在通道操作源位置聚合阻塞的 goroutine 后,在单个源位置的大量阻塞的 goroutine(由可配置的阈值确定)将被视为潜在的 goroutine 泄漏。
我们的方法不完美,分别出现漏报和误报。当泄漏数量未超过阈值时,会导致漏报;相反,当大量 goroutine 由于预期的语义被故意阻塞时,而非泄漏,这就是误报。为了改进过滤误报,我们正在开发基于静态分析轻量级算法。例如分析可疑的 select 语句的 AST 以确定其中一个 case 分支是否等待已知的非阻塞操作(比如使用 Go 标准库提供的计时器或定时器);如果满足则无论有多少个阻塞的 goroutine 都不会上报泄漏,因为这是明确的误报。它还可以配置已知的误报表。总之,该方法在实践中非常有效地检测出对生产服务产生重大影响的复杂泄漏问题。
Uber 部署 LeakProf 利用分析信息来收集阻塞的 goroutine 信息,并自动通知服务所有者可以的并发操作。如果阻塞的 goroutine 数量超过阈值,则判断是可疑的操作,而且仅当它们来自 Uber 的代码库时才发送通知。该方法的有效性很快得到了证明,找到了 10 处关键 goroutine 泄漏,只有一次误报。其中两处缺陷导致服务峰值内存暴增至 2.5 倍和 5 倍。
泄漏的 Go 程序模式
以下常见泄漏代码模式来自在生产环境中的 goroutine 泄漏分析。
函数过早返回
这种泄漏模式由期望多个 goroutine 通信,但某些代码路径过早返回而不参与通道通信,导致其他参与者永久等待引起。当通信双方没有考虑到彼此所有可能的执行路径时就会发生这种情况。
func UnbalancedConditionalCommunication(...) {
c := make(chan error)
go func() {
if err != nil {
c <- err
return
}
c <- nil
}()
if ... {
...
if ... {
return ... //
} else if {
return ... //
}
}
err = <-c
}
通道 c
被子 goroutine 用于发送错误。父线程中相关的接收操作前有几个 if
语句,可能在等待从通道接收数据之前就返回,无论执行哪个发送操作 goroutine 都会永久阻塞。
一种解决方案是创建带有大小为 1 的缓冲区的通道,这使得发送者不阻塞在通信操作上,无视接受者的行为。
超时泄漏
这种 bug 也可以被归为函数过早返回模式。当无缓冲通道与定时器或上下文(context)和 select
语句结合使用时经常发生。定时器或上下文通常用于短路父 goroutine 的执行并尽早中止。但是如果子 goroutine 没考虑到这种情况,则可能导致泄漏。
func TimeoutBug(ctx context.Context, ...) {
ctx, cancel = context.WithTimeout(ctx, ...)
done := make(chan any)
go func() {
...
done <- data
}()
select {
case data := <- done:
return data
case <- ctx.Done()
return ...
}
}
done
通道被子 goroutine 使用。当子 goroutine 发送一条消息,它就会阻塞直到父 goroutine 从 done
读取。同时,父 goroutine 在 select
语句等待,直到它与子 goroutine 同步,或 ctx
超时。如果上下文超时,父 goroutine 直接就返回了,这样通道也就没有了接收者。最终子 goroutine 将泄漏。
通过将 done
的缓冲区容量设置为 1,同样可以避免该泄漏。
NCast 泄漏
这种泄漏发生在并发系统设计成多发送方和单一接收方在同一条通道上通信时。此外,如果接收者只对该通道执行一次接收操作,则除一个发送者外的所有发送方都将永久阻塞。
func CommunicationContention(...) {
ch := make(chan any)
for _, item := range items {
go func(c chan any, ...) {
c <- result
}(ch, ...)
}
result := <-ch
return
}
ch
通道作为参数传给 for
循环中创建的 goroutine。每个子 goroutine 尝试向 ch
发送结果,但是父 goroutine 只从 ch
接收一次,然后就返回了,此时丢失了 ch
的引用。因为 ch
无缓冲,任意没有和父 goroutine 同步的子 goroutine 都会永久阻塞。
解决方案将 ch
缓冲区的容量设置为 items
的长度。这样保证了即使只有第一个发送至 ch
的结果被父 goroutine 接收,其余的子 goroutine 不会阻塞并退出。
这个问题更通用的形式:当 N 个发送者和 M 个接收者,N > M,且每个接收者只执行一次接收操作。
误用通道迭代
当对通道使用 range
语句时可能出现这种泄漏模式。理解它需要熟悉关闭操作和 range
是如何与通道一起用的。每当通道迭代结束但从未被关闭时,就会导致泄漏。这是因为除非关闭通道,循环就不会结束;从通道接收完所有项目后,for
循环就阻塞了。
func ChannelIterationMisuse(...) {
wg := &sync.WaitGroup{}
jobs := make(chan any, 1)s
for ... := range jks {
wg.Add(1)
go func() {
jobs <- data
}()
}
go func() {
for data := range jobs {
jobs := append(jobs, j)
wg.Done()
}
}()
wg.Wait()
}
为了简洁起见,我们将借用生产者-消费者模式:生产者是 for
循环中拉起的 goroutine,每个都发送一条消息;消费者通过遍历 jobs
通道读取消息。只要有未消费的消息,循环就会迭代一次。我们的期望是一旦生产者不再发送,消费者将退出循环并终止。但是如果缺失了对通道的关闭操作,range
将在接收不到消息时阻塞从而导致泄漏。
由于生产者和消费者的父线程会等待直到所有消息都被传递(通过 WaitGroup),解决方案是在 wg.Wait()
后加上 close(jobs)
,或在 jobs
定义后作为 defer 语句。一旦所有消息都发送完,父线程就关闭 jobs
,向消费者发出停止迭代 jobs
通道的信号,因此将被垃圾回收。
总结
goroutine 泄漏是一种普遍存在的问题,很难静态检测,并会导致大量计算资源的浪费。LeakProf 识别那些在长期运行的生产服务中非常明显或逐渐积累起来的 goroutine 泄漏。它通过对程序进行分析并聚合相同源码位置上阻塞的 goroutine 探测通道操作上的 goroutine 阻塞,这通常是泄漏的症状。当泄漏的 goroutine 数量很多时,LeakProf 有效地发出警报。
LeakProf 的有效性快速地得到了证实,因为它在较短的时间内发现了许多泄漏。另一个关键组成部分是通过检查导致泄漏的代码获得的洞察力,总结出了几个有问题的编码模式。值得注意的是,发现的模式展示了潜在的机会,开发用于报告和检查符合那些引起泄漏的问题模式的代码的 linter。其他未来工作包括设计更好的启发式算法以确定配置阻塞 goroutine 的阈值,以更有效地检测较小程序中的泄漏,并进一步增强静态分析套件。