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

4603

积分

0

好友

589

主题
发表于 2 小时前 | 查看: 5| 回复: 0

有一个数字,经常被用来描述 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 之间共同构成的执行世界。




上一篇:深入Go Race Detector:数据竞争检测原理与工程实践
下一篇:浏览器到服务器全链路拆解:DNS、TCP、TLS、HTTP/3 与 Go 网络编程
您需要登录后才可以回帖 登录 | 立即注册

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

GMT+8, 2026-9-11 23:51 , Processed in 0.405010 second(s), 41 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

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