goroutine泄漏导致请求聚合失败的典型表现是HTTP请求长时间无响应、runtime.NumGoroutine()持续上升、pprof显示大量goroutine停在select或chan receive;根本原因是未约束goroutine生命周期,如channel未关闭、超时后未退出、未绑定context.Context。

goroutine 泄漏导致请求聚合失败的典型表现
用 goroutine 做请求聚合时,最常遇到的不是“没聚合”,而是“聚合了一半就卡住”或“内存持续上涨”。这往往是因为未对 goroutine 生命周期做约束,比如用 for range 读取 channel 却没关闭它,或等待超时后没主动退出 goroutine。现象包括:HTTP 请求长时间无响应、runtime.NumGoroutine() 持续上升、pprof 显示大量 goroutine 停在 select 或 chan receive。
关键原则是:每个启动的 goroutine 必须有明确退出路径,且不能依赖 GC 回收——它不会帮你关掉正在阻塞的 goroutine。
- 所有用于并发请求的
goroutine都应绑定context.Context,并在ctx.Done()触发时立即返回 - 避免直接
go fn()启动匿名函数;优先封装为带ctx参数的可取消函数 - 聚合用的接收 channel 不要用
make(chan T)(无缓冲),而用make(chan T, N),N 至少等于预期并发请求数,否则第一个 goroutine 就会阻塞在发送上
用 sync.WaitGroup + channel 实现基础聚合模式
这是最可控、不依赖第三方库的写法。核心是用 sync.WaitGroup 确保所有请求 goroutine 启动完毕,再用带缓冲的 channel 收集结果,最后统一关闭 channel 并 range 消费。
func aggregateRequests(ctx context.Context, urls []string) ([]Result, error) {
ch := make(chan Result, len(urls))
var wg sync.WaitGroup
<pre class="brush:php;toolbar:false;">for _, url := range urls {
wg.Add(1)
go func(u string) {
defer wg.Done()
select {
case <-ctx.Done():
return
default:
res, err := fetchURL(ctx, u)
if err != nil {
ch <- Result{URL: u, Err: err}
} else {
ch <- res
}
}
}(url)
}
// 启动 goroutine 等待全部完成并关闭 channel
go func() {
wg.Wait()
close(ch)
}()
var results []Result
for r := range ch {
results = append(results, r)
}
return results, nil}
立即学习“go语言免费学习笔记(深入)”;
注意:wg.Wait() 必须在 close(ch) 前调用,否则可能因 channel 未关闭导致 range 永久阻塞;但也不能把 close(ch) 放在主 goroutine 里——那会提前关闭,漏掉还在执行的 goroutine 的结果。
timeout 和 cancel 对聚合结果完整性的影响
聚合场景下,超时不等于失败。你通常希望“尽力获取”,即:已发出的请求继续跑完,新请求不再发起,已超时的则丢弃结果。直接用 context.WithTimeout 是不够的——它会中断所有子 goroutine,包括那些已经发出去、正在等响应的 HTTP 请求。
- 对每个请求单独套一层
context.WithTimeout(childCtx, reqTimeout),而不是共用一个父 timeout ctx - 聚合主逻辑用
context.WithCancel,由time.AfterFunc或 select 超时触发cancel(),只终止未开始的请求 - 不要在
fetchURL内部忽略ctx.Err();HTTP client 必须传入该 ctx,否则 timeout 不生效 - 结果切片长度可能小于输入 URL 数量,需检查
Result.Err字段区分是失败还是被跳过
并发数控制不当引发连接池耗尽
不做限制地为每个 URL 启 goroutine,容易打爆 http.DefaultTransport 的连接池(默认 MaxIdleConnsPerHost=100)。现象是大量请求卡在 net/http.Transport.roundTrip,日志里反复出现 dial tcp: lookup xxx: no such host 或 context deadline exceeded 却不是真超时。
解决方式不是减少 goroutine 数量,而是复用 transport 并显式限流:
- 自定义
http.Transport,设MaxIdleConnsPerHost≥ 预期并发数 - 用
semaphore(如golang.org/x/sync/semaphore)或带缓冲 channel 控制并发请求数,例如sem := semaphore.NewWeighted(20) - 在每个 goroutine 开头
sem.Acquire(ctx, 1),结尾sem.Release(1) - 避免在循环里重复创建
http.Client;全局复用一个 client 实例
真正难处理的不是“怎么并发”,而是“并发多少才既快又稳”——这得结合下游服务的吞吐能力、网络 RTT、以及你自己的错误容忍度来压测定值,没法靠代码一劳永逸。


















