不能每个请求起一个goroutine,因调度器压力大、内存暴涨、连接耗尽、无超时导致假死;应使用带缓冲channel+信号量控制并发,如var sem = make(chan struct{}, 100)配合select+default实现非阻塞限流。

直接用 go 启动 goroutine 处理每个网络请求,在流量稍大时就会失控——内存暴涨、调度延迟飙升、连接耗尽,不是慢,是崩。
为什么不能每个请求起一个 goroutine
看似轻量,实则隐患密集:
-
runtime.NumGoroutine()超过 5 万后,pprof明显看到调度器findRunnableGoroutine占用 CPU,不是业务在跑,是调度器在“找活干” - 每个 goroutine 初始栈 2KB,10 万并发就是 200MB 内存,还不算动态增长和 GC 压力
- HTTP 客户端若未复用
*http.Client,瞬间新建数千 TCP 连接,触发EMFILE(打开文件数超限) - 没有超时控制的
http.Get会卡住整个 goroutine,堆积后池子“假死”
用带缓冲 channel + 信号量控制并发量
最简、最稳、上线即生效的方案,不依赖第三方库:
var sem = make(chan struct{}, 100) // 最大并发 100
<p>func handleRequest(w http.ResponseWriter, r *http.Request) {
select {
case sem </p><pre class="brush:php;toolbar:false;">// 实际请求逻辑
resp, err := http.DefaultClient.Get("https://api.example.com/data")
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
defer resp.Body.Close()
io.Copy(w, resp.Body)
}
- 缓冲大小设为 100 是经验起点:8 核机器上,IO 密集型服务通常 50–200 之间效果最佳
-
select+default实现非阻塞获取,避免 handler 卡住;失败直接返回 429,不进队列 - 务必用
defer func() { ,不是defer —— 后者语法错误,且无法保证执行
必须配合的 HTTP 客户端优化
光控 goroutine 数量不够,下游连接池没调好,照样雪崩:
var client = &http.Client{
Transport: &http.Transport{
MaxIdleConns: 200,
MaxIdleConnsPerHost: 200,
IdleConnTimeout: 30 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
},
Timeout: 15 * time.Second,
}
-
MaxIdleConnsPerHost必须 ≥ 信号量大小,否则空闲连接被提前回收,重复建连 -
Timeout是整个请求生命周期上限,比单个http.Get的 context 超时更兜底 - 绝不要在 handler 里 new
*http.Client,全局复用,否则Transport不生效
goroutine 泄漏的典型征兆与自查点
线上服务响应变慢、内存缓慢爬升、runtime.NumGoroutine() 持续不降——八成是泄漏:
- 检查所有
http.Client调用是否都defer resp.Body.Close(),漏掉一个就卡住一个 goroutine - 检查 channel 操作:往已关闭的
chan发送会 panic;从无缓冲chan接收但没人发,会永久阻塞 - 检查
context.WithTimeout是否传入了所有下游调用,尤其是db.QueryContext、redis.Conn.DoWithContext - 用
go tool pprof看堆栈,重点搜http://localhost:6060/debug/pprof/goroutine?debug=2 http.readResponse、select、chan receive
真正难的不是写对第一版,而是每次加新依赖、换新 SDK 时,重新核对每处超时和资源释放——这步跳过,池子迟早变成僵尸 goroutine 停尸房。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!











