惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

Jina AI
Jina AI
V
Visual Studio Blog
博客园 - 司徒正美
TaoSecurity Blog
TaoSecurity Blog
博客园 - 聂微东
IT之家
IT之家
博客园_首页
Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
C
Cyber Attacks, Cyber Crime and Cyber Security
博客园 - Franky
雷峰网
雷峰网
罗磊的独立博客
S
Schneier on Security
C
Cybersecurity and Infrastructure Security Agency CISA
The Cloudflare Blog
T
Tailwind CSS Blog
B
Blog RSS Feed
H
Help Net Security
T
The Blog of Author Tim Ferriss
C
CXSECURITY Database RSS Feed - CXSecurity.com
T
Threatpost
C
CERT Recently Published Vulnerability Notes
博客园 - 三生石上(FineUI控件)
P
Palo Alto Networks Blog
I
Intezer
G
GRAHAM CLULEY
Engineering at Meta
Engineering at Meta
S
Securelist
J
Java Code Geeks
V
V2EX
Y
Y Combinator Blog
Simon Willison's Weblog
Simon Willison's Weblog
L
LINUX DO - 热门话题
云风的 BLOG
云风的 BLOG
Spread Privacy
Spread Privacy
MongoDB | Blog
MongoDB | Blog
P
Privacy International News Feed
cs.CL updates on arXiv.org
cs.CL updates on arXiv.org
B
Blog
Forbes - Security
Forbes - Security
Google Online Security Blog
Google Online Security Blog
Help Net Security
Help Net Security
S
SegmentFault 最新的问题
N
Netflix TechBlog - Medium
Webroot Blog
Webroot Blog
Microsoft Security Blog
Microsoft Security Blog
SecWiki News
SecWiki News
Scott Helme
Scott Helme
aimingoo的专栏
aimingoo的专栏
N
News and Events Feed by Topic

smallyu的博客

为什么买的加密货币一定要去中心化 RISC-V 虚拟机无法代替 EVM 虚拟机 Polkadot 的 Existential Deposit 机制错在哪里 利息,套利,交易策略,金融市场 LayerZero 从来不挑战比特币的地位 默认参数引起的以太坊节点运行错误 硬件钱包与资产安全 预测市场比币圈更赌场 为什么去中心化的跨链桥不可能实现 我对于 AI 时代的答案 不要投资任何隐私币 区块链技术世界的三大真理 以太坊 AA 钱包的致命问题是什么 基于 AI 语义执行的 MCP 区块链的设计 一个集成 Geth 和 CometBFT 的兼容层 我的加密货币定投策略(二) DeFi 基础: 理解 AMM 定价机制 Web3 项目分析计划 对 Psyche Network 项目的分析 continuation 教程: 理解 CPS 如何开发一个比特币符文(Runes)协议 比特币脚本开发教程 我从王垠的计算机科学视频班学到了什么 区块链技术面试题(2025年版) Rust 语言容易让新手困惑的一个“过度优化” Solana 智能合约开发教程 (1) 尝试开发一个最小 EVM 虚拟机 基于 ZK 的链上身份系统设计 一个 Web3 打赏系统的设计 鼓吹 Cursor 的人技术能力都差 关于 Code Review 的礼节 假如启动一个新的以太坊 PoS 网络 发行加密货币的最好方式 所有 BFT 共识的区块链都是中心化的 对 2025 年区块链行业的预测 Restaking 项目的经济难题 如何看懂任意区块链项目的技术架构 为什么不要做以太坊的二次开发 为什么不要做智能合约开发和 DeFi 开发 我的加密货币定投策略(一) PoS 类型的区块链如何处理分叉 Ethereum Casper 为什么需要 EIP-7251 区块链中的 PBFT 不需要第二次投票 开发者的思维方式 发币的核心要点 炒币投资的小 tips 为什么说 PoW 比 PoS 更加去中心化 牛市对普通人来说意味着什么 如何健康地远程工作 为什么比特币不用概率加密函数 程序员的 “服从权威” 心理 区块链技术面试题 如何区分公有链和联盟链 对 Layer 2 项目创业想法的回复 对区块链共识机制的理解 Pebbling Game 鹅卵石游戏 PDP 文件证明的局限性 不要小瞧 ChatGPT 为什么炒币不是一个好主意 一种在区块链上生成随机数的机制 为什么以太坊的私钥计算不可逆 关于以太坊的私钥碰撞 “猜均值的2/3” dApp 游戏设计 Proof of Storage/Space/Replication 的区别 Proofs of Retrievability 文件证明的含义 对 S-PDP 文件证明的示例和解释 我的加密货币交易机器人 对区块链行业的见闻 随机确认块的共识机制 VRF + BFT 共识引起交易失败的问题 为什么要重视编程思想 对 Web 3.0 的理解 GitBook 好用吗? 一种区块链节点存储扩容的方式 一种基于“自我中心主义”的共识机制 链表常见算法题及解析 基于 Multi-Linked List 的区块链设想 理解哈希函数与序列化 联盟链比公有链差在哪儿 在 Dijkstra 算法中保存路径 为什么数字货币使用区块链是政治问题 区块链:下一代数字身份认证体系的基石 网页技术能实现 3D 建模吗? 给区块链一个定义 从 Erlang 开始了解 Actor 模型 一种侧边导航栏的交互方式 Rust 的 ownership 是什么? Haskell 中的 Monad 是什么? 浅析 Libra 背后的区块链技术 对区块链的理性认识 Rust 基础语法概述 基于 Java 的爬虫框架 WebCollector Kotlin:简化版的 Scala JavaScript 有关联数组吗? 主流编程语言的异常处理机制 Go 语言基本语法 Scala 语法基础 用 Scala 改写 Java 浅度实践 Python 获取海贼王更新信息 HTML5 音乐可视化
Go 语言 GMP 调度器的原理是什么
2025-08-18 · via smallyu的博客

