runtime.NumGoroutine()持续单向增长是Goroutine泄漏的确凿证据,需结合pprof?debug=2定位chan receive/select阻塞点,并排查未关闭channel、缺失context超时、Ticker未Stop等常见源头。

Go 微服务里并发模型不是“要不要用 Goroutine”,而是“怎么让 Goroutine 不拖垮服务”。绝大多数线上事故不是因为没并发,而是因为 Goroutine 泄漏、Channel 死锁、或调度被 CGO 卡死——这些在压测时可能不显,上线后第七天突然 OOM。
runtime.NumGoroutine() 持续上涨意味着什么
这不是警告,是已泄漏的证据。只要 runtime.NumGoroutine() 在稳态负载下持续单向增长(比如每小时+50),基本可判定存在 Goroutine 泄漏。
- 常见源头:
for range ch用于单次通信(channel 未关闭)、HTTP handler 启动 goroutine 但没配context.WithTimeout、定时器time.Ticker启动后没Stop() - 别依赖日志打点——goroutine 堆栈里看不到业务逻辑名,得靠
/debug/pprof/goroutine?debug=2看阻塞点,重点关注chan receive或select行 - 缓冲 channel 不能防泄漏:写满后
ch 仍会阻塞,如果接收方已退出,发送 goroutine 就卡死了
无缓冲 channel 在微服务里几乎总是错的
无缓冲 channel 要求收发双方严格同步就绪,这在 HTTP handler、gRPC server、消息消费者等异步场景中根本不可控。
- 典型翻车:主 goroutine
ch ,worker goroutine <code>for range ch—— 如果 worker 因 panic 退出,ch没关,所有后续ch 全卡住 - 正确姿势:用带缓冲的
make(chan *Request, 1)解耦;或改用select+default非阻塞发送,失败则降级或丢弃 - 例外情况极少:仅限两个 goroutine 一对一、生命周期完全绑定(如初始化阶段 handshake),且双方都明确知道对方存在
CGO 调用让 GMP 调度彻底失效
一次 C.sqlite3_exec 耗时 300ms,不是“慢”,是让整个 M(系统线程)上所有其他 goroutine 被饿死 300ms。
立即学习“go语言免费学习笔记(深入)”;
- 现象:pprof 显示大量 goroutine 状态为
running但实际没执行——它们在等那个被 CGO 占着的 M - 解决不是加
runtime.LockOSThread()(那会让问题更糟),而是把 CGO 调用包进独立 goroutine,并用runtime.UnlockOSThread()主动释放 M - 更稳妥方案:用纯 Go 实现替代(如
github.com/mattn/go-sqlite3的纯 Go 模式),或把 CGO 操作下沉到独立进程(gRPC bridge)
Context 传参不是为了“取消”,而是为了“可中断”
很多开发者只在 ctx.Done() 上 select,却忽略 ctx.Err() 本身就能提前终止 IO。真正的并发安全来自上下文感知,不是靠 channel 等待。
-
http.Client、database/sql、grpc.ClientConn都原生支持ctx,直接传进去比自己再套一层 channel 更可靠 - 不要在 goroutine 内部用
context.Background()—— 这等于切断了父级超时和取消信号,泄漏风险翻倍 - worker goroutine 启动时,必须检查
ctx.Err() != nil再进入循环,否则可能处理完一个请求后,发现 context 已 cancel 却还在跑下一个
最麻烦的从来不是写并发代码,而是证明它在 10 万 QPS 下不会悄悄吃掉内存。pprof 快照、runtime.ReadMemStats 对比、以及每次上线前强制触发一次 runtime.GC() 后观察 goroutine 数回落——这些才是微服务里真正管用的并发防线。


















