[译]Goroutine Leaks - The Forgotten Sender
文章目录
原文链接:Goroutine Leaks - The Forgotten Sender (ardanlabs.com)
简介
并发编程可以让程序员以多个执行路径来解决问题,而且通常是试图提高程序性能。并发并不意味着这些执行路径是以并行的方式执行,而是说这些执行路径以无序异步的方式执行而不是同步顺序执行。历史上这种编程模型是通过标准款或者第三方库来实现的。
Go语言中,并发特性是通过语言内置的Goroutine和channel来实现,这样就减少了对库的依赖。这容易给人一种错觉,觉得使用Go来编写并发程序会很简单。你得非常小心,因为如果你没有正确的使用Go的并发特性,往往会引入特定的边界效应或者调入陷阱。一不小心,这些陷阱就会给你程序带来很多复杂度以及很恶心的bug。
本文中,我将讨论的可能出现的陷阱是Goroutine泄漏。
Goroutine泄漏
关于内存管理,Go语言为了处理了许许多多的内存管理细节。Go编译器根据逃逸分析来决定值应该存放在内存中的什么地方(原文站点引用了一篇逃逸分析的博文Language Mechanics On Escape Analysis (ardanlabs.com),回头我也会翻译出来)。运行时通过垃圾回收器管理跟踪和管理堆上分配的内存,正因为此,虽然不能完全肯定你的程序不会发生内存泄漏,但这种概率已经被极大降低了。
一种常见的内存泄漏是由于Goroutine泄漏引起的。如果你启动了一个Goroutine并且你期望它最终会退出,但由于某种原因最终这个Goroutine没有退出,这时候就发生了泄漏。这种情况下,这个Goroutine就存在于你的应用的整个生命周期,分配给这Goroutine的内存也无法释放。这就是Dave Cheney所建议的“Never start a goroutine without knowing how it will stop”背后的逻辑。
下面的代码演示了一种非常常见的Goroutine泄漏。
Listing 1
https://play.golang.org/p/dsu3PARM24K
131 // leak is a buggy function. It launches a goroutine that
232 // blocks receiving from a channel. Nothing will ever be
333 // sent on that channel and the channel is never closed so
434 // that goroutine will be blocked forever.
535 func leak() {
636 ch := make(chan int)
737
838 go func() {
939 val := <-ch
1040 fmt.Println("We received a value:", val)
1141 }()
1242 }
Listing 1定义了一个叫leak
的函数。这个函数在第36行创建了一个channel,通过这个channel把数据传递给后面启动的Goroutine。第38行启动了一个Goroutine,然后这个Goroutine在第39行被阻塞以等待接收channel上的来的数据。当这个Goroutine等待的时候,启动它的函数leak
就执行完返回了,这时候程序中的没有其他任何地方可以给这个channel发送数据,这就导致Goroutine被永久的阻塞在了第39行。第40行的fmt.Println
就永远不会执行。
在这个例子中,这种Goroutine泄漏可以在一次代码审核中很快被识别出来。不幸的是,生成环境Goroutine泄漏往往很难被发现。我没办法穷举Goroutine泄漏的情景,但是本博文会消息讨论你可能会遇到的一种Goroutine泄漏。
泄漏:被遗忘的发送者
(注:个人理解,原作者想表达的”被遗忘的发送者“是指 - gorouting阻塞在了往(非缓冲)channel发送数据,因为由于种种原因程序中其他地方没有从channel中接收)
在这个泄漏的例子中,你将看到一个Goroutine被永久阻塞,等待发送数据给channel
我们将看到的这个程序 - 基于关键字查找记录然后打印。该程序围绕一个search
函数构建:
Listing 2
https://play.golang.org/p/o6_eMjxMVFv
129 // search simulates a function that finds a record based
230 // on a search term. It takes 200ms to perform this work.
331 func search(term string) (string, error) {
432 time.Sleep(200 * time.Millisecond)
533 return "some value", nil
634 }
Listing 2中的search
函数的第31行,模拟了一个长时间运行的耗时操作,比如数据库查询或者Web调用。在这里,假设这个耗时操作为200ms。调用search
函数的代码在Listing 3中,如下所示:
Listing 3
117 // process is the work for the program. It finds a record
218 // then prints it.
319 func process(term string) error {
420 record, err := search(term)
521 if err != nil {
622 return err
723 }
824
925 fmt.Println("Received:", record)
1026 return nil
1127 }
Listing 3中第19行,定义了一个process
函数,它接受一个字符串类型的参数term
,这个参数就表示搜索关键字。第20行,term
传递给search
函数该函数返回一条记录和错误。如果有错误发生,在第22行返回错误,否则在第25行打印这条记录。
对于一些应用而言,串行的调用search
带来的时延或许无法接受。假设search
无法更快的运行了,processs
函数可以修改成不必完全消耗在等待search
返回结果。为了达到这个目的,如下面Listing 4所示,可以使用一个Goroutine来解决这个问题。不幸的是,这个尝试是有问题的,它带来了潜在的Goroutine泄漏。
Listing 4
https://play.golang.org/p/m0DHuchgX0A
138 // result wraps the return values from search. It allows us
239 // to pass both values across a single channel.
340 type result struct {
441 record string
542 err error
643 }
744
845 // process is the work for the program. It finds a record
946 // then prints it. It fails if it takes more than 100ms.
1047 func process(term string) error {
1148
1249 // Create a context that will be canceled in 100ms.
1350 ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
1451 defer cancel()
1552
1653 // Make a channel for the goroutine to report its result.
1754 ch := make(chan result)
1855
1956 // Launch a goroutine to find the record. Create a result
2057 // from the returned values to send through the channel.
2158 go func() {
2259 record, err := search(term)
2360 ch <- result{record, err}
2461 }()
2562
2663 // Block waiting to either receive from the goroutine's
2764 // channel or for the context to be canceled.
2865 select {
2966 case <-ctx.Done():
3067 return errors.New("search canceled")
3168 case result := <-ch:
3269 if result.err != nil {
3370 return result.err
3471 }
3572 fmt.Println("Received:", result.record)
3673 return nil
3774 }
3875 }
Listing 4的第50行,process
函数创建了一个100ms的Cancel Context
。关于如何使用Context
你可以阅读golang.org blog post。
第54行,程序创建了一个非缓冲(阻塞)channel允许对它发送result
类型数据或者从它接收。第58行到61行定义了一个匿名函数并启动了一个Goroutine开始执行。这个Goroutine调用了search
函数而后在第60行把返回结果发送到channel。在这个Goroutine工作的时候,process
执行第65行的select
代码块,这个代码块有两个case
分支等待从对应的channel接收数据。
第66行,这个分支从ctx.Done()
这个channel上接收数据,如果对应的Context
由于100ms超时这个case则被执行。如果这个分支被执行,process
函数则返回错误报告seacrh
超时了。或者说,第68行的分支执行了,意味着search
正常返回了并把结果赋给了变量result
。如上面Listing 3中串行调用search
的那样,第69和70行,程序判断search
函数是否返回错误,如果有则处理错误。如果没有错误,那么在第72行打印结果而后最终发挥nil
给上层调用者。
这个版本的重构,在process
函数中设定了一个等待search
完成的最长时间,也就是100ms。然后,这种方式也带来了潜在的Goroutine泄漏。想一想这个Goroutine做的事情,在第60行发送结果到channel - 这个操作会阻塞直到另一个Goroutine读取了这个channel里的数据。但是考虑超时的情况,如果超时先发生了(search
还没返回结果),那么select
块就结束等待往下进行(case <-ctx.Done()
分支),由于channel里的数据没有地方消费那么Goroutine就永久阻塞(第60行),这就导致了Goroutine泄漏。
修复:给channel点空间(非缓冲 -> 缓冲)
上面的泄漏问题,最简单的解决方式就是 - 把原先的非缓冲(阻塞)channel修改为容量为1的缓冲(非阻塞)channel。
Listing 5
https://play.golang.org/p/u3xtQ48G3qK
153 // Make a channel for the goroutine to report its result.
254 // Give it capacity so sending doesn't block.
355 ch := make(chan result, 1)
现在在超时的那个分支,即使由于超时导致select
块结束等待往下进行(case <-ctx.Done()
分支),启动search
的那个Goroutine最终也会得到搜索结果并发送到channel进而执行完成返回而不阻塞。最终Goroutine以及channel占用的内存都会被释放掉。所有事情自然而然的都解决了。
在William Kennedy的一片博文The Behavior of Channels中,他给出了一些很不错的关于channel行为的例子,同时也介绍了如何使用channel的哲学。那篇文章中最后一个例子Listing 10中演示的程序和本文中timeout的例子相似。你可以读一读他的博文以了解更多关于如何使用缓冲channel和如何设置容量的建议。
结论
Go语言中很容易去使用Goroutine,但是如何明智的使用是我们的责任。在本文中,我展示一个错误的使用Goroutine的例子。还有许许多多其他陷阱一样,也是有许许多多的方式会造成并发编程中的Goroutine的泄漏。在将来的博文中,我会提供更多关于Goroutine泄漏的例子和陷阱。现在我留给你一个建议:任何时候你需要启动一个Goroutine的时候,你必须要问问你自己:
- 它何时会终止?
- 有哪些情况会阻碍它终止?
并发是非常有用的工具,但是你一定要非常小心的使用它。