外观
排查实录:channel 阻塞引发的 goroutine 泄漏
问题背景:goroutine 数为什么只涨不跌
一次线上 Go 服务在低峰期仍然出现内存缓慢上涨,go_goroutines 指标从平时的数百个持续增长到数万个。接口 RT 没有明显抖动,但服务每隔几天就会因为内存接近限制而被重启。
初步看日志,没有 panic,也没有明显的死锁报错。使用 /debug/pprof/goroutine?debug=2 抓取协程栈后,发现大量 goroutine 停在相似位置:
text
goroutine 12038 [chan receive]:
main.(*Worker).Run(...)
/app/worker.go:42 +0x...
main.(*Scheduler).dispatch.func1()
/app/scheduler.go:67 +0x...关键信号是 [chan receive]:这些 goroutine 正在等待从 channel 读取数据,而上游已经不再发送。它们不是“执行很慢”,而是永远失去了被唤醒的机会,这就是典型的 goroutine 泄漏。
常见成因:channel 阻塞与取消信号丢失
下面这段代码模拟了问题场景。每次请求都启动一个 worker,从结果 channel 读取数据;但生产者在遇到错误或请求超时后直接返回,没有关闭任务 channel,也没有监听取消信号。
go
package main
import (
"fmt"
"net/http"
"time"
)
type Task struct {
ID int
}
func startWorker(tasks <-chan Task) <-chan string {
results := make(chan string) // 无缓冲 channel
go func() {
// 风险点:如果上游不再发送 tasks,goroutine 会永久阻塞在这里
task := <-tasks
results <- fmt.Sprintf("task %d done", task.ID)
}()
return results
}
func badHandler(w http.ResponseWriter, r *http.Request) {
tasks := make(chan Task)
results := startWorker(tasks)
// 模拟上游偶发失败,任务没有被投递
if time.Now().UnixNano()%2 == 0 {
// 直接返回后,worker 仍阻塞在 <-tasks
http.Error(w, "upstream failed", http.StatusBadGateway)
return
}
tasks <- Task{ID: 1}
fmt.Fprintln(w, <-results)
}
func main() {
http.HandleFunc("/bad", badHandler)
_ = http.ListenAndServe(":8080", nil)
}这里有两个阻塞点:
- worker 阻塞在
task := <-tasks,等待一个永远不会到来的任务; - 即使任务已经执行,
results <- ...也可能因为请求方超时退出而无人接收。
Go 不会因为创建 goroutine 的函数返回而自动杀死该 goroutine。goroutine 只能在自身运行结束、进程退出,或者阻塞条件被解除后结束。
方案对比:靠日志猜,不如用 pprof 看栈
| 方案 | 能看到什么 | 优点 | 局限 |
|---|---|---|---|
| 日志排查 | 某个请求是否异常返回 | 接入简单 | 很难知道 goroutine 卡在哪一行 |
| runtime.NumGoroutine | 当前协程总数 | 适合做监控告警 | 只有数量,没有调用栈 |
| pprof goroutine | 全量 goroutine 堆栈与阻塞原因 | 定位直接,可聚合分析 | 需要提前暴露调试端点 |
| context 取消传播 | 请求结束、超时、取消信号 | 从机制上避免永久等待 | 需要所有并发路径正确 select |
实战中建议把 pprof 用于定位,把 context 取消用于修复,把协程指标用于预防,三者缺一不可。
实操步骤一:接入 pprof 并抓取阻塞栈
生产服务建议通过独立的调试 HTTP 端口暴露 pprof,避免把调试入口直接挂在公网网关上。
go
package main
import (
"log"
"net/http"
_ "net/http/pprof"
)
func startDebugServer() {
// 只允许内网或本机访问,生产环境应配合网络策略保护
go func() {
log.Println(http.ListenAndServe("127.0.0.1:6060", nil))
}()
}抓取当前所有 goroutine 的文本栈:
bash
curl 'http://127.0.0.1:6060/debug/pprof/goroutine?debug=2' > goroutine.txt也可以抓取 profile,在本机交互分析:
bash
go tool pprof 'http://127.0.0.1:6060/debug/pprof/goroutine'进入 pprof 后常用命令如下:
text
(pprof) top
(pprof) list Worker.Run
(pprof) tracestop 用来查看数量最多的栈,list 能直接定位到源码行。如果大量协程都停在同一处 channel 收发,那么优先检查该 channel 的生产者、消费者生命周期是否一致。
实操步骤二:用 context 取消传播修复阻塞
正确做法是让 worker 同时监听任务、结果和取消信号。请求结束或超时时,ctx.Done() 会关闭,worker 必须及时退出。
go
package main
import (
"context"
"fmt"
"net/http"
"time"
)
type Task struct {
ID int
}
func startWorker(ctx context.Context, tasks <-chan Task) <-chan string {
results := make(chan string, 1)
go func() {
defer close(results)
select {
case <-ctx.Done():
// 请求已取消,不再等待任务
return
case task, ok := <-tasks:
if !ok {
return
}
select {
case <-ctx.Done():
// 结果无人接收时,不能盲目写入无缓冲 channel
return
case results <- fmt.Sprintf("task %d done", task.ID):
}
}
}()
return results
}
func goodHandler(w http.ResponseWriter, r *http.Request) {
// 每个请求独立设置超时,超时后 ctx.Done 自动关闭
ctx, cancel := context.WithTimeout(r.Context(), 200*time.Millisecond)
defer cancel()
tasks := make(chan Task, 1)
results := startWorker(ctx, tasks)
if time.Now().UnixNano()%2 == 0 {
// 即使不上游发送任务,worker 也会在 ctx 取消后退出
http.Error(w, "upstream failed", http.StatusBadGateway)
return
}
tasks <- Task{ID: 1}
select {
case <-ctx.Done():
http.Error(w, "timeout", http.StatusGatewayTimeout)
case result := <-results:
fmt.Fprintln(w, result)
}
}
func main() {
http.HandleFunc("/good", goodHandler)
_ = http.ListenAndServe(":8080", nil)
}这段代码的关键点是:所有可能阻塞的 channel 操作都放在 select 中,并与 ctx.Done() 一起等待。这样无论任务没来、结果没人收,还是请求超时,goroutine 都有明确退出路径。
实操步骤三:批量并发时控制生命周期
实际业务里经常会一次启动多个 goroutine 调用下游。此时除了 context,还需要控制并发上限,并等待子任务收敛。
go
func fetchAll(ctx context.Context, ids []int) ([]string, error) {
ctx, cancel := context.WithCancel(ctx)
defer cancel() // 任一子任务失败后通知其他任务停止
sem := make(chan struct{}, 10) // 限制最多 10 个并发
out := make(chan string, len(ids))
go func() {
var wg sync.WaitGroup
for _, id := range ids {
wg.Add(1)
go func(id int) {
defer wg.Done()
select {
case sem <- struct{}{}:
case <-ctx.Done():
return
}
defer func() { <-sem }()
value, err := fetchOne(ctx, id)
if err != nil {
cancel()
return
}
select {
case out <- value:
case <-ctx.Done():
}
}(id)
}
wg.Wait()
close(out)
}()
var values []string
for {
select {
case <-ctx.Done():
return nil, ctx.Err()
case v, ok := <-out:
if !ok {
return values, nil
}
values = append(values, v)
}
}
}
func fetchOne(ctx context.Context, id int) (string, error) {
// HTTP、RPC、数据库调用都应传入 ctx,确保超时能传递到最底层
return fmt.Sprintf("value-%d", id), nil
}需要特别注意:cancel 必须被调用,不能只依赖超时自动触发。推荐使用 defer cancel(),既可以及时释放 context 资源,也能在错误返回时通知其他 goroutine。
避坑总结:这些现象我在事故里都见过
1. 只给下游传 context,自己却还在裸写 channel
常见错误现象是 HTTP client 使用了 NewRequestWithContext,但业务 goroutine 仍然写着 result <- value。当下游超时后,请求方已经返回,子 goroutine 仍可能阻塞在结果发送上。
修正方法是所有 channel 收发都参与 select:
go
select {
case result <- value:
case <-ctx.Done():
return
}2. 误以为 close channel 一定能解决问题
很多人会在生产者结束时随手 close(tasks),但如果多个 goroutine 并发写同一个 channel,某个写方关闭后,其他写方继续发送会直接 panic:send on closed channel。
修正方法是明确唯一关闭方,通常由发送方或协调者关闭;多个生产者时用 sync.WaitGroup 等待全部生产者结束后再关闭,而不是每个 goroutine 各自关闭。
3. context.WithTimeout 后忘记 cancel
错误现象是代码压测时短期正常,运行一段时间后 context 相关对象增多,甚至出现子 goroutine 没有及时释放。虽然超时最终也会触发,但资源释放时间不可控。
修正方法是创建可取消 context 后立即写:
go
ctx, cancel := context.WithTimeout(parent, time.Second)
defer cancel()4. pprof 显示很多 goroutine 就判断为泄漏
goroutine profile 是某一瞬间的快照,高并发下短暂存在大量协程并不异常。真正的泄漏通常具备三个特征:数量持续增长、压力下降后不回落、堆栈长期集中在相同的 channel、锁或网络等待位置。
建议同时监控 go_goroutines、内存和请求 QPS,并在低峰期连续抓两次 profile 对比。如果相同堆栈的 goroutine 数一直增长,才更符合泄漏特征。
5. 无限制地 go func 启动任务
即使每个 goroutine 都能退出,瞬时流量也可能创建数十万协程,导致调度和内存压力。修正方法是用带缓冲信号量、worker pool 或限流中间件控制并发,并在入口处尊重超时与取消。
最终结论
channel 阻塞型 goroutine 泄漏的本质,是并发任务的生命周期没有和请求、任务或下游调用绑定。排查时不要只看协程数量,应该通过 pprof 找到具体阻塞栈;修复时也不要只加超时日志,而要让 context 取消沿调用链传播,并用 select 覆盖所有可能永久阻塞的收发点。
一套可靠的 Go 并发代码至少应满足三点:启动有边界、阻塞有出口、结束有等待。这样 goroutine 才能在请求取消、下游失败和超时场景下确定性退出,而不是慢慢堆积成下一次线上事故。
免责声明
本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。