有一个数字,经常被用来描述 Go 的并发能力:百万 Goroutine。
它听起来像一个性能神话。有人看到这个数字,会得出一个非常简单的结论:
Go 的 Goroutine 很轻,所以可以创建一百万个。
这个结论并没有错,但它远远不够。真正值得研究的问题其实是:
为什么一百万个 Goroutine 没有立即把操作系统压垮?
如果今天把一百万个任务全部换成一百万条传统操作系统线程,会发生什么?内存会怎样?线程调度会怎样?上下文切换会怎样?CPU Cache 会怎样?系统调用又会怎样?
更重要的是:为什么 Go Runtime 能把几十万、甚至百万级的并发任务,压缩到远少于这个数量的操作系统线程上执行?
这才是“百万 Goroutine”真正值得学习的地方。
百万 Goroutine 从来不是某一个 API 带来的能力,它背后是一整套系统设计:用户态调度、轻量级执行栈、M:N 调度模型、网络轮询、阻塞唤醒、抢占式调度、分片状态、局部队列、工作窃取、垃圾回收,以及一套专门为了高并发服务设计的 Runtime。
Go 真正厉害的地方,不是“能创建很多 Goroutine”,而是:它重新定义了一个并发任务应该如何存在。
一、先不要创建 Goroutine
假设我们现在有一个聊天服务器,每一个客户端建立连接之后,都需要处理:
读取数据
↓
解析消息
↓
执行业务逻辑
↓
写回响应
假设服务器只有 10 个客户端,这件事情非常简单。但如果有:
10,000 个连接
100,000 个连接
1,000,000 个连接
问题就完全不同了。因为网络服务器真正面对的不是“CPU 够不够快”,而是:
大量连接中的绝大多数时间,都在等待。
等待网络。等待数据。等待数据库。等待锁。等待另一个服务。等待定时器。甚至等待用户下一次发送消息。
于是我们很快会发现:一个客户端,并不等于一个 CPU 任务。这是理解 Goroutine 的第一道门。
二、过去的答案:一个任务对应一个线程
在传统模型中,一个非常自然的设计是:
Client
|
v
Thread
一个客户端连接来了:
pthread_create(...)
启动一个线程。线程负责:
read()
↓
处理
↓
write()
如果有 1000 个客户端:
1000 Client
|
v
1000 Threads
这个模型非常直观,而且它利用操作系统提供的线程调度能力。问题在于:线程是一个很重的执行资源。
线程不仅仅是一个函数,它背后包含:
线程栈
寄存器状态
调度状态
内核对象
线程上下文
更重要的是,线程是操作系统调度器真正管理的实体。如果线程数量巨大,那么调度压力也会进入操作系统内核。
三、真正的问题不是“线程慢”
这里必须纠正一个非常常见的认知。很多文章喜欢说:
“线程很重,所以 Go 使用 Goroutine。”
这句话太粗糙了。真正的问题不是线程本身“慢”,真正的问题是:线程的资源粒度太大。
我们可以把一个并发任务拆成:
任务本身
+
执行状态
+
执行资源
传统线程把三者绑定在了一起:
Task
|
v
Thread
|
v
OS Scheduler
|
v
CPU
也就是说:你只是想表达“这里有一个需要稍后继续执行的任务”,却不得不创建一个操作系统线程。
这就是资源粒度失配。Go 做的事情,是把两件事情拆开:
Goroutine = Task / Execution State
M = OS Thread
然后再加入:
P = 执行 Go 代码所需的 Runtime 资源
于是出现了经典的:G-M-P。
Go Runtime 官方文档直接把 G、M、P 定义为调度器的核心资源:
- G:goroutine
- M:OS thread
- P:执行 Go 代码所需的资源
而 Runtime 的调度任务,就是把 G、M、P 组合起来运行。这一层设计,才是百万 Goroutine 的基础。
四、G、M、P 到底是什么
先不要把 GMP 想得太复杂。可以先把它理解成:
G = 要做什么
M = 谁来执行
P = 允许执行 Go 代码的运行资源
于是:
P
执行 Go 代码的资格
|
v
G ---------------------- M
任务 OS线程
更准确一点:
G
|
| 任务
|
v
P
|
| 执行资源
|
v
M
|
| OS线程
|
v
CPU
这里最关键的一点是:一个 P 并不等于一个线程。
Go Runtime 中,P 的数量与 GOMAXPROCS 对应;P 可以理解为执行用户 Go 代码所需的一组运行时资源。M 可以执行用户 Go 代码,但必须持有一个 P。M 进入系统调用等状态时,则可以没有 P。
这意味着,假设:
GOMAXPROCS = 8
你可以有:
1,000,000 G
但同时真正执行 Go 用户代码的并行度仍然受到 P 数量约束。也就是说:百万 Goroutine 并不意味着百万 Goroutine 同时运行。 这是第一个非常重要的认知。
五、一百万个 Goroutine,到底有多少在运行?
很多人第一次看到:
for i := 0; i < 1_000_000; i++ {
go work(i)
}
会产生一个误解:
“是不是同时有一百万个执行单元在 CPU 上跑?”
当然不是。更合理的模型是:
1,000,000 Goroutine
|
v
Scheduler
|
+---- Running
|
+---- Runnable
|
+---- Waiting
|
+---- Syscall
在一个时刻,真正处于执行状态的 Goroutine 数量远小于总 Goroutine 数量。
Go 1.26.5 的 runtime/metrics 提供了专门的调度统计,例如:
/sched/goroutines
/sched/goroutines/runnable
/sched/goroutines/running
/sched/goroutines/waiting
/sched/gomaxprocs
其中 /sched/goroutines 表示存活 Goroutine 数量,而 /sched/goroutines/running 表示正在执行的 Goroutine 数量,并且这个数量不会超过 GOMAXPROCS。
所以:
1000000 Goroutine
真正意味着的更接近:
1000000 个并发任务状态
而不是
1000000 个 CPU 执行实体
这就是 Go 并发模型最重要的一次抽象。
六、Goroutine 为什么可以这么轻?
现在来到第二个问题:如果 Goroutine 不是线程,它到底保存了什么?
一个 Goroutine 至少需要保存:
执行位置
寄存器状态
栈
调度状态
这里最重要的是:栈。
传统线程通常会为线程准备比较大的栈空间。而 Goroutine 不需要一开始就拿到一个巨大的固定栈。Go Runtime 为 Go 代码使用的栈设置了一个很小的最小起始规模;Go 1.26.5 的 Runtime metrics 甚至直接提供了:
/gc/stack/starting-size
用于表示新 Goroutine 的起始栈大小。Runtime 的源码则把 stackMin 定义为 2048 字节。
注意:2KB 不等于整个 Goroutine 永远只占 2KB。 它只是非常小的起点。当函数调用深度增加,需要更多栈空间时,Runtime 会处理栈增长。
也就是说:
Goroutine
初始
|
v
小栈
|
| 不够
v
增长
|
| 继续需要
v
继续增长
而不是:
创建 Goroutine
↓
永久分配巨大线程栈
这就是第一个关键:
按需获得执行资源。
七、动态栈改变了什么?
我们来看一个简单程序:
func work() {
var buf [1024]byte
_ = buf
work()
}
随着函数持续调用:
调用深度增加
|
v
栈空间需求增加
|
v
Runtime 检测
|
v
扩大 Goroutine Stack
这意味着:Goroutine 的内存成本与实际执行需求更加相关。 它并不需要一开始就为最坏情况准备资源。
这其实是一种非常典型的工程思想:
先低成本进入系统,需要更多资源时再增长。
这也是为什么可以存在海量 Goroutine 的重要原因。
八、但仅仅“小栈”远远不够
假设:
每个 Goroutine 非常小
是不是就意味着:
1,000,000 × 很小
=
完全没问题
并不是。因为 Goroutine 并不只有栈,还包括:
G对象
栈
调度状态
等待关系
Channel / Mutex 等同步关系
引用的业务对象
网络连接
定时器
缓存
所以百万 Goroutine 最终消耗多少内存,取决于:Goroutine 本身 + 栈 + 它引用的对象 + 外部资源。
这也是为什么:
for i := 0; i < 1_000_000; i++ {
go func() {
select {}
}()
}
虽然可以用来展示 Runtime 能力,却不是一个完整的性能结论。
真实业务中:
1,000,000 Goroutine
如果每个 Goroutine 还持有:
1MB buffer
数据库连接
缓存
大量对象引用
系统一样会崩。所以:Goroutine 轻量,不等于无限资源。
九、真正改变规模的,是 M:N
现在我们进入 Goroutine 最核心的系统设计:M:N 调度。
传统模型更加接近:
N Task
=
N Thread
而 Go 希望做到:
N Goroutine
↓
M OS Thread
其中:
N >> M
例如:
1,000,000 Goroutine
|
v
几十 / 数百级别的 OS Threads
|
v
有限数量 CPU
这就是 M:N。这里的关键并不是“线程变少”,而是:线程和业务并发任务不再绑定。
十、为什么 M:N 特别重要?
考虑一个聊天服务器,有 100 万个连接:
1000000 Client
但在某一个时刻:
999000
都在等待数据。真正活跃的可能只有:
1000
如果使用传统线程模型:
1000000 Client
|
v
1000000 Threads
你为大量“等待”付出了线程资源。而 Goroutine 模型更像:
1000000 G
|
v
Scheduler
|
+---- runnable
|
+---- waiting
|
+---- syscall
只有真正需要运行的 Goroutine 才需要 CPU。这是一种巨大的资源压缩。
十一、等待本身不是问题
这是理解 Go Runtime 的另一个重要视角:并发系统真正害怕的,并不是等待。 害怕的是:
为了等待,你必须长期占用昂贵的执行资源。
举一个非常简单的例子:
go func() {
time.Sleep(time.Hour)
}()
这个 Goroutine 在一个小时里绝大多数时间都没有真正执行用户代码。
如果它对应一个完整操作系统线程:
Thread
|
| Sleep 1 hour
|
v
仍然占据线程资源
而 Goroutine 模型可以让:
G
↓
waiting
线程去执行别的任务。于是:
G1 waiting
G2 waiting
G3 running
G4 runnable
G5 waiting
同一个 M 可以不断切换到其他可运行的 G。这才是:并发密度。
十二、Scheduler 到底在做什么?
我们终于可以回到 Runtime。Go Runtime 的核心调度代码位于:
runtime/proc.go
官方源码直接写明:
scheduler 的工作,是把 ready-to-run goroutines 分配到 worker threads 上。
当前 Runtime 中的核心调度结构仍然围绕:
G
M
P
展开。而其中非常重要的函数包括:
schedule()
findRunnable()
runqput()
runqgrab()
例如 schedule() 的核心职责就是进入下一轮调度,然后通过 findRunnable() 找到一个可以执行的 Goroutine。Runtime 源码明确说明 findRunnable() 会尝试从其他 P 窃取工作、从本地队列或全局队列取 G,并通过网络轮询器寻找就绪工作。
所以调度器并不是:
G1
↓
G2
↓
G3
↓
G4
这么简单。它实际上是在不断解决一个复杂问题:
下一份最合适、最值得执行的工作在哪里?
十三、本地队列:为什么不把所有 G 放进一个大队列?
假设有:
8 P
如果所有 Goroutine 都进入一个全局队列:
Global Queue
|
+----------+----------+
| | |
P1 P2 P3
那么所有 P 都会竞争这个队列。结果就是:
锁竞争
Cache Miss
同步开销
于是 Go Runtime 使用了更加分布式的调度状态。每个 P 有自己的本地运行队列。可以抽象成:
P1 -> G G G G
P2 -> G G G
P3 -> G G G G G
P4 -> G
这种设计的核心思想是:绝大多数情况下,让任务尽可能在本地解决。
Runtime 官方代码也明确说明,调度器状态是有意分散的,尤其是每个 P 的本地工作队列,这是为了扩展性。
这和数据库、CPU Cache、分布式系统中的一个核心原则非常像:
尽量减少共享。
十四、任务分布不均怎么办?
如果:
P1 -> G G G G G G G G
P2 -> G
P3 -> G
P4 -> G
显然不公平。P1 很忙,P2、P3、P4 却快闲着了。
于是 Runtime 需要一个机制:Work Stealing。 也就是:
空闲的 P 去其他 P 那里拿一些工作。
可以抽象成:
P1
|
+-- G
+-- G
+-- G
+-- G
+-- G
+-- G
P2
|
+-- idle
P2 发现自己没有工作:
P2
|
v
寻找其他 P
|
v
P1
|
v
偷走一部分 G
当前 Go Runtime 的 runqgrab 就用于从其他 P 的运行队列中批量获取 Goroutine;源码中可以直接看到它以批量方式抓取队列中的 G。
注意这个设计的精髓:不是所有工作都集中到一个调度中心。 而是:
Local First
+
Occasional Stealing
这是一种非常典型的高性能并发调度策略。
十五、为什么“本地优先”特别重要?
因为现代 CPU 有 Cache。CPU 并不是:
CPU
|
v
Memory
这么简单。更接近:
CPU
|
L1
|
L2
|
L3
|
Memory
数据离 CPU 越近,访问通常越快。
如果一个 P 长时间运行相关 Goroutine:
G1
↓
G2
↓
G3
↓
G4
那么:
数据
状态
代码
缓存
都可能具有更好的局部性。如果每执行一个 Goroutine 都把任务到处搬运:
P1
↓
P2
↓
P3
↓
P4
就会增加状态迁移成本。
所以工作窃取并不是:
“看谁忙就抢谁。”
而是:
平时尊重局部性,真的失衡时再进行再分配。
这是成熟调度器的重要思想。
十六、网络等待是百万 Goroutine 的另一块拼图
现在回到服务器。假设:
conn.Read(buf)
如果数据还没有到:
Goroutine
|
v
Read
|
v
等待网络
如果 Runtime 简单地让这个 Goroutine 对应的线程一直等待:
M
|
v
阻塞
那么:
1000000 waiting G
还是可能导致:
大量 Threads
所以 Go Runtime 还需要解决:阻塞 I/O 与 Goroutine 调度之间的转换。 这就是:
netpoll
十七、netpoll 让等待变得便宜
现代操作系统提供:
epoll
kqueue
...
这些机制允许程序:
不要一个线程盯着一个连接,而是让内核告诉我哪些连接已经准备好了。
于是 Go 的路径可以抽象为:
Goroutine
|
net/http / net
|
netpoll
|
OS Event API
|
Kernel
当一个连接没有数据:
G
|
v
等待
Goroutine 不必一直占据 CPU。等内核告诉 Runtime:
这个 socket 有数据了
Runtime 再把对应工作重新变为可运行任务。于是:
Waiting
|
v
Ready
|
v
Runnable
|
v
Running
这就是 Go 网络服务器能够承载高并发连接的关键。
十八、所以百万 Goroutine 其实是“压缩等待”
现在可以重新理解那“一百万”。假设:
1,000,000 Client
其中:
900,000 Waiting
90,000 Runnable
10,000 Active
这并不意味着:
1,000,000 CPU tasks simultaneously executing
而更接近:
1,000,000 logical concurrency states
|
v
Runtime Scheduler
|
v
much smaller execution set
|
v
CPU Cores
所以 Go 真正做的是:把大量逻辑并发,压缩到有限物理执行资源上。 这是“百万 Goroutine”背后的第一个核心能力。
十九、抢占:让一个 Goroutine 不能霸占 CPU
如果只有协作式调度:
G1 running
|
| 不主动让出
|
X
那么一个错误的或者极端计算型 Goroutine 可能长时间占据执行资源。
现代 Go Runtime 已经具有异步抢占能力。Runtime 对抢占点进行了专门设计,包括:
blocked safe-point
synchronous safe-point
asynchronous safe-point
其中异步安全点允许 Runtime 在满足条件的用户代码指令位置暂停 Goroutine。
因此,调度器并不是:
“希望大家自觉。”
而是在更大程度上:
Runtime
|
v
监控执行
|
v
需要时抢占
|
v
调度其他 G
这保证了高并发程序在复杂情况下仍然有机会维持公平性。
二十、Goroutine 多了,GC 怎么办?
问题又来了。如果存在:
1,000,000 Goroutine
GC 是否也要处理:
1,000,000 stacks
答案是:要考虑。 因为 Goroutine 的栈也是 GC 需要理解的运行时状态。
所以 Goroutine 数量扩大,不只是 Scheduler 的问题,同时也是:
Memory
+
GC
+
Stack Scanning
的问题。这也是为什么:百万 Goroutine 的能力,本质上不是 Goroutine 一个组件的能力。 而是:
Scheduler
+
Stack
+
GC
+
Network Poller
+
Memory Allocator
+
OS
共同形成的能力。
二十一、Go 1.26.5 又推进了一步:Green Tea GC
对于你这套教程,我们必须特别关注 Go 1.26.5。因为这一版本的 Runtime 已经有一个重要变化:Green Tea GC 默认启用。
Go 1.26 的官方发布说明指出,Green Tea GC 此前在 Go 1.25 中作为实验功能存在,到 Go 1.26 后默认启用;其目标之一,是通过更好的局部性提升小对象标记和扫描的效率。官方资料预计,对大量使用 GC 的真实程序,GC 开销可能降低约 10%~40%,具体收益依工作负载而变化。
这和百万 Goroutine 有什么关系?因为海量并发通常意味着:
更多任务
+
更多状态
+
更多对象
+
更多生命周期变化
这会让 GC 更重要。
Green Tea 的核心思想之一,就是通过延迟扫描和按 Span 聚合对象来改善局部性:
Objects
|
v
Group by Span
|
v
Batch Scan
|
v
Better Locality
官方 Runtime 源码对这一设计的描述也明确强调了:
better locality
prefetching
amortized metadata cost
等方向。
因此在 Go 1.26.5 下谈“百万 Goroutine”,已经不能只讲 GMP。我们必须把:Scheduler + GC 一起看。
二十二、百万 Goroutine 真正的瓶颈是什么?
假设今天我们真的创建:
1,000,000 Goroutine
真正可能出现的问题包括:
1. 内存占用
不是只有 Goroutine 栈,还可能包括:
业务对象
Channel
Buffer
网络连接
Timer
Context
Map
Slice
缓存
所以:
Goroutine数量
≠
内存消耗
2. 调度压力
如果:
1,000,000 G
同时大量进入:
Runnable
那就完全是另外一个世界了。
注意:
1,000,000 Waiting
和:
1,000,000 Runnable
是完全不同的问题。前者可能主要是资源与状态管理问题,后者则会直接制造:
Scheduler Pressure
因为 CPU 根本不可能同时运行一百万个任务。
二十三、一个特别重要的区别:Concurrent ≠ Parallel
这是理解百万 Goroutine 最值得记住的一句话。
Concurrent
表示:
很多任务可以同时处于进行中的状态。
Parallel
表示:
很多任务实际上同时在不同 CPU 核心上执行。
比如:
1,000,000 Goroutine
可以是:
Concurrent = 1,000,000
Parallel = 8
假设:
GOMAXPROCS = 8
那么大致可以理解成:
1,000,000 G
|
v
Scheduler
|
+---- 8 执行资源
这就是为什么:
Go 可以拥有极高的并发量,但不意味着 CPU 可以同时执行那么多任务。
这是互联网后端工程中特别重要的一个认知。
二十四、为什么百万个“等待型”Goroutine 比百万个“计算型”Goroutine 现实?
来看两个模型。
模型 A:等待型
for i := 0; i < 1_000_000; i++ {
go func() {
<-ch
}()
}
这里大量 Goroutine 会进入:
Waiting
真正需要 CPU 的时间很少,因此 Runtime 可以维持大量并发状态。
模型 B:计算型
for i := 0; i < 1_000_000; i++ {
go func() {
for {
heavyCompute()
}
}()
}
这完全不同。这些 Goroutine 都在竞争:
CPU
最终结果不会是:
1,000,000 × performance
而是:
CPU 核数有限
+
Scheduler 必须不断管理 runnable G
+
Cache 压力增加
+
GC 与系统资源也增加
所以:Goroutine 解决的是“高并发任务管理”,不是“凭空创造 CPU”。
二十五、Runtime 其实在做一件非常残酷的事情
如果把整个 Go Scheduler 放到最底层,你会发现:它一直在做资源压缩。
把:
海量任务
压缩成:
有限运行资源
把:
大量等待
转化成:
低成本状态
把:
线程阻塞
转化成:
Goroutine waiting
把:
大量任务
放进:
Local Queue
+
Global Queue
+
Netpoll
再由:
P
决定谁能够真正得到执行机会。
整个过程可以画成:
Millions of G
|
+--------------+--------------+
| | |
Waiting Runnable Syscall
| | |
| v |
| Local Run Queue |
| | |
+---------> Scheduler <--------+
|
Work Stealing
|
v
P
|
v
M
|
v
CPU
这才是百万 Goroutine 的真实结构。
二十六、为什么 Runtime 不直接把所有东西放一起?
因为共享本身就是成本。
想象:
1,000,000 G
|
v
Global Queue
|
v
All CPUs
所有 CPU 都去抢一个地方,那么就会不断产生:
Lock Contention
Cache Coherence
Atomic Operations
Memory Traffic
最终:调度器自己可能成为系统瓶颈。
所以 Go Runtime 的设计一直强调:
Local State
+
Distributed Scheduling
+
Occasional Global Coordination
这其实已经非常接近现代分布式系统的思想。不要让所有请求都经过一个中心,能本地处理,就本地处理,只有必要的时候,才协调。
二十七、这也是为什么“GMP”不能只背概念
很多人学习 GMP 时,会背:
G = Goroutine
M = Machine
P = Processor
然后认为自己理解了。其实这只是词汇。
真正应该问的是:
为什么要有 P?
为了把:
线程
和:
执行 Go 代码
分离。
为什么 P 有本地运行队列?
减少全局竞争。
为什么需要 Work Stealing?
处理负载不均。
为什么需要 Netpoll?
把网络等待从线程模型中解耦出来。
为什么要抢占?
防止单个 Goroutine 长时间霸占执行资源。
为什么 Goroutine 栈动态增长?
让大量轻量任务可以低成本进入系统。
为什么 Runtime 要不断优化 GC?
因为海量并发状态最终也会转化为内存管理压力。
当这些问题串起来:GMP 才真正活了起来。
二十八、传统线程模型、Go 模型、事件循环模型
现在把 Go 和其他模型放在一起。
| 模型 |
并发任务 |
执行资源 |
调度位置 |
典型问题 |
| C/POSIX Thread |
Thread |
OS Thread |
OS |
线程资源成本 |
| Java Thread |
Thread |
OS Thread |
OS/JVM |
线程数量、GC与调度成本 |
| Node.js |
Callback/Promise |
Event Loop |
用户态 |
CPU任务容易阻塞事件循环 |
| Rust async |
Future |
Runtime Thread |
用户态 Runtime |
Future模型与异步生态复杂度 |
| Go |
Goroutine |
OS Thread + P |
Go Runtime |
Runtime与GC本身也有成本 |
没有任何一种模型是:
“万能答案”
Go 的优势来自一个非常明确的取舍:
愿意用 Runtime 的复杂度,换取应用开发者更简单的并发模型。
这是一笔工程交易。
二十九、Go 付出了什么代价?
既然 Go 能做到这么多,它当然也不可能没有代价。
Runtime 变复杂了
应用开发者不需要理解所有调度细节,但 Runtime 团队必须理解。必须处理:
Scheduler
GC
Stack
Netpoll
Preemption
Syscall
Timers
Memory
复杂性没有消失,只是:从业务代码转移到了 Runtime。
三十、GC 也是一笔交易
Go 使用垃圾回收。好处是:
降低手动内存管理负担
提高开发效率
减少大量典型内存错误
但代价是:
GC CPU 成本
Heap 管理
对象分配成本
GC Assist
扫描成本
Go 1.26 通过 Green Tea GC 继续优化其中一部分成本,但这并不意味着 GC “免费”。
所以:
百万 Goroutine 并不是因为 Go 没有成本。
而是:
Go 把成本放到了更适合集中优化的位置。
三十一、真正的工程问题:你到底应该创建多少 Goroutine?
答案不是:
越多越好
也不是:
永远不能超过CPU核数
而应该根据:
任务类型
等待时间
CPU占比
内存占用
I/O模型
业务对象
调度压力
决定。
如果工作是:
网络等待为主
那么大量 Goroutine 很自然。
如果工作是:
CPU密集型
那么无限增加 Goroutine 没有意义。因为:
Goroutine数量
↑
不会让
CPU核心数量
↑
三十二、如何验证,而不是相信“百万并发神话”?
真正做系统工程的时候,不应该只写:
go func() {}
然后看到程序没崩,就宣布:
Go 能撑百万并发。
这不是工程。工程需要测量。
Go 1.26.5 的 runtime/metrics 可以直接观察调度器的一些状态,例如:
/sched/goroutines:goroutines
/sched/goroutines/runnable:goroutines
/sched/goroutines/running:goroutines
/sched/goroutines/waiting:goroutines
/sched/latencies:seconds
其中 scheduler latency 可以帮助判断 Goroutine 在 runnable 状态等待多久后才能真正运行。
一个简单的观测程序可以写成:
package main
import (
"fmt"
"runtime/metrics"
"time"
)
func main() {
samples := []metrics.Sample{
{Name: "/sched/goroutines:goroutines"},
{Name: "/sched/goroutines/runnable:goroutines"},
{Name: "/sched/goroutines/running:goroutines"},
{Name: "/sched/goroutines/waiting:goroutines"},
{Name: "/sched/gomaxprocs:threads"},
}
for {
metrics.Read(samples)
fmt.Printf(
"live=%d runnable=%d running=%d waiting=%d gomaxprocs=%d\n",
samples[0].Value.Uint64(),
samples[1].Value.Uint64(),
samples[2].Value.Uint64(),
samples[3].Value.Uint64(),
samples[4].Value.Uint64(),
)
time.Sleep(time.Second)
}
}
真正有价值的不是:
我创建了100万个G
而是:
100万个G里:
多少在等待?
多少Runnable?
多少真正Running?
Scheduler latency多少?
内存多少?
GC多少?
CPU多少?
这才是性能工程。
三十三、一个更重要的实验
我们可以设计三个阶段。
实验一:大量等待
package main
import (
"fmt"
"runtime"
"time"
)
func main() {
for i := 0; i < 1_000_000; i++ {
go func() {
select {}
}()
}
time.Sleep(10 * time.Second)
fmt.Println("goroutines:", runtime.NumGoroutine())
}
这个实验的意义:观察“海量等待状态”到底是什么样。
但它绝不意味着:
100万G = 正常生产环境
因为每个 Goroutine 的生命周期、栈、运行状态等都会影响资源使用。
三十四、实验二:大量 Runnable
把等待改成持续计算:
for i := 0; i < 1_000_000; i++ {
go func() {
for {
// CPU-intensive work
}
}()
}
你会发现系统完全进入另一个状态。因为:
大量G
+
大量Runnable
=
Scheduler Pressure
这时真正限制你的已经不是:
“能不能创建G”
而是:
CPU
Scheduler
Memory
GC
三十五、实验三:真实网络等待
真正有意义的高并发服务器实验应该更接近:
大量连接
+
大量等待
+
少量活跃请求
例如:
100,000 connections
其中:
95,000 waiting
4,000 idle / timer
1,000 active
这才接近很多真实网络服务的结构。这也是:
Goroutine
+
Netpoll
+
Scheduler
真正发挥作用的地方。
三十六、百万 Goroutine 并不等于百万连接
又是一个需要纠正的概念。
很多文章喜欢把:
1 million goroutines
直接等同:
1 million network connections
实际上不是。网络连接还会消耗:
Socket
Kernel Memory
TCP State
Receive Buffer
Send Buffer
TLS State
Application State
所以:
1,000,000 G
只是说明:
Runtime 可以管理大量并发任务。
它不能直接证明:
机器可以承受一百万真实 TCP 连接。
后者必须考虑整个系统。
三十七、从百万 Goroutine 看懂系统工程
现在我们终于可以把整个系统重新画出来。
Application
|
+--------+--------+
| | |
G G G
| | |
+--------+--------+
|
Scheduler
|
+------------+------------+
| | |
P1 P2 P3
| | |
M1 M2 M3
| | |
CPU CPU CPU
|
Netpoll
|
Kernel
|
NIC
但实际上,还存在另一条链:
Goroutine
|
Stack
|
Heap Objects
|
Garbage Collector
于是我们得到:
Go Runtime
|
+---------------+---------------+
| | |
Scheduler GC Netpoll
| | |
GMP Heap Kernel
|
CPU
这才是 Go 高并发能力真正的全貌。
三十八、Go 为什么敢把并发交给 Runtime?
因为 Go 的设计哲学从一开始就包含一个非常重要的思想:
把复杂性集中起来。
如果让每个开发者自己处理:
Thread Pool
epoll
Scheduler
Stack
Synchronization
Preemption
Memory
那么一套大型网络系统很容易陷入:
复杂代码
+
大量状态
+
难以调试
+
高维护成本
Go 的路线则是:
Application
|
v
Goroutine
|
v
Runtime
|
v
OS
应用层只需要表达:
“这里有一个任务。”
然后:Runtime 负责决定什么时候运行、在哪里运行、等待时怎么办、恢复时怎么办。
这其实是一种非常大胆的工程设计。
三十九、Go 真正解决的问题是什么?
如果把今天的标题:
《百万Goroutine背后的系统能力》
压缩成一句话。答案不是:
Goroutine很轻。
也不是:
Go比线程快。
真正的答案是:Go 把“任务”和“执行资源”分离了。
这句话非常重要。
传统模型倾向于:
任务
=
线程
而 Go 更接近:
任务
=
Goroutine
执行资源
=
P + M + CPU
于是:
1,000,000 Tasks
不再要求:
1,000,000 Execution Resources
这就是规模差异真正产生的地方。
四十、与 C、C++、Rust、Java 再比较一次
C
C 可以提供非常极致的控制能力。你可以直接控制:
线程
内存
Socket
系统调用
但代价也是:
工程复杂度高
开发成本高
并发抽象需要自己设计
C++
C++ 给了更多抽象能力。可以构建:
Thread Pool
Async Framework
Coroutine
Executor
但语言与生态本身的复杂度也更高。
Rust
Rust 的方向非常有意思。它可以通过:
async
Future
Executor
构建高性能异步系统,同时利用所有权和借用规则提供强大的内存安全保障。
但异步模型对开发者理解:
Future
Pin
Waker
Executor
提出了更高要求。
Java
Java 长期依赖:
JVM
Thread
GC
Executor
现代 Java 又进一步发展出了虚拟线程等方案。它同样在解决:
如何让大量逻辑并发任务不必一一绑定昂贵执行资源。
Go
Go 的路线非常直接:
Goroutine
+
Channel
+
Runtime Scheduler
+
GC
+
Netpoll
它牺牲了一部分:
极致底层控制
换取:
更简单的并发编程模型
所以 Go 从来不是:
“所有语言里最强。”
它真正强的是:在网络服务与系统工程中,把并发复杂度压缩到了一个相对容易使用的抽象里。
四十一、这也是 Go 进入云原生的原因之一
云原生系统很少只有一个任务。一个 Kubernetes Controller 可能同时处理:
Watch
API
Reconcile
Timer
Queue
Network
Cache
微服务也同样:
HTTP
RPC
Database
Cache
Message Queue
Timer
Background Tasks
这天然产生大量:
等待
唤醒
重试
超时
取消
并发执行
Go Runtime 的价值就在这里体现出来。
开发者可以写:
go handleConnection()
而不必自己管理:
Thread Pool
epoll Loop
Task Queue
Worker Lifecycle
当然,工程师仍然必须理解这些东西。只是:不需要每次重新发明一遍。
四十二、真正值得学习的不是“百万”
“百万 Goroutine”这个标题非常容易把注意力带向一个数字。但数字本身并不重要。
重要的是:
一百万个什么?
如果是:
一百万个等待状态
问题可能主要是:
Memory
Runtime Metadata
GC
如果是:
一百万个Runnable
问题会迅速转向:
Scheduler
CPU
如果是:
一百万个TCP连接
问题还会进入:
Kernel
Socket
Network
Memory Buffer
如果是:
一百万个CPU密集任务
那么最终限制你的就是:
CPU
所以:“百万”不是性能指标,负载结构才是。
四十三、Go Runtime 的真正能力
如果一定要把今天的内容压缩成一张图:
Massive Concurrency
|
+----------------+----------------+
| | |
Goroutine Stack Waiting
| | |
+----------------+----------------+
|
Scheduler
|
+---------+---------+
| | |
Local Global Stealing
| | |
+---------+---------+
|
P
|
M
|
CPU
+
Netpoll
|
Kernel
+
GC
|
Memory
百万 Goroutine 的背后,没有一个神奇函数。它是很多系统能力叠加后的结果。
四十四、最后真正值得记住的几个结论
第一:Goroutine 不是线程。
第二:百万 Goroutine 不等于百万并行执行。
第三:真正让 Go 拥有高并发密度的,是 M:N 调度。
第四:本地运行队列与 Work Stealing 降低了调度共享成本。
第五:Netpoll 把大量网络等待从线程模型中剥离出来。
第六:动态栈让大量轻量任务能够低成本进入 Runtime。
第七:抢占机制让 Runtime 拥有更强的执行控制能力。
第八:GC 决定了海量并发状态最终能否稳定地存在于内存系统里。
第九:Go 1.26.5 默认使用 Green Tea GC,这让我们今天理解 Go 高并发时,不能再只盯着 GMP。
第十:百万 Goroutine 真正代表的,不是“Go 可以创建一百万个函数”。
而是:
Go Runtime 可以把海量逻辑并发任务,映射到有限的物理执行资源上。
四十五、真正值得思考的问题
如果我们把今天的视角再提高一点,会发现 Goroutine 并没有消灭复杂性,它只是重新安排了复杂性。
应用程序看到的是:
go func()
而 Runtime 看到的是:
Scheduler
Stack
Queue
Atomic
Preemption
Netpoll
GC
Memory
OS
CPU
开发者写下一行:
go handle(conn)
背后却可能发生:
创建G
↓
初始化执行状态
↓
准备Stack
↓
加入Runnable Queue
↓
P获取任务
↓
M执行
↓
遇到I/O
↓
进入等待
↓
Netpoll检测事件
↓
重新Ready
↓
再次进入调度
↓
继续执行
↓
最终退出
↓
资源回收
真正令人惊叹的从来不是:
“一百万个 Goroutine。”
而是:
在这一百万个并发状态背后,Runtime 仍然试图让整个系统保持可管理、可调度、可预测。
这才是 Go 真正的系统能力。
而当我们继续往下研究,会出现一个更值得追问的问题:
如果 Goroutine 可以由 Runtime 管理,那么 Runtime 自己到底如何知道——什么时候应该让一个 Goroutine 停下来,什么时候又应该让它继续运行?
答案,就藏在:Go Scheduler 的调度策略、抢占机制,以及下一层更深的 Runtime 状态机里。
那里,已经不再只是“并发编程”,而是真正进入了:操作系统、运行时系统与 CPU 之间共同构成的执行世界。