Go 协程泄漏排查实战:我是如何把线上内存从 500MB 压到 20MB 的

0 阅读3分钟

摘要:凌晨两点半,告警群里一条 "内存使用率 > 90%" 的消息把我从睡梦中炸醒。排查后发现,一个不起眼的 goroutine 泄漏,硬生生把一个本该只占 20MB 的微服务撑到了 500MB。本文完整复盘整个排查过程,附带 4 种常见泄漏模式的可运行代码和修复方案,建议收藏。


一、凌晨两点半的内存告警

事情发生在一个普通的周二凌晨。

我们的订单查询服务上线两周,QPS 从最初的 200 慢慢爬到了 3000。一切看起来岁月静好,直到凌晨 2:30,Prometheus 的告警直接把我叫醒:

[CRITICAL] service=order-query, memory_usage=92%, threshold=85%, duration=5m

登上机器一看,整个人都不好了:

$ docker stats
CONTAINER           CPU %   MEM USAGE     / LIMIT
order-query-01      15.2%   502.4MiB      / 512MiB

500MB。而同类服务正常情况下,内存应该在 15-20MB 左右。

更离谱的是内存曲线——它不是突增,而是持续缓慢上涨,像极了那个只进不出的貔貅。

内存使用量 (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 数量。我在服务里加了一个简单的监控端点:

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)
	}
}

请求一下看看:

$ 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 包是排查利器,接入成本极低:

import (
	"net/http"
	_ "net/http/pprof" // 只需一行空白导入
)

func main() {
	// 其他代码...
	// pprof 自动注册到 DefaultServeMux
	http.ListenAndServe(":8080", nil)
}

然后直接抓取 goroutine 堆栈:

# 获取 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 数量:

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 可以记录更详细的执行轨迹:

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 的堆栈信息,我定位到了问题代码。以下是泄漏点的还原版本(已脱敏):

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
}

泄漏原因一目了然

  1. cacheCh 是一个 buffered channel,容量 100
  2. CreateOrder 往 channel 发送数据时,没有使用 select + context.Done()
  3. 当 channel 满了(100 条),后续的 20 个 goroutine 永久阻塞在发送操作上
  4. 更致命的是:如果 cacheWriter 因为 panic 或者其他原因退出,所有等待发送的 goroutine 全部泄漏

四、修复方案

修复很简单——给 channel 操作加上 selectcontext 取消机制:

// 修复后的 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 秒自动返回,不会无限等
  • 有错误返回:调用方能感知失败,而不是傻傻等

改完上线后,内存曲线当天就断崖式下降:

内存使用量 (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 或者消费者没有退出机制。

// 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)
}

运行结果:

Goroutines: before=1, after=51, leaked=50

修复方案:确保 channel 在合适时机关闭,或者消费者有退出机制。

// 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)
}

运行结果:

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 就一直挂着。

// 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):

heartbeat...
heartbeat...
...
Goroutines: before=1, after=11, leaked=10

修复方案:使用 context.WithCancel,并在不再需要时调用 cancel 函数。

// 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)
}

运行结果:

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.Aftertime.Ticker 创建后,如果没有及时 Stop() 或等待其触发,底层会挂起一个 goroutine。

// 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)
}

运行结果:

Goroutines: before=1, after=101, leaked=100

这 100 个 goroutine 要等 10 秒后才会释放。如果 Ticker 间隔更长,或者 goroutine 里有循环,泄漏时间更久。

修复方案:使用 defer ticker.Stop(),或者配合 context 使用。

// 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)
}

运行结果:

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 的问题,而是等待条件永远不会满足

// 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)
}

运行结果:

task 1 completed
task 2 completed
panic: unexpected error

goroutine 18 [running]:
...

程序直接 crash 了。在真实服务中,panic 通常被 recover 捕获了,但 wg.Done() 没有执行,导致 Wait() 永远等不到所有任务完成。

修复方案defer wg.Done() 放在 goroutine 的第一行,不管是否 panic 都会执行。

// 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)
}

运行结果:

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 前先问自己三个问题:

  1. 它什么时候退出?
  2. 如果 channel 满了/空了会怎样?
  3. 如果上游服务挂了会怎样?
// ❌ 错误示范:没有退出条件
go func() {
	for {
		// do something
	}
}()

// ✅ 正确示范:有明确的退出条件
go func() {
	for {
		select {
		case <-ctx.Done():
			return
		case data := <-ch:
			// handle data
		}
	}
}()

规则 2:用 errgroup 替代裸 WaitGroup

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

// ❌ 可能永久阻塞
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:

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())
	},
)

配合告警规则:

# 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:

# 每天自动抓取
curl http://localhost:8080/debug/pprof/goroutine?debug=2 -o /tmp/goroutine-$(date +%Y%m%d).txt

对比不同时间的 profile,能发现缓慢泄漏的趋势。

6.3 排查阶段

规则 6:pprof + trace + 日志 = 黄金组合

排查顺序:

  1. runtime.NumGoroutine() → 确认泄漏
  2. pprof goroutine?debug=1 → 定位阻塞点
  3. pprof goroutine?debug=2 → 看完整堆栈
  4. go tool trace → 看时序和阻塞时长
  5. 结合业务日志 → 确认触发条件

规则 7:善用 goroutine dump

// 在 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 一样,可以按需暴露。


七、总结

回顾整个排查过程,核心链路只有三步:

监控告警 → pprof 定位 → 修复 + 验证
  ↓            ↓            ↓
内存 > 90%   23000 goroutines  内存回到 20MB
             全卡在 channel    泄漏归零

Go 的 goroutine 轻量到让人忘记它的存在,但"轻量"不等于"免费"。泄漏的 goroutine 会持续消耗内存,持有的资源(数据库连接、文件句柄、锁)不会释放,最终拖垮整个服务。

记住三条铁律

  1. 每个 goroutine 都必须有退出路径(context 或 channel)
  2. 每个 timer/ticker 都必须有 Stop(defer 最安全)
  3. 每个 channel 操作都必须有 select 保护(超时 + 取消)

这三条做到了,90% 的 goroutine 泄漏都能避免。

最后送一句程序员圈的至理名言:

"Go 有 GC,所以不用担心内存泄漏。"

—— 说这话的人,一定没在凌晨两点半被内存告警叫醒过。


参考链接


如果你觉得这篇文章有帮助,欢迎点赞收藏,也欢迎在评论区分享你踩过的 goroutine 泄漏大坑。