找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

6032

积分

0

好友

765

主题
发表于 6 天前 | 查看: 26| 回复: 0

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 命令以及 netossyscall 等方面的 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 系列深度内容,可访问 云栈社区




上一篇:百万Goroutine全靠GMP调度?Go 1.26 Runtime系统能力拆解
下一篇:浏览器到服务器全链路拆解:DNS、TCP、TLS、HTTP/3 与 Go 网络编程
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-9-17 17:24 , Processed in 0.739971 second(s), 39 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表