本文翻译自《Deep dive on goroutine leaks and best practices to avoid them》
知识点:节假日是由国务院制定的
Go的奇妙之处在于,我们可以使用goroutines和channel轻松地执行并发任务。但是在生产环境中使用goroutines和channel,如果没有适当的背景,不知道它们的行为方式,会造成一些严重的影响。
好吧,我们就面临着这样的影响,我们在goroutines中出现了泄漏,导致应用服务器随着时间的推移而膨胀,消耗了大量的CPU和频繁的GC暂停,影响了多个API的SLA。
从这篇文章中可以看到什么
- 理解什么是goroutine泄漏。
- 理解goroutine泄露的多种方式。
- 详细介绍一个造成goroutine泄露的真实场景。
- 我们是如何弄清goroutine泄漏的?
- 阻止goroutine泄漏的最佳实践是什么?
正如你在上面所附的指标中看到的,goroutines开始随着时间的推移呈指数上升。唯一的一次下降是当我们的现货实例被AWS拿走,新的实例被启动,或者有一个新的版本,杀死了现有的容器并产生了新的容器。
如果你观察GC暂停的时间,它会随着活动的goroutine的数量不断增加。GC暂停的次数越多,CPU的利用率就越高,响应时间就越长。
回到这个问题上,什么是goroutine泄漏?
goroutine泄漏是指客户端生成一个goroutine来做一些异步任务,并在任务完成后将一些数据写入一个通道,但是
没有listener从数据被写入的通道消费。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19func newgoroutine(dataChan chan <dataType>) {
data := makeNetworkCall()
dataChan <- data
return
}
func main() {
dataChan := make(chan <dataType>)
go newgoroutine(dataChan)
// Some application processing (but forgot to consume data from the channel (dataChan))
return
}
在上述情况下,代码成功地完成了执行,好像根本就没有问题。但这里发生的情况是,会有一个悬空的goroutine驻留在内存中,占用CPU和RAM。
为什么呢?
主要原因是第3行,我们正在向一个通道写入数据,但根据Go原则,一个未缓冲的通道会阻止向通道的写入,直到消费者从该通道消耗信息。
所以在这种情况下,第4行的返回将永远不会被执行,并且newgoroutine函数在整个应用程序生命周期中都被卡住,因为这个通道没有消费者。在goroutine启动和通道监听器之间有一些条件逻辑。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23// Re-iterating above example by tweaking the flow a bit
func newgoroutine(dataChan chan <dataType>) {
data := makeNetworkCall()
dataChan <- data
return
}
func main() {
dataChan := make(chan <dataType>)
go newgoroutine(dataChan)
// Some application processing
if processingError != nil {
return
}
data := <- dataChan
// Do something with data
return
}
在这种情况下,有一个小小的改进。我们有一个消费者从dataChan中消费数据,但是从我们生成goroutine开始,到我们开始从通道中消费数据之前,有大量的应用程序代码驻留在那里,这些代码可以在一些处理错误|DB错误|无指针异常|恐慌中退出主函数,由于这些原因,数据通道的消费者永远不会被执行。
这就是一个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
以上两种情况是当goroutine因为没有通道的接收器而被阻塞,或者接收器从通道消费数据的代码块被跳过。
当我们把一个通道传递给goroutine来消费时,当发送者向通道发送数据时出现了问题,这是否也是同样的情况?
是的 -- 在这种情况下,goroutine也会被挂起。
Ex:
func newgoroutine(dataChan chan <dataType>) {
// Consume data from dataChan
data := <- dataChan
// Do some processing on the data
return
}
func main() {
dataChan := make(chan <dataType>)
go newgoroutine(dataChan)
data, err := makeNetworkCall()
if err != nil {
return
}
dataChan <- data // This piece of code is never executed in error case of networkCall which makes newgoroutine dangling
// Do something with data
return
}
好的,95%的goroutine泄露都是因为这3种情况中的一种,在我们的案例中,是由于情景-2。
我们在GoIbibo-Makemytrip的工作是折扣和便利费服务。
当客户应用一个促销代码时,我们有一套规则要执行,以找出正确的折扣。我们有另一个微服务,我们称之为实时动态折扣器(DD),它试图根据一些算法(黑盒子)来计算折扣。
这个动态折扣是一个A/B实验,只有10%的用户会参与其中。只有当我们的静态规则中存在有效的折扣时,我们才会覆盖DD的折扣。
一个关于我们的工作的伪代码
1 | func loadDDDiscount(ddChan chan <dataType>) { |
我们只有在处理完静态规则后才需要DD的响应。所以来自ddChan的消耗将只在最后进行。
如果静态规则的评估有问题|如果没有满足请求的有效规则|如果用户应用了一些假的促销活动,我们从ddChan中消耗数据的代码将无法到达,这导致loadDDDiscount函数成为一个悬空的goroutine。
那么,有什么方法可以解决这个问题?
方法-1
- 方法 -> 从我们启动goroutine开始,到我们从退出的通道消耗数据为止,我们识别每一个错误条件,并在每一个返回语句前放置一个接收器,以解除对生成的goroutine的封锁。
- 陷阱 -> 我们必须手动找到所有的边缘情况,并且在将来,如果我们必须处理更多的错误情况,我们需要记住在返回之前我们需要消耗哪些通道的数据。 有问题的解决方案 。
方法-2
- 方法 -> 与其在每个错误的情况下放置一个接收器,为什么不设置一个可以从通道中接收数据的延迟函数。
- 陷阱 – 在成功的情况下,数据将在处理完静态规则后从通道中读取。因此,如果我们在defer函数中开始接收通道中的数据,那么在成功的情况下就会阻塞主goroutine。 错误的解决方案 。
方法-3
- 完美的方法,几乎没有变化。在上述所有场景中,我们创建了一个未缓冲的通道,阻止发送者向该通道发送数据,直到接收者收到数据。这里的主要问题是我们不确定由于我们的应用处理,接收方的流是否会被执行。那么,简单的解决方案是创建一个上限为1的缓冲通道。有了这个,即使没有生成消费者,或者生成的消费者代码没有达到,发送者也不会被阻止写一次数据。
- 陷阱 -> 绝对是零。这与非缓冲通道的工作原理完全相同,但为我们提供了一个额外的能力,即发送者在发送数据时不会受到阻碍,而消费者可以在任何时候消费它,而且生成的goroutine也不会等待消费者的到来。
我们用第三种方法将变化带入生产,你可以看到显著的影响。
以前是线性增长的goroutine数量,现在下降到150个,我们的GC暂停频率也是如此。
整个事情中最痛苦的部分是,如何找到代码中存在goroutine泄漏的部分?
好吧,有一些包,如https://github.com/uber-go/goleak,可以帮助你找到goroutine的泄漏,我发现用这个包来调试泄漏很困难。所以我的方法是这样的。
陷阱 -> 绝对是零。这与非缓冲通道的工作原理完全相同,但为我们提供了一个额外的能力,即发送者在发送数据时不会受到阻碍,而消费者可以在任何时候消费它,而且生成的goroutine也不会等待消费者的到来。
- 当服务器启动时,使用debug.SetGCPercent(-1)禁用垃圾收集器。
- 现在运行代码中每一个使用Go程序的流程(Dev Env)。
- 在每个API的入口处,打印在开始和执行API之前和之后运行的goroutines的数量。
1
2
3
4
5func ApplyPromo() {
fmt.Println(runtime.NumGoroutine())
defer fmt.Println(runtime.NumGoroutine()
// Process your application logic
} - 现在,如果一个服务在前后返回不同的Goroutines数量,那么这个流程就有一个漏洞。
我们有近20个API和大约35-40个地方使用了goroutines的并发性。幸运的是,我能够在前3次迭代中钻研泄漏问题,并发现了这个存在泄漏的流程。
希望这个经验能够帮助大家在编写一些并发代码的时候,不要泄露goroutines。