goroutine 和线程到底有什么区别?为什么十万个 goroutine 能把内存撑爆?

goroutine 和线程到底有什么区别?为什么十万个 goroutine 能把内存撑爆?

goroutine 和线程的核心区别在三点:栈(goroutine 初始 2KB 且按需增长,线程一次性分配 MB 级)、切换(goroutine 用户态约 200ns,线程内核态 1~2µs)、调度(GMP 用户态 M:N 复用,线程内核 1:1 调度)。goroutine 便宜不代表可以滥用,因为 Go 的调度器不产生背压,下游变慢时 goroutine 会堆积,最后以内存爆掉的方式炸掉服务。

goroutine 为什么便宜?

三个"省":

  • 省栈:内核线程栈启动时一次性划出,Linux 默认 8MB(ulimit -s 可查),10 万线程光栈就要 800GB 虚拟内存。goroutine 用连续栈,初始只有 2KB,不够时 runtime 分配一块 2 倍大的新栈,把旧内容拷贝过去再改指针。10 万个空转 goroutine 的栈开销约 200MB。
  • 省切换:线程切换要陷入内核,保存全套寄存器、切换 TLB、内核调度器决策,一次 1~2 微秒起步,还有缓存污染。goroutine 切换全在用户态,runtime 只把当前 G 的 SP、PC 等几个寄存器存进 g.sched,从本地队列取下一个 G 恢复现场,实测约 200 纳秒。
  • 省调度开销:每个 P 维护一个 256 槽位的本地运行队列,G 的创建与消费大多只碰自己 P 的队列,几乎无锁;本地队列满了溢出到全局队列;M 空闲时从别的 P"偷"一半任务。

GMP 模型里 G、M、P 分别是什么?

角色 全称 作用 关键参数
G Goroutine 一次并发执行的载体,持有栈、指令指针、状态 初始栈 2KB
M Machine 内核线程,真正干活的执行者 数量可动态增长
P Processor 逻辑处理器,G 的调度上下文,持有本地运行队列 数量 = GOMAXPROCS

几个关键设计:

  • 每调度 61 次强制检查一次全局队列,防止全局队列饿死
  • 系统调用阻塞时 M 与 P 解绑,P 转手交给别的 M 继续跑队列里的 G
  • Go 1.14 起支持异步抢占,sysmon 监测到某个 G 独占超过 10ms 就发信号强制打断,死循环再也饿不死别的 G

为什么十万个 goroutine 会把内存撑爆?

看一个真实事故的账。一个消息网关从 Kafka 消费订单事件,每条消息起一个 goroutine 调下游短信服务:

func (c *Consumer) loop() {
    for msg := range c.kafkaCh {
        go c.handle(msg)
    }
}

平时单机 QPS 200,runtime.NumGoroutine() 稳定在 200 左右。大促当晚下游短信服务响应从 200ms 涨到 8 秒:

  • 每秒新增 200 个 goroutine,每个存活 8 秒,稳态堆积 = 200 × 8 = 1600 个,还能接受
  • 下游进一步劣化到超时率飙升、消息重投,Kafka 积压回放,新增速率冲到每秒 4000 条
  • 十分钟后 runtime.NumGoroutine() 冲过 103000,容器内存从 300MB 爬到 2.6GB,Kubernetes OOMKiller 介入,进程被杀

排查路径:

  1. 看 goroutine 数量曲线(通过 expvar 暴露 runtime.NumGoroutine()):曲线从 200 垂直拉起,这种形态说明是瞬时堆积,不是泄漏(泄漏是缓慢爬坡)
  2. 抓 goroutine 剖面看卡在哪: curl http://localhost:6060/debug/pprof/goroutine?debug=1 > goroutines.txt debug=1 按调用栈分组聚合,十万个 goroutine 里 99.6% 聚在同一组栈上,全卡在等 HTTP 响应
  3. 算账定级:goroutine 栈开销 2KB 起步,但真正的内存大头是每个 goroutine 持有的业务数据——HTTP 请求体、响应缓冲、http.Request 与连接状态,实测单协程 15~25KB。10 万 × 20KB ≈ 2GB,和监控里 2.6GB 对得上

自己跑一遍:不同并发量的内存占用

起 N 个 goroutine,各自 sleep 一秒并持有 20KB 数据,前后各采一次 runtime.ReadMemStats:

并发量 创建总耗时 goroutine 栈开销 含业务数据总内存
1,000 ~0.4 ms ~2 MB ~25 MB
10,000 ~4 ms ~20 MB ~250 MB
100,000 ~45 ms ~200 MB ~2.5 GB

两个结论:10 万 goroutine 创建只要几十毫秒、栈开销只有 200MB;但 2.5GB 内存里 92% 是业务数据,跟调度器毫无关系。

同样 10 万并发用 OS 线程实现,按 8MB 栈算是 800GB,Linux 默认配置下连几千个线程都起不来。

正确的写法是什么?

原则一句话:并发度是资源,必须有上限;排队是数据,必须有去处。

方案一:errgroup.SetLimit(golang.org/x/sync/errgroup)

func (c *Consumer) loop() {
    g, ctx := errgroup.WithContext(c.ctx)
    g.SetLimit(64) // 同一时刻最多 64 个 handle 在飞
    for msg := range c.kafkaCh {
        m := msg // Go 1.22 前必须拷贝
        g.Go(func() error {
            return c.handle(ctx, m)
        })
    }
    _ = g.Wait()
}

SetLimit 的语义:第 65 个任务会在 g.Go 处阻塞,直到有空位。这就是背压——压力还给 Kafka 消费循环,Kafka 的 lag 监控会报警,而不是内存监控。

方案二:worker pool

func (c *Consumer) Start(workers int) {
    var wg sync.WaitGroup
    for i := 0; i < workers; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            for msg := range c.jobs {
                c.handle(c.ctx, msg)
            }
        }()
    }
    wg.Wait()
}

配套三个不能省的动作:

  • 把 30 秒的下游超时拆细(连接 3 秒、响应头 5 秒)
  • NumGoroutine 进监控并配告警阈值(常态值的 10 倍)
  • 容量规划算清守恒式:吞吐 = 并发数 ÷ 平均时延。下游 8 秒时延、闸门 64,吞吐上限就是 8 条/秒,要 200 条/秒就得下游扩容或降级

goroutine 和线程的区别,面试怎么答?

四层递进:

  1. 栈:goroutine 2KB 连续栈按需拷贝增长,线程一次性分配 MB 级
  2. 切换:用户态保存恢复少量寄存器约 200ns,内核态切换 1~2µs
  3. 调度:GMP 用户态调度器,M:N 复用;线程由内核 1:1 调度
  4. 代价:因为便宜,语言层不产生背压,滥用会以内存方式爆掉,工程上必须限流

goroutine 不是线程,它更便宜,但便宜不产生背压——不用限流,它就会把下游的慢翻译成你机器的内存爆掉。

posted @ 2026-10-11 15:53  技术白菜  阅读(3)  评论(0)    收藏  举报