返回首页

Golang高级:3.1 Go 运行时与调度器

并发是 Go 最具辨识度的能力之一,但如果只停留在 go func()channelsync.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 持有本地运行队列、调度缓存、定时器等资源

这套设计至少解决了三个问题:

  1. 限制并行度:即使 Goroutine 数量极大,同时并行执行 Go 代码的“槽位”仍由 P 控制。
  2. 减少全局竞争:每个 P 有本地运行队列,绝大多数取任务动作不必竞争全局锁。
  3. 应对线程阻塞:当某个 M 陷入系统调用,P 可以被摘下来交给别的 M 继续使用。

1.3 调度器如何选取下一个 Goroutine

在简化模型下,调度器大致遵循以下优先级:

  1. 先看当前 P 的本地运行队列
  2. 本地队列为空时,再尝试全局运行队列
  3. 如果还没有任务,就尝试从其他 P 偷取一半可运行任务
  4. 仍然没有时,线程可能进入休眠,等待新的任务到来

这里有两个值得注意的点:

  • 本地队列优先:这是提高 cache locality、减少锁竞争的重要手段。
  • 工作窃取(work stealing):这是 Go 能在多核场景下自动做负载均衡的关键机制之一。

1.4 GOMAXPROCSP 的关系

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 长时间运行纯计算代码,且几乎不发生函数调用或阻塞,那么它可能长时间占住执行权。

从效果上看,这很像一种“协作式调度”:只在适合切换的位置让出执行机会。

这种模式有两个明显问题:

  1. 长时间运行的 CPU 密集型 Goroutine 可能导致其他 Goroutine 延迟变大。
  2. 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 工程实践中的启示

  1. 不要依赖 runtime.Gosched() 作为常规设计手段。现代 Go 大多不需要靠手动让出来维持基本公平性。
  2. CPU 密集型任务仍应考虑拆分粒度。抢占能改善延迟,但不能替代合理的任务切分。
  3. 性能分析要关注 runnable goroutine。如果很多 Goroutine 处于 runnable 状态但迟迟得不到执行,说明调度压力可能已经很高。
  4. 理解版本差异。不同 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 的原因主要有:

  1. 减少 hot split 问题:避免频繁在边界处分裂栈段。
  2. 内存局部性更好:连续内存对访问模式更友好。
  3. 实现与维护更统一:栈增长逻辑更清晰。
  4. 更符合现代 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 进入系统调用时,运行时通常做什么

在简化理解下,大致过程如下:

  1. G 在某个 M 上运行,并且该 M 绑定了一个 P
  2. G 即将进入可能阻塞的系统调用
  3. 运行时意识到当前 M 可能长时间被卡住
  4. 会尽量把 P 从这个 M 身上摘下来
  5. 让别的 M 绑定该 P,继续执行其他可运行的 G
  6. 原来的系统调用返回后,再尝试重新接入调度体系

这正是 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 工程实践建议

  1. 不要只看线程数判断性能。线程数上涨可能意味着阻塞 syscall 变多。
  2. 对外部调用务必设置超时。调度器能兜住“别的任务继续跑”,但兜不住“你的请求迟迟不返回”。
  3. 排查卡顿时要区分 CPU 饱和 vs syscall 阻塞。这两类问题的优化策略完全不同。
  4. 理解 goroutine 状态比只看 qps 更重要。运行中、可运行、系统调用中、阻塞中,含义完全不同。

五、把四个知识点串起来理解

到这里,我们可以把这篇文章的四个主题串成一条完整链路:

  1. GMP 模型回答的是:Go 如何把海量 Goroutine 映射到有限线程上执行。
  2. 抢占式调度演进回答的是:长时间运行的 Goroutine 为什么不会轻易独占执行权。
  3. 栈管理演进回答的是:为什么 Goroutine 能以很低成本批量创建并按需扩展。
  4. 系统调用影响回答的是:线程被阻塞时,调度器如何尽量保证其他任务继续推进。

如果你把这四点放在一起看,就会发现 Go 运行时设计的核心目标其实非常一致:

  • 让并发任务足够轻
  • 让调度足够灵活
  • 让阻塞的影响尽量局部化
  • 让多核资源得到更高效利用

这也是为什么 Go 很适合构建高并发服务:它不是只有“写并发很方便”,而是从语言运行时层面就围绕并发执行做了成体系的设计。

六、小结

理解 Go 运行时与调度器之后,你对很多表面现象就不会只停留在“经验层面”:

  • 为什么 Goroutine 可以很多,但并行度依旧可控
  • 为什么现代 Go 对 CPU 密集型任务的调度公平性比早期更好
  • 为什么 Goroutine 的初始开销很低,却仍然能支持较深调用栈
  • 为什么一个阻塞的系统调用不一定会把整个程序都拖死

从学习路径上说,这一章是后续理解以下主题的重要前置基础:

  • GC 与 STW 的调度关系
  • netpoll 与网络模型
  • runtime / pprof 性能分析
  • Goroutine 泄漏与阻塞定位
  • 高并发服务中的延迟诊断

如果前两阶段的内容帮助你建立了“如何使用 Go 写并发代码”的能力,那么这一章更重要的价值,是帮助你建立“Go 为什么会这样运行”的底层认知。


📝 版权声明:本文为原创技术博客,转载请注明出处。

如文章中存在错误或不准确之处,欢迎在评论区指正,感谢您的阅读与支持!

上一篇

Golang进阶:2.8 Go 工具链

下一篇

Golang高级:3.2 内存管理与垃圾回收