跳到主要内容
约 13 分钟6 张卡

Go 并发诊断

goroutine 泄漏是工作已经不再需要执行,负责它的 goroutine 却没有按预期退出,继续占用运行时资源。数据竞争是多个 goroutine 访问同一内存位置、至少一次是写入,而这些访问之间缺少必要的同步。这两类问题可以分别出现,也可能同时存在。

排查时先分清是在问“任务为什么还活着”,还是“这些读写由什么保证次序”。前者沿着阻塞位置找退出条件,后者沿着共享对象找访问路径。本文以 Go 1.22.12 为准,重点是如何形成可验证的故障解释。

1. 面试核心​

先记住这几条,再读正文
  • 阻塞中的 goroutine 不一定泄漏,长期运行的后台任务也可能是正常设计。判断泄漏要结合任务的预期生命周期,检查工作结束后还有谁负责让它退出。卡 · 生命周期与泄漏
  • 取消是通知,退出是任务实际完成的结果。每一个可能长期等待的操作都需要可行的退出路径,调用方还要能确认清理已经完成。卡 · 不同等待点需要不同退出条件
  • goroutine 数量上升只是线索。结合负载变化、多个时刻的调用栈和具体阻塞代码,才能判断是在处理积压还是留下了无用任务。卡 · goroutine 栈与故障定位
  • go test -race 检测实际执行中的数据竞争。报告要连着看两处冲突访问和 goroutine 的创建位置,修复完整的共享访问协议。卡 · 数据竞争与修复
  • 没有数据竞争不代表业务正确,也不代表没有泄漏、死锁或重复处理;验证需要覆盖业务结果和任务退出。卡 · 工具与问题对应
  • 把一个阻塞调用放到 goroutine 中,再让外层超时返回,不会自动取消那个调用。需要下层支持取消、期限或其他明确的解除阻塞方式。卡 · 超时包装的边界

2. 生命周期与泄漏​

2.1 下游提前退出​

消费者不再需要结果时,生产者仍可能等待发送。下面的 produce 依次发送两个数,调用方只读取第一个:

package main

import "fmt"

func produce() <-chan int {
out := make(chan int)
go func() {
defer close(out)
out <- 1
out <- 2
}()
return out
}

func main() {
out := produce()
fmt.Println(<-out)
}

这个独立程序打印 1 后随 main 返回而结束,因此运行一次看不到长期积累。如果同样的消费逻辑出现在持续运行的服务里,且后续再没有接收者,生产者会停在第二次发送,连 defer close(out) 都执行不到。请求处理函数返回,并不等于它启动的 goroutine 返回。

垃圾回收器不会替我们终止这个任务。goroutine 的栈和运行时状态仍需保留,栈上仍然有效的引用也可能让关联对象保持可达。具体保留多少内存取决于实际引用,不能把某个固定栈大小直接乘以 goroutine 数量就当成完整内存成本。

把 out 改成容量为 1 的 channel,可以容纳本例剩下的一个结果。但这依赖“只剩一次发送”这一明确条件。对于数量不确定的流,增大缓冲只会推迟再次阻塞;缓冲容量不是取消协议。

2.2 不同等待点需要不同退出条件​

退出条件必须覆盖当前正在等待的那一步。只在循环开头检查一次 ctx.Err(),不能解除随后已经发生的普通阻塞发送。

等待或运行位置需要检查的条件
channel 发送、接收对端是否仍会通信;能否在同一个 select 中观察取消
for range ch谁负责在所有发送结束后关闭 ch;是否还需要提前退出
nil channel它是有意停用的 select 分支,还是导致单独收发永远等待
Mutex.Lock持锁方能否释放;是否存在反向锁顺序;该调用本身不接受 context
WaitGroup.Wait每个计入的任务是否都能执行 Done;等待方是否反过来持有任务所需资源
网络或其他 I/O具体 API 是否支持 context、deadline 或关闭资源以解除阻塞
计算循环、重试循环是否会观察取消;是否有退出条件、退避和工作量上限

阻塞与泄漏不能仅凭状态名划等号。例如服务器等待新连接、固定数量的 worker 等待任务,都是可能合理的长期等待。要进一步确认服务关闭时它们如何停止,以及其数量是否随请求无限增加。

死锁关注的是相关任务因为互相等待而无法继续推进;泄漏关注的是任务超出了应有生命周期。部分请求形成等待环时,服务中的其他 goroutine 仍可能工作,所以不能等运行时报告“所有 goroutine 都睡眠”才认为存在死锁。

