bigywater.tech
Go · 55

goroutine 的成本:创建一个要多少内存、多少时间

“goroutine 很轻”是 Go 的卖点。轻到什么程度?这篇不说“几 KB”这种概数,而是真的创建几万到 50 万个,量内存、创建时间、切换时间,再拿 OS 线程做对照,最后看线程数的上限怎么表现。

≈ 2.8 KB
一个阻塞的 goroutine 的总内存(栈 2050 字节 + 堆约 650 字节)
346 ns
创建并跑完一个 goroutine(一次 channel 收发是 131 ns)
22–122 µs
创建一个 OS 线程的耗时,随线程数增加而变长

内存

实测 · 本机执行输出
  10000 个 goroutine(都阻塞在 channel 上):NumGoroutine = 10001;创建 + 就绪 16 ms(每个 1631 ns)
    StackInuse +20 MiB;HeapInuse +6 MiB;MSpanInuse +0 MiB;Sys +30 MiB;进程最大 RSS 增加 27 MiB
    折算每个 goroutine:栈 2058 字节 + 堆 671 字节(g 结构体等)+ span 元数据 26 字节;Sys 合计 3131 字节
  100000 个 goroutine(都阻塞在 channel 上):NumGoroutine = 100001;创建 + 就绪 103 ms(每个 1034 ns)
    StackInuse +196 MiB;HeapInuse +63 MiB;MSpanInuse +2 MiB;Sys +263 MiB;进程最大 RSS 增加 262 MiB
    折算每个 goroutine:栈 2050 字节 + 堆 665 字节(g 结构体等)+ span 元数据 23 字节;Sys 合计 2760 字节
  500000 个 goroutine(都阻塞在 channel 上):NumGoroutine = 500001;创建 + 就绪 448 ms(每个 897 ns)
    StackInuse +977 MiB;HeapInuse +317 MiB;MSpanInuse +11 MiB;Sys +1311 MiB;进程最大 RSS 增加 1309 MiB
    折算每个 goroutine:栈 2049 字节 + 堆 665 字节(g 结构体等)+ span 元数据 23 字节;Sys 合计 2750 字节

栈是大头(2 KiB 起步),另有 650 字节左右的 g 结构体及运行时元数据,合计约 2.8 KB。规模对线性:10 万个 262 MiB,50 万个 1.3 GiB。创建加就绪的平均耗时从 0.8 µs 涨到 1.1 µs,说明规模大了之后分配与调度的开销在增加(没有查具体原因)。

创建

实测 · 本机执行输出
  go func(){ wg.Done() }() 创建并跑完:中位 341 ns/个(5 轮:330 / 332 / 341 / 370 / 366);对照:无缓冲 channel 一次发送加接收 131 ns

创建并跑完一个最小的 goroutine 约 346 ns,约等于三次 channel 通信(131 ns 一次)。所以“每个请求一个 goroutine”在 Go 里是合理的默认,开销是亚微秒级。

切换

实测 · 本机执行输出
  GOMAXPROCS= 1:两个 goroutine 经无缓冲 channel 来回一次(两次切换):中位 189 ns
  GOMAXPROCS= 2:两个 goroutine 经无缓冲 channel 来回一次(两次切换):中位 246 ns
  GOMAXPROCS=10:两个 goroutine 经无缓冲 channel 来回一次(两次切换):中位 254 ns

两个 goroutine 经无缓冲 channel 来回一次(两次切换)只要 189 ns(P=1)。有意思的是 P 更多时反而更慢(240–254 ns),我猜是强同步的来回要跨 P 唤醒对方,这个解释没有做进一步验证。

与线程对比

实测 · 本机执行输出
  1000 个 LockOSThread 的 goroutine(各占一个 OS 线程):创建 21 ms(每个 22 µs);进程最大 RSS 增加 56 MiB(每个线程约 57 KiB)
  2000 个 LockOSThread 的 goroutine(各占一个 OS 线程):创建 131 ms(每个 66 µs);进程最大 RSS 增加 111 MiB(每个线程约 57 KiB)
  4000 个 LockOSThread 的 goroutine(各占一个 OS 线程):创建 487 ms(每个 122 µs);进程最大 RSS 增加 221 MiB(每个线程约 57 KiB)
  本机对单个进程线程数的限制:4096(kern.num_taskthreads)

让每个 goroutine 占住一个 OS 线程(LockOSThread),创建一个要 22 µs 起,线程多了还会变慢(4000 个时 122 µs),比 goroutine 慢约两个数量级;每个线程光已驻留的内存就约 57 KiB(RSS,不含未触碰的虚拟栈),本机对单进程线程数的上限是 4096。这些数字是 macOS 上的,Linux 上不同。

线程上限

实测 · 本机执行输出
  退出码 2;SetMaxThreads(500) 返回旧值: 10000 / runtime: program exceeds 500-thread limit / fatal error: thread exhaustion / runtime stack:

Go 运行时自己也限制线程数(默认 10000),超出时 fatal error: thread exhaustion。普通的 goroutine 不占线程,只有阻塞在系统调用、cgo 或 LockOSThread 里的才会占——所以几十万个 goroutine 没问题,几千个阻塞在 cgo 里的就可能出问题(见 cgo 一篇)。

分步演示

goroutine 的成本
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64 / Go 1.27.1(brew);用 go run / go build / go test 直接编译运行,需要第三方库时经代理从 proxy.golang.org 下载;计时用 time.Now / testing.B,数字每次运行会有小幅抖动。内存数字来自 runtime.MemStats 的 StackInuse / HeapInuse / MSpanInuse / Sys 在创建前后的差与 getrusage 的最大 RSS;所有 goroutine 阻塞在 channel 上,创建时间含等待它们都就绪;线程实验用 runtime.LockOSThread;每个设置只跑一次(创建时间 5 轮)
    • 没有验证:Linux 上的数字(线程栈大小、内核限制不同)、自适应初始栈大小对 goroutine 内存的影响、更大规模(百万级以上)时的行为、GC 扫描大量 goroutine 栈的成本(见 GC 停顿一篇的现象)、goroutine 切换在多 P 下变慢的原因
    京ICP备2026067502号-1京公网安备11010502063487号