Goroutine太多导致OOM?是时候给你的Go程序加上协程池了

admin 2026-06-30 09:21:07 网络安全文章 来源:ZONE.CI 全球网 0 阅读模式

文章总结: 本文针对Go程序因无限制创建goroutine导致OOM的问题,提出使用协程池控制并发量的解决方案。通过代码示例展示如何实现具备超时控制、队列管理和panic恢复的协程池,并强调需根据任务类型配置worker数量、监控队列长度及制定满队列处理策略。 综合评分: 85 文章分类: 安全开发,解决方案,实战经验,应用安全,安全工具


cover_image

Goroutine 太多导致 OOM?是时候给你的 Go 程序加上协程池了

原创

go go

Go语言教程

2026年6月28日 13:29 陕西

在小说阅读器读本章

去阅读

线上机器被 OOMKilled,第一眼别急着怀疑 Go 的 GC。

我一般先看两个东西:内存曲线是不是一路往上拱,goroutine 数是不是也跟着涨。要是这俩一起涨,业务代码里八成有这种东西:

for _, orderID := range orderIDs {
 go func(id int64) {
  err := syncOrderToWarehouse(ctx, id)
  if err != nil {
   log.Printf("sync order failed, order_id=%d err=%v", id, err)
  }
 }(orderID)
}

这段代码看着挺 Go。

来一个订单,起一个 goroutine;来一批订单,起一批 goroutine。开发环境跑 100 条没事,测试环境跑 1000 条也没事,到了线上导入几十万条订单,机器直接开始喘。

日志大概会长这样:

runtime: goroutine stack exceeds limit
k8s: container OOMKilled
goroutines=182394 heap=3.8GB next_gc=4.6GB

这时候再去调 GC 参数,基本属于绕远路。

goroutine 确实轻,但不是不要钱。每个 goroutine 都有栈,栈会扩容;阻塞在 HTTP、DB、Redis、MQ 上的 goroutine,也都要被 runtime 管着。数量一多,内存顶上去,调度也开始乱。更烦的是,下游接口本来就慢,你还一口气打过去几万并发,下游没扛住,你这边也跟着堆死。

我更愿意把这种逻辑改成协程池。不是为了显得高级,就是把“无限创建”改成“有上限排队”。

下面这段是我现场会写的版本,不追求框架感,够用、能停、能看队列压力。

package worker

import (
 "context"
 "errors"
 "log"
 "sync"
 "time"
)

type OrderJob struct {
 OrderID int64
 Trace  string
}

type Pool struct {
 jobs chan OrderJob
 wg   sync.WaitGroup
}

func NewPool(workerNum int, queueSize int, handle func(context.Context, OrderJob) error) *Pool {
 p := &Pool{
  jobs: make(chan OrderJob, queueSize),
 }

&nbsp;for&nbsp;i :=&nbsp;0; i < workerNum; i++ {
&nbsp; workerID := i +&nbsp;1

&nbsp; p.wg.Add(1)
&nbsp;&nbsp;go&nbsp;func()&nbsp;{
&nbsp; &nbsp;defer&nbsp;p.wg.Done()

&nbsp; &nbsp;for&nbsp;job :=&nbsp;range&nbsp;p.jobs {
&nbsp; &nbsp; ctx, cancel := context.WithTimeout(context.Background(),&nbsp;3*time.Second)
&nbsp; &nbsp; err := handle(ctx, job)
&nbsp; &nbsp; cancel()

&nbsp; &nbsp;&nbsp;if&nbsp;err !=&nbsp;nil&nbsp;{
&nbsp; &nbsp; &nbsp;log.Printf(
&nbsp; &nbsp; &nbsp;&nbsp;"order sync failed worker=%d order_id=%d trace=%s err=%v",
&nbsp; &nbsp; &nbsp; workerID, job.OrderID, job.Trace, err,
&nbsp; &nbsp; &nbsp;)
&nbsp; &nbsp; }
&nbsp; &nbsp;}
&nbsp; }()
&nbsp;}

&nbsp;return&nbsp;p
}

func&nbsp;(p *Pool)&nbsp;Submit(ctx context.Context, job OrderJob)&nbsp;error&nbsp;{
&nbsp;select&nbsp;{
&nbsp;case&nbsp;p.jobs <- job:
&nbsp;&nbsp;return&nbsp;nil
&nbsp;case&nbsp;<-ctx.Done():
&nbsp;&nbsp;return&nbsp;errors.New("submit order job timeout")
&nbsp;}
}

