跳到主要内容
约 16 分钟8 张卡

Go 调度器

Go 调度器是运行时中安排 goroutine 在线程上执行的部分。一个 goroutine 可以暂时等待,再恢复执行;承担它的线程也可以转去处理其他工作。调度器负责衔接这些变化,操作系统仍然负责调度实际的线程。

理解 GMP,重点是能解释一条工作为什么开始、为什么停下、接下来由谁继续执行。本文以 Go 1.22.12 为实现基线;队列容量、调度检查频率和抢占阈值都属于这个版本的实现,不是程序可以依赖的执行顺序保证。goroutine 的启动、等待和共享数据规则见 goroutine。

1. 面试核心​

先记住这几条,再读正文
  • G 表示 goroutine,M 表示操作系统线程,P 保存执行 Go 代码所需的调度和分配资源。执行普通 Go 代码需要把三者联系起来;它们不是固定的一对一关系。卡 · 三类对象
  • P 把执行资源与可能阻塞的 M 分开。每个 P 持有本地就绪队列等状态,减少所有任务争抢同一队列;一个 M 进入系统调用后,P 还能交给其他 M 使用。卡 · 为什么需要 P
  • 就绪不等于正在运行,等待中的 G 也不在就绪队列中反复排队。Go 1.22.12 的每个 P 有 256 个普通本地队列槽位和独立的 runnext;全局队列承担共享调度,不能把 256 当作 goroutine 数量上限。卡 · 本地队列、runnext 与全局队列
  • 工作窃取搬运的是其他 P 上尚未执行的就绪任务,用于缓解工作分布不均;不会直接把另一条线程正在执行的 G 抢来运行。卡 · 工作窃取
  • channel 等待、网络轮询和阻塞系统调用对应不同路径。挂起 G 可以让当前 M 继续工作;系统调用则可能连同 M 一起阻塞,P 的交接让其他工作仍有机会推进。卡 · 挂起 G,继续使用 M
  • 抢占请求不是立即切换的保证。Go 1.22.12 同时有同步安全点和受平台支持限制的异步抢占;源码中的 10 毫秒阈值不能解释成每个 goroutine 最长只能运行 10 毫秒。卡 · 同步与异步抢占
  • GOMAXPROCS 控制 P 的数量和 Go 代码的执行并行度,不限制 G 总数或进程线程总数。增加它也不能创造更多 CPU,线程保护上限与业务并发限制另有职责。卡 · 并行度与线程数量
  • 每个 M 的 g0 提供运行时使用的系统栈,用于调度、切换等需要离开用户栈的工作;它不是普通业务 goroutine,也不是一个单独的全局调度线程。卡 · g0 与用户栈

2. G、M、P​

2.1 三类对象​

G 是 goroutine 对应的运行时对象,保存栈、执行位置、状态等信息。M 对应操作系统线程,实际执行指令。P 是执行普通 Go 代码所需的资源集合,包含本地可运行队列、部分内存分配状态等。

对象主要职责不能等同于什么
G表示一条可暂停、恢复的执行流程不是独占的内核线程
M在线程上执行 Go 代码、运行时代码或系统调用不一定始终持有 P
P提供调度与分配资源,控制 Go 代码执行并行度不是物理 CPU 核,也不是操作系统进程

一个 M 持有一个 P 时,可以选择一个就绪 G 执行。G 等待、完成或被重新调度后,同一个 M 可以执行另一个 G。另一个时间点,这个 P 也可能交给其他 M。因此,不应把“某个 G 永远属于某个 P 或线程”当作默认保证。

P 不是硬件绑核。多个持有 P 的 M 是否真的同时执行,还取决于操作系统调度、可用 CPU 和当前负载。

2.2 为什么需要 P​

P 让可复用的执行资源不必始终跟随线程。M 可能长时间卡在系统调用里,如果就绪任务和分配状态只能随这个 M 使用,就难以让其他线程接着推进工作。

把状态按 P 分开,还能让常见调度先处理本地任务,减少每次都访问全局队列的竞争。需要均衡负载时,再通过全局队列和工作窃取交换任务。P 的作用因此不只是“加一层中间对象”,而是同时承担资源分区和执行资格的管理。

这也不意味着调度器不需要锁:全局队列等共享状态仍需要协调,本地队列也要处理其他 P 窃取任务时的并发访问。

2.3 g0 与用户栈​

g0 是与 M 关联的特殊 G,提供这个线程执行部分运行时代码时使用的系统栈。普通业务 G 有自己的用户栈,调度器需要切换 G 或执行某些不能扩栈、不能被抢占的操作时,会切到系统栈。