2025-08-18

声明:我看不起 “Go 语言 GMP 调度器的原理是什么” 这种技术话题。

我平时没兴趣研究这种问题。因为在面试中被问到的频率太高了,现在想花 2 个小时的时间来了解下。一方面研究下这个问题背后到底有多大的技术含量,另一方面把这个问题的答案写下来。但是我不会让这种内容停留在我的头脑里,所以下次面试被问到,我肯定还说不会 😏

基本概念

GMP 是一个缩写:

  • G(goruntine):就是协程,代码里每 go 一个,G 的数量就多一个
  • M(Machine):就是系统级别的线程,在其他语言里的 thread
  • P(Processor):数量为 GOMAXPROCS,通常默认是 CPU 核心数。

GMP 的意思是,启动多少个 M(线程) 来执行 G(协程),最多允许 P(核心数)个 M 并行执行。

三个不变量

无聊的(简化后的)定义来了:

  1. 只有拿到 P 的 M 才能执行任务
  2. 可运行的 G 只会在某个 P 的本地 runq 或者全局队列
  3. 当 M 进入阻塞状态(syscall/cgo)时,会及时把 P 让出

这几句话看着很费劲,不需要现在理解,接下来会用一些代码例子来说明他们的含义。

GMP 的调试日志

这是一个最简单的代码文件,用来演示启动一个协程:

package main

import (
    "fmt"
    "sync"
)

func main() {
    var wg sync.WaitGroup
    wg.Add(1)
    go func() {
        defer wg.Done()
        fmt.Println("Hello from goroutine")
    }()
    wg.Wait()
}

然后带上调试参数运行一下:

go build demo0.go
GODEBUG='schedtrace=200,scheddetail=1' ./demo0

注意不要用 go run,因为会引入一些 Go 语言运行时的日志。这个二进制版本的日志比较干净,内容是:

SCHED 0ms: gomaxprocs=10 idleprocs=7 threads=5 spinningthreads=1 needspinning=0 idlethreads=0 runqueue=0 gcwaiting=false nmidlelocked=1 stopwait=0 sysmonwait=false
  P0: status=0 schedtick=0 syscalltick=0 m=nil runqsize=0 gfreecnt=0 timerslen=0
  P1: status=1 schedtick=0 syscalltick=0 m=2 runqsize=0 gfreecnt=0 timerslen=0
  P2: status=0 schedtick=0 syscalltick=0 m=nil runqsize=0 gfreecnt=0 timerslen=0
  P3: status=0 schedtick=0 syscalltick=0 m=nil runqsize=0 gfreecnt=0 timerslen=0
  P4: status=0 schedtick=0 syscalltick=0 m=nil runqsize=0 gfreecnt=0 timerslen=0
  P5: status=0 schedtick=0 syscalltick=0 m=nil runqsize=0 gfreecnt=0 timerslen=0
  P6: status=0 schedtick=0 syscalltick=0 m=nil runqsize=0 gfreecnt=0 timerslen=0
  P7: status=0 schedtick=0 syscalltick=0 m=nil runqsize=0 gfreecnt=0 timerslen=0
  P8: status=0 schedtick=0 syscalltick=0 m=nil runqsize=0 gfreecnt=0 timerslen=0
  P9: status=0 schedtick=0 syscalltick=0 m=nil runqsize=0 gfreecnt=0 timerslen=0
  M3: p=0 curg=nil mallocing=0 throwing=0 preemptoff= locks=1 dying=0 spinning=true blocked=false lockedg=nil
  M2: p=1 curg=nil mallocing=0 throwing=0 preemptoff= locks=6 dying=0 spinning=false blocked=false lockedg=nil
  M1: p=nil curg=nil mallocing=0 throwing=0 preemptoff= locks=2 dying=0 spinning=false blocked=false lockedg=nil
  M0: p=nil curg=nil mallocing=0 throwing=0 preemptoff= locks=0 dying=0 spinning=false blocked=true lockedg=1
  G1: status=1() m=nil lockedm=0
  G2: status=4(force gc (idle)) m=nil lockedm=nil
