译|Goroutine 泄漏:被遗忘的发送者

原文:Goroutine Leaks - The Forgotten Sender


介绍

并发编程允许开发者使用多个执行路径来解决问题,并通常用在为提高性能所做的尝试中。但是,并发并不意味着,这些路径并行执行;而是意味着这些路径乱序执行而不是顺序执行。从历史上看,使用标准库或者第三方开发者提供的库可以很方便地进行此类编程。

而在 Go 中,诸如 goroutine 和 channel 这样的并发特性内置在语言和运行时中,以减少或者消除对库对需求。这就造成了用 Go 编写并发程序很容易的错觉。当你决定使用并发时必须很小心,因为如果不正确使用的话,它们就会带来一些独特的副作用或者陷阱。一不小心,这些陷阱就会造成复杂性以及令人讨厌的错误。

而在这篇文章中,我将讨论的陷阱则与 Goroutine 泄漏有关。

goroutine 泄漏

在内存管理方面,Go 为你处理了许多细节。Go 编译器使用 escape analysis 来确定值在内存中的位置。运行时使用 垃圾收集器来跟踪和管理堆分配。虽然在应用中创造内存泄漏 不是不可能,但是这种概率已经被大大降低了。

内存泄漏的一种常见类型是 Goroutine 泄漏。你启动了一个 Goroutine,并且你期望它最终会终止,但如果它永远结束不了,那么说明它已经泄露了。它存在于应用的整个生命周期中,并且为这个 Goroutine 分配的任何内存都不会得到释放。这也是“永远不要启动一个不知道它将如何停止的 Goroutine”这个建议背后的部分原因。

为了描述基本的 Goroutine 泄露,让我们看看下面的代码:

清单 1
https://play.golang.org/p/dsu3PARM24K

1
2
3
4
5
6
7
8
9
10
11
12
31 // leak is a buggy function. It launches a goroutine that
32 // blocks receiving from a channel. Nothing will ever be
33 // sent on that channel and the channel is never closed so
34 // that goroutine will be blocked forever.
35 func leak() {
36 ch := make(chan int)
37
38 go func() {
39 val := <-ch
40 fmt.Println("We received a value:", val)
41 }()
42 }

清单 1 定义了一个名为 leak 的函数。该函数在第 36 行创建了一个 channel,以允许 Goroutine 传递整型数据。然后,在第 38 行创建了 Goroutine,该 Goroutine 在第 39 行阻塞,以等待从 channel 中接收值。当这个 Goroutine 在等待(接收值)时,leak 函数却返回了。此时,该程序的其他任何部分都不能通过这个 channel 发送信号。这使得该 Goroutine 阻塞在第 39 行,并且永远处于等待状态。第 40 行的 fmt.Println 调用将永远不会发生。

在这个例子中,代码审查阶段就可以快速识别到这个 Goroutine 泄露。不幸的是,生产代码中的 Goroutine 泄露往往更难以发现。我不可能展示 Goroutine 泄露可能会发生的所有方式,但是,这篇文章将详细说明你可能会遇到的一类 Goroutine 泄露:

泄露:被遗忘的发送者

在这个泄露示例中,你将会看到一个无限期阻塞的 Goroutine,它等待向 channel 发送值。

我们将看到的这个程序会基于一些搜索词来查找记录,然后将其打印出来。该程序建立在一个名为 search 的函数之上:

清单 2
https://play.golang.org/p/o6_eMjxMVFv

29 // search simulates a function that finds a record based
30 // on a search term. It takes 200ms to perform this work.
31 func search(term string) (string, error) {
32     time.Sleep(200 * time.Millisecond)
33     return "some value", nil
34 }

在清单 2 中,第 31 行的 search 函数是一个模拟实现,用以模拟像数据库查询或者 web 调用这样的长耗时操作。在这个例子中,耗时硬编码为 200ms。

清单 3 展示了调用 search 函数的应用。

清单 3
https://play.golang.org/p/o6_eMjxMVFv

17 // process is the work for the program. It finds a record
18 // then prints it.
19 func process(term string) error {
20     record, err := search(term)
21     if err != nil {
22         return err
23     }
24
25     fmt.Println("Received:", record)
26     return nil
27 }

在清单 3 中的第 19 行,定义了一个名为 process 的函数,它接收一个表示搜索词的 string 参数。在第 20 行,将 term 变量传递给 search 函数(它会返回一个记录和一个错误信息)。如果发生了错误,那么在第 22 行就会将错误返回给调用者。如果没有错误发生,那么,会在第 25 行打印这个记录。