例如,挂起当前 G 后,调度器不能继续把这个 G 的用户栈当作另一项业务的工作空间。M 可以在自己的 g0 栈上完成调度工作,再切到被选中的 G 的用户栈。

g0 不作为普通业务任务进入本地就绪队列,也不是所有 M 共同使用的一个 G。不能把 G0、M0、主 goroutine 和 main 函数混成同一个对象。

3. 就绪队列与任务选择​

3.1 从创建到就绪​

go f() 创建的是一项待调度的工作,并不保证新任务立刻运行。Go 1.22.12 的 newproc 在系统栈上创建或复用 G,准备执行所需的栈和上下文,再把它设为可运行,放入调度结构,并在需要时唤醒执行资源。

准备好的 G 与已经在执行的 G 要分开理解:

状态含义如何继续
可运行,_Grunnable已经具备执行条件,但还没取得执行机会被调度器选中
运行,_Grunning正在 M 和 P 上推进 Go 代码继续执行,或因等待、让出、抢占、完成而转移
等待,_Gwaiting在 channel、锁、计时器等条件上等待条件满足后被唤醒为可运行
系统调用,_Gsyscall正在执行系统调用,不执行普通 Go 代码返回后重新取得执行资源

这张表只列本篇需要的主要状态,运行时还要处理新建、退出、栈复制和 GC 扫描等状态。等待中的 G 通常保存在相应等待结构中,不会一直挤在就绪队列里轮询条件。

3.2 本地队列、runnext 与全局队列​

Go 1.22.12 的 P 中有 runq [256]guintptr,用于存放普通本地可运行任务,另外有一个独立的 runnext 槽位。runnext 允许刚被唤醒、与当前任务有紧密交接关系的 G 获得较近的执行机会,执行时可以继承当前时间片的剩余部分。

runnext 不是用户可设置的任务优先级,也不保证新建或刚唤醒的 G 必然先于所有其他 G。比如 GC、全局队列的公平性检查、其他 P 的窃取,以及开启 race detector 后的调度扰动,都会影响具体执行顺序。

全局可运行队列由调度器共享,采用链接队列,没有与本地 runq 相同的 256 项固定数组容量。这不等于它的资源无限:大量可运行 G 仍会消耗内存和调度时间,业务要限制任务规模,见 Go 并发任务管理。

本地队列满时,runqputslow 会取出本地普通队列的一半,再加上本次要入队的 G,一批转入全局队列。按这个版本的容量计算,就是 128 项已有任务加 1 项待加入任务;存在 runnext 替换时,这个待加入者也可能是被替换下来的旧 runnext。

全局队列的来源不只有本地队列溢出。主动 Gosched 让出、部分抢占后重新排队,以及 P 被缩减时转移剩余任务,也会用到全局调度结构。不能把“进入全局队列”理解为某个任务发生了异常。

3.3 调度器怎样寻找下一项工作​

调度器通常先利用本地工作,再获取其他来源,但不是一条永远不变的“本地、全局、窃取”顺序。

在 Go 1.22.12 的 findRunnable 中,定时器、GC 和运行时工作会参与检查;普通任务路径会周期性检查全局队列,再尝试 runnext 与本地队列、本地没有工作时的全局批量获取、网络就绪事件以及工作窃取。

其中,schedtick % 61 == 0 时对非空全局队列的检查,是为了避免本地任务反复相互唤醒,让全局任务长期没有机会。61 是调度计数条件,不是 61 毫秒,也不是每完成 61 个业务请求。 runnext 继承时间片时也会影响计数变化,不能据此承诺某个 G 在固定时长内运行。

从全局队列取任务时,可以批量放到当前 P,减少频繁争抢全局锁。这个版本会结合全局任务数、P 数量、调用者给出的数量限制以及本地队列容量决定批量大小,不是每次把全局队列全部搬走。

4. 工作窃取​

工作窃取是当前 P 缺少可运行工作时,从其他 P 的本地队列获取尚未执行的 G。它解决的是任务分布不均:一个 P 排了很多任务,另一个 P 却没有工作,不能只因为任务最初在哪里创建,就让后者闲着。

Go 1.22.12 的普通本地队列窃取会尝试取走约一半任务,计算为 n - n/2。例如观察到对方有 5 项时,成功的一次尝试取走 3 项;其中 1 项直接作为接下来执行的任务,其余留在本地队列。这个描述以成功取得队列所有权的那次观察为准,其他执行者也可能并发取走任务,失败后需要重新检查。