Hello from goroutine

这些日志显示了这些信息:

  • 第一行 SCHED 开头的是汇总信息,告诉我们程序启动了 10 个 P(gomaxprocs=10)。
  • 只有 P1M2 拿着运行
  • P0M3 拿着处于 spinning 状态,也就是等待任务的状态。

没看到 print 相关的 G,是因为任务运行时间太短了,没被 trace 捕获就结束了,这里主要展示 GMP 的详细信息可以用 debug 命令来看。

抢占式调度

package main

import (
    "fmt"
    "runtime"
    "time"
)

func busy(tag string, d time.Duration) {
    end := time.Now().Add(d)
    x := 0
    for time.Now().Before(end) {
        x++
    }
    fmt.Println(tag, "done", x)
}

func main() {
    runtime.GOMAXPROCS(1)
    go busy("A", 1500*time.Millisecond)
    busy("B", 1500*time.Millisecond)
}

这个代码的运行结果是,有时候 AB 前面,有时候 BA 前面。

我们已经用 runtime.GOMAXPROCS(1) 设定只有一个 P,但是 Go 语言的 GMP 调度器,仍然会 10ms 释放一次时间片,也就意味着,即使 go busy("A") 处于阻塞状态,时间片之后也会让出执行权,交给主线程去运行 B

可以用这个 busy 的函数定义来让抢占式调度更加肉眼可见:

func busy(tag string, d time.Duration) {
    end := time.Now().Add(d)
    next := time.Now()
    for time.Now().Before(end) {
        if time.Now().After(next) {
            fmt.Print(tag, " ") // 每 ~100ms 打印一次
            next = time.Now().Add(100 * time.Millisecond)
        }
    }
    fmt.Println(tag, "done")
}

程序的打印结果会是 B A B A B A A B A B A B A B A B A B A B A B A B B A B A B A B done。这意味着不是 tag 为 A 的 P 一路执行到底,也不是 tag 为 B 的 P 一路执行到底,他们在 GMP 调度器中交替执行。

P 偷活干(work-stealing)

来看这个代码示例:

package main

import (
    "runtime"
    "sync"
    "time"
)

func spin(d time.Duration) {
    deadline := time.Now().Add(d)
    for time.Now().Before(deadline) {
    } // 纯CPU忙等
}

func main() {
    runtime.GOMAXPROCS(1) // 先让所有 G 挤到同一个 P 的本地队列

    const N = 120
    var wg sync.WaitGroup
    wg.Add(N)
    for i := 0; i < N; i++ {
        go func() { defer wg.Done(); spin(500 * time.Millisecond) }()
    }

    time.Sleep(30 * time.Millisecond) // 给点时间把队列堆满到 P0

    runtime.GOMAXPROCS(4) // 突然放大并行度:P1~P3 会去“偷” P0 的一半
    wg.Wait()
}

这个代码干了什么呢,首先设定之后一个 P,然后启动 120 个 G 给这个 P 去执行。30 毫秒后,突然增大 P 的数量。

用 debug 日志能看到,运行后半段有这样的日志:

P0: status=1 schedtick=46 syscalltick=2 m=0 runqsize=17 gfreecnt=0 timerslen=0
P1: status=1 schedtick=58 syscalltick=0 m=4 runqsize=5 gfreecnt=15 timerslen=0
P2: status=1 schedtick=60 syscalltick=0 m=2 runqsize=5 gfreecnt=18 timerslen=0
P3: status=1 schedtick=42 syscalltick=0 m=3 runqsize=17 gfreecnt=0 timerslen=0

