并发是 Go 最具辨识度的能力之一,但如果只停留在 go func()、channel 和 sync.WaitGroup 这一层,实际上还没有真正理解 Go 为什么能把大量并发任务组织得相对高效。Go 的关键不只是语法层面支持并发,更重要的是它在运行时里内建了一套调度系统:用用户态调度器把海量 Goroutine 映射到有限的操作系统线程之上运行。
这一章我们聚焦 Go 运行时中的四个核心问题:
- GMP 模型到底如何分工,为什么需要
P - Go 调度器为什么要从协作式调度演进到抢占式调度
- Goroutine 栈为什么从分段栈演进到连续栈
- 当 Goroutine 进入系统调用时,调度器如何尽量避免整个程序“卡住”
如果你已经具备扎实的 Go 基础,那么这篇文章建议你重点关注“设计动机”和“运行时行为”两条主线。理解这些内容后,你会对性能分析、阻塞定位、goroutine 泄漏排查、调度延迟诊断有更稳的判断力。
一、GMP 模型详解:G、M、P 各自负责什么
Go 调度器最核心的抽象就是 GMP:
- G(Goroutine):待执行的协程任务
- M(Machine):真正执行代码的操作系统线程
- P(Processor):运行 Go 代码所需要的逻辑处理器与调度上下文
很多人第一次接触 GMP 时最困惑的问题通常是:既然已经有线程 M 了,为什么还要单独设计一个 P?
答案是:Go 运行时想把“线程”与“调度资源”拆开管理。
如果只有 G 和 M,那么所有可运行 Goroutine 的分发、缓存、窃取、定时器、本地队列等状态,都只能直接挂在线程上。这样一来,线程一旦因为系统调用阻塞,调度上下文也会被一起拖住,灵活性就会明显下降。
引入 P 之后,Go 可以把“谁在执行”和“谁负责调度”分开:
M负责真正跑代码P负责维护本地运行队列、缓存、调度状态G作为最小执行单元,被放入不同队列等待调度
1.1 GMP 三者关系的 ASCII 示意图
下面这个简化示意图可以帮助你快速建立整体认知:
+----------------------+
| Global Run Queue |
+----------+-----------+
|
+--------------+--------------+
| |
v v
+-------------+ +-------------+
| P0 | | P1 |
| local runq | | local runq |
+------+------+ +------+------+
| |
| bind | bind
v v
+---+---+ +---+---+
| M0 | | M1 |
| thread| | thread|
+---+---+ +---+---+
| |
v v
+---+---+ +---+---+
| G | | G |
|running | |running|
+--------+ +-------+
说明:
1. G 是任务,M 是线程,P 是执行 Go 代码必须持有的调度资源。
2. M 必须先绑定 P,才能运行 G。
3. 每个 P 有自己的本地队列,优先从本地队列取 G 执行。
4. 本地队列空了,会尝试从全局队列或其他 P “偷”任务。
1.2 为什么 P 是调度器的关键设计
P 可以理解为“可运行 Go 代码的许可证 + 本地调度上下文”。它不是 CPU 核心本身,但默认数量通常与 GOMAXPROCS 一致,因此可以把它看作 Go 运行时眼中的并行度上限。
| 角色 | 含义 | 核心职责 |
|---|---|---|
| G | Goroutine | 保存执行栈、程序计数器、状态等上下文 |
| M | Machine | 对应 OS 线程,真正执行指令 |
| P | Processor | 持有本地运行队列、调度缓存、定时器等资源 |
这套设计至少解决了三个问题:
- 限制并行度:即使 Goroutine 数量极大,同时并行执行 Go 代码的“槽位”仍由
P控制。 - 减少全局竞争:每个
P有本地运行队列,绝大多数取任务动作不必竞争全局锁。 - 应对线程阻塞:当某个
M陷入系统调用,P可以被摘下来交给别的M继续使用。
1.3 调度器如何选取下一个 Goroutine
在简化模型下,调度器大致遵循以下优先级:
- 先看当前
P的本地运行队列 - 本地队列为空时,再尝试全局运行队列
- 如果还没有任务,就尝试从其他
P偷取一半可运行任务 - 仍然没有时,线程可能进入休眠,等待新的任务到来
这里有两个值得注意的点:
- 本地队列优先:这是提高 cache locality、减少锁竞争的重要手段。
- 工作窃取(work stealing):这是 Go 能在多核场景下自动做负载均衡的关键机制之一。
1.4 GOMAXPROCS 与 P 的关系
GOMAXPROCS 决定的是最多有多少个 P 同时参与运行 Go 代码。这并不等于 Goroutine 数量,也不等于线程数量。
例如:
- 你可以创建 100 万个 Goroutine
- 但如果
GOMAXPROCS=4 - 那么同一时刻最多只有 4 个
P在并行执行 Go 代码
这也是为什么“Goroutine 很多”与“并行度很高”是两回事。大量 Goroutine 更多代表的是高并发任务组织能力,而不是无限制的并行执行能力。
1.5 示例:观察大量 Goroutine 被有限并行度调度
下面的代码把 GOMAXPROCS 设置为 2,然后启动多个任务。你会看到可以同时存在很多 Goroutine,但真正并行推进的执行资源是受限的。
package main
import (
"fmt"
"runtime"
"sync"
"time"
)
func worker(id int, wg *sync.WaitGroup) {
defer wg.Done()
for step := 1; step <= 3; step++ {
fmt.Printf("worker=%d step=%d running on logical slot\n", id, step)
time.Sleep(150 * time.Millisecond)
}
}
func main() {
runtime.GOMAXPROCS(2)
var wg sync.WaitGroup
for i := 1; i <= 6; i++ {
wg.Add(1)
go worker(i, &wg)
}
wg.Wait()
fmt.Println("all workers done")
}
这段代码不能直接把 GMP 内部状态打印出来,但它很好地说明了一个事实:Goroutine 数量与真正的并行执行槽位不是一回事。Go 调度器会把这些 Goroutine 放到有限的 P 上轮流执行。
1.6 从工程视角理解 GMP
理解 GMP 后,你对很多运行时现象就会更容易解释:
| 现象 | 底层原因 |
|---|---|
| Goroutine 数量远大于线程数 | G 由运行时复用映射到少量 M 上 |
| 某些任务执行延迟忽高忽低 | 调度、抢占、系统调用、GC 都可能影响可运行时机 |
| CPU 没打满但 Goroutine 很多 | 可能大量 G 处于阻塞态,或者 P 数不足 |
| 某个线程阻塞后程序仍能继续推进 | 调度器会尽量让 P 转交给其他 M |
一句话概括:GMP 的本质,是用用户态调度把“海量任务”和“有限线程”之间做高效映射。
二、调度器的抢占式调度演进
理解调度器,不仅要知道它“能调度”,还要知道它“什么时候切走当前 Goroutine”。这就涉及 Go 调度器的重要演进:从更偏协作式,到逐步增强抢占能力。
2.1 早期 Go 调度为什么更像协作式
在较早期的 Go 版本中,调度器虽然也会在一些点进行任务切换,但整体上更依赖显式的安全点,例如:
- channel 收发
- 锁竞争
- 系统调用
time.Sleep- 函数调用边界
- GC 相关安全点
这意味着:如果某个 Goroutine 长时间运行纯计算代码,且几乎不发生函数调用或阻塞,那么它可能长时间占住执行权。
从效果上看,这很像一种“协作式调度”:只在适合切换的位置让出执行机会。
这种模式有两个明显问题:
- 长时间运行的 CPU 密集型 Goroutine 可能导致其他 Goroutine 延迟变大。
- 在
GOMAXPROCS=1等场景下,更容易表现出“某个任务独占 CPU”的现象。
2.2 为什么 Go 需要更强的抢占能力
Go 的目标不是只跑吞吐型批处理,而是大量用于服务端、网络程序、RPC 框架、网关、任务系统等延迟敏感场景。
如果一个 Goroutine 因为纯计算长期不让出执行权,会直接影响:
- 定时器触发时机
- 网络事件处理延迟
- 其他 Goroutine 的调度公平性
- GC 停顿与安全点进入效率
所以,Go 调度器必须增强“即使你不主动配合,也能把你切下来”的能力,这就是抢占式调度演进的背景。
2.3 Go 1.14 之后的异步抢占
Go 在后续版本中逐步增强了抢占机制,尤其是 Go 1.14 开始引入更实用的异步抢占(asynchronous preemption)。你可以把它理解成:
- 调度器不再只依赖 Goroutine 主动走到某些“容易切换”的点
- 对长时间运行的 Goroutine,运行时会尝试发起抢占
- 从而显著改善调度公平性与延迟表现
当然,抢占并不是“随时随地、零成本、绝对即时”发生,它仍然要满足运行时安全条件。但和早期相比,现代 Go 对 CPU 密集型任务的调度已经健壮得多。
2.4 协作式与更强抢占能力的对比
| 维度 | 更偏协作式阶段 | 增强抢占后的现代 Go |
|---|---|---|
| 切换依赖 | 更依赖阻塞点、函数调用、安全点 | 可对长时间运行任务发起异步抢占 |
| CPU 密集型任务影响 | 更容易长期占住执行权 | 公平性明显改善 |
| 延迟敏感场景 | 更容易抖动 | 更稳定 |
| GC 协同 | 更依赖任务配合 | 进入安全点效率更高 |
2.5 示例:单核下观察 CPU 密集型 Goroutine 仍会被抢占
下面这个例子把 GOMAXPROCS 设置为 1,启动一个纯计算 Goroutine,再启动一个周期性打印的观察者 Goroutine。若调度器完全无法抢占,观察者就会很难及时得到执行机会;而在现代 Go 中,你会看到它仍然能持续输出。
package main
import (
"fmt"
"runtime"
"sync/atomic"
"time"
)
func busyLoop(stop *atomic.Bool) {
var x uint64
for !stop.Load() {
x++
if x%1_000_000_000 == 0 {
fmt.Println("busy loop milestone:", x)
}
}
fmt.Println("busy loop stopped at:", x)
}
func observer(stop *atomic.Bool) {
ticker := time.NewTicker(200 * time.Millisecond)
defer ticker.Stop()
for i := 1; i <= 8; i++ {
<-ticker.C
fmt.Printf("observer tick %d\n", i)
}
stop.Store(true)
}
func main() {
runtime.GOMAXPROCS(1)
var stop atomic.Bool
go busyLoop(&stop)
observer(&stop)
time.Sleep(300 * time.Millisecond)
}
运行这段代码时,通常会看到 observer tick 仍能持续打印出来。这说明即便有一个 Goroutine 正在执行持续的 CPU 计算,运行时也能够把执行权重新分配出去,而不是让它无限占住唯一的执行槽位。
2.6 抢占不是银弹:它解决公平性,不替代并发设计
这里要特别避免一个误区:调度器具备抢占能力,不等于你的并发设计就天然合理。
例如下面这些问题,抢占并不能根治:
- 任务本身没有取消机制
- 单个 Goroutine 内部包含极长的不可中断外部调用
- 锁粒度太大导致其他任务长期等待
- 大量无边界 Goroutine 把内存和调度队列压垮
换句话说,抢占式调度解决的是“调度公平性”和“长任务不主动让出时怎么办”,而不是替你完成系统级并发治理。
2.7 工程实践中的启示
- 不要依赖
runtime.Gosched()作为常规设计手段。现代 Go 大多不需要靠手动让出来维持基本公平性。 - CPU 密集型任务仍应考虑拆分粒度。抢占能改善延迟,但不能替代合理的任务切分。
- 性能分析要关注 runnable goroutine。如果很多 Goroutine 处于 runnable 状态但迟迟得不到执行,说明调度压力可能已经很高。
- 理解版本差异。不同 Go 版本的调度表现可能不同,线上分析要结合实际版本。
三、Goroutine 栈管理:分段栈 → 连续栈
Goroutine 之所以轻量,除了调度模型之外,另一个核心原因就是:它的栈不是像传统线程那样一开始就分配很大,而是从很小的初始栈开始,按需增长。
3.1 为什么栈设计对 Goroutine 如此关键
假设每个并发执行单元一开始都像传统线程那样保留几 MB 栈空间,那么成千上万个并发任务会立刻把内存吃爆。
Go 选择的方向是:
- 初始栈尽量小
- 需要时再增长
- 不需要时可以收缩或复用
这也是 Goroutine 能以很低成本批量创建的重要基础。
3.2 早期方案:分段栈(segmented stack)
Go 早期曾使用分段栈。它的大致思路是:
- 当前栈段空间不够时,再挂接一个新的栈段
- 看起来像一串链表式的栈块
- 每段空间独立分配,按需扩展
这个方案的优点很直观:按需增长,起步成本低。
但它也有明显问题,最典型的是所谓 hot split:
- 某些函数频繁进入栈扩容边界
- 不断触发分裂、回退、再分裂
- 带来额外开销和性能抖动
而且,分段栈在实现复杂度、回溯处理、性能稳定性上都不够理想。
3.3 现代方案:连续栈(contiguous stack)
后来 Go 运行时转向连续栈方案。它的核心思路是:
- 每个 Goroutine 仍从较小栈开始
- 当空间不足时,分配一块更大的连续内存
- 把原有栈内容整体拷贝过去
- 更新相关指针后继续执行
也就是说,现代 Go 并不是不断拼接很多小栈段,而是倾向于维护一块连续的栈空间。
3.4 连续栈为什么更适合 Go
| 方案 | 特点 | 主要问题/收益 |
|---|---|---|
| 分段栈 | 栈不足时追加新段 | 易出现 hot split,实现和性能表现更复杂 |
| 连续栈 | 栈不足时扩容并搬迁 | 拷贝有成本,但整体更稳定,局部性更好 |
连续栈更适合 Go 的原因主要有:
- 减少 hot split 问题:避免频繁在边界处分裂栈段。
- 内存局部性更好:连续内存对访问模式更友好。
- 实现与维护更统一:栈增长逻辑更清晰。
- 更符合现代 Go 的运行时需求:和 GC、栈扫描等机制更好协同。
3.5 栈增长并不是“无限免费”的
要注意,Goroutine 栈虽然可以动态增长,但这不代表它没有成本:
- 深递归仍然可能导致明显的栈扩容开销
- 大量在栈上放置大对象会增加栈复制成本
- 极端递归依然可能触发栈溢出
所以动态栈的意义是“按需增长”,不是“可以毫无节制地消耗栈”。
3.6 示例:通过深递归观察 Goroutine 栈会按需增长
下面这段代码在递归函数中放入一定大小的局部数组,以制造稳定的栈使用。它通常可以在一个初始栈很小的 Goroutine 中顺利完成深递归,从侧面说明 Go 的栈会按需增长。
package main
import "fmt"
func grow(depth, max int) int {
var padding [512]byte
padding[0] = byte(depth)
if depth == max {
return int(padding[0])
}
return int(padding[0]) + grow(depth+1, max)
}
func main() {
result := grow(0, 4000)
fmt.Println("recursion done, result:", result)
}
这段程序的重要意义不在于计算结果,而在于:grow 每一层调用都要消耗一定栈空间,但程序依然可以完成较深递归。这说明 Goroutine 使用的不是一块写死的超大固定栈,而是会随着调用深度逐步扩展。
3.7 用 ASCII 图理解“分段栈”和“连续栈”的差异
分段栈(早期)
+---------+ +---------+ +---------+
| segment1| -> | segment2| -> | segment3|
+---------+ +---------+ +---------+
连续栈(现代)
+---------------------------------------+
| one stack |
+---------------------------------------+
扩容时整体搬迁到更大空间
3.8 栈设计对工程实践的影响
理解栈管理后,你会更容易理解以下现象:
| 现象 | 解释 |
|---|---|
| Goroutine 可以非常多 | 初始栈很小,不像线程那样一开始就占大块内存 |
| 深递归会增加运行时开销 | 需要更多栈空间,可能触发扩容 |
| 栈上大对象并不总是“免费” | 栈复制与扫描成本会上升 |
| 调试栈相关问题要注意版本差异 | 不同版本在栈管理细节上可能有所不同 |
工程上最重要的结论是:Goroutine 的“轻”不是没有代价,而是把代价从“预先一次性支付”改成了“按需逐步支付”。
四、系统调用对调度的影响
在真正的生产环境里,Goroutine 不会只做纯计算,它们经常会进入:
- 文件读写
- 网络读写
- DNS 解析
- 进程调用
- 锁等待
- 阻塞式系统调用
而系统调用恰恰是理解调度器行为时最关键的现场之一。
4.1 为什么系统调用会影响调度
当 Goroutine 需要执行某些阻塞式系统调用时,底层通常要通过线程 M 进入内核态执行。如果这个调用长时间不返回,那么承载它的线程可能会被内核挂住。
如果 Go 没有 GMP 这种解耦设计,后果会很糟:
- 阻塞调用卡住线程
- 线程绑定的调度状态也一起被卡住
- 该线程原本负责推进的 Goroutine 全部被拖慢
但 Go 的运行时恰恰就是围绕这个问题做了专门设计。
4.2 Goroutine 进入系统调用时,运行时通常做什么
在简化理解下,大致过程如下:
G在某个M上运行,并且该M绑定了一个PG即将进入可能阻塞的系统调用- 运行时意识到当前
M可能长时间被卡住 - 会尽量把
P从这个M身上摘下来 - 让别的
M绑定该P,继续执行其他可运行的G - 原来的系统调用返回后,再尝试重新接入调度体系
这正是 P 存在价值最直观的体现之一:线程阻塞,不等于调度器整体阻塞。
4.3 系统调用对程序表现的直接影响
| 场景 | 可能表现 | 运行时应对 |
|---|---|---|
| 少量短系统调用 | 影响通常较小 | 正常调度切换 |
| 大量阻塞式系统调用 | 线程数可能上升 | 运行时可能创建/唤醒更多 M |
| 外部调用长期卡住 | goroutine 堆积、延迟上升 | 需要业务层超时与取消机制配合 |
| syscall 很多但 CPU 不高 | 常见于 IO/阻塞等待场景 | 要结合 goroutine 状态而非只看 CPU |
这里有一个非常重要的工程认知:Go 调度器只能尽量减少阻塞传播,但不能消灭慢系统调用本身。
例如,一个数据库查询卡了 30 秒,运行时也许能保证别的 Goroutine 继续跑,但这 30 秒的业务延迟依然真实存在。所以真正可靠的系统,必须把调度器能力和业务级超时控制结合起来。
4.4 示例:一个 Goroutine 阻塞在系统调用上,其他 Goroutine 仍能继续执行
下面这个例子通过 os.Pipe() 模拟阻塞读操作。一个 Goroutine 调用 Read 等待数据,另一个 Goroutine 继续定时输出。即使把 GOMAXPROCS 设为 1,你仍会看到程序整体在推进。
package main
import (
"fmt"
"os"
"runtime"
"time"
)
func main() {
runtime.GOMAXPROCS(1)
r, w, err := os.Pipe()
if err != nil {
panic(err)
}
defer r.Close()
defer w.Close()
go func() {
buf := make([]byte, 5)
fmt.Println("reader: start blocking read")
n, err := r.Read(buf)
fmt.Println("reader: read finished, n=", n, "err=", err, "data=", string(buf[:n]))
}()
go func() {
for i := 1; i <= 5; i++ {
fmt.Println("ticker goroutine tick", i)
time.Sleep(200 * time.Millisecond)
}
}()
time.Sleep(1 * time.Second)
fmt.Println("main: write data to pipe")
_, err = w.Write([]byte("hello"))
if err != nil {
panic(err)
}
time.Sleep(500 * time.Millisecond)
}
如果系统调用会把整个调度器都锁死,那么 reader 阻塞在 Read 后,另一个定时输出的 Goroutine 理论上很难继续推进。但实际上程序通常会持续打印 ticker goroutine tick,直到主 Goroutine 写入数据把 Read 唤醒。
这说明 Go 运行时会尽量把阻塞调用和其他可运行任务隔离开,而不是让一个被系统调用卡住的线程拖垮整个调度流程。
4.5 系统调用与网络模型的差别
还需要补充一个常见易混点:
- 阻塞式系统调用:线程可能真的被挂住
- 网络 IO(尤其是 Go 网络库常见路径):往往会结合 netpoll 机制,不一定等价于“每个连接都绑死一个阻塞线程”
也就是说,Go 运行时对不同类型的阻塞事件处理策略并不完全相同。网络程序之所以能承载高并发连接,除了 GMP 之外,还和网络轮询机制密切相关。
4.6 工程实践建议
- 不要只看线程数判断性能。线程数上涨可能意味着阻塞 syscall 变多。
- 对外部调用务必设置超时。调度器能兜住“别的任务继续跑”,但兜不住“你的请求迟迟不返回”。
- 排查卡顿时要区分 CPU 饱和 vs syscall 阻塞。这两类问题的优化策略完全不同。
- 理解 goroutine 状态比只看 qps 更重要。运行中、可运行、系统调用中、阻塞中,含义完全不同。
五、把四个知识点串起来理解
到这里,我们可以把这篇文章的四个主题串成一条完整链路:
- GMP 模型回答的是:Go 如何把海量 Goroutine 映射到有限线程上执行。
- 抢占式调度演进回答的是:长时间运行的 Goroutine 为什么不会轻易独占执行权。
- 栈管理演进回答的是:为什么 Goroutine 能以很低成本批量创建并按需扩展。
- 系统调用影响回答的是:线程被阻塞时,调度器如何尽量保证其他任务继续推进。
如果你把这四点放在一起看,就会发现 Go 运行时设计的核心目标其实非常一致:
- 让并发任务足够轻
- 让调度足够灵活
- 让阻塞的影响尽量局部化
- 让多核资源得到更高效利用
这也是为什么 Go 很适合构建高并发服务:它不是只有“写并发很方便”,而是从语言运行时层面就围绕并发执行做了成体系的设计。
六、小结
理解 Go 运行时与调度器之后,你对很多表面现象就不会只停留在“经验层面”:
- 为什么 Goroutine 可以很多,但并行度依旧可控
- 为什么现代 Go 对 CPU 密集型任务的调度公平性比早期更好
- 为什么 Goroutine 的初始开销很低,却仍然能支持较深调用栈
- 为什么一个阻塞的系统调用不一定会把整个程序都拖死
从学习路径上说,这一章是后续理解以下主题的重要前置基础:
- GC 与 STW 的调度关系
- netpoll 与网络模型
runtime/pprof性能分析- Goroutine 泄漏与阻塞定位
- 高并发服务中的延迟诊断
如果前两阶段的内容帮助你建立了“如何使用 Go 写并发代码”的能力,那么这一章更重要的价值,是帮助你建立“Go 为什么会这样运行”的底层认知。
📝 版权声明:本文为原创技术博客,转载请注明出处。
如文章中存在错误或不准确之处,欢迎在评论区指正,感谢您的阅读与支持!