func&nbsp;(p *Pool)&nbsp;Stop()&nbsp;{
&nbsp;close(p.jobs)
&nbsp;p.wg.Wait()
}

用的时候别一股脑塞。入口这里也要有超时,不然队列满了,请求线程全卡住,最后还是换个姿势把自己拖死。

func&nbsp;SyncOrders(ctx context.Context, ids []int64, trace&nbsp;string)&nbsp;{
&nbsp;pool := NewPool(32,&nbsp;2000,&nbsp;func(ctx context.Context, job OrderJob)&nbsp;error&nbsp;{
&nbsp;&nbsp;return&nbsp;syncOrderToWarehouse(ctx, job.OrderID)
&nbsp;})
&nbsp;defer&nbsp;pool.Stop()

&nbsp;for&nbsp;_, id :=&nbsp;range&nbsp;ids {
&nbsp; submitCtx, cancel := context.WithTimeout(ctx,&nbsp;200*time.Millisecond)
&nbsp; err := pool.Submit(submitCtx, OrderJob{
&nbsp; &nbsp;OrderID: id,
&nbsp; &nbsp;Trace: &nbsp; trace,
&nbsp; })
&nbsp; cancel()

&nbsp;&nbsp;if&nbsp;err !=&nbsp;nil&nbsp;{
&nbsp; &nbsp;log.Printf("drop order sync job order_id=%d trace=%s err=%v", id, trace, err)
&nbsp; &nbsp;continue
&nbsp; }
&nbsp;}
}

这地方我不太喜欢写成“有多少任务开多少 goroutine”。因为线上不是只跑你这一个功能。一个批量同步占满 CPU、内存、连接池,旁边的支付回调、库存扣减、订单查询全会受影响。

协程池真正挡住的是三件事。

第一,挡住 goroutine 数量。比如 workerNum 是 32,那同一时刻真正干活的就是 32 个,不会突然冒出几万个。

第二,挡住下游压力。DB 连接池就 50 个,你开 5000 个 goroutine 也没意义,最后都是排队抢连接。还不如在自己进程里老老实实排队。

第三,挡住内存峰值。任务先进 channel,队列满了就超时或者丢弃,至少系统还活着。系统活着,后面才有补偿、重试、人工处理的机会。

worker 数怎么配?

这个别背公式。CPU 密集型,别超过 CPU 核数太多;IO 密集型,可以放大一点,但要看下游连接池和接口耗时。比如同步订单要访问仓库接口,接口 p95 已经几百毫秒,那 worker 开太大,只会把对方打慢。

我一般还会加一条监控,至少把队列长度打出来:

func&nbsp;(p *Pool)&nbsp;QueueLen()&nbsp;int&nbsp;{
&nbsp;return&nbsp;len(p.jobs)
}

然后日志里定时打一行:

log.Printf("order_sync_pool queue_len=%d", pool.QueueLen())

别嫌土。真出问题的时候,这种日志比一堆漂亮封装管用。

协程池也不是银弹。

任务必须能接受排队,不能是用户请求里必须立刻完成的强一致动作。队列满了之后怎么处理,也要提前定:是返回繁忙,是落库补偿,还是直接丢弃低优先级任务。这个不定清楚,协程池只是把 OOM 改成了请求超时。

还有一点,panic 要兜住。线上 worker 直接 panic 掉,池子少一个工人,时间长了也会慢慢死。

defer&nbsp;func()&nbsp;{
&nbsp;if&nbsp;r :=&nbsp;recover(); r !=&nbsp;nil&nbsp;{
&nbsp; log.Printf("order sync worker panic worker=%d err=%v", workerID, r)
&nbsp;}
}()

我排这种问题,一般顺序很固定:先看 goroutine 数,再看堆内存,再看阻塞点,最后才看 GC。要是发现 goroutine 大量卡在 HTTP 请求、DB 查询、Redis 读写上,先别优化算法,先把并发上限收回来。

Go 给了我们很便宜的 goroutine,但便宜不是免费。

线上程序最怕的不是慢一点,是没有边界。协程池干的就是这件事:给并发画一条线,超过这条线就排队、超时、降级,别让它把整个进程拖进 OOM。


免责声明:

本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。

任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。

本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我

本文转载自:Go语言教程 go go《Goroutine 太多导致 OOM?是时候给你的 Go 程序加上协程池了》

评论:0   参与:  0