也就是说,本应该 G 全在 P0 上运行,等到 P1、P2、P3 出来后,它们发现 P0 很忙,就去 P0 的队列里拿了几个任务过来执行。

P 的 runq 队列和全局队列

一个 P 想找活干的时候,上面的代码是偷其他 P 的示例。更严谨的流程是,P 先从本地 runq 队列找,再到全局队列找,找不到再去偷其他 P 的。

什么是 runq 队列,什么是全局队列?可以看这个代码:

package main

import (
    "runtime"
    "sync"
    "time"
)

func spin(d time.Duration) {
    end := time.Now().Add(d)
    for time.Now().Before(end) {
    } // 纯CPU忙等:保持 runnable
}

func main() {
    runtime.GOMAXPROCS(1) // 只有 P0:所有新 G 先进入 P0 的本地 runq

    const N = 600 // 让它明显超过本地 runq 容量(当前实现通常是 256)
    var wg sync.WaitGroup
    wg.Add(N)
    for i := 0; i < N; i++ {
        go func() { defer wg.Done(); spin(800 * time.Millisecond) }()
    }

    time.Sleep(500 * time.Millisecond) // 给运行时时间把“溢出的一半”推到全局队列

    runtime.GOMAXPROCS(4) // 其它 P 进场,会先从“全局队列”拿活(不是偷)
    wg.Wait()
}

debug 状态运行:

go build demo4.go   
GODEBUG='schedtrace=200,scheddetail=1' ./demo4 &> demo4.log

日志会比较多,日志前面几行像这样:

SCHED 0ms: gomaxprocs=10 idleprocs=9 threads=2 spinningthreads=0 needspinning=0 idlethreads=0 runqueue=0 gcwaiting=false nmidlelocked=0 stopwait=0 sysmonwait=false
  P0: status=1 schedtick=0 syscalltick=0 m=0 runqsize=0 gfreecnt=0 timerslen=0
  P1: status=0 schedtick=0 syscalltick=0 m=nil runqsize=0 gfreecnt=0 timerslen=0

其中首行的 runqueue=0 就是全局队列,P0 后面的 runqsize=0 是 P0 的本地队列,P1 后面的 runqsize=0 是 P1 的本地队列。可以看到此时的 P1 状态是 0,也就是不可运行。

随着程序的运行,P0 会启动非常多个 G,日志状态是这样:

SCHED 200ms: gomaxprocs=1 idleprocs=0 threads=5 spinningthreads=0 needspinning=1 idlethreads=3 runqueue=395 gcwaiting=false nmidlelocked=0 stopwait=0 sysmonwait=false
  P0: status=1 schedtick=10 syscalltick=2 m=0 runqsize=204 gfreecnt=0 timerslen=1

一般 P 的本地队列默认是上限是 256,达到这个峰值后,就会把任务溢出到全局队列。

再然后,P1、P2、P3 启动,开始从全局队列拿任务(全局队列有任务则不需要偷其他 P 的):

SCHED 826ms: gomaxprocs=4 idleprocs=0 threads=5 spinningthreads=0 needspinning=1 idlethreads=0 runqueue=217 gcwaiting=false nmidlelocked=0 stopwait=0 sysmonwait=false
  P0: status=1 schedtick=35 syscalltick=2 m=0 runqsize=179 gfreecnt=0 timerslen=0
  P1: status=1 schedtick=14 syscalltick=0 m=3 runqsize=90 gfreecnt=0 timerslen=0
  P2: status=1 schedtick=14 syscalltick=0 m=4 runqsize=64 gfreecnt=0 timerslen=0
  P3: status=1 schedtick=13 syscalltick=0 m=2 runqsize=46 gfreecnt=0 timerslen=0

另外,当 P 依次从本地 runq、全局队列、其他 P 都找不到任务时,会再去问一下 netpoll(问一下 OS)有没有新的 G,要是有就执行,没有就自旋(待命)。这就是 P 执行任务的逻辑。

阻塞 syscall 会及时让出

看这个代码例子:

package main

import (
    "fmt"
    "runtime"
    "time"
)

func main() {
    runtime.GOMAXPROCS(2)

    go func() {
        time.Sleep(2 * time.Second) // 类比阻塞 syscall/cgo
        fmt.Println("blocking done")
    }()

    go func() {
        for i := 0; i < 6; i++ {
            time.Sleep(300 * time.Millisecond)
            fmt.Println("still running", i)
        }
    }()

    time.Sleep(3 * time.Second)
}