对某些应用而言,顺序调用 search 产生的延时可能是不能接受的。假设 search 不可能更快了,那么,可以修改 process 函数为不消耗 search 产生的总延迟。

为此,可以使用一个 Goroutine,如下清单 4 所示。不幸的是,这第一次尝试存在错误,因为它创造了潜在的 Goroutine 泄露。

清单 4
https://play.golang.org/p/m0DHuchgX0A

38 // result wraps the return values from search. It allows us
39 // to pass both values across a single channel.
40 type result struct {
41     record string
42     err    error
43 }
44 
45 // process is the work for the program. It finds a record
46 // then prints it. It fails if it takes more than 100ms.
47 func process(term string) error {
48 
49     // Create a context that will be canceled in 100ms.
50     ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
51     defer cancel()
52 
53     // Make a channel for the goroutine to report its result.
54     ch := make(chan result)
55 
56     // Launch a goroutine to find the record. Create a result
57     // from the returned values to send through the channel.
58     go func() {
59         record, err := search(term)
60         ch <- result{record, err}
61     }()
62 
63     // Block waiting to either receive from the goroutine's
64     // channel or for the context to be canceled.
65     select {
66     case <-ctx.Done():
67         return errors.New("search canceled")
68     case result := <-ch:
69         if result.err != nil {
70             return result.err
71         }
72         fmt.Println("Received:", result.record)
73         return nil
74     }
75 }

在清单 4 中的第 50 行,重写了 process 函数,创建了一个将会在 100ms 后取消的 Context。关于如何使用 Context 的更多信息,请阅读 golang.org 博文

然后在第 54 行,程序创建了一个无缓存 channel,允许 Goroutine 传递 result 类型的数据。在第 58 行到第 61 行,定义了一个匿名函数,然后以此创建了一个 Goroutine。这个 Goroutine 调用 search,然后在第 60 行试图通过该 channel 发送调用结果。

在 Goroutine 运行的过程中,在第 65 行,process 执行了 select 块。这个块的两个 case 都是 channel 接收操作。

第 66 行是一个从 ctx.Done() channel 接收数据的 case。如果取消了前面定义的 Context(过了 100ms),那么就会执行这个 case。 而如果执行了这个 case,那么在第 67 行,process 就会返回一个错误,表示它放弃等待。

或者,第 68 行的 case 从 ch channel 接收值,然后把值赋给一个名为 result 的变量。和前面的顺序实现一样,程序在第 69 行和第 70 行检查和处理错误。如果没有错误,那么该程序会在第 72 行打印记录,并返回 nil,表示成功。

此重构版本设置了 process 函数将会等待 search 完成的最长持续时间。然而,该实现也会产生潜在的 Goroutine 泄露。想一想这个代码中 Goroutine 在做的事情吧;在第 60 行,它往 channel 发送数据。向这个 channel 发送数据会阻塞执行,直到另一个 Goroutine 准备好接收该数据。在超时的情况下,接收者停止等待从 Goroutine 接收数据并继续执行。这将会导致 Goroutine 永远 阻塞在等待接收者,而显然,接收者永远都不会出现。这正是 Goroutine 泄露之时。

修复:创建一些空间

解决这种泄露最简单的方式是,将 channel 从无缓存 channel 更改为容量为 1 的有缓存 channel。

清单 5
https://play.golang.org/p/u3xtQ48G3qK

53     // Make a channel for the goroutine to report its result.
54     // Give it capacity so sending doesn't block.
55     ch := make(chan result, 1)

现在,在超时的情况下,接收者继续执行之后,通过将 result 值放在 channel,search Goroutine 将会完成其发送操作,然后返回。最终,用于这个 Goroutine 以及这个 channel 的内存将会被回收。一切都会自然而然地发挥作用。

channel 的行为(The Behavior of Channels)中,William Kennedy 提供了几个关于 channel 行为的很好的例子,并且提供了有关其使用的哲学。那篇文中的最后一个示例 “清单 10” 展示了一个类似于这个超时示例的程序。阅读那篇文章,以获取关于何时使用带缓存 channel,以及什么级别的容量合适的更多建议。

总结

Go 让启动 Goroutine 变得简单,但是,明智使用它们则是我们的责任。在这篇文章中,我展示了如何错误使用 Goroutine 的一个例子。有许多其他的方式会创造 Goroutine 泄露,并且在使用并发的时候也可能遇到其他陷阱。在以后的文章中,我将提供更多的 Goroutine 泄露和其他并发陷阱的例子。现在,我会给你这个建议;任何时候当你启动一个 Goroutine 时,都必须问你自己:

  • 它何时终止?
  • 什么可能会阻止它终止?

并发是良器,但必须小心使用。