[译]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的时候,你必须要问问你自己:

  • 它何时会终止?
  • 有哪些情况会阻碍它终止?

并发是非常有用的工具,但是你一定要非常小心的使用它。