跳到主要内容

Go 里更隐蔽的问题,往往不是死锁而是 goroutine 泄漏

· 阅读需 2 分钟
一介布衣
全栈开发者

刚开始学 Go,并发部分最容易让人紧张的词就是“死锁”。这当然没错,因为死锁一出现,程序常常会非常明显地挂在那里。可等你真正把 Go 服务跑起来一段时间后,会发现另一类问题更难受:goroutine 没有马上死掉,而是在后台一点点堆起来。

它不像死锁那样高调,却会把系统慢慢拖沉。

为什么 goroutine 泄漏更隐蔽

因为泄漏往往不影响第一次请求。
接口也许还能返回,服务也许还在跑,监控甚至一开始都不明显。可只要这类 goroutine 在高频请求里不断产生、又没有正常退出,资源消耗迟早会上来。

最糟糕的是,很多 goroutine 泄漏不一定伴随明显报错,它只是默默在那里等一个永远不会来的结果。

我见过的几种典型来源

最常见的情况通常有这些:

  • channel 等待方还在,发送方提前结束
  • 超时和取消信号没有真正往下游传递
  • 后台重试协程缺少退出条件
  • 消费者停止后,生产者还在继续推送

这些问题看起来都不算复杂,但它们共同特点是:代码能跑,只是退出路径没设计完整。

死锁和泄漏的差别,不只是严重程度

死锁更像“立刻撞墙”,泄漏更像“悄悄漏水”。
前者你通常会马上处理,后者却容易被拖进“再观察一下”的区间。可线上系统最怕这种慢性问题,因为它会和负载增长、请求抖动、内存上涨混在一起,让人误以为只是机器不稳。

我后来写 Go 并发时更看重什么

我会更刻意地检查三个问题:

  • 这个 goroutine 由谁启动
  • 它在什么条件下结束
  • 如果下游不工作了,它能不能被及时取消

只要这三个问题答不清楚,我就默认这里未来可能出泄漏。

结尾

Go 的并发模型很优雅,但优雅不等于不用收尾。
2018 年以后我越来越觉得,真正让 Go 服务稳下来的,不只是把 channel 和 select 写对,而是把每个 goroutine 的生命周期想清楚。死锁当然要防,可更隐蔽、也更值得长期警惕的,往往是那些悄悄不退出的协程。