普通队列为空时,特定窃取轮次还可以尝试 runnext,因此不能把“永远严格取走固定数量”当作保证。窃取目标的选择和重试也属于实现策略。

它不会移动正在执行的指令,也不会把等待网络、锁或 channel 的 G 直接改成就绪。工作本来就需要等待时,再增加可执行它的线程也没有帮助。任务之间共享锁、依赖链过长、CPU 已满或下游容量不足时,工作窃取同样不能消除这些瓶颈。

5. 阻塞与恢复​

5.1 挂起 G,继续使用 M​

channel 没有可完成的收发、某些锁等待或计时等待,可以让运行时挂起当前 G,把执行线程用于其他可运行任务。gopark 一类机制安排挂起;等对应条件满足,唤醒路径再让 G 变成可运行。

被唤醒只是重新取得候选资格,不表示立即开始执行。以 channel 为例,通信操作会从相应等待队列找到等待者,并在配合其锁与数据交付规则后唤醒它,不需要另启一个 goroutine 不断扫描所有 channel。具体收发机制见 channel。

下面把 Go 代码的并行度设为 1。一个任务在 release 上等待,另一个任务仍能计算并交付结果:

package main

import (
"fmt"
"runtime"
)

func main() {
previous := runtime.GOMAXPROCS(1)
defer runtime.GOMAXPROCS(previous)

release := make(chan struct{})
stopped := make(chan struct{})
result := make(chan int)
go func() {
<-release
close(stopped)
}()
go func() {
result <- 6 * 7
}()

fmt.Println(runtime.GOMAXPROCS(0))
fmt.Println(<-result)
close(release)
<-stopped
}

输出 1 和 42。P 的数量为 1,仍可以存在多个 G,它们通过等待与调度交错推进;stopped 用于确认第一个任务真正结束。这个结果没有证明进程只有一个操作系统线程,也没有让两个计算在同一时刻并行执行。

5.2 网络等待与系统调用​

受运行时网络轮询支持的网络 I/O,在暂时不能完成时,可以登记关注的就绪事件并挂起 G。之后由就绪通知或超时等路径唤醒;不需要为每个等待连接保留一条阻塞线程。底层多路复用机制见 I/O 多路复用。

阻塞系统调用则不同:G 可能随 M 一起进入调用,M 暂时不能执行其他 Go 代码。Go 1.22.12 的普通 entersyscall 路径把 G 标为 _Gsyscall,解除 M 当前持有的 P,并把 P 标为 _Psyscall;它保留与原 M 的关联信息,P 可以随后被运行时收回并交给其他 M。

因此,不能说每次系统调用都“立即创建一个新线程”,也不能说所有系统调用一进入就把 P 放进空闲池。快速返回时可能重新取得原来的 P;运行时明确知道调用会阻塞的路径,则可以更早交接 P。是否需要启动线程,还取决于可用 M、可运行任务等条件。

系统调用返回后,M 要恢复执行 Go 代码,仍须取得 P。它先尝试重新取得原来的 P,再尝试其他空闲 P;如果仍然没有,就把 G 重新交给调度器,M 等待可用工作或资源。这个 G 后续不保证一定由原 M 执行,显式线程绑定另有约束。

普通文件、原始系统调用、cgo 与网络连接不应一概而论。进入内核也不等于必然发生操作系统线程切换:有的系统调用很快完成,有的会等待资源;从一次 G 的用户态切换,同样不能推导出必然发生线程切换。

6. 让出与抢占​

6.1 主动让出不是任务完成​

runtime.Gosched() 主动让出执行机会,让调度器有机会安排其他 G。当前 G 仍然是可运行任务,之后可以继续执行,并没有因为调用 Gosched 而等待某个业务条件。

Gosched 也不指定下一项任务是谁,不能拿它代替 channel、锁或完成通知。time.Sleep 是计时等待,Gosched 是让出,两者都不能证明其他任务已经完成。

6.2 同步与异步抢占​

抢占是运行时请求正在执行的 G 暂停,把执行机会交给其他工作。同步安全点会在合适的运行时检查位置观察请求,例如利用函数入口的栈检查;但没有函数调用的长计算循环,不能只靠这类入口检查及时让出。

Go 1.14 起引入异步抢占,Go 1.22.12 在支持的平台上继续使用它。以 Unix 平台的实现为例,运行时可以向线程发送信号,检查被中断的位置是否适合安全暂停;条件满足时安排进入抢占处理,保存必要状态并重新调度。不是任意一条指令都能无条件停下,部分运行时代码和不安全位置仍需等待合适时机。

