Go goroutine 泄漏
1. goroutine 泄漏的本质
原先理解
goroutine 泄漏可以理解为:
成功创建了 goroutine,但它没有被正确关闭。
这个方向接近,但表述不够准确。
修正后理解
goroutine 没有“关闭”这个外部操作。更准确的说法是:
goroutine 因为缺少退出条件、阻塞等待、取消信号未传递、外部 IO 卡住等原因,无法按预期退出,并且后续也不会再被使用或唤醒。
goroutine 泄漏不只是浪费 goroutine 本身的调度资源。泄漏的 goroutine 还可能持有 channel、对象、请求上下文、buffer、连接等引用,导致这些对象无法被 GC 回收,从而进一步造成内存增长。
面试表达
goroutine 泄漏是指启动后的 goroutine 因为缺少退出条件、阻塞等待或取消信号未传递等原因,长期无法退出。它不仅浪费 goroutine 栈和调度资源,还可能持有对象引用,导致相关内存无法被 GC 回收。
2. goroutine 泄漏和内存泄漏的关系
原先理解
goroutine 泄漏和内存泄漏都来自生命周期管理不到位,会浪费服务器资源,严重时导致程序崩溃。
这个判断正确,但可以补充得更具体。
修正后理解
goroutine 泄漏本身就会消耗资源:
- goroutine 栈;
- runtime 调度结构;
- channel / context / request 相关引用;
- goroutine 内部持有的业务对象;
- 可能还包括网络连接、文件句柄、定时器等外部资源。
因此 goroutine 泄漏经常会伴随内存增长,但它不等同于普通内存泄漏。普通内存泄漏强调对象无法被回收;goroutine 泄漏强调执行单元无法退出。两者经常相互影响。
面试表达
goroutine 泄漏和内存泄漏都属于生命周期管理问题。goroutine 泄漏本身会占用栈和调度资源,同时由于泄漏的 goroutine 仍然持有对象引用,可能导致这些对象无法被 GC 回收,因此也可能表现为内存泄漏。
3. 无缓冲 channel + 调用方提前退出
典型代码
func gen() <-chan int {
ch := make(chan int)
go func() {
for i := 0; ; i++ {
ch <- i
}
}()
return ch
}
func main() {
ch := gen()
fmt.Println(<-ch)
}原先理解
gen 内部 goroutine 是无限循环,调用方只读取一次,所以 goroutine 还活着,会发生泄漏。
修正后理解
如果这是一个短生命周期 main 程序,main 返回后整个进程退出,goroutine 会随进程结束,因此不会形成长期线上意义的泄漏。
但如果这段逻辑出现在长期运行的服务里,例如 HTTP handler 中,问题就成立:
调用方只消费一次
gen 内部 goroutine 继续循环
下一次 ch <- i 时没有接收方
goroutine 永久阻塞在发送操作上面试表达
这段代码有泄漏风险。
gen内部 goroutine 持续向无缓冲 channel 发送数据,如果调用方只消费一次后不再接收,生产 goroutine 会阻塞在下一次发送上,无法退出。短程序中进程退出会掩盖这个问题,但在长期运行的服务中会造成 goroutine 泄漏。
4. 常见 goroutine 泄漏场景
原先理解
常见原因可以概括为:
- 没有正确终止条件;
- goroutine 内部阻塞。
这个是根因级理解,但面试需要展开具体场景。
常见场景
4.1 channel 发送阻塞
ch <- v没有接收方,goroutine 永久阻塞。
4.2 channel 接收阻塞
v := <-ch没有发送方,channel 也不会被关闭,goroutine 永久阻塞。
4.3 select 没有退出分支
for {
select {
case v := <-ch:
_ = v
}
}没有 ctx.Done()、done channel 或其他退出条件。
4.4 context 没有传递或没有 cancel
启动 goroutine 后没有取消信号,或者创建了 WithCancel / WithTimeout 后忘记调用 cancel()。
4.5 ticker / timer 没有停止或没有退出条件
ticker := time.NewTicker(time.Second)
go func() {
for range ticker.C {
// ...
}
}()如果没有 ticker.Stop() 和退出条件,goroutine 可能无法退出。
4.6 网络请求 / RPC / IO 没有超时
goroutine 卡在外部依赖调用上,例如 HTTP 请求、数据库查询、RPC 调用等。
4.7 worker pool 没有关闭任务队列
worker 一直等待任务,但任务队列不再写入,也没有关闭信号。
4.8 fan-in / fan-out 消费数量不匹配
生产者数量多,但消费者提前退出,剩余生产者阻塞在发送结果上。
面试表达
常见 goroutine 泄漏包括:channel 发送或接收永久阻塞、
select没有退出分支、context 没有正确传递或取消、ticker 没有停止、外部 IO 没有超时、worker pool 的任务队列没有关闭,以及 fan-in/fan-out 中生产者和消费者数量不匹配。
5. 线上如何判断 goroutine 泄漏
原先理解
通过可观测工具记录 Go 性能指标。如果服务平稳运行,但 goroutine 数量持续增长,很可能发生泄漏。
这个方向正确。
修正后理解
常用指标和工具:
runtime.NumGoroutine()
Prometheus: go_goroutines
pprof goroutine profile
go tool pprof
go tool trace
日志、请求量、延迟、错误率、内存指标判断思路:
请求量稳定
业务流量稳定
goroutine 数量持续单调增长
增长后不回落这时应怀疑 goroutine 泄漏。
进一步排查时,看 goroutine profile:
curl http://host:port/debug/pprof/goroutine?debug=2重点观察大量 goroutine 卡在哪些栈上:
chan send
chan receive
select
net/http
database/sql
time.Sleep
sync.Cond.Wait
sync.Mutex.Lock面试表达
线上可以通过
go_goroutines、runtime.NumGoroutine()、pprof goroutine profile、go tool trace 等方式观察。如果服务请求量稳定但 goroutine 数持续增长且不回落,就要怀疑泄漏。进一步通过 goroutine profile 查看大量 goroutine 的阻塞栈,判断是卡在 channel、select、锁、网络 IO 还是外部 RPC 上。
6. 如何避免 goroutine 泄漏
原先理解
写 goroutine 并发代码时要注意生命周期,通常用 context 做生命周期管理。
这个判断正确,但还可以具体化。
修正后理解
核心原则:
启动 goroutine 时,就要明确它什么时候退出、由谁通知退出、退出时如何释放资源。
常用手段:
- 用
context传递取消信号; - channel 生产消费双方明确关闭责任;
select中监听ctx.Done()或 done channel;- 外部 IO / RPC 设置 timeout;
- ticker / timer 用完要 Stop;
- worker pool 需要关闭任务队列;
- 使用
errgroup.WithContext管理一组 goroutine; - 必要时用
sync.WaitGroup等待 goroutine 完成。
面试表达
避免 goroutine 泄漏的关键是管理生命周期。启动 goroutine 时必须明确退出条件,通常通过 context、关闭 channel、WaitGroup、errgroup、超时控制等方式管理。对于 IO/RPC 要设置 timeout,对于 ticker/timer 要及时 Stop,对于生产消费模型要明确 channel 的关闭责任。
7. channel 关闭后的读取问题
典型代码
func worker(ch <-chan int) {
for {
v := <-ch
fmt.Println(v)
}
}原先理解
for 没有退出方式。channel 关闭后会不断读取默认零值,导致 CPU 打满。
这个判断正确。
修正点
不能通过“收到零值”判断 channel 已关闭。因为零值可能是合法业务数据。
错误方式:
if v == 0 {
return
}正确方式:
v, ok := <-ch
if !ok {
return
}更惯用写法:
func worker(ch <-chan int) {
for v := range ch {
fmt.Println(v)
}
}for range ch 会持续接收数据,直到 channel 被关闭且缓冲区数据被读完后退出。
面试表达
这段代码的问题是没有处理 channel 关闭。channel 关闭后,
<-ch会立即返回元素类型零值,导致死循环打印零值,可能打满 CPU。应该使用v, ok := <-ch判断ok,或者直接用for v := range ch,让 worker 在 channel 关闭后退出。
8. 无缓冲 channel + context timeout 的泄漏风险
典型代码
func handle(ctx context.Context) {
ch := make(chan int)
go func() {
result := slowQuery()
ch <- result
}()
select {
case v := <-ch:
fmt.Println(v)
case <-ctx.Done():
return
}
}理解偏差
容易认为主要问题是 channel 没有关闭。
修正后理解
主要问题不是 channel 没关闭,而是:
ctx 先超时
handle 返回
接收方消失
slowQuery 后续返回
子 goroutine 向无缓冲 channel 发送
无人接收
子 goroutine 永久阻塞改法一:容量为 1 的 buffered channel
func handle(ctx context.Context) {
ch := make(chan int, 1)
go func() {
result := slowQuery()
ch <- result
}()
select {
case v := <-ch:
fmt.Println(v)
case <-ctx.Done():
return
}
}这个改法可以避免发送阻塞,但不能取消 slowQuery 本身。
改法二:让 slowQuery 支持 context
func handle(ctx context.Context) {
ch := make(chan int, 1)
go func() {
result, err := slowQuery(ctx)
if err != nil {
return
}
select {
case ch <- result:
case <-ctx.Done():
return
}
}()
select {
case v := <-ch:
fmt.Println(v)
case <-ctx.Done():
return
}
}面试表达
这段代码的风险不是 channel 没关闭,而是 ctx 超时后
handle返回,接收方消失,子 goroutine 在slowQuery返回后向无缓冲 channel 发送结果时永久阻塞。可以用容量为 1 的 buffered channel 避免发送阻塞,更根本的方式是让slowQuery接收 context,并在发送结果时也监听ctx.Done()。
9. time.Tick 和 time.NewTicker
原先理解
time.Tick 返回只读 channel,无法 Stop,底层资源不会被释放。
这是传统面试语境下的常见答案,但需要注意 Go 版本差异。
修正后理解
在 Go 1.23 之前,time.Tick 更容易导致资源泄漏,因为只返回 channel,拿不到 Ticker 对象,无法调用 Stop()。
time.NewTicker 返回 *Ticker,可以显式调用:
ticker := time.NewTicker(time.Second)
defer ticker.Stop()从 Go 1.23 开始,未被引用的 ticker 即使没有 Stop,也可以被 GC 回收。因此“time.Tick 一定会泄漏”这个说法已经不严格。
但从工程生命周期控制角度:
如果 ticker 不是要跟随程序整个生命周期存在,仍然优先使用
time.NewTicker,并在退出时调用Stop()。
面试表达
在传统 Go 版本中,
time.Tick因为只返回 channel,无法 Stop,适合程序整个生命周期都需要的 ticker;需要停止时应使用time.NewTicker并调用Stop()。Go 1.23 以后未引用 ticker 可以被 GC 回收,但从生命周期控制角度,NewTicker + Stop仍然是更明确的写法。
10. 谁负责关闭 channel
原先理解
生产者负责关闭 channel。消费者关闭 channel 时,如果生产者继续写,会触发 panic。
方向正确。
修正后理解
更准确原则:
谁发送,谁关闭;更准确说,是最后一个发送者负责关闭 channel。
因为 close 的语义是:
不会再有新数据发送发送方才知道什么时候不会再发送。接收方通常不应该关闭 channel,因为它不知道发送方是否还会继续发送。
如果有多个生产者,不能让任意一个生产者随便关闭 channel。常见模式是:
func run(tasks []int) {
ch := make(chan int)
var wg sync.WaitGroup
wg.Add(len(tasks))
for _, task := range tasks {
go func(t int) {
defer wg.Done()
ch <- process(t)
}(task)
}
go func() {
wg.Wait()
close(ch)
}()
for v := range ch {
fmt.Println(v)
}
}面试表达
通常由发送方关闭 channel,因为 close 表示不会再有数据发送。接收方不应该关闭 channel,否则发送方继续发送会 panic。在多生产者场景中,需要由协调者等待所有生产者结束后统一关闭 channel。
11. context.WithTimeout 为什么要 defer cancel()
原先理解
context.WithTimeout 会启动一个定时器,需要在函数结束时关闭并回收资源。
这个判断正确。
修正后理解
ctx, cancel := context.WithTimeout(parent, time.Second)
defer cancel()cancel() 的作用包括:
- 释放 context 内部 timer;
- 从父 context 的 children 中移除;
- 尽早通知下游 goroutine 退出;
- 避免等到 timeout 自然触发才释放资源。
即使操作提前成功,也应该调用 cancel(),让相关资源尽早释放。
面试表达
context.WithTimeout会创建带定时器的子 context。即使最终没有超时,也应该调用cancel(),用于释放 timer 资源、从父 context 中移除子节点,并及时通知下游 goroutine 退出。因此通常写成defer cancel()。
12. fan-out 中消费数量不匹配
典型代码
func run(tasks []int) {
ch := make(chan int)
for _, task := range tasks {
go func(t int) {
ch <- process(t)
}(task)
}
for i := 0; i < 3; i++ {
fmt.Println(<-ch)
}
}原先理解
如果 tasks 长度大于 3,多余 goroutine 会阻塞;如果小于 3,接收方会阻塞。
这个判断正确。
修正后理解
核心问题不是 channel 没关闭,而是:
发送数量和接收数量不匹配具体表现:
len(tasks) > 3:只接收 3 个结果,其余 goroutine 可能阻塞在发送;len(tasks) < 3:接收次数超过发送次数,主 goroutine 阻塞;process(t)卡住时,对应 goroutine 也会长期无法退出。
改法一:消费所有结果
func run(tasks []int) {
ch := make(chan int)
for _, task := range tasks {
go func(t int) {
ch <- process(t)
}(task)
}
for range tasks {
fmt.Println(<-ch)
}
}改法二:WaitGroup + close
func run(tasks []int) {
ch := make(chan int)
var wg sync.WaitGroup
wg.Add(len(tasks))
for _, task := range tasks {
go func(t int) {
defer wg.Done()
ch <- process(t)
}(task)
}
go func() {
wg.Wait()
close(ch)
}()
for v := range ch {
fmt.Println(v)
}
}实际工程中,如果任务很多,还需要限制并发数,例如 worker pool 或 semaphore。
面试表达
这段 fan-out 代码的问题是发送结果的 goroutine 数量和接收次数不匹配。
len(tasks) > 3时,多余 goroutine 会阻塞在发送;len(tasks) < 3时,接收方会一直等待。应该消费所有结果,或者用WaitGroup等待所有生产者结束后关闭 channel,再用range消费。
13. 多生产者如何安全关闭 channel
原先理解
channel 没有原生并发安全机制让所有 writer 感知其他 writer 的状态。多生产者时,一个生产者关闭 channel,其他生产者继续写会 panic。更好的方式是用 manager 管理多个生产者,常见方式是 WaitGroup。
这个理解正确。
标准模式
func run(tasks []int) {
ch := make(chan int)
var wg sync.WaitGroup
wg.Add(len(tasks))
for _, task := range tasks {
go func(t int) {
defer wg.Done()
ch <- process(t)
}(task)
}
go func() {
wg.Wait()
close(ch)
}()
for v := range ch {
fmt.Println(v)
}
}面试表达
多生产者场景下,不能由任意生产者关闭 channel。通常使用
sync.WaitGroup统计所有生产者,等所有生产者退出后,由一个统一的协调 goroutine 关闭结果 channel。这样可以保证 close 发生在所有 send 之后,避免 send on closed channel。
14. for { select { ... } } 的泄漏风险
原先理解
需要检查 select 的不同 case 中有没有合适的 break 方式。没有退出方式就容易泄漏或空转。
方向正确,但要注意 Go 语义。
修正点
在 Go 里,break 默认只跳出当前 select,不会跳出外层 for。
错误示例:
for {
select {
case <-ctx.Done():
break
}
}这段代码中,break 只跳出 select,然后马上进入下一轮 for,可能造成空转。
正确写法:
for {
select {
case <-ctx.Done():
return
case v := <-ch:
_ = v
}
}或者使用 label:
loop:
for {
select {
case <-ctx.Done():
break loop
case v := <-ch:
_ = v
}
}检查清单
判断 for { select { ... } } 是否有泄漏风险,重点看:
是否有 ctx.Done() / done channel / close signal
收到退出信号后是否真的 return 或跳出外层 for
channel 关闭时是否处理 ok
是否存在 default 分支导致 busy loop
是否存在永远不会触发的 case,例如 nil channel
外部 IO 是否有 timeout
ticker / timer 是否 Stop面试表达
对
for { select { ... } },重点看是否有明确退出条件,例如ctx.Done()或 done channel;退出分支里是否真的return或跳出外层循环;channel 关闭时是否检查ok;是否有default导致 busy loop;以及 ticker、IO、外部依赖是否有超时和释放逻辑。
15. 易错点汇总
易错点 1:说 goroutine “没有关闭”
不准确说法:
goroutine 没有被正确关闭。
更准确说法:
goroutine 无法按预期退出。Go 不能从外部强制关闭 goroutine,只能通过协作式取消让它自己返回。
易错点 2:把 channel 没关闭当成所有泄漏的根因
很多场景中,channel 没关闭不是主要问题。真正的问题通常是:
接收方提前退出
发送方仍然发送
无缓冲 channel 无人接收
goroutine 阻塞易错点 3:用零值判断 channel 关闭
错误方式:
v := <-ch
if v == 0 {
return
}正确方式:
v, ok := <-ch
if !ok {
return
}或者:
for v := range ch {
// ...
}易错点 4:消费者关闭 channel
通常不应该由消费者关闭 channel。 close 表示“不会再有新数据发送”,这个信息应该由发送方或统一协调者掌握。
易错点 5:break 只能跳出 select
在 for { select { ... } } 中:
break默认只跳出 select,不会跳出外层 for。退出 goroutine 应优先使用 return。
易错点 6:只用 buffered channel 解决所有问题
容量为 1 的 buffered channel 可以避免“发送结果时无人接收导致阻塞”,但不能取消内部耗时操作。
如果 doSearch() 或 slowQuery() 本身卡住,仍然需要它支持 context 或 timeout。
16. 最小复习版本
goroutine 泄漏定义
goroutine 泄漏是指 goroutine 因缺少退出条件、阻塞等待、取消信号未传递或外部 IO 卡住等原因,长期无法退出。它会浪费栈和调度资源,并可能持有对象引用导致内存无法释放。
常见原因
channel 发送阻塞
channel 接收阻塞
select 无退出分支
context 未传递或未 cancel
ticker/timer 未停止
IO/RPC 无 timeout
worker pool 任务队列未关闭
fan-in/fan-out 消费数量不匹配排查方式
go_goroutines
runtime.NumGoroutine()
pprof goroutine profile
go tool trace
观察大量 goroutine 阻塞栈避免方式
启动 goroutine 时明确退出条件
用 context / done channel 管理生命周期
用 WaitGroup / errgroup 管理一组 goroutine
IO/RPC 设置 timeout
ticker/timer 及时 Stop
生产者负责关闭 channel
多生产者由协调者统一 close高频场景结论
无缓冲 channel + ctx timeout 是常见泄漏场景:外层函数返回后接收方消失,子 goroutine 后续发送结果时永久阻塞。容量为 1 的 buffered channel 可以避免发送阻塞,但更好的方式是让内部耗时操作支持 context。
for-select 结论
for { select { ... } }必须有退出条件,并且退出时要return或跳出外层循环。不能只在select里写break,否则只会跳出select,不会退出for。