一、goroutine 便宜但不是免费
Go 的并发模型简单好用,很多后端服务会自然地把耗时任务丢进 goroutine。问题是 goroutine 虽然比线程轻,但仍然占内存、调度资源,也会持有连接、上下文和闭包变量。没有上限的 goroutine,在线上就是慢性泄漏。
典型问题包括:请求里开 goroutine 但不等待;向无缓冲 channel 发送后没人接收;后台任务没有监听 context;批量处理时对每条数据都启动协程。
| 场景 | 风险 | 控制方式 |
|---|---|---|
| 批量调用下游 | 瞬时并发过高 | worker pool 或 semaphore |
| 后台消费 | 无法退出 | context |
| HTTP 请求 | IO 卡住 | timeout |
| channel 通信 | 发送阻塞 | 明确关闭和缓冲 |
二、用 errgroup 管住生命周期
errgroup适合处理一组相关任务:任何一个任务失败,就取消其他任务;所有任务结束后统一返回错误。它比手写sync.WaitGroup更适合请求链路。
funcLoadDashboard(ctx context.Context,uidint64)(*Dashboard,error){g,ctx:=errgroup.WithContext(ctx)result:=&Dashboard{}g.Go(func()error{profile,err:=userClient.GetProfile(ctx,uid)iferr!=nil{returnerr}result.Profile=profilereturnnil})g.Go(func()error{orders,err:=orderClient.ListRecent(ctx,uid)iferr!=nil{returnerr}result.Orders=ordersreturnnil})iferr:=g.Wait();err!=nil{returnnil,err}returnresult,nil}业务复杂时应避免共享可变状态。更稳妥的写法是每个 goroutine 写局部变量,Wait后再组装结果。
三、批量任务必须有限流
下面是一个带并发上限的批量处理示例。sem控制同时运行的任务数量,errgroup管理错误和退出。并发数不是越大越好,要结合下游 QPS、数据库连接池和机器 CPU 设定。
funcSyncUsers(ctx context.Context,ids[]int64,concurrencyint)error{g,ctx:=errgroup.WithContext(ctx)sem:=make(chanstruct{},concurrency)for_,id:=rangeids{id:=idselect{casesem<-struct{}{}:case<-ctx.Done():returnctx.Err()}g.Go(func()error{deferfunc(){<-sem}()returnsyncOneUser(ctx,id)})}returng.Wait()}如果任务数量很大,更稳定的方案是固定 worker pool,从 jobs channel 里消费任务。worker pool 适合长队列,semaphore 适合中小批量并发调用。
四、用 pprof 证明问题
线上怀疑 goroutine 泄漏时,不要只看日志。Go 服务应该接入 pprof,并限制访问来源。看 goroutine 堆栈、heap、block profile,通常能定位卡在哪个函数。
import_"net/http/pprof"funcmain(){gofunc(){_=http.ListenAndServe("127.0.0.1:6060",nil)}()runServer()}curlhttp://127.0.0.1:6060/debug/pprof/goroutine?debug=2|head-80go tool pprof http://127.0.0.1:6060/debug/pprof/heap go tool pprof http://127.0.0.1:6060/debug/pprof/profile?seconds=30实践建议:给所有批量并发代码设置上限,所有跨服务调用设置超时,所有后台 goroutine 监听 context,并把 pprof 纳入服务模板。goroutine 的问题通常不是语法层面的,而是生命周期没有设计;能退出、有限流、可观测,才算并发代码写完整。
如果你正在学习云原生或搭建个人项目,推荐试试腾讯云轻量应用服务器,新用户专享优惠,一键部署开发环境非常方便:点击查看优惠