跳到主内容

排查实录: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)
}

这里有两个阻塞点:

  1. worker 阻塞在 task := <-tasks,等待一个永远不会到来的任务;
  2. 即使任务已经执行,results <- ... 也可能因为请求方超时退出而无人接收。

Go 不会因为创建 goroutine 的函数返回而自动杀死该 goroutine。goroutine 只能在自身运行结束、进程退出,或者阻塞条件被解除后结束。

方案对比:靠日志猜,不如用 pprof 看栈 ​

方案能看到什么优点局限
日志排查某个请求是否异常返回接入简单很难知道 goroutine 卡在哪一行
runtime.NumGoroutine当前协程总数适合做监控告警只有数量,没有调用栈
pprof goroutine全量 goroutine 堆栈与阻塞原因定位直接,可聚合分析需要提前暴露调试端点
context 取消传播请求结束、超时、取消信号从机制上避免永久等待需要所有并发路径正确 select

实战中建议把 pprof 用于定位,把 context 取消用于修复,把协程指标用于预防,三者缺一不可。

监控发现协程上涨NumGoroutine抓取 goroutinepprof 堆栈识别阻塞原因chan receive/send定位代码行调用链聚合context 取消传播select 监听 Done收敛并发与关闭WaitGroup/Close

实操步骤一:接入 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) traces

top 用来查看数量最多的栈,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 才能在请求取消、下游失败和超时场景下确定性退出,而不是慢慢堆积成下一次线上事故。

免责声明

本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。