摘要:凌晨两点半,告警群里一条 "内存使用率 > 90%" 的消息把我从睡梦中炸醒。排查后发现,一个不起眼的 goroutine 泄漏,硬生生把一个本该只占 20MB 的微服务撑到了 500MB。本文完整复盘整个排查过程,附带 4 种常见泄漏模式的可运行代码和修复方案,建议收藏。
一、凌晨两点半的内存告警
事情发生在一个普通的周二凌晨。
我们的订单查询服务上线两周,QPS 从最初的 200 慢慢爬到了 3000。一切看起来岁月静好,直到凌晨 2:30,Prometheus 的告警直接把我叫醒:
ini
[CRITICAL] service=order-query, memory_usage=92%, threshold=85%, duration=5m
登上机器一看,整个人都不好了:
bash
$ docker stats
CONTAINER CPU % MEM USAGE / LIMIT
order-query-01 15.2% 502.4MiB / 512MiB
500MB 。而同类服务正常情况下,内存应该在 15-20MB 左右。
更离谱的是内存曲线------它不是突增,而是持续缓慢上涨,像极了那个只进不出的貔貅。
lua
内存使用量 (MB)
500 | ✗ 告警触发
| ╱╱
400 | ╱╱╱
| ╱╱╱
300 | ╱╱╱
| ╱╱╱
200 | ╱╱╱
|╱╱
100 | ← 上线初期
0 +------------------------------------→ 时间
Day1 Day3 Day5 Day7 Day10 Day14
这种缓慢增长的特征,老手一看就知道:大概率是资源泄漏。
Go 有 GC,所以泄漏的不是对象------而是 goroutine。每个 goroutine 初始化栈 2KB,如果泄漏的 goroutine 持有数据库连接、channel、timer 等资源,内存占用会滚雪球般膨胀。
接下来就是排查时间。
二、排查三板斧
2.1 第一步:确认 goroutine 数量
Go 提供了 runtime.NumGoroutine() 来获取当前活跃的 goroutine 数量。我在服务里加了一个简单的监控端点:
go
package main
import (
"encoding/json"
"fmt"
"net/http"
"runtime"
"time"
)
func main() {
// 模拟业务 goroutine 启动
go backgroundWorker()
http.HandleFunc("/debug/stats", func(w http.ResponseWriter, r *http.Request) {
stats := map[string]interface{}{
"goroutines": runtime.NumGoroutine(),
"mem_sys_mb": formatMB(runtime.MemStats{}.Sys),
"uptime": time.Since(startTime).String(),
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(stats)
})
fmt.Println("Server running on :8080")
http.ListenAndServe(":8080", nil)
}
var startTime = time.Now()
func formatMB(bytes uint64) float64 {
return float64(bytes) / 1024 / 1024
}
func backgroundWorker() {
// 模拟泄漏的 worker
for {
time.Sleep(time.Hour)
}
}
请求一下看看:
bash
$ curl http://localhost:8080/debug/stats
{
"goroutines": 5,
"mem_sys_mb": 12.4,
"uptime": "3h12m"
}
线上实际数据 :goroutines 的数量已经飙到了 23000+,而服务启动时只有不到 20 个。
23000 个 goroutine,平均每个即使只有 2KB 栈 + 各种资源引用,轻松突破 500MB。
结论:确认是 goroutine 泄漏。
2.2 第二步:pprof 抓取 goroutine 快照
Go 的 net/http/pprof 包是排查利器,接入成本极低:
go
import (
"net/http"
_ "net/http/pprof" // 只需一行空白导入
)
func main() {
// 其他代码...
// pprof 自动注册到 DefaultServeMux
http.ListenAndServe(":8080", nil)
}
然后直接抓取 goroutine 堆栈:
bash
# 获取 goroutine 文本报告
$ curl http://localhost:8080/debug/pprof/goroutine?debug=1
# 或者下载 profile 文件,用 go tool pprof 可视化分析
$ curl http://localhost:8080/debug/pprof/goroutine -o goroutine.prof
$ go tool pprof -http=:9999 goroutine.prof
?debug=1 参数返回的是可读文本,直接能看到每个阻塞点的 goroutine 数量:
shell
goroutine profile: total 23456
23000 @ 0x1037a1e 0x1037a1f 0x104f2e0 0x105c890 0x105c87f 0x105c5b7 0x108e5d0 0x10c37a0 0x10c378f
# 0x104f2e0 sync.runtime_Semacquire+0x30
# 0x105c890 sync.(*Mutex).lockSlow+0xf0
# 0x105c87f sync.(*Mutex).Lock+0x7f
# 0x105c5b7 sync.(*RWMutex).Lock+0x17
# 0x108e5d0 order-query/worker.(*Worker).process+0x50
# 0x10c37a0 order-query/worker.(*Worker).run+0x120
200 @ 0x1037a1e 0x106d2d0 0x106d2b1 0x109a5a0 0x10c3b00
# 0x106d2d0 internal/poll.runtime_pollWait+0x60
# 0x109a5a0 net.(*netFD).accept+0x40
关键信息 :23000 个 goroutine 全部卡在 worker.(*Worker).process 这个方法里,等待某个 channel 或者锁。
问题范围瞬间从整个服务缩小到 worker.process 这一个方法。
2.3 第三步:trace 追踪(可选但强大)
如果 pprof 不够用,runtime/trace 可以记录更详细的执行轨迹:
go
import (
"os"
"runtime/trace"
)
func main() {
f, _ := os.Create("trace.out")
defer f.Close()
trace.Start(f)
defer trace.Stop()
// 服务运行...
}
然后通过 go tool trace trace.out 在浏览器中查看:
- 每个 goroutine 的生命周期
- 阻塞时间和原因
- GC 事件时间线
trace 的粒度更细,但开销较大,建议只在排查时临时开启。
三、找到真凶:泄漏代码还原
通过 pprof 的堆栈信息,我定位到了问题代码。以下是泄漏点的还原版本(已脱敏):
go
package main
import (
"context"
"fmt"
"sync"
"time"
)
type OrderService struct {
mu sync.RWMutex
orders map[string]*Order
cacheCh chan *Order // ← 问题源头
}
type Order struct {
ID string
Amount float64
}
func NewOrderService() *OrderService {
ch := make(chan *Order, 100)
svc := &OrderService{
orders: make(map[string]*Order),
cacheCh: ch,
}
// 启动异步处理 goroutine
go svc.cacheWriter()
return svc
}
// cacheWriter 从 channel 读取订单并写入缓存
func (s *OrderService) cacheWriter() {
for order := range s.cacheCh {
s.mu.Lock()
s.orders[order.ID] = order
s.mu.Unlock()
fmt.Printf("cached order: %s\n", order.ID)
}
fmt.Println("cacheWriter exiting") // ← 永远执行不到
}
// CreateOrder 创建订单并发送到缓存 channel
func (s *OrderService) CreateOrder(ctx context.Context, id string, amount float64) error {
order := &Order{ID: id, Amount: amount}
// 问题 1:没有 ctx 超时控制,channel 满了就永远阻塞
// 问题 2:没有 select + ctx.Done(),无法取消
s.cacheCh <- order
return nil
}
func main() {
svc := NewOrderService()
ctx := context.Background()
// 模拟高并发场景:快速创建 120 个订单
for i := 0; i < 120; i++ {
go func(i int) {
id := fmt.Sprintf("order-%d", i)
svc.CreateOrder(ctx, id, float64(i)*10)
}(i)
}
time.Sleep(2 * time.Second)
// 检查泄漏
fmt.Printf("Active goroutines: %d\n", countGoroutines())
}
func countGoroutines() int {
buf := make([]byte, 1<<20)
n := runtime.Stack(buf, true)
// 简单统计 goroutine 行数
count := 0
for _, line := range strings.Split(string(buf[:n]), "\n") {
if strings.HasPrefix(line, "goroutine ") {
count++
}
}
return count
}
泄漏原因一目了然:
cacheCh是一个 buffered channel,容量 100CreateOrder往 channel 发送数据时,没有使用select + context.Done()- 当 channel 满了(100 条),后续的 20 个 goroutine 永久阻塞在发送操作上
- 更致命的是:如果
cacheWriter因为 panic 或者其他原因退出,所有等待发送的 goroutine 全部泄漏
四、修复方案
修复很简单------给 channel 操作加上 select 和 context 取消机制:
go
// 修复后的 CreateOrder
func (s *OrderService) CreateOrder(ctx context.Context, id string, amount float64) error {
order := &Order{ID: id, Amount: amount}
select {
case s.cacheCh <- order:
return nil
case <-ctx.Done():
return fmt.Errorf("order %s create cancelled: %w", id, ctx.Err())
case <-time.After(3 * time.Second):
return fmt.Errorf("order %s create timeout", id)
}
}
改动只有几行,但效果立竿见影:
- 有取消机制:context 取消时,goroutine 正常退出
- 有超时保护:超过 3 秒自动返回,不会无限等
- 有错误返回:调用方能感知失败,而不是傻傻等
改完上线后,内存曲线当天就断崖式下降:
yaml
内存使用量 (MB)
500 | ✗ 修复前
|╱
200 |
|
20 | ← 修复部署 ────────────────────→
|
0 +------------------------------------→ 时间
Day1 Day3 Day5 Day7 Day10 FixDay
内存从 500MB 直接压到 20MB ,goroutine 数量回到正常的 20+。
五、4 种常见 Goroutine 泄漏模式(附可运行代码)
排查了那么多泄漏 case,我总结了 4 种最常见的模式。以下代码都可以直接 go run 验证。
模式 1:Channel 未关闭导致消费者永久阻塞
这是最经典的泄漏模式。生产者启动了,消费者从 channel 读取,但生产者没有正确关闭 channel 或者消费者没有退出机制。
go
// leak_channel.go ------ 泄漏版本
package main
import (
"fmt"
"runtime"
"time"
)
func leakByChannel() {
ch := make(chan int)
// 消费者:等待 channel 数据
go func() {
for v := range ch {
fmt.Println("received:", v)
}
fmt.Println("consumer exiting") // ← 不会执行
}()
// 生产者:发送一个值后就不再发送,但也不关闭 channel
ch <- 1
// 忘记 close(ch),消费者永远在 range 上阻塞
// 生产者 goroutine 也泄漏了(如果有多个生产者)
}
func main() {
before := runtime.NumGoroutine()
// 模拟泄漏 50 次
for i := 0; i < 50; i++ {
leakByChannel()
}
time.Sleep(100 * time.Millisecond)
after := runtime.NumGoroutine()
fmt.Printf("Goroutines: before=%d, after=%d, leaked=%d\n",
before, after, after-before)
}
运行结果:
ini
Goroutines: before=1, after=51, leaked=50
修复方案:确保 channel 在合适时机关闭,或者消费者有退出机制。
go
// fix_channel.go ------ 修复版本
package main
import (
"fmt"
"runtime"
"time"
)
func fixedByChannel() {
ch := make(chan int)
go func() {
defer func() {
// 确保消费者退出后关闭 channel
close(ch)
}()
for i := 0; i < 3; i++ {
ch <- i
}
// 正常退出,defer 关闭 channel
}()
go func() {
for v := range ch {
fmt.Println("received:", v)
}
fmt.Println("consumer exiting")
}()
}
func main() {
before := runtime.NumGoroutine()
for i := 0; i < 50; i++ {
fixedByChannel()
}
time.Sleep(200 * time.Millisecond)
after := runtime.NumGoroutine()
fmt.Printf("Goroutines: before=%d, after=%d, leaked=%d\n",
before, after, after-before)
}
运行结果:
makefile
received: 0
received: 1
received: 2
consumer exiting
Goroutines: before=1, after=1, leaked=0
要点:
range channel只有在 channel 关闭时才会退出- 谁创建 channel,谁负责关闭(通常是发送方)
- 使用
sync.WaitGroup确保所有 goroutine 完成后再 close
模式 2:Context 未取消导致 goroutine 无限等待
这是隐蔽性最强 的泄漏模式。goroutine 监听 ctx.Done(),但 context 从未被取消,goroutine 就一直挂着。
go
// leak_context.go ------ 泄漏版本
package main
import (
"context"
"fmt"
"runtime"
"time"
)
type EventMonitor struct {
done chan struct{}
}
func NewEventMonitor(ctx context.Context) *EventMonitor {
mon := &EventMonitor{done: make(chan struct{})}
// goroutine 监听 context
go func() {
for {
select {
case <-ctx.Done(): // ← ctx 永远不会被取消
fmt.Println("monitor stopping")
close(mon.done)
return
case <-time.After(1 * time.Second):
// 定时执行一些操作
fmt.Println("heartbeat...")
}
}
}()
return mon
}
func main() {
before := runtime.NumGoroutine()
for i := 0; i < 10; i++ {
ctx := context.Background() // ← 没有 cancel 的 context
NewEventMonitor(ctx)
}
time.Sleep(3 * time.Second)
after := runtime.NumGoroutine()
fmt.Printf("Goroutines: before=%d, after=%d, leaked=%d\n",
before, after, after-before)
}
运行结果(3 秒内会打印 30 次 heartbeat):
erlang
heartbeat...
heartbeat...
...
Goroutines: before=1, after=11, leaked=10
修复方案 :使用 context.WithCancel,并在不再需要时调用 cancel 函数。
go
// fix_context.go ------ 修复版本
package main
import (
"context"
"fmt"
"runtime"
"time"
)
type EventMonitor struct {
done chan struct{}
cancel context.CancelFunc
}
func NewEventMonitor(ctx context.Context) *EventMonitor {
ctx, cancel := context.WithCancel(ctx)
mon := &EventMonitor{
done: make(chan struct{}),
cancel: cancel,
}
go func() {
defer close(mon.done)
for {
select {
case <-ctx.Done():
fmt.Println("monitor stopping")
return
case <-time.After(1 * time.Second):
fmt.Println("heartbeat...")
}
}
}()
return mon
}
func (m *EventMonitor) Stop() {
m.cancel() // 取消 context
<-m.done // 等待 goroutine 退出
}
func main() {
before := runtime.NumGoroutine()
var monitors []*EventMonitor
for i := 0; i < 10; i++ {
mon := NewEventMonitor(context.Background())
monitors = append(monitors, mon)
}
time.Sleep(2 * time.Second)
// 正确停止所有 monitor
for _, m := range monitors {
m.Stop()
}
after := runtime.NumGoroutine()
fmt.Printf("Goroutines: before=%d, after=%d, leaked=%d\n",
before, after, after-before)
}
运行结果:
erlang
heartbeat...
monitor stopping
monitor stopping
...
Goroutines: before=1, after=1, leaked=0
要点:
- 永远不要 把
context.Background()直接传给后台 goroutine,至少用WithCancel包装 - 每个
WithCancel/WithTimeout/WithDeadline都必须有对应的 cancel 调用 - 把
cancel函数封装在结构体的Stop()方法里,调用方不容易忘记
模式 3:Timer / Ticker 未 Stop 导致泄漏
time.After 和 time.Ticker 创建后,如果没有及时 Stop() 或等待其触发,底层会挂起一个 goroutine。
go
// leak_timer.go ------ 泄漏版本
package main
import (
"fmt"
"runtime"
"time"
)
func leakByTimer() {
for i := 0; i < 100; i++ {
// time.After 内部创建 timer 和 goroutine
// 如果没有被 select 消费掉,timer 在到期前不会释放
go func(id int) {
ticker := time.NewTicker(10 * time.Second)
// 忘了 ticker.Stop()
// 而且这个 goroutine 没有退出逻辑
<-ticker.C // 等待 10 秒后才释放
}(i)
}
}
func main() {
before := runtime.NumGoroutine()
leakByTimer()
time.Sleep(500 * time.Millisecond)
after := runtime.NumGoroutine()
fmt.Printf("Goroutines: before=%d, after=%d, leaked=%d\n",
before, after, after-before)
}
运行结果:
ini
Goroutines: before=1, after=101, leaked=100
这 100 个 goroutine 要等 10 秒后才会释放。如果 Ticker 间隔更长,或者 goroutine 里有循环,泄漏时间更久。
修复方案 :使用 defer ticker.Stop(),或者配合 context 使用。
go
// fix_timer.go ------ 修复版本
package main
import (
"context"
"fmt"
"runtime"
"time"
)
func fixedByTimer(ctx context.Context) {
for i := 0; i < 100; i++ {
go func(id int) {
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop() // ← 关键
for {
select {
case <-ctx.Done():
return // context 取消,立即退出
case <-ticker.C:
fmt.Printf("ticker fired: %d\n", id)
return // 触发一次就退出
}
}
}(i)
}
}
func main() {
ctx, cancel := context.WithCancel(context.Background())
before := runtime.NumGoroutine()
fixedByTimer(ctx)
time.Sleep(200 * time.Millisecond)
cancel() // 取消所有 goroutine
time.Sleep(100 * time.Millisecond)
after := runtime.NumGoroutine()
fmt.Printf("Goroutines: before=%d, after=%d, leaked=%d\n",
before, after, after-before)
}
运行结果:
ini
Goroutines: before=1, after=1, leaked=0
要点:
time.NewTicker()和time.NewTimer()一定要配合defer Stop()time.After()在select中使用是安全的(到期后自动 GC),但如果在循环里反复调用time.After(),每次都会创建新 timer,前一个到期前不会释放------这种场景必须用time.NewTicker+Stop()- 搭配
context.Context一起使用,确保 goroutine 有退出路径
模式 4:Goroutine 无限阻塞在无缓冲 Channel 或 WaitGroup 上
有些泄漏不是 channel 的问题,而是等待条件永远不会满足。
go
// leak_waitgroup.go ------ 泄漏版本
package main
import (
"fmt"
"runtime"
"sync"
"time"
)
type TaskRunner struct {
wg sync.WaitGroup
}
func (r *TaskRunner) RunTask(id int) {
r.wg.Add(1)
go func() {
defer r.wg.Done()
// 模拟一些可能 panic 的操作
if id%3 == 0 {
panic("unexpected error") // ← 某些条件触发 panic
}
fmt.Printf("task %d completed\n", id)
}()
}
func main() {
before := runtime.NumGoroutine()
runner := &TaskRunner{}
for i := 0; i < 20; i++ {
runner.RunTask(i)
}
// panic 发生在 goroutine 内部,recover 没有处理
// wg.Done() 不会执行,Wait 永远阻塞
runner.wg.Wait()
fmt.Println("all tasks done")
time.Sleep(200 * time.Millisecond)
after := runtime.NumGoroutine()
fmt.Printf("Goroutines: before=%d, after=%d\n",
before, after)
}
运行结果:
go
task 1 completed
task 2 completed
panic: unexpected error
goroutine 18 [running]:
...
程序直接 crash 了。在真实服务中,panic 通常被 recover 捕获了,但 wg.Done() 没有执行,导致 Wait() 永远等不到所有任务完成。
修复方案 :defer wg.Done() 放在 goroutine 的第一行,不管是否 panic 都会执行。
go
// fix_waitgroup.go ------ 修复版本
package main
import (
"fmt"
"runtime"
"sync"
"time"
)
type TaskRunner struct {
wg sync.WaitGroup
}
func (r *TaskRunner) RunTask(id int) {
r.wg.Add(1)
go func() {
defer r.wg.Done() // ← 必须在 defer 中,确保无论如何都执行
// 加上 recover 防止 panic 影响其他 goroutine
defer func() {
if err := recover(); err != nil {
fmt.Printf("task %d recovered from: %v\n", id, err)
}
}()
if id%3 == 0 {
panic("unexpected error")
}
fmt.Printf("task %d completed\n", id)
}()
}
func main() {
before := runtime.NumGoroutine()
runner := &TaskRunner{}
for i := 0; i < 20; i++ {
runner.RunTask(i)
}
// 现在即使有 panic,wg.Done() 也会被调用
runner.wg.Wait()
fmt.Println("all tasks done")
time.Sleep(100 * time.Millisecond)
after := runtime.NumGoroutine()
fmt.Printf("Goroutines: before=%d, after=%d\n",
before, after)
}
运行结果:
vbnet
task 1 completed
task 2 completed
task 4 completed
task 5 completed
task 0 recovered from: unexpected error
task 3 recovered from: unexpected error
...
all tasks done
Goroutines: before=1, after=1
要点:
defer wg.Done()必须是 goroutine 函数的第一条语句- 对可能 panic 的操作加
defer recover() - 如果用
errgroup.Group(golang.org/x/sync/errgroup),它内部已经处理了这些细节,推荐在生产环境使用
六、避坑指南和最佳实践
排查了上百个泄漏 case,以下是血泪总结:
6.1 开发阶段
规则 1:每个 goroutine 都必须有退出路径
写 goroutine 前先问自己三个问题:
- 它什么时候退出?
- 如果 channel 满了/空了会怎样?
- 如果上游服务挂了会怎样?
go
// ❌ 错误示范:没有退出条件
go func() {
for {
// do something
}
}()
// ✅ 正确示范:有明确的退出条件
go func() {
for {
select {
case <-ctx.Done():
return
case data := <-ch:
// handle data
}
}
}()
规则 2:用 errgroup 替代裸 WaitGroup
go
import "golang.org/x/sync/errgroup"
g, ctx := errgroup.WithContext(context.Background())
for i := 0; i < 10; i++ {
i := i // capture loop variable
g.Go(func() error {
return doSomething(ctx, i)
})
}
// 任何一个 goroutine 出错,ctx 会被 cancel,其他 goroutine 也会收到信号
err := g.Wait()
errgroup 帮你处理了三个裸 WaitGroup 搞不定的事:
- 错误传播:一个失败,全部取消
- Context 联动:自动 cancel
- Panic 隔离:不会让整个进程崩溃
规则 3:Channel 操作必须配合 select
go
// ❌ 可能永久阻塞
ch <- data
// ✅ 有超时和取消保护
select {
case ch <- data:
case <-ctx.Done():
return ctx.Err()
case <-time.After(5 * time.Second):
return ErrTimeout
}
6.2 监控阶段
规则 4:把 goroutine 数量纳入监控
在 Prometheus 中加一个 metric:
go
import "github.com/prometheus/client_golang/prometheus"
var goroutineCount = prometheus.NewGaugeFunc(
prometheus.GaugeOpts{
Name: "go_goroutines",
Help: "Number of goroutines",
},
func() float64 {
return float64(runtime.NumGoroutine())
},
)
配合告警规则:
yaml
# Prometheus alerting rules
- alert: GoroutineLeak
expr: go_goroutines > 1000
for: 5m
labels:
severity: warning
annotations:
summary: "Goroutine count is unusually high"
规则 5:定期采集 pprof profile
生产环境常驻开启 pprof,定期采集 goroutine profile:
bash
# 每天自动抓取
curl http://localhost:8080/debug/pprof/goroutine?debug=2 -o /tmp/goroutine-$(date +%Y%m%d).txt
对比不同时间的 profile,能发现缓慢泄漏的趋势。
6.3 排查阶段
规则 6:pprof + trace + 日志 = 黄金组合
排查顺序:
runtime.NumGoroutine()→ 确认泄漏pprof goroutine?debug=1→ 定位阻塞点pprof goroutine?debug=2→ 看完整堆栈go tool trace→ 看时序和阻塞时长- 结合业务日志 → 确认触发条件
规则 7:善用 goroutine dump
go
// 在 handler 中输出完整 goroutine dump
http.HandleFunc("/debug/goroutine-dump", func(w http.ResponseWriter, r *http.Request) {
buf := make([]byte, 1<<20) // 1MB buffer
n := runtime.Stack(buf, true)
w.Write(buf[:n])
})
runtime.Stack 是 pprof 的底层实现,输出格式和 pprof/goroutine?debug=2 一样,可以按需暴露。
七、总结
回顾整个排查过程,核心链路只有三步:
markdown
监控告警 → pprof 定位 → 修复 + 验证
↓ ↓ ↓
内存 > 90% 23000 goroutines 内存回到 20MB
全卡在 channel 泄漏归零
Go 的 goroutine 轻量到让人忘记它的存在,但"轻量"不等于"免费"。泄漏的 goroutine 会持续消耗内存,持有的资源(数据库连接、文件句柄、锁)不会释放,最终拖垮整个服务。
记住三条铁律:
- 每个 goroutine 都必须有退出路径(context 或 channel)
- 每个 timer/ticker 都必须有 Stop(defer 最安全)
- 每个 channel 操作都必须有 select 保护(超时 + 取消)
这三条做到了,90% 的 goroutine 泄漏都能避免。
最后送一句程序员圈的至理名言:
"Go 有 GC,所以不用担心内存泄漏。"
------ 说这话的人,一定没在凌晨两点半被内存告警叫醒过。
参考链接:
- pprof 官方文档
- Go blog: Profiling Go Programs
- Go blog: Go Concurrency Patterns: Context
- golang.org/x/sync/errg...
如果你觉得这篇文章有帮助,欢迎点赞收藏,也欢迎在评论区分享你踩过的 goroutine 泄漏大坑。