答案是必须限制goroutine数量并配合超时控制。HTTP请求若裸启goroutine处理不可控输入(如未知长度的批量ID),每秒1万请求将拉起10万goroutine,导致调度器积压、P队列等待飙升、QPS骤降;应使用信号量限流(N=CPU核心数×2~4)并强制搭配context.WithTimeout防IO卡死。

goroutine数量失控导致OOM或调度延迟
微服务里一个HTTP请求启动10个goroutine去调下游,每秒1万请求就瞬间拉起10万个goroutine——Go runtime扛得住内存分配,但调度器会明显变慢,runtime.schedtrace日志里能看到P队列积压、G等待时间飙升。这不是理论风险,真实压测中QPS从2万掉到8千往往就卡在这一步。
- 永远别用
go f()裸写法处理不可控输入,比如用户上传的批量ID列表长度未知 - 用
sem := make(chan struct{}, N)做信号量限流,N建议设为CPU核心数×2~×4(不是拍脑袋,是实测gmp调度器吞吐拐点) - 对超时敏感的场景(如支付网关),必须配合
context.WithTimeout,否则goroutine卡在阻塞IO里不释放,pprof/goroutine堆栈里全是select { case 挂起状态
channel关闭时机错乱引发panic
常见错误是多个goroutine往同一个chan int发数据,主goroutine一边for v := range ch一边等sync.WaitGroup,结果某个worker提前close(ch),其他worker再ch 直接panic: send on closed channel。这在扇出/扇入模式里高频发生,尤其下游服务响应时间不一致时。
- 只允许单一goroutine负责
close(ch),通常是启动所有worker后,用WaitGroup等待它们全部Done()再关闭 - 避免用
len(ch) == 0判断channel是否空——这是陷阱,len只反映缓冲区当前长度,和是否关闭无关 - 接收端要用
v, ok := 检查<code>ok,而不是依赖range自动退出,因为有些场景需要在channel关闭后继续处理已接收数据
context取消信号没穿透到深层调用链
API层传入ctx,但数据库查询、gRPC调用、Redis操作都没接这个ctx,导致上游超时后goroutine还在跑,连接池耗尽、下游服务被打满。典型表现是net/http返回504,但pprof/goroutine里仍有上百个database/sql.(*DB).query阻塞着。
- 所有阻塞操作必须接受
context.Context参数:db.QueryContext、grpc.ClientConn.Invoke、redis.Client.GetWithContext - 自定义函数若含IO操作,签名强制带
ctx context.Context,哪怕暂时没用也要留着——后期加超时比重构调用链成本低得多 -
context.WithCancel创建的cancel函数,务必在defer里调用,且不能放在goroutine里(容易漏调)
WaitGroup误用导致死锁或计数错乱
wg.Add(1)放错位置最常见:写在go func()内部,结果还没执行到就调度走了,wg.Wait()永远卡住;或者多个goroutine重复wg.Add(1)导致计数翻倍,Wait()提前返回。
立即学习“go语言免费学习笔记(深入)”;
-
wg.Add(n)必须在启动goroutine前、主线程里执行,且n要等于实际启动数量 - 不要在循环里反复
go func(){ wg.Add(1); defer wg.Done(); ... }()——闭包捕获的循环变量i会错乱,正确写法是go func(i int){ ... }(i) - 如果goroutine可能提前退出(比如被context取消),
defer wg.Done()要包在select外层,否则Done()根本不会执行
真正难的不是写对单个goroutine,而是让成百上千个goroutine在超时、取消、失败、重试这些状态切换中不互相拖垮。多数线上事故都发生在边界条件——比如第999个请求刚好撞上连接池耗尽+context超时+channel已关闭的三重组合。这时候代码逻辑再“正确”,也救不了系统。


















