goroutine 和线程到底有什么区别?为什么 Go 能轻松跑十万协程?
goroutine 和线程的核心区别在三点:栈、切换、调度。goroutine 初始栈只有 2KB 且按需增长,线程栈一次性分配 MB 级;goroutine 切换在用户态完成、约 200 纳秒,线程切换要陷入内核、约 1~2 微秒;goroutine 由 GMP 用户态调度器做 M:N 复用,线程由内核 1:1 调度。但 goroutine 便宜也意味着它不产生背压,滥用会以内存爆掉的方式出问题,所以工程上必须限流。
goroutine 和线程的区别是什么?
30 秒能讲清的四层递进:
- 栈:goroutine 用连续栈,初始 2KB,不够时 runtime 分配一块 2 倍大的新栈把旧内容拷过去;线程栈启动时一次性划出,Linux 默认 8MB(
ulimit -s可查)。 - 切换:goroutine 切换全在用户态,runtime 只把当前 G 的 SP、PC 等几个寄存器存进
g.sched,约 200 纳秒;线程切换要保存全套寄存器、切 TLB、内核调度器决策,一次 1~2 微秒起步,还有缓存污染。 - 调度:goroutine 走 GMP 用户态调度器,M:N 复用;线程由内核 1:1 调度。
- 代价:因为便宜,语言层不产生背压。滥用 goroutine 不会像线程那样连不上(fd 不足),而是推迟到 OOM 才爆——故障更晚出现,但更致命。
GMP 模型是什么?三个角色分别干什么?
| 角色 | 全称 | 作用 | 关键参数 | |---|---|---|---| | G | Goroutine | 一次并发执行的载体,持有栈、指令指针、状态 | 初始栈 2KB | | M | Machine | 内核线程,真正干活的执行者 | 数量可动态增长 | | P | Processor | 逻辑处理器,G 的调度上下文,持有本地运行队列 | 数量 = GOMAXPROCS |
一次调度路径:go 创建的 G 优先放进当前 P 的本地队列(256 槽位,满了溢出到全局队列);M 必须绑定一个 P 才能执行 G。
几个值得记住的设计细节:
- 每调度 61 次强制检查一次全局队列,防止全局队列饿死。
- 系统调用阻塞时 M 与 P 解绑,P 转手给别的 M 继续跑队列里的 G。
- M 空闲时会从别的 P「偷」一半任务(work stealing)。
- Go 1.14 起支持异步抢占,sysmon 监测到某个 G 独占超过 10ms 就发信号强制打断,死循环饿不死别的 G。
十万 goroutine 会占多少内存?
用一段代码就能复现(起 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 默认配置下连几千个线程都起不来。
为什么「每条消息一个 goroutine」会把服务打挂?
一个真实事故的账:消息网关从 Kafka 消费订单事件、调下游短信服务发送。下游响应从 200ms 涨到 8 秒后,消费速度没变(每秒 200 条),每条照样 go handle(),但 goroutine 处理不完也退不出,全卡在 HTTP 等待里。
- 稳态堆积量 = 200 × 8 = 1600 个,还能接受。
- 下游进一步劣化、消息重投、Kafka 积压回放,新增速率冲到每秒 4000 条。
- 十分钟后
runtime.NumGoroutine()冲过 103000,容器内存从 300MB 爬到 2.6GB,被 Kubernetes OOMKiller 杀掉。 - 重启后 Kafka 从头消费,新的积压打进来,循环往复。
事后第一反应是「没写内存泄漏,内存怎么会爆」——确实没泄漏,每个 goroutine 都正常退出。问题不在「忘了还」,而在「借得太快」。
怎么排查 Go 服务 goroutine 暴涨?
三步,能套用在几乎所有「Go 服务内存暴涨」问题上:
- 看 goroutine 数量曲线。用 expvar 暴露
runtime.NumGoroutine()。曲线垂直拉起说明是瞬时堆积(进得比出得快),不是泄漏(泄漏是缓慢爬坡)。 - 抓 goroutine 剖面看卡在哪。一行命令:
curl http://localhost:6060/debug/pprof/goroutine?debug=1 > goroutines.txtdebug=1会按调用栈分组聚合、标注数量。十万个 goroutine 里 99.6% 聚在同一组栈上,全阻塞在等下游 HTTP 响应。 - 算账定级。goroutine 栈 2KB 起步,但内存大头是每个 goroutine 持有的业务数据(请求体、响应缓冲、
http.Request与连接状态),实测单协程 15~25KB。10 万 × 20KB ≈ 2GB,和监控 2.6GB 对得上。
并发怎么加闸门?
原则一句话:并发度是资源,必须有上限;排队是数据,必须有去处。
方案一:errgroup.SetLimit
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 { // jobs 是带缓冲 channel
c.handle(c.ctx, msg)
}
}()
}
wg.Wait()
}
配套三个不能省的动作:
- 把 30 秒的下游超时拆细(连接 3 秒、响应头 5 秒),别让一个慢站占住闸门 30 秒。
NumGoroutine进监控并配告警阈值(常态值的 10 倍)。- 容量规划算清守恒式:吞吐 = 并发数 ÷ 平均时延。下游 8 秒时延、闸门 64,吞吐上限就是 8 条/秒;要 200 条/秒得下游扩容或降级,而不是把闸门开到 1600。
goroutine 不是线程,它更便宜、切换更快、由 GMP 用户态调度,但便宜不等于可以无限开,并发必须加上限,否则省下的切换开销会以内存爆掉的方式还回来。
本文是速览版,完整教程看这篇:Go 语言实战(一):goroutine 不是线程,原文含完整代码示例与推导过程。
