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 介入,进程被杀
排查路径:
- 看 goroutine 数量曲线(通过 expvar 暴露
runtime.NumGoroutine()):曲线从 200 垂直拉起,这种形态说明是瞬时堆积,不是泄漏(泄漏是缓慢爬坡) - 抓 goroutine 剖面看卡在哪:
curl http://localhost:6060/debug/pprof/goroutine?debug=1 > goroutines.txtdebug=1按调用栈分组聚合,十万个 goroutine 里 99.6% 聚在同一组栈上,全卡在等 HTTP 响应 - 算账定级: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 和线程的区别,面试怎么答?
四层递进:
- 栈:goroutine 2KB 连续栈按需拷贝增长,线程一次性分配 MB 级
- 切换:用户态保存恢复少量寄存器约 200ns,内核态切换 1~2µs
- 调度:GMP 用户态调度器,M:N 复用;线程由内核 1:1 调度
- 代价:因为便宜,语言层不产生背压,滥用会以内存方式爆掉,工程上必须限流
goroutine 不是线程,它更便宜,但便宜不产生背压——不用限流,它就会把下游的慢翻译成你机器的内存爆掉。

浙公网安备 33010602011771号