3. 取消与退出确认​

3.1 让发送同时观察取消​

生产者需要知道下游已经停止消费,调用方也需要知道生产者已经完成清理。下面用 ctx 传递取消,用 done 确认生产者完成:

package main

import (
"context"
"fmt"
)

func produce(ctx context.Context) (<-chan int, <-chan struct{}) {
out := make(chan int)
done := make(chan struct{})
go func() {
defer close(done)
defer close(out)
for _, n := range []int{1, 2} {
select {
case out <- n:
case <-ctx.Done():
return
}
}
}()
return out, done
}

func main() {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
out, done := produce(ctx)
fmt.Println(<-out)
cancel()
<-done
fmt.Println("producer stopped")
}

输出是 1 和 producer stopped。第一次接收后不再有接收者,第二次发送不能完成;取消使 ctx.Done() 就绪,生产者返回。两个 defer 按逆序执行,先关闭 out,再关闭 done,所以调用方收到完成通知时,结果通道也已经关闭。

这里的 done 是这个例子约定的完成信号。多个任务可以使用 WaitGroup 等待,取消传播可以使用 context,但都需要具体任务执行相应的退出和完成通知。

如果还有其他消费者,取消与发送也可能同时就绪;select 不保证取消优先。需要“取消后绝不提交新的业务结果”时,应另行定义提交时的状态检查或协调协议,不能仅靠调换 case 顺序。相关规则见 select 的取消选择。

3.2 超时包装的边界​

外层停止等待,只能决定外层何时返回。假设一个 goroutine 调用不支持取消的 slowCall(),另一个 goroutine 等它的结果或超时信号。超时分支被选中时,slowCall() 可能仍在运行,也可能永久阻塞。

容量为 1 的结果 channel 可以保证一次结果发送不必等接收者,前提是任务确实能够返回、且最多发送一次。它解决的是返回后的发送阻塞,不能解决 slowCall() 自身不返回。

处理这类调用时,要把期限传到实际执行操作的 API,或使用该 API 明确支持的解除阻塞机制。若任务确实应转成后台工作,就要移交给有并发限制、错误记录和关闭流程的管理者,而不是让已结束的请求继续隐式拥有它。

同样,另起一个 goroutine 等锁、外层再超时,并不会取消 Lock。如果后台后来取得锁却没有执行匹配的解锁,还会制造新问题,见 Go 互斥锁的等待取消。

4. goroutine 栈与故障定位​

4.1 从数量变化找到具体等待​

runtime.NumGoroutine() 给出当前存活的 goroutine 数量,适合看趋势,不能直接回答哪些是泄漏。排查时可以按下面的顺序缩小范围:

  1. 对齐请求量、并发数、错误和取消事件,观察任务增加是否与负载相符。
  2. 在停止新增工作、等待合理清理时间后,检查数量是否仍持续积累。
  3. 采集多个时刻的 goroutine 栈,比较哪些业务调用位置反复出现、对应数量是否增长。
  4. 回到这些位置的代码,找出本应发生的接收、关闭、解锁或取消为何没有发生。
  5. 用可控制的输入复现,再验证修复后任务能退出、结果仍正确。

“合理清理时间”应来自任务期限和关闭约定,不是随手设置一个固定休眠后就下结论。后台维护任务、测试框架和运行时本身也会影响总数,因此单次精确比较全进程 goroutine 数量容易误判。

4.2 采集并阅读调用栈​

runtime/pprof 的 goroutine profile 可以输出当前调用栈。在需要诊断的进程内调用下面的函数,把结果写到受控的文件或其他输出目标:

func writeGoroutineStacks(w io.Writer) error {
return pprof.Lookup("goroutine").WriteTo(w, 2)
}

这个函数需要导入 io 和 runtime/pprof。debug=2 输出便于直接阅读的各 goroutine 栈;debug=0 输出供 pprof 工具读取的二进制 profile。两者的格式用途不同。

阅读栈时,先看等待状态,例如 [chan send]、[chan receive]、[select] 或 [IO wait],再看业务函数和源码行,而不是停留在 runtime 内部函数名。对于前面的错误生产者,关键是第二次 out <- 2 的发送位置。栈中的 created by 信息还可以帮助追到启动任务的代码。

同一个业务栈重复出现并不自动证明泄漏:正常 worker 也可能在同一处等待。需要结合消费者是否已经退出、取消是否已经发生,以及后续快照是否仍保留这些任务来判断。

