原文地址
许多Go开发人员都熟悉这个格言: 在不知道如何停止的情况下,永远不要启动goroutine。然而,泄漏goroutines仍然非常容易。让我们看一下泄漏goroutine的一种常见方法以及如何修复它。
为此,我们将构建一个具有自定义map类型的库,map的键配置配置成在持续时间后过期。我们给这个库取名为ttl,它将具有如下所示的API:
// Create a map with a TTL of 5 minutes
m := ttl.NewMap(5*time.Minute)
// Set a key
m.Set("my-key", []byte("my-value"))
// Read a key
v, ok := m.Get("my-key")
// "my-value"
fmt.Println(string(v))
// true, key is present
fmt.Println(ok)
// ... more than 5 minutes later
v, ok := m.Get("my-key")
// no value here
fmt.Println(string(v) == "")
// false, key has expired
fmt.Println(ok)
为了确保键到期能被删除,我们在NewMap函数中启动一个工作协程:
func NewMap(expiration time.Duration) *Map {
m := &Map{
data: make(map[string]expiringValue),
expiration: expiration,
}
// start a worker goroutine
go func() {
for range time.Tick(expiration) {
m.removeExpired()
}
}()
return m
}
工作协程将根据配置时间,周期性的调用map类型的方法以删除任何过期的键。因此在调用SetKey时需要记录键添加的时间,这就是数据字段包含expiringValue类型的原因,该类型将实际值与到期时间相关联:
type expiringValue struct {
expiration time.Time
data []byte // the actual value
}
乍一看,工作协程的调用似乎很好。如果这不是一个关于协程泄漏的帖子,这段代码也许不会有什么问题。但实际上,我们在构造函数中泄漏了一个协程。是怎么泄漏的呢?
让我们来看看Map的典型生命周期。首先,调用者创建Map的实例。创建实例后,一个工作协程开始运行。接下来,调用者可以对Set和Get进行任意数量的调用。但最终,调用者将完成使用Map实例并释放对它的所有引用。此时,垃圾收集器通常能够收集实例的内存。但是,工作仍在运行,并且还保留了Map实例的引用。由于没有明确的调用来停止工作协程,我们泄漏了一个协程,并泄漏了Map实例的内存。
我们把问题再搞得明显一点。为达到目的,我们将使用运行时包来查看有关内存分配器的统计信息以及在特定时刻运行的Go协程的数量。
func main() {
go func() {
var stats runtime.MemStats
for {
runtime.ReadMemStats(&stats)
fmt.Printf("HeapAlloc = %d\n", stats.HeapAlloc)
fmt.Printf("NumGoroutine = %d\n", runtime.NumGoroutine())
time.Sleep(5*time.Second)
}
}()
for {
work()
}
}
func work() {
m := ttl.NewMap(5*time.Minute)
m.Set("my-key", []byte("my-value"))
if _, ok := m.Get("my-key"); !ok {
panic("no value present")
}
// m goes out of scope
}
很快就可以看到堆分配和Go协程的数量增长很多。
HeapAlloc = 76960
NumGoroutine = 18
HeapAlloc = 2014278208
NumGoroutine = 1447847
HeapAlloc = 3932578560
NumGoroutine = 2832416
HeapAlloc = 5926163224
NumGoroutine = 4322524
所以现在很明显我们需要停止那个Go协程。目前,Map API没有办法关闭工作协程。最好避免修改任何API,并在调用者使用完Map实例时能停止工作协程。但只有调用者知道什么时候该停止。
解决此问题的常见模式是实现io.Closer接口。当调用者使用完Map时,他们可以调用Close来告诉Map停止其工作协程。
func (m *Map) Close() error {
close(m.done)
return nil
}
现在在我们的构造函数中需要像下面这样启动工作协程:
func NewMap(expiration time.Duration) *Map {
m := &Map{
data: make(map[string]expiringValue),
expiration: expiration,
done: make(chan struct{}),
}
// start a worker goroutine
go func() {
ticker := time.NewTicker(expiration)
defer ticker.Stop()
for {
select {
case <-ticker.C:
m.removeExpired()
case <-m.done:
return
}
}
}()
return m
}
现在,工作协程包含一个select语句,除了计时器的Channel之外还会检查done Channel。注意,我们也更改了timer.TIcker的调用。请注意,因为之前的版本没有调用ticker.Stop,也会泄漏。
修改之后,我们简单的分析看起来像这样:
HeapAlloc = 72464
NumGoroutine = 6
HeapAlloc = 5175200
NumGoroutine = 59
HeapAlloc = 5495008
NumGoroutine = 35
HeapAlloc = 9171136
NumGoroutine = 240
HeapAlloc = 8347120
NumGoroutine = 53
数字很小,这是因为调用工作协程的循环比较小。但更重要的是,Go协程或堆分配数量没有再大幅增长了。这正是我们想要的结果。注意,在这里可以找到完整的代码。
这篇文章展示了一个明显的例子,说明为什么知道何时停止Go协程如此重要。另一方面,也可以说监控应用程序中的Go协程数量一样很重要。如果Go协程泄漏潜入代码库,这样的监控程序可以提供一个警告系统。还值得记住的是,有时Go协程泄漏需要数天甚至数周才能在应用程序中显示,所以拥有更短和更长时间跨度的监视程序很有必要。
原文地址:How to Leak a Goroutine and Then Fix It