sysmon 是运行时的监控工作,会检查长时间没有推进调度计数的 P。Go 1.22.12 的 forcePreemptNS 为 10 毫秒,达到相关条件后请求抢占;检查间隔、安全点、操作系统调度和当前运行状态都会影响真正切换的时间。

所以,“一个 goroutine 最多运行 10 毫秒”不是准确答案。这个数字是实现中的请求阈值,不是实时调度承诺,更不是执行总时长上限。抢占也只是暂时让出,不能把正在计算的业务任务当成已经取消或结束。

下面的循环不主动 Sleep 或 Gosched,只读取一个原子停止标记:

package main

import (
"fmt"
"runtime"
"sync/atomic"
"time"
)

//go:noinline
func spin(stop *atomic.Bool) {
for !stop.Load() {
}
}

func main() {
previous := runtime.GOMAXPROCS(1)
defer runtime.GOMAXPROCS(previous)
var stop atomic.Bool
started := make(chan struct{})
done := make(chan struct{})
go func() {
close(started)
spin(&stop)
close(done)
}()

<-started
time.Sleep(20 * time.Millisecond)
stop.Store(true)
<-done
fmt.Println("stopped")
}

在本文的 Go 1.22.12、darwin/arm64 验证环境中,默认异步抢占开启时输出 stopped。主 goroutine 的计时等待结束后,能够重新取得执行机会并设置 stop;原子操作避免停止标记本身产生数据竞争,完成通知保证随后再退出。

这只是观察该平台上长循环可以被调度打断的实验,不能证明恢复恰好发生在 20 毫秒,也不能把忙等当作推荐的任务等待方案。实际业务应使用合适的阻塞等待,长计算也要根据任务约定检查取消。正确性不能依赖某个 G 很快获得下一次执行机会。

7. 并行度与线程数量​

GOMAXPROCS 控制执行普通 Go 代码的并行资源数量,对应 P 的数量。Go 1.22 的默认值受启动时可用逻辑 CPU 数和 GOMAXPROCS 环境变量影响;runtime.GOMAXPROCS(n) 可以调整,参数不大于零时只查询当前值。

设置成 1 仍可以创建很多 G,并发处理需要等待的任务;设置得比可用 CPU 多,也不会增加物理算力。CPU 密集任务的收益取决于可拆分工作、可用 CPU、同步和调度开销,不能从“Go 支持高并发”推出“多开 goroutine 就更快”。

M 的总数可能大于 P:有的线程在系统调用或 cgo 中,有的承担运行时工作,还有的暂时空闲。GOMAXPROCS 不把这些全部限制为同一个数字。

runtime/debug.SetMaxThreads 则设置进程的操作系统线程保护上限,Go 1.22.12 的初始值为 10000。超过限制会使程序崩溃,它用于限制失控创建线程的损害,不是让多余业务任务排队的并发控制器。不要把这个值当成 P 的默认数量、P 的推荐上限或可以安全创建 10000 条线程的资源保证。

面试追问 P“最多多少个”时,应先确认问的是当前 GOMAXPROCS、可用硬件并行度,还是指定版本的内部表示限制。没有一个由本地队列容量推导出的通用 256 上限,也不应为了求出极端数值,在实际进程里创建大量调度资源。

8 · 面试题卡片​

本页题目与参考回答

8.1 G、M、P 分别是什么,goroutine 怎样获得执行机会?​

考察重点:运行时任务、内核线程、执行资源和调度的关系。

相关内容:三类对象、从创建到就绪、调度器怎样寻找下一项工作

参考回答

G 表示 goroutine,M 对应操作系统线程,P 保存执行 Go 代码所需的调度和分配资源。 新 G 准备好执行上下文后进入可运行状态,由持有 P 的 M 选择执行;操作系统再调度实际线程。

G 暂停、等待或完成后,M 可以执行其他 G。G、M、P 没有永久的一对一绑定,P 也不是物理 CPU 核。新建和唤醒只让任务具备执行条件,不保证马上运行。

8.2 为什么需要 P,而不只让 M 管理 goroutine 队列?​

考察重点:状态分区、全局竞争和线程阻塞后的资源交接。

相关内容:为什么需要 P、网络等待与系统调用

参考回答

P 把可复用的执行资源与可能阻塞的线程分开,并通过本地状态减少全局竞争。 M 进入系统调用后,P 可以被交给其他 M,让原来积压的工作继续推进。