已有诊断 HTTP 服务时,也可以通过 net/http/pprof 获取相应 profile。仅导入包会注册默认 mux 的处理器,不会自动启动服务器;使用自定义 mux 时还需显式注册。诊断栈会暴露内部实现信息,应只向有诊断权限的调用方开放。

4.3 工具与问题对应​

工具或证据主要回答的问题不能据此直接断定什么
goroutine 数量与调用栈当前有哪些任务,正在什么位置等待或执行某个等待一定是泄漏
CPU profileCPU 时间主要花在哪里长期睡眠的任务不存在泄漏
block profile同步等待主要出现在哪些调用位置当前所有未结束的等待都已完整记录
mutex profile哪些临界区产生锁竞争某条等待链的完整因果关系
race detector已执行的共享访问是否发生数据竞争所有路径都安全、任务会退出、业务结果正确

block 与 mutex profile 需要分别通过 runtime.SetBlockProfileRate、runtime.SetMutexProfileFraction 启用采样。它们是统计证据,不替代当前 goroutine 栈;尚未结束的等待尤其不能只靠累计 profile 判断。采样本身也有成本,应围绕待验证的问题使用。

5. 数据竞争与修复​

5.1 读取竞争报告​

数据竞争的关键是共享访问之间缺少同步关系,并不要求两条机器指令在同一个时刻执行。单核或把 GOMAXPROCS 设为 1,也不能替代同步,具体依据见 Go 内存模型。

下面是刻意保留数据竞争的反例。WaitGroup 等待两个任务结束,但两个 count++ 之间没有互斥:

package main

import (
"fmt"
"sync"
)

func main() {
var count int
var wg sync.WaitGroup
wg.Add(2)
for i := 0; i < 2; i++ {
go func() {
defer wg.Done()
for j := 0; j < 1000; j++ {
count++ // 故意保留数据竞争,用于诊断。
}
}()
}
wg.Wait()
fmt.Println(count)
}

把程序保存为 main.go 后运行 go run -race main.go。竞争报告会指出 count++ 对应的冲突访问,并列出相关 goroutine 的创建位置。不要把某次打印出 2000 当成正确性证据:结果碰巧符合预期,并不会补上缺失的同步。

拿到 WARNING: DATA RACE 后,先确认冲突的是哪个对象,再沿着两处访问追踪它如何被共享。随后检查其他读写是否也遵守同一协议;只给报告中某一行加锁,可能漏掉另一条访问路径。

5.2 保护整个操作​

对这个计数例子,可以在启动 goroutine 之前声明一个共享的 mu sync.Mutex,把两处循环执行的 count++ 都包在同一个锁内:

mu.Lock()
count++
mu.Unlock()

锁需要覆盖“读取旧值、加一、写回”整个操作。若每个任务各自持有一把锁,它们仍然不能互相排斥。若把读取和写回分别加锁,可能消除了数据竞争,却仍然丢失更新,见 Go 互斥锁的临界区。

只需要单个计数器加减时,也可以使用 atomic.Int64.Add。多个字段需要共同满足约束时,要保护完整的不变量,不能把每个字段独立改成原子变量就认为事务已经成立,见 Go 原子操作。

传递切片或指针也不代表已经转移全部访问权。发送方若继续修改底层数组,接收方同时读取,仍可能发生竞争。要么明确约定发送之后不再访问,要么复制独立数据,或使用共同的同步机制。

6. 最小复现与回归验证​

最小复现是保留故障成立条件、去掉无关依赖的一小段可重复执行的程序或测试。它的价值在于能解释“哪个条件改变后故障就消失”,而不只是让错误再出现一次。

验证泄漏时,保留真实的阻塞条件:例如下游读一个值后提前返回、上游还有待发送结果。通过完成通知确认取消后生产者能够结束;必要时采集栈证明修复前停在预期位置。测试结束后也要解除刻意制造的阻塞并回收任务,不能让测试自身残留 goroutine。

验证数据竞争时,保留真实共享对象和冲突访问,运行 go test -race ./...。用 channel、屏障或明确的阶段信号安排关键交错,比“先睡几十毫秒,猜另一个任务已经走到某行”更容易解释。安排阶段时还要注意:新加的同步可能刚好消除了原本要复现的竞争。

修复后的检查至少要回答对应故障提出的问题:

  • 正常消费时,结果是否完整、顺序是否满足既定契约。
  • 提前结束、父级取消、错误返回时,所有相关等待是否能够解除。
  • 没有消费者、输入已经关闭或期限已经到达时,清理是否仍能完成。
  • 共享状态是否正确;不仅看 race 报告,也看是否丢失更新、重复提交或破坏约束。