运行结果会是:

still running 0
still running 1
still running 2
still running 3
still running 4
still running 5
blocking done

这个代码示例的含义是,第一个 G 明明会阻塞任务队列,一直占着 P 执行,但实际上第二个 G 仍然在运行。

说明 GMP 调度器不会因为某个 G 的阻塞,影响到其他 G 的执行。(其实这是协程调度器很基本的要求)

关闭异步抢占

对于这个代码示例:

package main

import (
    "fmt"
    "runtime"
    "time"
)

func spin() {
    for { /* 紧密循环 */
    }
}

func main() {
    runtime.GOMAXPROCS(1)
    go spin()
    time.Sleep(100 * time.Millisecond)
    fmt.Println("I should still print unless preemption is off")
}

可以分别用两个命令来运行,一个是

go build demo7.go
GODEBUG='schedtrace=1000,scheddetail=1' ./demo7

另一种是:

go build demo7.go
GODEBUG='schedtrace=1000,scheddetail=1,asyncpreemptoff=1' ./demo7

asyncpreemptoff=1 可以关闭异步抢占。也就是说,如果没有关闭,没有带这个参数,程序会正常运行,打印出:

I should still print unless preemption is off

如果关闭了异步抢占,则程序会被死循环卡住。这个例子主要可以体现 GMP 主动让出 CPU 的特点,当关闭了主动让出的能力后,GMP 就会被阻塞住了。

Go 语言源码

我没有深入看源码,比如 G、M、P 的常量定义在 src/runtime/runtime2.go 文件:

再比如 src/runtime/proc.go 文件中的 runqputslow 函数,功能就是判断本地队列有没有满,如果满了就放到全局队列:

进一步深入

这篇文章肯定有不全面和不到位的地方,我不想进一步深入了,也许有人喜欢折腾这些吧。

Go 语言的 GMP,就是协程调度器的一种具体的工程化的实现,估计很多人在意的,是这种工程化实现背后的细节,比如怎么用栈结构来管理任务队列、怎么实现抢占、让出逻辑等。协程调度器的具体实现方式可以有各种各样的变化,但它们的基本原理都是 continuation。只是 Go 语言把协程作为卖点了。只要其他语言愿意,也是可以开发出自己版本的协程调度器的。

那么问题来了,那些喜欢研究 GMP 原理的人,你们有没有了解过其他语言的协程(coroutine)、虚拟线程、异步函数、Process 是怎么实现的,它们都是比线程更轻量的类似于协程的东西,和 Go 语言的 gorountine 有什么区别?横向对比一下?

如果什么时候,我的工作需要,只有我了解这些内容,才能把工作做好,那么我肯定去把这些东西搞明白。

疑问

我之前写过一个观点:

Go 语言 “千辛万苦” 做出了自动的垃圾回收,减轻程序员对于内存管理的头脑负担。而有些面试官 “千辛万苦” 去搞明白 Go 语言 GC 的原理是什么,怎么标记怎么释放之类,不但引以为豪,而且拿来考察候选人。作为 Go 语言的教徒,你知不知道你的行为在否定 Go 语言设计者的努力?如果真的相信用头脑来管理内存的力量,为什么不去搞 Rust?好比我是一个汽车驾驶员,我要去考驾照,难道需要我搞清楚发动机的工作原理、是怎么把汽油燃烧转变为机械动力的、能量转化公式是什么?我又不是在制造汽车,也不是在开发编程语言。

同样的道理:

Go 语言为了让广大程序员能便捷简单地、用上轻量级的协程,“千辛万苦” 搞出来一个 go 关键字,然而有些人却费尽 “千辛万苦” 研究这个调度器是怎么实现的,懂原理则说明会 Go 语言,不懂则说明 Go 语言水平不行,这是什么道理?作为 Go 语言的教徒,你在否定 Go 语言设计者的努力,明白吗?如果这个语言需要你搞清楚协程调度的原理,才能写出好的代码,那就说明这个语言实现的不到位,偏离了设计者的初衷,没有达到设计者本来的意图。

如果你是编程语言的开发者,需要在另一种语言中借鉴、实现、优化 Go 语言的调度器,那么你就尽情研究吧,这样的工作确实需要懂 GMP 调度器的原理。如果不是那样的工作呢?