本地队列减少每次调度都访问全局共享结构的成本,工作分布不均时再通过全局队列和窃取协调。P 同时控制 Go 代码执行并行度,不能只解释成多出来的一层名字。

8.3 本地队列和全局队列各有什么作用,任务什么时候进入全局队列?​

考察重点:可运行状态、队列容量、溢出、公平性和调度顺序的条件。

相关内容:本地队列、runnext 与全局队列、调度器怎样寻找下一项工作

参考回答

本地队列服务当前 P 的常见调度,全局队列提供共享的可运行任务来源。 Go 1.22.12 的普通本地队列有 256 个槽位,另有独立 runnext;全局是链接队列,不能用 256 限制整个进程的 G 数。

普通本地队列满时,把其中一半加上待加入的 G 转到全局;主动让出、部分抢占和 P 缩减也可能让任务进入全局。调度器会周期性检查全局队列,避免只处理反复相互唤醒的本地任务;具体顺序还受定时器、GC、网络就绪等影响。

8.4 工作窃取解决什么问题,一次取多少任务?​

考察重点:负载不均、就绪任务、并发窃取和版本实现。

相关内容:工作窃取

参考回答

当前 P 缺少工作时,尝试从其他 P 取得尚未执行的就绪 G,减少一边积压、一边空闲的情况。 它不窃取正在运行或仍在等待外部条件的 G。

Go 1.22.12 普通队列的成功窃取尝试按 n - n/2 取得约一半任务,一项直接执行,其余放入本地队列;并发访问可能使尝试失败,特定轮次也会考虑 runnext。数量是实现条件,不能作为业务任务分配保证。

8.5 goroutine 等待 channel 或进入系统调用时,G、M、P 怎样变化?​

考察重点:挂起、唤醒、线程阻塞、P 交接和系统调用返回。

相关内容:挂起 G,继续使用 M、网络等待与系统调用

参考回答

channel 等待可以只挂起当前 G,让 M 继续执行其他任务;阻塞系统调用则可能连同 M 一起等待。 channel 条件满足后,等待者被唤醒为可运行,仍需再次调度。

普通系统调用入口让 G 进入 _Gsyscall,M 脱离当前 P,P 标为 _Psyscall,之后可被运行时交给其他 M。返回时先尝试原 P,再尝试空闲 P,仍没有就重新调度 G。不是每次系统调用都立即创建线程,也不是进入内核必然发生线程切换。

8.6 长时间不调用函数的 goroutine 会一直占用执行机会吗?10 毫秒意味着什么?​

考察重点:同步安全点、异步抢占、请求与实际切换的区别。

相关内容:同步与异步抢占

参考回答

在 Go 1.22.12 支持异步抢占的平台上,长循环也可以被运行时暂停调度,不只依赖函数入口的同步检查。 Unix 实现会通过线程信号及安全点检查安排抢占,并非任意指令都能立即暂停。

10 毫秒是 sysmon 判断长时间占用后发出抢占请求所用的实现阈值,实际切换还受检查时机、安全点和操作系统调度影响,不是严格的最长连续运行时间,更不是任务执行总时长上限。

8.7 GOMAXPROCS 控制什么,怎样理解 G、M、P 的数量?​

考察重点:Go 代码并行度、线程数量、资源容量与 CPU 密集任务。

相关内容:并行度与线程数量

参考回答

GOMAXPROCS 决定 P 的数量和执行 Go 代码的并行资源,不限制 G 总数或所有 M。 很多 G 可以等待;一些 M 可以阻塞在系统调用中,其他 M 持有 P 推进工作。

增加 P 或 G 不会创造 CPU,计算任务还受同步和调度开销影响。SetMaxThreads 是超限崩溃的线程保护上限,不是任务排队策略;P 的数量也不能从本地队列的 256 槽位推出。

8.8 M 上的 g0 有什么作用?​

考察重点:用户栈、系统栈和调度代码的执行位置。

相关内容:g0 与用户栈

参考回答

g0 为每个 M 提供系统栈,运行时可在这个栈上执行调度、切换等工作。 当当前用户 G 被挂起或需要进行不能扩栈的运行时操作时,不能一直依赖它的用户栈。

g0 不作为普通业务 G 排队,也不是一个全局唯一的调度 goroutine。它与主 goroutine、M0 以及 main 函数各有不同含义。

9. 参考资料​

来源:Coding 101 原文 · 免费阅读,引用请保留出处。内容使用说明