race detector 只检查实际执行的路径。测试没有覆盖错误分支,或者负载没有触发那组交错,干净的检测结果就不能替那些路径作保证。并发测试的超时用于防止测试无限挂起,不证明业务操作一定能在相同时间内完成。

7 · 面试题卡片​

本页题目与参考回答

7.1 goroutine 会泄漏吗?举一个例子并解释原因。​

考察重点:任务生命周期、阻塞条件与垃圾回收的职责。

相关内容:生命周期与泄漏。

参考回答

会。工作已经结束或不再需要,goroutine 却没有退出,就是生命周期上的资源泄漏。 例如生产者向无缓冲 channel 发送多个结果,消费者只取一个就返回,生产者可能一直等不到下一次接收。

垃圾回收不会替它终止执行。需要让生产者能够接收取消通知并返回,或由明确的接收方继续消费;不能仅依赖请求函数返回。合理的后台 worker 长期等待任务则未必是泄漏,要结合它的关闭约定判断。

7.2 编写 Go 并发代码时,怎样避免 goroutine 泄漏?​

考察重点:启动责任、阻塞操作、取消传播与退出确认。

相关内容:不同等待点需要不同退出条件、取消与退出确认。

参考回答

为每个启动的任务明确谁负责取消、哪些操作可能阻塞,以及怎样确认任务结束。 channel 收发可以在 select 中同时观察取消;I/O 要使用具体 API 支持的期限或取消方式;锁和完成等待也要有能够满足的释放条件。

调用方提前返回时,要通知不再需要的工作停止并完成清理。关闭数据 channel 应由能够确认发送已经结束的一方负责。缓冲只能承接有限的积压,不能代替退出协议。

7.3 服务的 goroutine 数量持续增加,怎样判断是否泄漏?​

考察重点:趋势、负载、重复调用栈与退出条件之间的证据关系。

相关内容:goroutine 栈与故障定位。

参考回答

先对齐负载和任务生命周期,再用多个时刻的 goroutine 栈定位持续残留的业务位置。 流量增大时任务增多可能正常;停止新增工作后,已经过了约定清理阶段仍不断积累,才是需要继续追查的现象。

从等待状态、业务源码行和创建位置找到发送、接收、锁或 I/O 的退出条件,检查对应的消费者、解锁方或取消信号是否还存在。最后构造提前退出或失败输入,用回归检查确认任务确实能够结束,不能只看总数下降。

7.4 race detector 报告 count++ 存在竞争,应该怎样修复?​

考察重点:共享访问识别、完整临界区与检测范围。

相关内容:数据竞争与修复。

参考回答

查看报告中的两处冲突访问和创建栈,确认所有访问同一个 count 的路径遵守共同的同步协议。 计数加一可以由同一把锁保护完整的读取、修改、写回,也可以改用合适的原子加法。

WaitGroup 只负责此处的完成等待,不会让两个任务中的 count++ 互斥。修复后既要运行 race 检测,也要验证最终计数;只把读取、写入分别保护起来,仍可能丢失更新。

7.5 go test -race 通过后,还需要检查什么?​

考察重点:数据竞争检测、业务正确性与任务生命周期的不同范围。

相关内容:工具与问题对应、最小复现与回归验证。

参考回答

还要检查未执行的分支、业务不变量和任务是否能够退出。 检测通过只说明这次实际执行中没有报告数据竞争,不会证明所有调度和输入都安全。

即使每次共享访问都同步,读后再写之间也可能被其他任务插入,造成丢失更新;一个不访问共享变量的 goroutine 也可能永久等待 channel。需要分别验证结果、取消、错误清理和关闭过程。

7.6 用 goroutine 包装阻塞调用,再让外层超时返回,能避免泄漏吗?​

考察重点:等待期限、实际工作取消与结果发送的独立边界。

相关内容:超时包装的边界。

参考回答

不一定。外层停止等待不会自动终止内层阻塞调用。 必须让实际操作使用支持取消或期限的 API,或采用该资源明确允许的解除阻塞方式。

一次性结果 channel 设置一个缓冲,可以让已经完成的任务发送一次结果后退出,却不能让永久阻塞的调用返回。若操作需要继续作为后台任务执行,则必须明确移交其生命周期管理责任。

8. 参考资料​

来源:Coding 101 原文 · 免费阅读,引用请保留出处。内容使用说明