Go 1.26.5 系列
当你写下 go func() 的时候,真正开始工作的并不只是一个函数。
后面站着的是一整套运行时系统:Goroutine、Scheduler、G/M/P、网络轮询器、抢占机制、定时器、垃圾回收器,以及操作系统线程。
Go 真正值得研究的地方,不是“Goroutine 很轻量”这句话。
而是:Go Runtime 到底是如何把大量并发任务,组织成一个可以持续运行的系统?
一、问题:并发真正难的,从来不是“同时运行”
假设我们正在做一个聊天服务器。
一分钟之内,系统收到 100 万个连接。其中一部分用户正在发消息,一部分正在等待网络数据,一部分正在做数据库查询,还有一部分连接几乎没有任何事情发生。
从业务角度看,这不过是:
for {
request := accept()
handle(request)
}
但从操作系统角度看,这里面至少存在几个问题:
100万任务
↓
谁来执行?
↓
CPU只有有限数量
↓
谁应该先执行?
↓
谁在等待?
↓
等待的时候CPU怎么办?
↓
某个任务执行太久怎么办?
↓
网络数据到了,谁来唤醒它?
↓
大量任务同时出现,又怎么办?
这才是真正的并发问题。现代服务器真正困难的地方,并不是“如何创建线程”,而是:
如何在有限的 CPU、内存和操作系统资源下,管理远远超过 CPU 数量的并发任务。
传统模型很自然地会想到:
一个任务
↓
一个线程
↓
CPU执行
那么一百万个任务是不是就需要一百万个线程?显然不是。
于是操作系统、语言运行时和网络框架几十年来一直在解决同一个核心问题:
任务和执行资源,必须解耦。
Go Runtime 的设计,就是这个问题的一种非常成熟的回答。
二、历史:从进程到线程,再到 Runtime Scheduler
理解 Go Runtime,不能从 Goroutine 开始。因为Goroutine并不是凭空出现的,它实际上是整个并发模型长期演化的结果。
1. 最早:一个进程干所有事情
早期程序的模型很简单:
Process
|
+--- Code
|
+--- Stack
|
+--- Data
一个进程运行一个执行流。如果想同时执行两个任务,就需要:
Process A
Process B
这就是多进程模型。优点很明显:进程之间相对隔离。
但问题也很明显:创建进程成本高,进程之间通信复杂,上下文切换也需要操作系统参与。于是,线程出现了。
三、线程模型:比进程轻,但仍然不够
线程让多个执行流可以共享一个进程地址空间:
Process
│
├── Thread A
├── Thread B
├── Thread C
└── Thread D
这已经比多进程模型灵活很多。但它仍然存在一个根本限制:
线程是操作系统调度的执行实体。
当应用拥有大量任务时:
任务1 → Thread 1
任务2 → Thread 2
任务3 → Thread 3
...
任务1000000 → Thread 1000000
这条路走不通。因为线程本身也需要资源,包括:
线程栈
线程控制块
调度开销
上下文切换
内核管理
于是工程界开始寻找新的方法:不要让每个任务直接对应一个 OS Thread。
而是:
大量任务
↓
Runtime Scheduler
↓
少量 OS Thread
↓
CPU
这一步非常重要。因为它意味着:
任务不再直接由操作系统管理,而是先进入语言 Runtime,由 Runtime 决定什么时候、在哪个线程上执行。
Go 的 Goroutine 就建立在这个思想之上。
四、Go真正解决的问题:任务和执行资源解耦
我们写:
go work()
表面看只是创建一个 Goroutine。实际上发生的是:
Goroutine
↓
进入 Runtime
↓
进入可运行队列
↓
Scheduler寻找执行机会
↓
分配到P
↓
由M承载
↓
OS Thread
↓
CPU
所以:
go work()
绝对不能简单理解成:
“启动一个线程。”
更准确的理解是:
创建一个由 Go Runtime 管理的可调度执行单元。
Go Runtime 的 Scheduler 负责把 ready-to-run 的 Goroutine 分配到 worker threads 上;当前 runtime 源码的 findRunnable 会综合考虑本地队列、全局队列、从其他 P 窃取任务以及网络轮询器。
这就是 Go 并发模型最核心的思想:
G
↓
Scheduler
↓
M
↓
CPU
但这里还缺了一个关键角色:P。
五、G、M、P到底是什么?
很多 Go 教程会把 GMP 讲成三个字母。真正理解 Runtime,必须把它看成三个不同层次的东西。
G:任务本身
G 可以理解为:
Goroutine 的 Runtime 表示。
一个 G 至少需要保存:
自己的栈
执行状态
程序计数器
调度信息
关联的 M
Runtime 中的 g 结构确实包含栈信息、调度状态以及当前关联的 m 等字段。
所以 G 本质上是:
“我要执行什么”
而不是:
“CPU现在就执行我”
这是第一层解耦。
六、M:真正站在操作系统里的执行线程
M 可以理解为:
Machine。
它最终对应一个操作系统线程。所以:
G
是任务。而:
M
是执行载体。Runtime 中的 m 结构包含调度栈 g0 等与线程执行有关的状态。
因此:
G ≠ M
这是理解 Go Runtime 最容易犯的第一个错误。
七、P:真正把G和M组织起来的关键
P 常被理解为 Processor。但不要把 P 简单理解成:
CPU核心。
P 更接近:
Runtime 调度这个执行上下文所需要的一组逻辑资源。
P 里面拥有本地运行队列等状态。也就是说:
P
|
+--- Local Run Queue
+--- Scheduler State
+--- Allocation State
+--- 其他Runtime资源
所以现在可以看到:
G = 我要做什么
M = 谁来真正执行
P = Runtime给执行过程提供什么调度上下文
于是:
Runtime
|
+-----+-----+
| | |
P P P
| | |
G G G
|
M
|
OS Thread
这就是 GMP。但 GMP 最重要的并不是三个结构。真正重要的是:
它把“任务”“调度资源”“操作系统执行线程”分成了三个层次。
八、为什么一定要有P?
这是理解 Go Scheduler 的关键。假设没有 P,所有 G 都直接扔进一个全局队列:
Global Queue
/ / | \ \
G1 G2 G3 G4 G5
所有 M 都来拿任务。问题出现了:
M1 ─┐
M2 ─┼──> Global Queue
M3 ─┤
M4 ─┘
所有线程都争抢同一个共享结构。于是:
锁
↓
竞争
↓
缓存失效
↓
同步成本
↓
性能下降
P 的出现,就是为了让任务尽可能先在本地解决。于是变成:
P0 → G1 G2 G3
P1 → G4 G5
P2 → G6 G7 G8
Scheduler 优先处理本地工作。只有本地没有足够工作时,才进一步考虑全局队列、偷取其他 P 的任务和网络轮询器。当前 Runtime 的 findRunnable 源码明确把这些来源纳入调度寻找过程。
这就是一个非常典型的系统设计思想:
局部性优先,全局协调兜底。
九、Local Run Queue:为什么任务首先放在本地
一个 P 拥有自己的 Runnable Queue:
P0
┌─────────────────────┐
│ G1 G2 G3 G4 G5 │
└─────────────────────┘
当某个 G 创建新的 G 时,新任务通常可以进入当前 P 的本地队列。这有两个好处。
第一:减少全局锁竞争。
第二:提高 CPU Cache 局部性。
也就是说:
本地队列
↓
少共享
↓
少同步
↓
更好的缓存行为
↓
更好的吞吐
这不是 Go 独有的设计思想。现代高性能调度器、任务系统、线程池以及很多并行计算系统,都非常依赖这种:
Local First
原则。
十、但是本地队列会产生新的问题
假设:
P0:
G1 G2 G3 G4 G5 G6 G7 G8 G9
P1:
空
P2:
空
如果只有 P0 有任务:
CPU 1 很忙
CPU 2 空闲
CPU 3 空闲
系统依然没有充分利用 CPU。因此 Runtime 必须解决:
如何让空闲的 P 帮助忙碌的 P?
答案:Work Stealing。
十一、Work Stealing:让空闲CPU主动找活
假设:
P0
├── G1
├── G2
├── G3
├── G4
├── G5
├── G6
├── G7
└── G8
而:
P1 = 空
那么 P1 可以尝试从 P0 偷任务。逻辑类似:
P0
├── G1
├── G2
├── G3
├── G4
└── G5
↓ steal
P1
├── G6
├── G7
└── G8
Runtime 源码里的 runqsteal 就直接实现了这种机制:从另一个 P 的本地 runnable queue 中取得一部分任务,并放入自己的本地队列。
注意:它不是:
“大家都访问一个共享队列。”
而是:
每个 P 默认管理自己的工作,只有需要时才主动从别的 P 获取工作。
这是一种非常重要的并行系统思想。
十二、为什么不是“所有任务都放全局队列”?
因为全局队列虽然简单,但扩展性非常差。想象:
10000 个 G
100 个 M
全部争抢一个中心结构:
Global Queue
/ / / / | \ \ \ \
M M M M M M M
这意味着:
共享状态
↓
同步
↓
竞争
↓
Cache Line争夺
↓
扩展性下降
而 GMP 更接近:
P0 → Local Queue
P1 → Local Queue
P2 → Local Queue
P3 → Local Queue
必要时
↓
Work Stealing
这实际上是在用:
更多局部状态,换更少的全局同步。
这也是高性能系统非常常见的设计。
十三、Scheduler并不是一直疯狂抢任务
真正的调度器最重要的能力之一,是:
知道什么时候应该运行,什么时候应该停止,什么时候应该休眠。
当前 Runtime 的 schedule() 被描述为一次调度轮次,它会寻找可运行的 G 并执行;寻找任务的过程还会综合本地队列、全局队列、work stealing 以及 network poll 等来源。
所以 Scheduler 实际上在不断回答:
有没有本地任务?
↓
有没有全局任务?
↓
别的 P 有没有工作?
↓
网络有没有事件?
↓
Timer有没有到期?
↓
GC有没有需要执行的工作?
↓
没有的话,我应该继续占CPU,
还是进入休眠?
这个问题非常重要。因为:
高性能调度器不是“尽可能多执行”,而是“尽可能少浪费”。
十四、网络IO:为什么一个G等待网络,不会让M一起睡死?
这是 Go Runtime 很重要的一层设计。假设:
data, err := conn.Read(buf)
从程序员视角:
Read
↓
等待
但 Runtime 不应该把整个 OS Thread 一起堵死。Go Runtime 集成了 network poller。
在 Unix 系统上,它会与 epoll、kqueue 等平台机制结合;Runtime 的 netpoll.go 明确将网络轮询器设计为与平台实现对接,并负责返回已经就绪的 Goroutine。
于是整个过程可以理解为:
G
↓
等待网络
↓
进入等待状态
M
↓
不必陪着G一起等待
↓
继续寻找其他G
↓
执行其他任务
网络事件发生:
Kernel
↓
netpoll
↓
G ready
↓
Scheduler
↓
重新执行G
这就是为什么 Go 能够用非常简单的网络 API,承载大量 IO 并发。
十五、这也是 Go 并发模型非常漂亮的地方
程序员写:
go handle(conn)
然后:
conn.Read(...)
看起来像同步代码。但底层却可能是:
G
↓
Runtime
↓
网络轮询器
↓
OS Kernel
↓
epoll/kqueue/Windows mechanism
也就是说:
Go 把复杂性从业务代码转移到了 Runtime。
这不是“隐藏复杂度”那么简单。它实际上是在做:
一次性构建一个复杂的执行基础设施,然后让大量业务程序复用。
十六、真正麻烦的问题:如果一个G一直运行怎么办?
假设:
for {
calculate()
}
这个 Goroutine 一直占着 CPU。如果 Runtime 完全依赖:
G主动让出CPU
那么就可能出现:
G1
██████████████████████████████
G2
等待
G3
等待
G4
等待
这会造成严重的问题。所以现代 Go Runtime 必须能够:
在合适的时候强制让出执行权。
这就是:Preemption。
十七、抢占:Runtime开始控制执行时间
抢占的思想是:
G正在执行
↓
Runtime判断该让了
↓
保存G状态
↓
切换到其他可运行G
最终:
G1
↓
运行
↓
被抢占
↓
G2
↓
运行
↓
G3
这里必须理解一个重要概念:Go Scheduler 并不等于“每个 Goroutine 跑完再调下一个”。
它是一套真正的调度系统。Runtime 会追踪 Goroutine 的运行状态,并利用抢占机制防止某些任务无限期垄断执行资源。
十八、为什么抢占如此重要?
因为 Goroutine 数量可能远大于 CPU 数量:
G = 1,000,000
CPU = 16
那么 Scheduler 必须不断做:
G
↓
选择
↓
执行
↓
切换
↓
选择
↓
执行
本质上是:
把少量 CPU 时间切成大量可管理的执行机会。
这就是 Runtime Scheduler 的意义。
十九、但Scheduler不能只关心G
如果只考虑业务 Goroutine:
G1
G2
G3
...
Runtime 仍然无法成为一个完整系统。因为它还有很多后台工作:
Timer
Network Poll
GC
Finalizer
Trace
System monitor
因此 Scheduler 其实是整个 Runtime 的总调度中心之一。它必须同时协调:
业务Goroutine
+
Runtime后台任务
+
网络事件
+
定时器
+
GC
所以:
Go Runtime Scheduler 从来不只是一个“任务队列”。
它实际上是 Runtime 内部整个并发资源的协调器。
二十、GC也是Runtime并发设计的一部分
很多人学习 Go GC 时,会把 GC 和 Goroutine 完全分开。实际上二者高度相关。
因为 Garbage Collector 本身也是并发工作的。当前 Go Runtime 的 GC 是并发标记清扫(concurrent mark and sweep),GC 线程可以并行执行;正常情况下大量工作与应用程序执行并行进行。
这意味着:
业务G
业务G
业务G
+
GC Worker
↓
共享CPU资源
所以 Scheduler 必须解决一个问题:
什么时候让业务运行,什么时候让 GC 工作?
这就是 Runtime 的第二层调度。
二十一、Go 1.26:GC设计又进一步变化
到了 Go 1.26,Green Tea GC 已经从 Go 1.25 的实验性实现变成默认 GC。官方文档明确指出,它通过更好的局部性和 CPU 可扩展性改善小对象的标记与扫描。
Green Tea 最值得理解的,不是“GC变快了”。而是:
Runtime开始更加主动地利用现代 CPU 的缓存和并行能力。
它的核心思路之一,是延迟对象扫描,在相同 span 中积累待扫描对象,然后批量扫描,从而改善局部性并增加预取机会。
这意味着:
过去:
对象
↓
对象
↓
对象
↓
对象
现在更加关注:
Span / Page级别
↓
批量扫描
↓
更好的Locality
↓
更好的CPU利用
这和前面的 Scheduler 思想其实是一致的:
尽可能利用局部性,减少昂贵的全局协调。
二十二、Scheduler与GC其实共享同一种思想
看到这里,可以发现一个很有意思的事情。
Scheduler:
Local Run Queue
↓
Work Stealing
↓
减少全局竞争
Green Tea GC:
Span Locality
↓
Batch Scan
↓
减少元数据访问
↓
提高Cache Locality
二者表面完全不同:
Scheduler
GC
但底层思想高度一致:
把工作尽可能组织在局部范围内,再进行必要的全局协调。
这是现代 CPU 时代非常重要的系统设计思想。
二十三、真正的Runtime优化,已经进入CPU微架构层面
当系统发展到今天:
CPU
↓
L1
↓
L2
↓
L3
↓
Memory
性能已经不只是:
算法复杂度是多少?
还包括:
Cache Hit
Cache Miss
Memory Latency
False Sharing
Lock Contention
Branch Prediction
Memory Bandwidth
所以一个现代 Runtime 必须同时考虑:
语言模型
+
调度模型
+
操作系统
+
CPU Cache
+
内存系统
Go Runtime 的很多设计,都可以从这一层理解。
二十四、为什么P的设计对Cache也有意义?
如果所有任务状态都存在全局共享结构中:
CPU 0
CPU 1
CPU 2
CPU 3
↓
Shared Queue
多个 CPU 会频繁修改相同数据。这意味着:
Cache Line
↓
Core之间同步
↓
一致性流量
↓
成本增加
而本地队列的设计允许很多操作先在:
P0 Local Queue
内部完成。于是:
局部访问
>
共享访问
这就是为什么:
Scheduler设计与CPU Cache行为是联系在一起的。
二十五、为什么Go不直接把调度交给操作系统?
因为操作系统只知道:
Thread
但它不知道:
Goroutine
Channel
Context
Timer
Netpoll
GC Worker
操作系统看到的是:
Thread 1
Thread 2
Thread 3
而 Go Runtime 看到的是:
G1
G2
G3
G4
...
G100000
只有 Runtime 最清楚:
谁正在等待
谁马上可以继续
谁正在做IO
谁可以被抢占
谁属于哪个P
哪个P没有工作
网络有没有就绪
所以:
Runtime Scheduler是站在语言语义这一层做调度。
这就是它存在的真正价值。
二十六、Runtime其实建立了三层调度体系
把整个 Go Runtime 看成一张图:
Go Program
│
▼
Goroutine Scheduler
│
┌─────────┴─────────┐
│ │
CPU Work IO Work
│ │
▼ ▼
Local RunQ Netpoll
│ │
└─────────┬─────────┘
▼
G
│
▼
M
│
▼
OS Thread
│
▼
CPU
而另一条线是:
Garbage Collector
│
┌────────┴────────┐
│ │
GC Worker Mutator Assist
│ │
└────────┬────────┘
▼
CPU Time
因此 Runtime 实际上一直在协调:
业务工作
+
网络工作
+
Runtime工作
+
GC工作
这才是完整的 Go 并发设计。
二十七、一个HTTP请求到底经历了什么?
把这些知识全部串起来。假设:
http.HandleFunc("/", handler)
用户发来一个请求。一个简化后的路径可以理解为:
Client
│
▼
Socket
│
▼
Kernel
│
▼
Network Poller
│
▼
Runnable Goroutine
│
▼
Scheduler
│
▼
P
│
▼
M
│
▼
OS Thread
│
▼
CPU
│
▼
handler()
如果 handler 又等待网络:
handler
│
▼
wait
│
▼
G blocked
│
└─────────────┐
│
M继续执行其他G
│
▼
G2 G3 G4...
等网络准备好:
Kernel
│
▼
netpoll
│
▼
G ready
│
▼
Scheduler
│
▼
重新执行
所以你写出的代码:
data, _ := conn.Read(buf)
背后实际上隐藏了整个系统:
用户代码
Runtime
Scheduler
Netpoll
OS
CPU
二十八、为什么Go代码看起来这么简单?
因为 Go 选择了一个非常激进的工程策略:
把复杂度集中到 Runtime。
程序员看到:
go func() {
work()
}()
就可以开始写业务。不用自己实现:
线程池
任务调度器
IO多路复用器
协作式调度
抢占
大量线程管理
网络唤醒
这些东西已经在 Runtime 里了。因此 Go 的“简单”从来不是:
Runtime 很简单。
恰恰相反。真正的情况是:
Runtime非常复杂,因此业务代码才可以简单。
二十九、Go的代价是什么?
任何设计都不是免费的。Go Runtime 把大量复杂度放进运行时,也意味着:
Runtime本身复杂
例如:
Scheduler
Netpoll
GC
Allocator
Timer
Stack Growth
Preemption
cgo
Signals
这些都需要与操作系统协调。所以 Go 的工程哲学并不是:
“消灭复杂度。”
而是:
把复杂度放在最值得集中维护的位置。
一次实现,所有 Go 程序受益。这是非常典型的工程化思路。
三十、Go与C、C++、Rust的区别,到底在哪里?
到了这个阶段,再去比较语言,会容易得多。
C
C让你最大程度接近机器:
CPU
Memory
Syscall
Thread
优点:
控制能力
性能
可预测性
代价:
调度
内存管理
并发模型
网络IO
很多东西需要开发者或者框架自己解决。
C++
C++提供更丰富的抽象能力。但自由度非常高。你可以:
Thread
Async
Coroutine
Thread Pool
Reactor
Actor
组合出自己的系统。这非常强大。同时,也意味着工程设计空间很大。
Rust
Rust的重点非常不同:
Memory Safety
Ownership
Zero-cost Abstraction
它更多把大量正确性问题交给:
Compiler
而 Go 则更倾向于:
Compiler
+
Runtime
+
Simple Language
共同构建整个工程环境。
Go
Go 最大的特点不是某一个单独的技术。而是:
简单语言
+
Runtime
+
GC
+
Scheduler
+
标准库
+
工具链
形成了一整套完整工程系统。所以:
Go真正的竞争力,从来不是 Goroutine 一个功能。
而是一整个 Runtime 驱动的工程模型。
三十一、一个容易被误解的问题:Goroutine是不是越多越好?
当然不是。例如:
for i := 0; i < 10000000; i++ {
go work()
}
“能创建很多 Goroutine”和“系统可以高效处理很多工作”完全是两回事。真实成本仍然存在:
Goroutine
↓
Stack
↓
Scheduling
↓
GC
↓
Memory
↓
Cache
↓
Synchronization
而且:
一百万个等待中的 Goroutine,和一百万个同时进行 CPU 密集计算的 Goroutine,是两个完全不同的问题。
所以“百万 Goroutine”从来不应该被理解为:
CPU真的同时执行一百万个任务。
真正发生的是:
大量G
↓
少量P
↓
有限数量M
↓
有限CPU
Runtime做的是:
在巨大的任务集合与有限的硬件执行资源之间进行调度。
三十二、CPU密集和IO密集,为什么表现完全不同?
假设:
IO密集:
G1 等网络
G2 等网络
G3 等数据库
G4 等磁盘
G5 正在计算
Runtime 可以利用大量等待时间。因此:
CPU利用率
↑
并发效率
↑
但是如果:
G1 → CPU
G2 → CPU
G3 → CPU
...
G100000 → CPU
那么最终瓶颈就变成:
CPU
Scheduler不可能突破物理 CPU 的计算能力。因此:
并发解决的是任务组织问题,不等于凭空增加计算能力。
三十三、真正应该学习的不是GMP,而是GMP背后的思想
很多人学完 GMP 后,只记住:
G = Goroutine
M = Machine
P = Processor
然后结束。其实这几乎没有真正学会。真正值得带走的是下面这几条设计原则。
原则一:任务和执行资源解耦
Task
≠
Thread
这是 Goroutine 的基础。
原则二:局部优先
Local Queue
>
Global Queue
尽量避免所有线程争抢同一个共享结构。
原则三:不工作就不要浪费CPU
No Work
↓
Park
原则四:有空闲CPU,就主动寻找工作
Idle P
↓
Steal
原则五:IO等待不应该拖垮执行线程
G blocked
↓
M continues
原则六:长时间运行的任务必须可被控制
Long-running G
↓
Preemption
原则七:Runtime工作与业务工作必须共同调度
Application
+
GC
+
Netpoll
+
Timer
+
Runtime
三十四、从Runtime看Go,才能真正理解“轻量”
“Goroutine轻量”这句话非常容易被误解。轻量不是因为:
Goroutine = Magic
而是因为 Go 做了很多工程工作:
小栈
+
动态栈增长
+
用户态调度
+
M:N执行模型
+
本地运行队列
+
Work Stealing
+
Netpoll
+
抢占
+
GC
这些机制共同作用。所以:
Goroutine的轻量,是Runtime工程长期积累的结果。
三十五、Runtime最终做的是一场“资源分配游戏”
把所有东西放到一起。Go程序拥有:
大量Goroutine
但是硬件只有:
有限CPU
有限内存
有限Cache
有限Kernel资源
Runtime必须不断解决:
谁运行?
在哪里运行?
运行多久?
等待时怎么办?
网络好了谁来处理?
GC什么时候运行?
CPU空了怎么办?
任务太多怎么办?
于是 Runtime 形成了:
Go Runtime
│
┌────────────┼────────────┐
│ │ │
Scheduler Netpoll GC
│ │ │
└────────────┼────────────┘
│
GMP
│
OS Thread
│
CPU
这已经不是一个“语言特性”。它实际上是一台:
运行 Go 程序的虚拟执行机器。
三十六、从这个角度再理解“Go是一门系统语言”
Go并不是传统意义上那种:
C
C++
Rust
式的系统语言。因为它主动隐藏了很多底层细节。但它又深入到了:
Scheduler
GC
Networking
Memory
Thread
Syscall
CPU
因此更准确地说:
Go把系统编程的复杂性向下吸收到了 Runtime,再把更干净的模型暴露给应用开发者。
这就是 Go 在服务器和云原生领域非常重要的原因之一。
三十七、Go 1.26.5时代,Runtime还在继续进化
需要特别注意版本。这篇文章以 Go 1.26.5 为基准。
Go 官方发布记录显示,Go 1.26.5 于 2026 年 7 月 7 日发布,该版本包含 Runtime、编译器、Go 命令以及 net、os、syscall 等方面的 bug 修复和安全更新。
Go 1.26 本身还有一个非常重要的 Runtime 变化:Green Tea GC 已经成为默认 GC。
这说明 Runtime 并没有停止发展。恰恰相反:
Goroutine
↓
Scheduler
↓
Netpoll
↓
GC
↓
CPU locality
↓
SIMD
Go Runtime 正在越来越深入现代硬件的执行模型。这也是为什么研究 Runtime 不能只停留在:
“GMP是什么?”
而应该继续问:
Runtime正在如何适应今天的CPU、内存系统和服务器规模?
三十八、真正的工程问题已经发生变化
早期程序设计关注:
如何让程序运行?
后来关注:
如何让程序更快?
再后来关注:
如何让程序并发?
而今天的大规模服务更关心:
如何让一千万任务,
在有限CPU下,
保持吞吐,
保持低延迟,
保持可预测,
同时还不把系统搞崩?
这个问题已经不属于单一语言。它属于:
Language
Runtime
OS
CPU
Network
Memory
Distributed System
的交汇处。而 Go Runtime,恰好站在这个交汇点上。
三十九、源码应该怎么读?
如果你现在第一次真正开始看 Go Runtime,不建议直接从几十万行源码里乱钻。应该建立一条路线:
runtime2.go
↓
理解 G / M / P
↓
proc.go
↓
理解 Scheduler
↓
runq*
↓
理解 Local Queue
↓
runqsteal
↓
理解 Work Stealing
↓
netpoll.go
↓
理解 IO如何进入Scheduler
↓
mgc.go
↓
理解GC如何参与Runtime
其中 proc.go 可以说是 Scheduler 的核心入口之一。当前源码中的 findRunnable 会寻找可以运行的 Goroutine,而 schedule 负责进行调度轮次;本地队列、全局队列、work stealing、network poll 等机制都在这一调度体系中协同工作。
这时候你再去看:
gopark()
goready()
schedule()
findRunnable()
runqget()
runqput()
runqsteal()
才不会只是“认识几个函数名”。你会开始看到:
Scheduler正在解决什么问题。
四十、性能分析:Runtime到底优化了什么?
可以把性能拆成四层。
第一层:算法成本
O(n)
O(log n)
O(1)
这是最基本的一层。
第二层:调度成本
包括:
Context Switch
Scheduler
Queue 操作
Wakeup
Preemption
第三层:同步成本
例如:
Mutex
Atomic
CAS
Lock Contention
第四层:硬件成本
最终来到:
Cache Miss
Memory Latency
Branch Prediction
Memory Bandwidth
一个成熟 Runtime 必须同时面对这四层。因此:
Runtime优化不是“把代码写得更快”,而是让整个任务执行系统减少浪费。
四十一、一个非常重要的认知变化
当你第一次学习 Go 时:
go func() {}
只是一个语法。学完今天之后,再看到:
go func() {}
应该想到的是:
创建 G
↓
进入 Scheduler
↓
进入 Run Queue
↓
由 P 管理
↓
由 M 执行
↓
运行在 OS Thread
↓
CPU执行
如果需要 IO:
G
↓
等待
↓
Netpoll
↓
Ready
↓
Scheduler重新调度
如果运行时间过长:
G
↓
Preemption
↓
Scheduler
↓
其他G
如果发生 GC:
Runtime
↓
GC Worker
↓
与应用并发协作
这时:Go 的 go 关键字才真正从“语法”变成了“系统”。
四十二、最终的系统图
把 Day 46 到 Day 60 学过的东西全部串起来,大致就是:
Go Application
│
▼
Goroutine (G)
│
┌──────────┼──────────┐
│ │ │
CPU IO Timer
│ │ │
▼ ▼ ▼
Scheduler Netpoll Runtime Timer
│ │ │
└──────────┼──────────┘
▼
P
│
Local Run Queue
│
┌────────┴────────┐
│ │
Local RunQ Work Stealing
│ │
└────────┬────────┘
▼
M
│
OS Thread
│
▼
CPU
+
Garbage Collector
│
Concurrent Runtime
这就是 Go Runtime 并发设计最核心的全景图。
四十三、真正值得记住的只有一句话
Go Runtime 的价值,不是让:
“一个 Goroutine 跑得更快。”
而是让:
成千上万、数十万甚至更多的并发任务,能够在有限的硬件资源之上,以相对简单的编程模型被持续组织、调度和执行。
Goroutine只是表面。GMP只是结构。Work Stealing只是算法之一。Netpoll只是网络机制。GC只是内存管理。
真正把这些东西连接起来的,是一个统一的思想:
把任务与执行资源分离,把局部工作与全局协调分离,把等待与执行分离,再让 Runtime 负责管理整个系统。
这就是 Go Runtime 并发设计的核心。
四十四、开放式思考:Runtime的边界在哪里?
当 CPU 从:
单核
进入:
多核
再进入:
几十核
上百核
同时:
L1
L2
L3
NUMA
SIMD
Memory Bandwidth
越来越复杂。Runtime 未来面对的问题,也不会只是:
“如何调度 Goroutine?”
而可能逐渐变成:
如何让语言 Runtime 真正理解现代硬件?
任务在哪里运行?数据在哪里?Cache在哪里?哪个 CPU Core 更合适?什么时候迁移任务反而更贵?GC 应该如何利用向量计算?网络数据应该如何减少 CPU 复制?
当一个服务器拥有几百个逻辑 CPU 时:
传统的“任务 → 调度 → 执行”模型还能不能继续线性扩展?
这些问题,已经不仅仅是 Go 的问题。它们是下一代 Runtime、操作系统和计算机体系结构共同面对的问题。
而这也许正是阅读 Go Runtime 最有价值的地方:你以为自己在研究一门语言,最后真正研究的,却是——一台计算机究竟应该如何运行程序。
更多 Go Runtime 系列深度内容,可访问 云栈社区。