原文:https://dev.to/nazar-boyko/goroutines-are-cheap-their-stacks-arent-4ena(作者 @nazar-boyko)
问任何一个Go开发者一个goroutine的成本,你总会得到同一个答案,甚至字节数都完全一致:2KB。官方FAQ说“几KB”。技术大会演讲说“你可以轻松创建百万个”。我也曾深信不疑,用的是那种对待大多数未验证数字的惯常懒散态度——直到某次我正好有空,手头有台68GB内存的笔记本,除了挂起百万个goroutine看看效果,也想不出更好的主意。
这个数字大体是准确的。一百万个空闲goroutine大约占用2.8GB:每个栈2KB,加上一些簿记开销。然后我改动了一点东西。让每个goroutine在挂起前调用一个函数,该函数在其栈上分配一个8KB的数组,仅调用一次。同样的百万个goroutine现在占用了13GB。这个差距就是本文要探讨的,而老实说,背后的机制比数字本身更有趣。
2KB是真实存在的,且仅指栈内存
测试程序设计得很精简。它启动N个goroutine,让它们阻塞在一个通道上,并等待它们全部挂起。然后打印运行时的内存计数器,(这部分稍后很重要)接着强制触发几次垃圾回收,并在每次后打印计数器。一个mode参数决定每个goroutine在挂起前是否触及栈,以及触及多少。
`main.go`
package main
import (
"fmt"
"os"
"runtime"
"strconv"
"sync"
"time"
)
func stats(label string) {
var m runtime.MemStats
runtime.ReadMemStats(&m)
fmt.Printf("%-24s goroutines=%-8d StackInuse=%8.1fMB HeapInuse=%6.1fMB Sys=%8.1fMB\n",
label, runtime.NumGoroutine(), float64(m.StackInuse)/1e6,
float64(m.HeapInuse)/1e6, float64(m.Sys)/1e6)
}
//go:noinline
func sink(b []byte) byte { return b[0] + b[len(b)-1] }
// 一个在栈上持有8KB局部变量的函数。切片传递给一个
// 不内联的 sink,确保编译器无法优化掉数组。
//go:noinline
func touch8() byte {
var buf [8 << 10]byte
buf[0], buf[len(buf)-1] = 1, 1
return sink(buf[:])
}
// 同上,使用64KB局部变量。
//go:noinline
func touch64() byte {
var buf [64 << 10]byte
buf[0], buf[len(buf)-1] = 1, 1
return sink(buf[:])
}
func main() {
n, _ := strconv.Atoi(os.Args[1])
mode := os.Args[2] // idle | 8k | 64k
var wg sync.WaitGroup
block := make(chan struct{})
start := time.Now()
for i := 0; i < n; i++ {
wg.Add(1)
go func() {
defer wg.Done()
switch mode {
case "8k":
touch8()
case "64k":
touch64()
}
<-block
}()
}
for runtime.NumGoroutine() < n {
time.Sleep(10 * time.Millisecond)
}
fmt.Printf("spawned %d (%s) in %v\n", n, mode, time.Since(start).Round(time.Millisecond))
stats("parked, before any GC")
for i := 1; i <= 6; i++ {
runtime.GC()
stats(fmt.Sprintf("after GC #%d", i))
}
close(block)
wg.Wait()
}
关于测试设置有两点说明,因为它们会影响结果。StackInuse是运行时自身统计的栈段(stack span)字节数,因此是反映“当前栈内存占用”的诚实指标。此外,我在创建goroutine的循环中设置了GOGC=off,这样垃圾回收器就不会在我仍在创建goroutine时偷偷回收任何内存。之后的六次显式runtime.GC()调用是唯一发生的垃圾回收。所有测试均在Apple Silicon Mac上的Go 1.26.4环境进行。
一百万个什么都不做的goroutine:
GOGC=off ./gor 1000000 idle # spawned 1000000 (idle) in 290ms # parked, before any GC goroutines=1000001 StackInuse= 2048.8MB HeapInuse= 724.8MB Sys= 2817.8MB # after GC #6 goroutines=1000001 StackInuse= 2048.9MB HeapInuse= 639.1MB Sys= 2821.0MB # maximum resident set size: 2812690432
1,000,001个goroutine占用2,048.8MB栈内存,平均每个正好是2,048字节。这正是runtime/stack.go里的stackMin = 2048,也就是所有人引用2KB时所指的那个常量。旁边还有639MB的堆内存,在经过六次垃圾回收后依然存在,因此并非垃圾。这是每个goroutine对应的运行时g结构体、闭包以及延迟调用(deferred call)的开销,大约每个640字节(我用一个不带闭包和defer的裸go park(c)验证过,同样得到639字节,因此大部分来自g结构体本身)。总驻留内存2.8GB。可以称之为每个goroutine 2.8KB,那么那个流行的数字在误差范围内是正确的。
作为参照,Linux系统上的一个操作系统线程会根据ulimit -s的设定预留其栈空间,在大多数系统上是8MB(详见pthread_create手册页)。这是虚拟内存,大部分未被触及,但一百万个线程就是8TB的地址空间,内核在远未达到这个数量时就会拒绝。因此FAQ所说的“如果goroutine只是线程,系统资源在远小得多的数量级就会耗尽”是正确的。问题在于FAQ紧接着说的那句话,却很少有人引用:运行时会“自动增长(和收缩)存储栈的内存”。
一个8KB的局部变量如何让2KB栈变成16KB
实际中增长意味着什么。几乎每个 Go 函数开始都会检查:这个 goroutine 的栈上还有足够空间容纳我的帧吗?如果没有,runtime.morestack 就会运行。从 Go 1.4 开始,它的做法是分配一个大小为两倍的新栈,将所有内容复制过去,并修复每个指向旧栈的指针。Keith Randall 在2013年的连续栈设计文档中将其描述为“使用2的幂大小,每次重新分配时只是加倍”,而现在的运行时确实有 newsize := oldsize * 2。
加倍意味着大小依次为 2KB、4KB、8KB、16KB。一个有8KB局部变量的函数无法放入8KB的栈中(帧需要空间用于返回地址、调用者的帧以及运行时在底部保留的保护区域),所以它最终会使用16KB。同样的10万个 goroutine,每个在挂起前调用一次 touch8:
GOGC=off ./gor 100000 8k # spawned 100000 (8k) in 151ms # parked, before any GC goroutines=100001 StackInuse= 1461.8MB HeapInuse= 72.2MB Sys= 1555.1MB # maximum resident set size: 2567208960
100,001个 goroutine 总共使用1,461.8MB,平均每个14.6KB,所以几乎全部使用16KB栈(我认为其余部分来自运行时每个P的栈缓存)。对于一个立即返回的函数调用,这比空闲情况多了八倍。64KB版本是同样的故事,再加倍一次:
GOGC=off ./gor 100000 64k # spawned 100000 (64k) in 619ms # parked, before any GC goroutines=100001 StackInuse= 12093.6MB HeapInuse= 72.8MB Sys= 12219.8MB # maximum resident set size: 11055398912
栈总内存达到12GB,每个128KB,因为64KB加上一个帧无法放入64KB栈。而且生成耗时从43毫秒增加到619毫秒。这就是增长:运行时在分配前计算所需大小(它持续加倍直到帧能放下,然后分配一次),所以每个 goroutine 付出了一个128KB栈、一次复制和一轮指针调整的成本,整个过程使用了12GB新内存。CockroachDB 在2016年处理其 gRPC 处理程序时遇到了同样的成本,当时增长还是分步进行的(下文会详细说明)。
这些都不是泄漏或bug,而是设计完全按文档工作。人们忽略的部分是,一个 goroutine 最终获得的栈大小取决于它曾调用的最深函数,而不是当前正在做的事情。
栈通过每次GC减半回落,最终停在4KB
FAQ说栈会收缩,确实如此,但规则比“收缩”更具体。它在 runtime/stack.go 的 shrinkstack 中:在垃圾回收期间,如果一个 goroutine 使用的栈空间少于四分之一,运行时会分配一个大小减半的栈并复制下来。减半,而不是“按需分配”,并且永远不会低于最小值。Randall在2013年的设计文档中有相同的计划:“在GC时,如果一个 goroutine 使用的栈最多为1/4,就释放栈的下半部分。”
这就是为什么测试强制进行六次收集并在每次后打印。以下是64KB运行在输出第一行之后的内容:
# parked, before any GC goroutines=100001 StackInuse= 12093.6MB # after GC #1 goroutines=100001 StackInuse= 6554.6MB # after GC #2 goroutines=100001 StackInuse= 3277.8MB # after GC #3 goroutines=100001 StackInuse= 1639.4MB # after GC #4 goroutines=100001 StackInuse= 820.3MB # after GC #5 goroutines=100001 StackInuse= 410.8MB # after GC #6 goroutines=100001 StackInuse= 410.8MB
从128KB到64KB、32KB、16KB、8KB、4KB,然后停止。100,001个 goroutine 总共使用410.8MB,平均每个4KB,而不是2KB。一个曾经调用过函数的挂起 goroutine 使用了略多于4KB栈的四分之一(它自己的帧加上 defer 记录加上运行时的保护空间),所以收缩规则将其保留在此。据我所知,它将永远保持,直到 goroutine 退出。8KB运行同样结束在410.6MB,只是早了两次GC。
所以一个 goroutine 的内存有三个数字,而不是一个。开始时的大小(2KB)。第一次调用真实函数时增长到的大小(足够容纳最深帧的2的幂)。以及经过足够多次垃圾回收后稳定下来的大小,对于任何曾经增长过的都是4KB。在GC每隔几秒运行一次的服务器中,中间这个数字是短暂的。在 GOGC=off 的批处理作业或堆巨大、GC间隔为几分钟的服务中,这才是你需要支付的代价。
输出中还有一行让我思考了一会儿:Sys 从第一次GC前的12.2GB增长到之后的19.4GB。收缩栈意味着分配一个新的更小的栈并复制,旧的栈区域返回到空闲列表,而不是立即归还给操作系统。所以进程向内核请求了更多内存,却使用更少。常驻内存峰值达到11GB。这种事会让仪表板看起来错误一分钟然后恢复正常,如果我没看到计数器,我可能不会相信图表。
百万个各做一件事的 Goroutine 耗费 13GB 内存
按会议演讲的规模将所有部分拼合起来。一百万个 goroutine,每个调用一次 touch8 然后挂起,使用正常的 GOGC,这样垃圾收集器在创建它们时运行:
./gor 1000000 8k # spawned 1000000 (8k) in 1.861s # parked, before any GC goroutines=1000001 StackInuse= 8995.1MB HeapInuse= 658.9MB Sys= 13411.7MB # after GC #6 goroutines=1000001 StackInuse= 4097.0MB HeapInuse= 639.3MB Sys= 13413.3MB # maximum resident set size: 13407453184
实际占用 13.4GB 内存,而不是 2GB。垃圾收集器一直在缩减栈大小,这就是为什么 StackInuse 是 9GB 而不是 16GB。经过六次额外的收集后,它降到了 4.1GB,即 4KB 基准乘以一百万。但进程已经从操作系统获取了 13.4GB,并且不会很快归还。在创建期间使用 GOGC=off,同样的运行峰值达到 25.5GB,我提到这一点只是因为这是一种预先分配大量内存且很少收集的任务类型。
同样一百万个处于空闲状态的 goroutine 占用 2.8GB。相同的 goroutine 和相同的代码,只是每个都多了一个过去的函数调用,进程就大了四倍半。
好吧,但什么让 Goroutine 的栈达到 8KB?
这是一个合理的反驳,因为 var buf [8 << 10]byte 并不是大多数 goroutine 的样子。有两个答案,第二个让我感到惊讶。
第一个是你不需要一个大的局部变量,你需要深度。一个处理程序调用路由器,路由器调用中间件,中间件调用你的代码,你的代码调用数据库驱动,数据库驱动调用编码器,这就有十几个栈帧,它们累积起来。我所知道的最好的公开数据来自 CockroachDB:2016年12月,Peter Mattis 提交了 Go 问题 18138,因为他们的 gRPC Server.Batch 入口点需要 16 到 32KB 的栈,他写道他可以“看到栈在4步内从 2KB 增长到 32KB”,并且“栈增长的成本轻微,因此值得欺骗运行时提前增长栈”。他们手动预增长栈以跳过复制。一个普通服务中的普通 HTTP 处理程序比这个小,但显然也不是 2KB。
第二个答案是 Go 知道这一点并更改了默认值。从 Go 1.19 开始,运行时“现在将根据 goroutine 的历史平均栈使用量分配初始 goroutine 栈”,发布说明说,“代价是对于低于平均水平的 goroutine 最多浪费 2 倍空间。”stack.go 中的代码在每次 GC 时从平均扫描栈重新计算 startingStackSize,向上取整为 2 的幂,并引用问题 18138 作为原因。所以在实际服务器中,大多数 goroutine 增长到 8 或 16KB,新 goroutine 不再从 2KB 开始。它们从 8 或 16KB 开始。运行时认为 2KB 的默认值对于人们引用的工作负载来说是个坏默认值。(GODEBUG=adaptivestackstart=0 可以关闭;在我的运行中,对于一百万个永不增长的 goroutine,没有区别。这就是重点:平均值是 2KB。)
还有一个上限,从 Go 1.2 开始:在 64 位系统上,单个 goroutine 的栈可以增长到 1GB,然后运行时会终止程序,debug.SetMaxStack 可以调整它。这个上限的存在是为了让失控的递归快速失败,而不是耗尽机器资源。
我实际会怎么做
将 StackInuse 除以 NumGoroutine,这就是指标。两者读取成本都很低(runtime/metrics 有 /memory/classes/heap/stacks:bytes 和 /sched/goroutines:goroutines,如果你不想在生产中调用 ReadMemStats,因为它会停止世界)。如果平均值是 2 到 4KB,你的 goroutine 就是廉价的那种,计数就是全部。如果是 16KB 或 32KB,计数的重要性比你想象的多四到十六倍,需要看的是这些 goroutine 调用了什么,而不是有多少个。
避免在大量使用的 goroutine 中放置大的局部变量。一个每连接 goroutine 中的 [64 << 10]byte 临时缓冲区会为每个连接提供 128KB 的栈,直到接下来的几次 GC,之后才是 4KB。将其放入 sync.Pool 或堆中,在那里它会被计数并按自己的计划收集,而不会在每次栈翻倍时被复制。
如果你使用工作池,要诚实面对原因。不是因为 goroutine 创建成本高(并不高,一百万个只需 290 毫秒)。而是因为有限数量的 goroutine 意味着有限数量的增长栈,而增长栈才是有实际成本的部分。
所以 2KB 是真实的,它只是一个尚未做任何事情的 goroutine 的成本。
原文:https://dev.to/nazar-boyko/goroutines-are-cheap-their-stacks-arent-4ena(作者 @nazar-boyko)



