[{"content":"","date":"2026-08-22","externalUrl":null,"permalink":"/CodeLab/blog/go/","section":"知识","summary":"","title":"Go","type":"blog"},{"content":"","date":"2026-08-22","externalUrl":null,"permalink":"/CodeLab/tags/go/","section":"标签","summary":"","title":"Go","type":"tags"},{"content":"","date":"2026-08-22","externalUrl":null,"permalink":"/CodeLab/tags/map/","section":"标签","summary":"","title":"Map","type":"tags"},{"content":" 旧版 Map 的整体结构 # Go 1.23 及以前，Map 本质上是一个哈希表，最核心的数据结构是 hmap 和 bmap。\ntype hmap struct { count int flags uint8 B uint8 noverflow uint16 hash0 uint32 buckets unsafe.Pointer oldbuckets unsafe.Pointer nevacuate uintptr extra *mapextra } 其中：\ncount：当前元素数量，len(map) 基本就是取它； B：bucket 数量的指数，bucket 数量是 2^B； hash0：每个 map 自己的哈希种子，降低恶意哈希碰撞风险； buckets：当前桶数组； oldbuckets：扩容时指向旧桶； nevacuate：记录扩容搬迁进度； noverflow：溢出桶数量。 一个 bmap 就是我们常说的一个 bucket，一个 bucket 可以放 8 个 key/value，并且有一个 tophash[8] 保存每个 key 哈希值的一部分。源码层面的 bmap 看起来只有 tophash，实际编译器会根据具体 key/value 类型构造出 key、value 和 overflow 指针的存储空间。\n可以画成：\nhmap │ ├── buckets │ │ │ ├── bucket0 │ │ ├── tophash[8] │ │ ├── keys[8] │ │ ├── values[8] │ │ └── overflow ──\u0026gt; overflow bucket │ │ │ ├── bucket1 │ └── ... │ └── oldbuckets // 扩容期间使用 bucket 里面不是按照 key/value/key/value... 交叉存储，而是先连续存 key，再连续存 value，这样可以减少因为类型对齐产生的内存浪费。\n查询、写入以及哈希冲突怎么解决 # 这部分是 Map 原理里最重要的。\n假设执行：\nv := m[key] 首先根据 key 和 hash0 计算哈希值：\nhash = hash(key, hash0) 旧版实现大致把 hash 分成两部分使用：\nhash ├── 高位：生成 tophash，加速 bucket 内查找 └── 低 B 位：定位 bucket 查询过程 # 比如：\nbucketIndex = hash \u0026amp; (2^B - 1) 用低 B 位找到 bucket。\n然后：\n取 hash 的高位生成 tophash； 遍历 bucket 的 8 个槽位； 先比较 tophash； tophash 相同才真正比较 key； 找不到就继续查 overflow bucket； 都找不到就说明 key 不存在。 这样做的好处是：先用很便宜的 tophash 比较过滤掉绝大多数 key，减少真正的 key 比较。\n旧实现的查询源码也是先定位 bucket，再比较 tophash，最后才调用 key 的 equal 方法。\n写入过程 # 执行：\nm[key] = value 基本也是：\n计算 hash ↓ 定位 bucket ↓ 比较 tophash ↓ 找到相同 key？ ↙ ↘ 是 否 ↓ ↓ 更新 value 找空槽 ↓ bucket 满了？ ↙ ↘ 否 是 ↓ ↓ 写入 overflow bucket 如果对应 bucket 的 8 个槽全部满了，就会创建 overflow bucket，通过链式结构继续存。\n所以旧版 Go Map 解决哈希冲突，本质上可以理解为：\nbucket + overflow bucket 的链式处理。\n什么时候扩容？怎么扩？ # 旧版 Go Map 有两个比较重要的扩容条件。\n装载因子过高 # 旧实现的装载因子：\nloadFactor = count / 2^B 超过大约 6.5 时触发扩容。 原因很好理解，每个 bucket 最多放 8 个元素，如果平均已经放到 6.5 个左右，就意味着发生冲突和 overflow 的概率越来越高，查询性能开始下降。这种情况下：\nB = B + 1 bucket 数： 2^B → 2^(B+1) 也就是 2 倍扩容。\noverflow bucket 太多 # 还有一种情况：元素并不多，但是 overflow bucket 特别多\n比如不断进行 插入 → 删除 → 插入 → 删除，可能导致 bucket 分布非常松散。\n这时候装载因子不一定超过 6.5，但是查找效率已经下降了。\nGo 会进行一次：等量扩容\n也就是：bucket 数量基本不变，但是把元素重新整理到新的 buckets 中，把大量没必要的 overflow bucket 消掉。\n所以两个扩容可以概括成：\n场景 扩容方式 目的 元素太多 2 倍扩容 增加空间 overflow 太多 等量扩容 整理碎片 扩容为什么不是一次搬完？ # 假设 map 特别大，如果扩容的时候一次性把几百万个 key 全搬完：\n一次插入 ↓ 触发扩容 ↓ 搬几百万元素 ↓ 延迟突然非常高 所以旧版 Go Map 使用的是渐进式扩容。\nhashGrow() 主要先：\nbuckets → oldbuckets 申请新的 buckets 真正的数据搬迁是在后续 map 操作过程中逐渐完成。\n例如执行：\nm[k] = v delete(m, k) 会顺手做一部分 growWork / evacuate。\n所以一次扩容 ≠ 一次性搬完全部数据\n而是：\n第 1 次写 → 搬一点 第 2 次写 → 搬一点 第 3 次写 → 搬一点 ... 以此平摊扩容成本。书里的源码分析同样指出，hashGrow 主要建立新旧 buckets 关系，真正搬迁由后续 growWork 完成。\n这也是一个很好的面试关键词：空间换时间 + 增量迁移，降低单次操作的尾延迟。\n并发和遍历有什么特点？ # 普通 Map 不是并发安全的 # Go 原生的 map[K]V 不支持无同步的并发读写、并发写。\n例如：\ngo func() { m[\u0026#34;a\u0026#34;] = 1 }() go func() { fmt.Println(m[\u0026#34;a\u0026#34;]) }() 这是 data race，运行时也可能直接报：\nfatal error: concurrent map read and map write 或者：\nfatal error: concurrent map writes 所以普通 map 不是线程安全的，需要外部加锁或使用 sync.Map。通常可以选择 sync.RWMutex + map，或者在特定场景下使用 sync.Map。\nMap 遍历顺序没有保证 # for k, v := range m { } 不能依赖遍历顺序。\n旧版实现中初始化 iterator 时会随机选择：\nstartBucket offset 从而让遍历没有稳定顺序。\n所以千万不要写：\nfor k := range m { // 默认认为按照插入顺序 } 如果业务确实需要按顺序遍历，标准做法分三步：先把 map 的所有 key 收集到一个 slice 里，再对这个 slice 排序，最后按排序后的 key 逐个去 map 里取值。\n// 1. 收集所有 key keys := make([]string, 0, len(m)) for k := range m { keys = append(keys, k) } // 2. 对 key 排序 sort.Strings(keys) // 3. 按排序后的 key 遍历取值 for _, k := range keys { v := m[k] fmt.Println(k, v) } 遍历期间可以 delete / insert，但有明确语义 # 这个很容易和「并发读写不安全」混淆。\n在同一个 goroutine 的 range 过程中：\nfor k := range m { delete(m, k) } 这是允许的。\n新增元素则可能遍历到，也可能遍历不到。\n但是另一个 goroutine 无同步地同时修改 map，仍然是不安全的。\nGo 1.24 的 Swiss Table # 从 Go 1.24 开始，我们平时用的 map 底层换了一套实现，名字叫 Swiss Table。官方的 Go 1.24 Release Notes 和 Go Blog 都明确说明了这一点。\n旧版的结构是一层层往下挂的：\nhmap（总控） ↓ bucket（桶，一桶 8 个格子） ↓ 装不下了？挂一个 overflow bucket ↓ 再装不下？再挂一个 新版的结构变成了分层管理：\nMap（总控） ↓ Directory（目录，负责分配） ↓ Table（一张小表） ↓ Group（一组，8 个格子） ↓ 8 个 slot + 一张 control word（标签条） 这是新旧两版最本质的区别。\n下面逐层看新版各层的核心结构体。\n顶层 Map 结构体（对应旧版的 hmap），源码在 internal/runtime/maps/map.go：\ntype Map struct { used uint64 // 元素个数，len() 直接取它 seed uintptr // 哈希种子，每个 map 随机生成 dirPtr unsafe.Pointer // 指向 Directory；小 map 时直接指向单个 group dirLen int // Directory 长度，0 表示小 map 模式 globalDepth uint8 // 用哈希高位多少 bit 选 table globalShift uint8 // 哈希右移位数，64 - globalDepth writing uint8 // 并发写检测标记 tombstonePossible bool // 是否可能存在 tombstone（墓碑） clearSeq uint64 // 清空操作序号，遍历中检测 clear } 其中：\nused：当前元素总数，len(map) 就是读这个字段，所以 O(1)； seed：每个 map 独有的随机哈希种子，防止哈希碰撞攻击； dirPtr / dirLen：指向 Directory（一个 *table 指针数组）。dirLen == 0 时是小 map 优化，数据直接存在一个 group 里，不需要 Directory 和 Table； globalDepth / globalShift：可扩展哈希的核心参数，用哈希的高位选 table，dirLen = 2^globalDepth； writing：并发写检测标记，多个 goroutine 同时写会触发 panic； tombstonePossible：标记是否可能有墓碑（删除标记），影响遍历和清理逻辑。 Table 结构体（一张独立的瑞士哈希表），源码在 internal/runtime/maps/table.go：\ntype table struct { used uint16 // 当前元素个数 capacity uint16 // 总 slot 数，始终是 2^N growthLeft uint16 // 还能放多少元素就需要扩容（含 tombstone） localDepth uint8 // 该 table 创建时的深度，可能小于 globalDepth index int // 在 Directory 中的起始索引，-1 表示已失效 groups groupsReference // Group 数组，每个 Group 8 个 slot + control word } 其中：\ncapacity：单张 table 最大不超过 maxTableCapacity（1024 个 slot），超过就拆分成两张 table； growthLeft：剩余可写入空间，用完就触发 rehash（原地翻倍扩容或拆分成两张 table）； localDepth：该 table 自己的深度，Directory 中多个索引可能指向同一张 table，所以它可能小于 globalDepth； groups：实际存储数据的 Group 数组。 Directory 本身不是复杂结构体，就是一个 *table 指针数组，长度为 2^globalDepth。多个索引可以指向同一张 table——这是可扩展哈希（extendible hashing）的关键，让单张 table 拆分时不需要立刻把整个目录翻倍。\nGroup 是最小存储单元，8 个 slot 加一个 8 字节 control word：\nGroup ├── ctrl [8]uint8 // 8 字节控制字，1 字节对应 1 个 slot └── slots [8] // 8 个 slot，每个存一个 key/value 整体结构可以画成：\nMap │ ├── used / seed / globalDepth ... // 元数据 │ └── dirPtr → Directory（*table 数组，长度 2^globalDepth） │ ├── table0 │ ├── used / capacity / growthLeft / localDepth │ └── groups[] │ ├── Group0: ctrl[8] + slot0~7 │ ├── Group1: ctrl[8] + slot0~7 │ └── ... │ ├── table1 （可能和 table0 指向同一张表） └── ... 另外有个小 map 优化：如果元素不超过 8 个，dirPtr 直接指向一个单独的 Group，不需要 Directory 和 Table，省一次间接寻址和内存分配。\nSwiss Table 的 Group：8 个格子一组 # 新版里，8 个 slot（格子）组成一个 Group，就像快递柜里一排有 8 个柜门。\n关键在于：这 8 个格子旁边，还跟着一张 control word（标签条），正好 8 个字节，每个字节对应一个格子。\n每个格子对应的那个字节（control byte），只可能是三种状态：\nempty：这个格子是空的 deleted：这里曾经存过东西，后来删了 H2：这个格子被占用了，字节里存的是该 key 哈希值的低 7 位 那 H2 是什么？一个 key 的哈希值会被拆成两部分：\n哈希值（64 位） ├── H1（高 57 位）：定位 Group，构造探查顺序 └── H2（低 7 位）：在 Group 内快速筛选 简单说：H1 负责定位到哪一组，H2 负责在组内快速判断\u0026quot;这个格子可能是我要找的吗\u0026quot;。当前 Go 源码也是这样定义的。\n查一个 key，到底经历了什么？ # 比如你写 m[key]，新版是这么找的：\n先算 hash(key) 用哈希的高位选到某个 Table 用 H1 确定一个\u0026quot;探查顺序\u0026quot;（probe sequence），也就是先看哪组、再看哪组 找到第一个 Group 后，一次性把这组 8 个格子的标签字节全扫一遍 筛出 H2 相同的格子（可能有多个） 只有这些候选格子，才会真正去比较 key 是不是相等 最大的优化就在第 4 步：8 个标签字节可以通过位运算（类似 SIMD 的方式）一次比较完，而不是像旧版那样 slot0 比一次、slot1 比一次、slot2 比一次……\n这就好比你找快递：旧版是一个个柜门拉开看；新版是先扫一眼柜门上的取件码尾号，尾号对得上的才去开柜门——大部分柜子连开都不用开。所以 CPU 缓存命中率更高，查得也更快。\n官方源码的查询逻辑也是如此：先通过 H1 构造探查顺序，然后对整个 Group 调用 matchH2 筛出候选 slot，只有 H2 命中的才真正比较 key；遇到 empty 就可以直接结束查找。\n哈希冲突了怎么办？ # 这是新旧版另一个本质区别。\n旧版用的是\u0026quot;链地址\u0026quot;思想：一个桶装不下了，就在后面挂一个 overflow bucket，像链条一样接下去。\nbucket → overflow → overflow 新版 Swiss Table 用的是\u0026quot;开放寻址\u0026quot;：这一组满了或者冲突了，就按照探查顺序（probe sequence）去下一组找空位，不挂链条。\nGroup A 满了 → 去 Group B → 再去 Group C 所以如果有人问\u0026quot;Go Map 怎么解决哈希冲突\u0026quot;，一定要分版本说：旧实现靠 overflow bucket 挂链；Go 1.24 之后的 Swiss Table 用开放寻址 + 顺序探查。\nSwiss Table 怎么扩容？ # 新版没有简单粗暴地把整个 map 一次性翻倍扩容。\n原因是 Go 很在意服务器的 tail latency（尾延迟）：想象一个几 GB 的大 map，如果某次插入突然要把整张表复制一遍，那这一次操作的延迟会非常夸张，可能把线上请求卡住。\n所以 Go 在 Swiss Table 外面又套了一层 extendible hashing（可扩展哈希），结构大概是这样：\nMap └── Directory（目录） ├── Table A │ └── Groups ├── Table B │ └── Groups └── Table C 哈希的高位用来选择进哪个 Table。当前实现中，一个 Table 最多装 1024 个 slot。Table 小的时候可以自己扩容；达到上限后，就把它拆成两个 Table：\nTable ↓ split ↙ ↘ T1 T2 必要时 Directory 也跟着翻倍。这样做的好处是：每次只拆一个 Table，而不是整个 map 一起搬，单次扩容的成本就被控制住了。官方源码明确把单个 table 最大容量设为 1024。\n另外，Swiss Table 每组 8 个格子，但平均最多只放 7 个，也就是最大平均装载率：\n7 / 8 = 87.5% 留一个空位是为了让探查（probing）更高效，当前源码中的 maxAvgGroupLoad = 7 就是这个意思。\n面试官最可能继续追问的几个点 # 你把 Map 讲到这个程度以后，我觉得下一轮大概率就是围绕这些问：\n为什么 bucket 是 8 个元素？ 为什么 tophash 能提高查询效率？ 为什么 Map 元素不能直接取地址？ 扩容的时候一个 key 怎么判断去 X bucket 还是 Y bucket？ 为什么遍历是无序的？ 为什么并发读写 Map 会 panic？ sync.Map 和 map + RWMutex 怎么选？ Swiss Table 为什么比旧 Map 快？ Swiss Table 的 tombstone 是干什么的？ 为什么 Swiss Table 用开放寻址后，Go 还要搞 Directory + 多 Table？ 其中 第 4、8、9、10 特别适合把你和只背旧版 hmap/bmap 的候选人区分开。\n（注：部分内容可能由 AI 生成）\n","date":"2026-08-22","externalUrl":null,"permalink":"/CodeLab/blog/go/map-principles-analysis/","section":"知识","summary":"旧版 Map 的整体结构 # Go 1.23 及以前，Map 本质上是一个哈希表，最核心的数据结构是 hmap 和 bmap。\n","title":"Map 原理分析","type":"blog"},{"content":" ","date":"2026-08-22","externalUrl":null,"permalink":"/CodeLab/tags/","section":"标签","summary":"","title":"标签","type":"tags"},{"content":"","date":"2026-08-22","externalUrl":null,"permalink":"/CodeLab/","section":"欢迎来到CodeLab","summary":"","title":"欢迎来到CodeLab","type":"page"},{"content":"","date":"2026-08-22","externalUrl":null,"permalink":"/CodeLab/blog/","section":"知识","summary":"","title":"知识","type":"blog"},{"content":"","date":"2026-08-22","externalUrl":null,"permalink":"/CodeLab/tags/gmp/","section":"标签","summary":"","title":"GMP","type":"tags"},{"content":" 概念梳理 # 线程 # 通常语义中的线程，指的是内核级线程，核心点如下：\n（1）是操作系统最小调度单元；\n（2）创建、销毁、调度交由内核完成，cpu 需完成用户态与内核态间的切换；\n（3）可充分利用多核，实现并行。\n协程 # 协程，又称为用户级线程，核心点如下：\n（1）与线程存在映射关系，为 M：1；\n（2）创建、销毁、调度在用户态完成，对内核透明，所以更轻；\n（3）从属同一个内核级线程，无法并行；一个协程阻塞会导致从属同一线程的所有协程无法执行。\ngoroutine # Goroutine，经 Golang 优化后的特殊“协程”，核心点如下：\n（1）与线程存在映射关系，为 M：N；\n（2）创建、销毁、调度在用户态完成，对内核透明，足够轻便；\n（3）可利用多个线程，实现并行；\n（4）通过调度器的斡旋，实现和线程间的动态绑定和灵活调度；\n（5）栈空间大小可动态扩缩，因地制宜。\nGMP模型 # gmp = goroutine(g) + machine(m) + processor(p) （+ 一套有机组合的机制）\nG # （1）g 即goroutine，是 golang 中对协程的抽象；\n（2）g 有自己的运行栈、状态、以及执行的任务函数（用户通过 go func 指定）；\n（3）g 需要绑定到 p 才能执行，在 g 的视角中，p 就是它的 cpu。\nM # （1）m 即 machine，是 golang 中对线程的抽象；\n（2）m 不直接执行 g，而是先和 p 绑定，由其实现代理；\n（3）借由 p 的存在，m 无需和 g 绑死，也无需记录 g 的状态信息，因此 g 在全生命周期中可以实现跨 m 执行.\n（4）一个M阻塞，P就会去创建或者切换另一个M\nP # （1）p 即 processor，是 golang 中的调度器；\n（2）所有的P都在程序启动时创建，并保存在数组中，即「全局 P 列表」；\n（3）p 是 gmp 的中枢，借由 p 承上启下，实现 g 和 m 之间的动态有机结合；\n（4）对 g 而言，p 是其 cpu，g 只有被 p 调度，才得以执行；\n（5）对 m 而言，p 是其执行代理，为其提供必要信息的同时（可执行的 g、内存分配情况等），并隐藏了繁杂的调度细节；\n（6）p 的数量决定了 g 最大并行数量，可由用户通过 GOMAXPROCS 进行设定（超过 CPU 核数时无意义）。\nGMP # GMP 宏观模型如上图所示，下面对其要点和细节进行逐一介绍：\n（1）M 是线程的抽象；G 是 goroutine；P 是承上启下的调度器；\n（2）M调度G前，需要和P绑定；\n（3）全局有多个M和多个P，但同时并行的G的最大数量等于P的数量；\n（4）G的存放队列有三类：P的本地队列、全局G队列、wait队列（图中未展示，为io阻塞就绪态goroutine队列）；\n（5）M调度G时，优先取P本地队列，其次取全局G队列，最后取wait队列；这样的好处是，取本地队列时，可以接近于无锁化，减少全局锁竞争；\n（6）为防止不同P的闲忙差异过大，设立work-stealing机制，本地队列为空的P可以尝试从其他P本地队列偷取一半的G补充到自身队列。\n调度器的设计策略 # 复用线程：避免频繁的创建、销毁线程，而是对线程的复用。\nwork stealing 机制 本地队列为空的P可以尝试从其他P本地队列偷取一半的G补充到自身队列，而不是销毁线程。\nhand off 机制 当本地队列因为G进行系统调用阻塞时，线程释放绑定的P，把P转移给其他空闲的线程执行。\n利用并行：GOMAXPROCS设置P的数量，最多有GOMAXPROCS个线程分布在多个CPU上同时运行。GOMAXPROCS也限制了并发的程度，比如GOMAXPROCS = 核数/2，则最多利用了一半的CPU核进行并行。\n抢占：在coroutine中要等待一个协程主动让出CPU才执行下一个协程，在Go中，一个goroutine最多占用CPU 10ms，防止其他goroutine被饿死，这就是goroutine不同于coroutine的一个地方。\n全局G队列：在新的调度器中依然有全局G队列，当P的本地队列为空时，优先从全局G队列获取，如果全局G队列为空时则通过work stealing机制从其他P的本地队列偷取G。\n核心数据结构 # G # gmp 数据结构定义为 runtime/runtime2.go 文件中，由于各个类的成员属性较多，那么只摘取核心字段进行介绍。\ntype g struct { // ... m *m // ... sched gobuf // ... } type gobuf struct { sp uintptr pc uintptr ret uintptr bp uintptr // for framepointer-enabled architectures } g.m：在 p 的代理，负责执行当前 g 的 m；\ng.sched：保存 goroutine 被调度出去时的执行现场，也就是 goroutine 的调度上下文。\nsched.sp：保存 CPU 的 rsp 寄存器的值，指向函数调用栈栈顶；\nsched.pc：保存 CPU 的 rip 寄存器的值，指向程序下一条执行指令的地址；\nsched.ret：保存系统调用的返回值；\nsched.bp：保存 CPU 的 rbp 寄存器的值，存储函数栈帧的起始位置.\n其中 g 的生命周期由以下几种状态组成：\nconst( _Gidle = itoa // 0 _Grunnable // 1 _Grunning // 2 _Gsyscall // 3 _Gwaiting // 4 _Gdead // 6 _Gcopystack // 8 _Gpreempted // 9 ) （1）_Gidle 值为 0，为协程开始创建时的状态，此时尚未初始化完成；\n（2）_Grunnable 值 为 1，协程在待执行队列中，等待被执行；\n（3）_Grunning 值为 2，协程正在执行，同一时刻一个 p 中只有一个 g 处于此状态；\n（4）_Gsyscall 值为 3，协程正在执行系统调用；\n（5）_Gwaiting 值为 4，协程处于挂起态，需要等待被唤醒。gc、channel 通信或者锁操作时经常会进入这种状态；\n（6）_Gdead 值为 6，协程刚初始化完成或者已经被销毁，会处于此状态；\n（7）_Gcopystack 值为 8，协程正在栈扩容流程中；\n（8）_Greempted 值为 9，协程被抢占后的状态.\nM # type m struct { g0 *g // goroutine with scheduling stack // ... tls [tlsSlots]uintptr // thread-local storage (for x86 extern register) // ... } g0：一类特殊的调度协程，不用于执行用户函数，负责执行 g 之间的切换调度. 与 m 的关系为 1:1；\ntls：thread-local storage，线程本地存储，存储内容只对当前线程可见。\n线程本地存储的是 m.tls 的地址，m.tls[0] 存储的是当前运行的 g，因此线程可以通过 g 找到当前的 m、p、g0 等信息. P # type p struct { // ... runqhead uint32 runqtail uint32 runq [256]guintptr runnext guintptr // ... } （1）runq：本地 goroutine 队列，最大长度为 256，本地队列属于P\n（2）runqhead：队列头部；\n（3）runqtail：队列尾部；\n（4）runnext：下一个可执行的 goroutine.\nschedt # type schedt struct { // ... lock mutex // ... runq gQueue runqsize int32 // ... } sched 是全局 goroutine 队列的封装：\n（1）lock：一把操作全局队列时使用的锁；\n（2）runq：全局 goroutine 队列，全局队列属于 runtime 调度器，所有 P 共享；\n（3）runqsize：全局 goroutine 队列的容量.\n调度流程 # 两种G的转换（g0\u0026lt;-\u0026gt;g） # goroutine 的类型可分为两类：\n负责调度普通 g 的 g0，执行固定的调度流程，与 m 的关系为一对一；\n负责执行用户函数的普通 g.\nm 通过 p 调度执行的 goroutine 永远在普通 g 和 g0 之间进行切换，当 g0 找到可执行的 g 时，会调用 gogo 方法，调度 g 执行用户定义的任务；当 g 需要主动让渡或被动调度时，会触发 mcall 方法，将执行权重新交还给 g0.\ngogo 和 mcall 可以理解为对偶关系，其定义位于 runtime/stubs.go 文件中：\nfunc gogo(buf *gobuf) // ... func mcall(fn func(*g)) 调度类型 # 这里谈及的调度类型是广义上的“调度”，指的是调度器 p 实现从执行一个 g 切换到另一个 g 的过程。\n可分为以下几种类型：\n主动调度 一种用户主动执行让渡的方式，主要方式是，用户在执行代码中调用了 runtime.Gosched 方法，此时当前 g 会当让出执行权，主动进入队列等待下次被调度执行.\n代码位于 runtime/proc.go：\nfunc Gosched() { checkTimeouts() mcall(gosched_m) } 被动调度 因当前不满足某种执行条件，g 可能会陷入阻塞态无法被调度，直到关注的条件达成后，g才从阻塞中被唤醒，重新进入可执行队列等待被调度.\n常见的被动调度触发方式为因 channel 操作或互斥锁操作陷入阻塞等操作，底层会走进 gopark 方法.\n代码位于 runtime/proc.go：\nfunc gopark(unlockf func(*g, unsafe.Pointer) bool, lock unsafe.Pointer, reason waitReason, traceEv byte, traceskip int) { // ... mcall(park_m) } goready 方法通常与 gopark 方法成对出现，能够将 g 从阻塞态中恢复，重新进入等待执行的状态.\n代码位于 runtime/proc.go：\nfunc goready(gp *g, traceskip int) { systemstack(func() { ready(gp, traceskip, true) }) } 正常调度 g 中的执行任务已完成，g0 会将当前 g 置为死亡状态（就是前文提到的g生命周期里的_Gdead状态），然后发起新一轮调度。\n抢占调度 倘若 g 执行系统调用超过指定时长，且全局 P 资源紧缺，调度器会将 P 与 g 解绑，把 P 交还给其他 g 使用。等 g 完成系统调用后，会重新进入可执行队列中等待被调度.\n值得一提的是，前 3 种调度方式都由 m 下的 g0 完成，唯独抢占调度不同。\n因为发起系统调用时需要打破用户态的边界进入内核态，此时 M 陷入阻塞系统调用后没法继续运行 Go 调度器，无法主动完成抢占调度的行为.\n因此，在 Golang 进程会有一个全局监控协程 monitor g 的存在，**这个 g 会越过 p 直接与一个 m 进行绑定，不断轮询对所有 p 的执行状况进行监控。**倘若发现满足抢占调度的条件，则会从第三方的角度出手干预，在必要时把 P 从 M 手中回收，让其他 M 使用。此外，monitor g 也负责发现长时间运行的 G 并触发抢占。\n宏观调度流程 # 下面我们尝试对 gmp 的宏观调度流程进行整体串联：\n（1）以 g0 -\u0026gt; g -\u0026gt; g0 的一轮循环为例进行串联；\n（2）g0 执行 schedule() 函数，寻找到用于执行的 g；\n（3）g0 执行 execute() 方法，更新当前 g、p 的状态信息，并调用 gogo() 方法，将执行权交给 g；\n（4）g 因主动让渡( gosche_m() )、被动调度( park_m() )、**正常结束( goexit0() )**等原因，调用 m_call 函数，执行权重新回到 g0 手中；\n（5）g0 执行 schedule() 函数，开启新一轮循环.\nSchedule() # 调度流程的主干方法是位于 runtime/proc.go 中的 schedule 函数，此时的执行权位于 g0 手中：\nfunc schedule() { // ... gp, inheritTime, tryWakeP := findRunnable() // blocks until work is available // ... execute(gp, inheritTime) } （1）寻找到下一个执行的 goroutine；\n（2）执行该 goroutine.\nFindRunnable() # findRunnable()用于寻找到下一个执行的goroutine。流程如下：\n调度流程中，一个非常核心的步骤，就是为 m 寻找到下一个执行的 g，这部分内容位于 runtime/proc.go 的 findRunnable 方法中：\nfunc findRunnable() (gp *g, inheritTime, tryWakeP bool) { _g_ := getg() top: _p_ := _g_.m.p.ptr() // ... if _p_.schedtick%61 == 0 \u0026amp;\u0026amp; sched.runqsize \u0026gt; 0 { lock(\u0026amp;sched.lock) gp = globrunqget(_p_, 1) unlock(\u0026amp;sched.lock) if gp != nil { return gp, false, false } } // ... if gp, inheritTime := runqget(_p_); gp != nil { return gp, inheritTime, false } // ... if sched.runqsize != 0 { lock(\u0026amp;sched.lock) gp := globrunqget(_p_, 0) unlock(\u0026amp;sched.lock) if gp != nil { return gp, false, false } } if netpollinited() \u0026amp;\u0026amp; atomic.Load(\u0026amp;netpollWaiters) \u0026gt; 0 \u0026amp;\u0026amp; atomic.Load64(\u0026amp;sched.lastpoll) != 0 { if list := netpoll(0); !list.empty() { // non-blocking gp := list.pop() injectglist(\u0026amp;list) casgstatus(gp, _Gwaiting, _Grunnable) return gp, false, false } } // ... procs := uint32(gomaxprocs) if _g_.m.spinning || 2*atomic.Load(\u0026amp;sched.nmspinning) \u0026lt; procs-atomic.Load(\u0026amp;sched.npidle) { if !_g_.m.spinning { _g_.m.spinning = true atomic.Xadd(\u0026amp;sched.nmspinning, 1) } gp, inheritTime, tnow, w, newWork := stealWork(now) now = tnow if gp != nil { // Successfully stole. return gp, inheritTime, false } if newWork { // There may be new timer or GC work; restart to // discover. goto top } if w != 0 \u0026amp;\u0026amp; (pollUntil == 0 || w \u0026lt; pollUntil) { // Earlier timer to wait for. pollUntil = w } } // p 每执行 61 次调度，会从全局g队列中获取一个 goroutine 进行执行，并将一个全局g队列中的 goroutine 填充到当前 p 的本地队列中。 图中流程如下：\ng0 每 61 次调度轮询全局队列 ↓ 从全局队列取出 g（要执行的） ↓ 顺便再取一个 g1（想放进本地队列的） ↓ 本地队列满了，g1 塞不进去 ↓ 执行runqputslow()： 把本地队列前一半的 g 全部取出 + g1 一并打包 → 扔回全局队列 本地队列现在只剩后一半（腾出了空间） ↓ 返回 g（直接拿去执行） 代码如下：\nif _p_.schedtick%61 == 0 \u0026amp;\u0026amp; sched.runqsize \u0026gt; 0 { lock(\u0026amp;sched.lock) gp = globrunqget(_p_, 1) unlock(\u0026amp;sched.lock) if gp != nil { return gp, false, false } } 除了获取一个 g 用于执行外，还会额外将一个 g1 从全局队列转移到 p 的本地队列，让全局队列中的 g 也得到更充分的执行机会.\nfunc globrunqget(_p_ *p, max int32) *g { if sched.runqsize == 0 { return nil } n := sched.runqsize/gomaxprocs + 1 if n \u0026gt; sched.runqsize { n = sched.runqsize } if max \u0026gt; 0 \u0026amp;\u0026amp; n \u0026gt; max { n = max } if n \u0026gt; int32(len(_p_.runq))/2 { n = int32(len(_p_.runq)) / 2 } sched.runqsize -= n gp := sched.runq.pop() n-- for ; n \u0026gt; 0; n-- { gp1 := sched.runq.pop() runqput(_p_, gp1, false) } return gp 将一个 g 由全局队列转移到 p 本地队列的执行逻辑位于 runqput 方法中：\nfunc runqput(_p_ *p, gp *g, next bool) { // ... retry: h := atomic.LoadAcq(\u0026amp;_p_.runqhead) // load-acquire, synchronize with consumers t := _p_.runqtail if t-h \u0026lt; uint32(len(_p_.runq)) { _p_.runq[t%uint32(len(_p_.runq))].set(gp) atomic.StoreRel(\u0026amp;_p_.runqtail, t+1) // store-release, makes the item available for consumption return } if runqputslow(_p_, gp, h, t) { return } // the queue is not full, now the put above must succeed goto retry I 取得 p 本地队列队首的索引，同时对本地队列加锁：\nh := atomic.LoadAcq(\u0026amp;_p_.runqhead) II 倘若 p 的局部队列未满，则成功转移 g，将 p 的队尾索引 runqtail 值加 1 并解锁队列.\nif t-h \u0026lt; uint32(len(_p_.runq)) { _p_.runq[t%uint32(len(_p_.runq))].set(gp) atomic.StoreRel(\u0026amp;_p_.runqtail, t+1) // store-release, makes the item available for consumption return } III 倘若发现本地队列 runq 已经满了，则会返回来将本地队列中一半的 g 放回全局队列中，帮助当前 p 缓解执行压力，这部分内容位于 runqputslow 方法中.\nfunc runqputslow(_p_ *p, gp *g, h, t uint32) bool { var batch [len(_p_.runq)/2 + 1]*g // First, grab a batch from local queue. n := t - h n = n / 2 // ... for i := uint32(0); i \u0026lt; n; i++ { batch[i] = _p_.runq[(h+i)%uint32(len(_p_.runq))].ptr() } if !atomic.CasRel(\u0026amp;_p_.runqhead, h, h+n) { // cas-release, commits consume return false } batch[n] = gp // Link the goroutines. for i := uint32(0); i \u0026lt; n; i++ { batch[i].schedlink.set(batch[i+1]) } var q gQueue q.head.set(batch[0]) q.tail.set(batch[n]) // Now put the batch on global queue. lock(\u0026amp;sched.lock) globrunqputbatch(\u0026amp;q, int32(n+1)) unlock(\u0026amp;sched.lock) return true 尝试从 p 本地队列中获取一个可执行的 goroutine，核心逻辑位于 runqget 方法中： if gp, inheritTime := runqget(_p_); gp != nil { return gp, inheritTime, false } func runqget(_p_ *p) (gp *g, inheritTime bool) { if next != 0 \u0026amp;\u0026amp; _p_.runnext.cas(next, 0) { return next.ptr(), true } for { h := atomic.LoadAcq(\u0026amp;_p_.runqhead) // load-acquire, synchronize with other consumers t := _p_.runqtail if t == h { return nil, false } gp := _p_.runq[h%uint32(len(_p_.runq))].ptr() if atomic.CasRel(\u0026amp;_p_.runqhead, h, h+1) { // cas-release, commits consume return gp, false } } I 倘若当前 P 的 runnext 非空，通过 CAS 将其置零并取出执行；此时 inheritTime 返回 true，表示该 g 继承当前时间片，不重新计时。\nif next != 0 \u0026amp;\u0026amp; _p_.runnext.cas(next, 0) { return next.ptr(), true } II CAS 从 p 的本地队列中获取 g.\n通过 CAS 原子操作从 P 的本地队列中获取 g。虽然本地队列属于当前 P 独有，但 work-stealing 机制允许其他 P 来窃取，因此读取 head 指针时需使用 atomic.LoadAcq 保证可见性，后续通过 CAS 原子地推进 head，以此应对并发竞争，实现真正的无锁访问。\nfor { h := atomic.LoadAcq(\u0026amp;_p_.runqhead) // load-acquire, synchronize with other consumers // ... } III 倘若本地队列为空，直接终止并返回；\nh := atomic.LoadAcq(\u0026amp;_p_.runqhead) // load-acquire, synchronize with other consumers t := _p_.runqtail if t == h { return nil, false } IV 倘若本地队列存在 g，取出队首的 g，通过 CAS 将 head 前移一位提交消费，成功后返回该 g。\ngp := _p_.runq[h%uint32(len(_p_.runq))].ptr() if atomic.CasRel(\u0026amp;_p_.runqhead, h, h+1) { // cas-release, commits consume return gp, false } 倘若本地队列没有可执行的 g，会从全局队列中获取： if sched.runqsize != 0 { lock(\u0026amp;sched.lock) gp := globrunqget(_p_, 0) unlock(\u0026amp;sched.lock) if gp != nil { return gp, false, false } } mutex加锁，尝试并从全局队列中取队首的元素。\n倘若本地队列和全局队列都没有 g，则会获取准备就绪的网络协程： if netpollinited() \u0026amp;\u0026amp; atomic.Load(\u0026amp;netpollWaiters) \u0026gt; 0 \u0026amp;\u0026amp; atomic.Load64(\u0026amp;sched.lastpoll) != 0 { if list := netpoll(0); !list.empty() { // non-blocking gp := list.pop() injectglist(\u0026amp;list) casgstatus(gp, _Gwaiting, _Grunnable) return gp, false, false } } 需要注意的是，刚获取网络协程时，g 的状态是处于 waiting 的，因此需要先更新为 runnable 状态.\nwork-stealing: 从其他 p 中偷取 g。 func stealWork(now int64) (gp *g, inheritTime bool, rnow, pollUntil int64, newWork bool) { pp := getg().m.p.ptr() ranTimer := false const stealTries = 4 for i := 0; i \u0026lt; stealTries; i++ { stealTimersOrRunNextG := i == stealTries-1 for enum := stealOrder.start(fastrand()); !enum.done(); enum.next() { // ... } } return nil, false, now, pollUntil, ranTime 偷取操作最多进行 4 轮尝试。每一轮都会按一个随机顺序去检查其他 P，看它们的本地运行队列里有没有 G 可以偷，过程中只要找到可窃取的 p 则会立即返回；如果没找到，会继续尝试其他来源：\nstealWork 返回 nil ↓ 检查 netpoll（网络事件有没有就绪的 g） ↓ 检查 timer（有没有到期的定时器） ↓ 再次检查全局队列 ↓ 真的什么都没有 → M 解绑 P，P 进入空闲列表 → M 陷入休眠（park），等待被唤醒 为保证窃取行为的公平性，遍历的起点是随机的。窃取动作的核心逻辑位于 runqgrab 方法当中：\nfunc runqgrab(_p_ *p, batch *[256]guintptr, batchHead uint32, stealRunNextG bool) uint32 { for { h := atomic.LoadAcq(\u0026amp;_p_.runqhead) // load-acquire, synchronize with other consumers t := atomic.LoadAcq(\u0026amp;_p_.runqtail) // load-acquire, synchronize with the producer n := t - h n = n - n/2 if n == 0 { if stealRunNextG { // Try to steal from _p_.runnext. if next := _p_.runnext; next != 0 { if _p_.status == _Prunning { if GOOS != \u0026#34;windows\u0026#34; \u0026amp;\u0026amp; GOOS != \u0026#34;openbsd\u0026#34; \u0026amp;\u0026amp; GOOS != \u0026#34;netbsd\u0026#34; { usleep(3) } else { osyield() } } if !_p_.runnext.cas(next, 0) { continue } batch[batchHead%uint32(len(batch))] = next return 1 } } return 0 } if n \u0026gt; uint32(len(_p_.runq)/2) { // read inconsistent h and t continue } for i := uint32(0); i \u0026lt; n; i++ { g := _p_.runq[(h+i)%uint32(len(_p_.runq))] batch[(batchHead+i)%uint32(len(batch))] = g } if atomic.CasRel(\u0026amp;_p_.runqhead, h, h+n) { // cas-release, commits consume return n } } } I 每次对一个 p 尝试窃取前，会通过原子操作读取目标 P 的 head 和 tail 指针，获取当前队列的状态快照，为后续计算可窃取数量做准备。\nh := atomic.LoadAcq(\u0026amp;_p_.runqhead) // load-acquire, synchronize with other consumers t := atomic.LoadAcq(\u0026amp;_p_.runqtail) // load-acquire, synchronize with the producer II 尝试偷取其现有的一半 g，并且返回实际偷取的数量.\nn := t - h n = n - n/2 // ... for i := uint32(0); i \u0026lt; n; i++ { g := _p_.runq[(h+i)%uint32(len(_p_.runq))] batch[(batchHead+i)%uint32(len(batch))] = g } if atomic.CasRel(\u0026amp;_p_.runqhead, h, h+n) { // cas-release, commits consume return n } execute() # 当 g0 为 m 寻找到可执行的 g 之后，接下来就开始执行 g. 这部分内容位于 runtime/proc.go 的 execute 方法中：\nfunc execute(gp *g, inheritTime bool) { _g_ := getg() _g_.m.curg = gp gp.m = _g_.m casgstatus(gp, _Grunnable, _Grunning) gp.waitsince = 0 gp.preempt = false gp.stackguard0 = gp.stack.lo + _StackGuard if !inheritTime { _g_.m.p.ptr().schedtick++ } gogo(\u0026amp;gp.sched) （1）更新 g 的状态信息，建立 g 与 m 之间的绑定关系；\n（2）更新 p 的总调度次数；\n（3）调用 gogo 方法，执行 goroutine 中的任务.\ngosched_m # g 执行主动让渡时，会调用 mcall 方法将执行权归还给 g0，并由 g0 调用 gosched_m 方法，位于 runtime/proc.go 文件中：\nfunc Gosched() { // ... mcall(gosched_m) } func gosched_m(gp *g) { goschedImpl(gp) } func goschedImpl(gp *g) { status := readgstatus(gp) if status\u0026amp;^_Gscan != _Grunning { dumpgstatus(gp) throw(\u0026#34;bad g status\u0026#34;) } casgstatus(gp, _Grunning, _Grunnable) dropg() lock(\u0026amp;sched.lock) globrunqput(gp) unlock(\u0026amp;sched.lock) schedule() （1）将当前 g 的状态由执行中切换为待执行 _Grunnable：\ncasgstatus(gp, _Grunning, _Grunnable) （2）调用 dropg() 方法，将当前的 m 和 g 解绑；\nfunc dropg() { _g_ := getg() setMNoWB(\u0026amp;_g_.m.curg.m, nil) setGNoWB(\u0026amp;_g_.m.curg, nil) } （3）将 g 添加到全局队列当中：\nlock(\u0026amp;sched.lock) globrunqput(gp) unlock(\u0026amp;sched.lock （4）开启新一轮的调度：\nschedule() park_m和ready # g 需要被动调度时，会调用 mcall 方法切换至 g0，并调用 park_m 方法将 g 置为阻塞态，执行流程位于 runtime/proc.go 的 gopark 方法当中：\nfunc gopark(unlockf func(*g, unsafe.Pointer) bool, lock unsafe.Pointer, reason waitReason, traceEv byte, traceskip int) { // ... mcall(park_m) } func park_m(gp *g) { _g_ := getg() casgstatus(gp, _Grunning, _Gwaiting) dropg() // ... schedule() （1）将当前 g 的状态由 running 改为 waiting；\n（2）将 g 与 m 解绑；\n（3）执行新一轮的调度 schedule.\n当因被动调度陷入阻塞态的 g 需要被唤醒时，会由其他协程执行 goready 方法将 g 重新置为可执行的状态，方法位于 runtime/proc.go .\n被动调度如果需要唤醒，则被其他 g 负责将 g 的状态由 waiting 改为 runnable，然后会将其添加到唤醒者的 p 的本地队列中：\nfunc goready(gp *g, traceskip int) { systemstack(func() { ready(gp, traceskip, true) }) } func ready(gp *g, traceskip int, next bool) { // ... _g_ := getg() // ... casgstatus(gp, _Gwaiting, _Grunnable) runqput(_g_.m.p.ptr(), gp, next) // ... } （1）先将 g 的状态从阻塞态改为可执行的状态；\n（2）调用 runqput 将当前 g 添加到唤醒者 p 的本地队列中，如果队列满了，会连带 g 一起将一半的元素转移到全局队列.\ngoexit0 # 当 g 执行完成时，会先执行 mcall 方法切换至 g0，然后调用 goexit0 方法，内容为 runtime/proc.go：\n// Finishes execution of the current goroutine. func goexit1() { // ... mcall(goexit0) } func goexit0(gp *g) { _g_ := getg() _p_ := _g_.m.p.ptr() casgstatus(gp, _Grunning, _Gdead) // ... gp.m = nil // ... dropg() // ... schedule() （1）将 g 状态置为 _Gdead；\n（2）解绑 g 和 m；\n（3）开启新一轮的调度.\nretake # retake 这条路径不是靠“当前这个 M 的 g0 主动发起”，而是由 monitor g 从外部检测并发起抢占。\nfunc retake(now int64) uint32 { n := 0 lock(\u0026amp;allpLock) for i := 0; i \u0026lt; len(allp); i++ { _p_ := allp[i] if _p_ == nil { // This can happen if procresize has grown // allp but not yet created new Ps. continue } pd := \u0026amp;_p_.sysmontick // ... if s == _Psyscall { // ... if runqempty(_p_) \u0026amp;\u0026amp; atomic.Load(\u0026amp;sched.nmspinning)+atomic.Load(\u0026amp;sched.npidle) \u0026gt; 0 \u0026amp;\u0026amp; pd.syscallwhen+10*1000*1000 \u0026gt; now { continue } unlock(\u0026amp;allpLock) if atomic.Cas(\u0026amp;_p_.status, s, _Pidle) { n++ _p_.syscalltick++ handoffp(_p_) } incidlelocked(1) lock(\u0026amp;allpLock) } } unlock(\u0026amp;allpLock) return uint32(n) } （1）加锁后，遍历全局的 p 队列，寻找需要被抢占的目标：\nlock(\u0026amp;allpLock) for i := 0; i \u0026lt; len(allp); i++ { _p_ := allp[i] // ... } unlock(\u0026amp;allpLock) （2）倘若某个 p 同时满足下述条件，则会进行抢占调度：\nI 执行系统调用超过 10 ms；\nII p 本地队列有等待执行的 g；\nIII 或者当前没有空闲的 p 和 m.\nif s == _Psyscall { // ... if runqempty(_p_) \u0026amp;\u0026amp; atomic.Load(\u0026amp;sched.nmspinning)+atomic.Load(\u0026amp;sched.npidle) \u0026gt; 0 \u0026amp;\u0026amp; pd.syscallwhen+10*1000*1000 \u0026gt; now { continue } // ... 抢占调度 lock(\u0026amp;allpLock) } （3）抢占调度的步骤是，先将当前 p 的状态更新为 idle，然后步入 handoffp 方法中，判断是否需要为 p 寻找接管的 m（因为其原本绑定的 m 正在执行系统调用）：\nif atomic.Cas(\u0026amp;_p_.status, s, _Pidle) { n++ _p_.syscalltick++ handoffp(_p_) } （4）当以下四个条件满足其一时，则需要为 p 获取新的 m：\nI 当前 p 本地队列还有待执行的 g；\nII 全局繁忙（没有空闲的 p 和 m，全局 g 队列为空）\nIII 需要处理网络 socket 读写请求\nfunc handoffp(_p_ *p) { if !runqempty(_p_) || sched.runqsize != 0 { startm(_p_, false) return } if atomic.Load(\u0026amp;sched.nmspinning)+atomic.Load(\u0026amp;sched.npidle) == 0 \u0026amp;\u0026amp; atomic.Cas(\u0026amp;sched.nmspinning, 0, 1) { startm(_p_, true) return } lock(\u0026amp;sched.lock) // ... if sched.runqsize != 0 { unlock(\u0026amp;sched.lock) startm(_p_, false) return } // If this is the last running P and nobody is polling network, // need to wakeup another M to poll network. if sched.npidle == uint32(gomaxprocs-1) \u0026amp;\u0026amp; atomic.Load64(\u0026amp;sched.lastpoll) != 0 { unlock(\u0026amp;sched.lock) startm(_p_, false) return } // ... （5）获取 m 时，会先尝试获取已有的空闲的 m，若不存在，则会创建一个新的 m.\nfunc startm(_p_ *p, spinning bool) { mp := acquirem() lock(\u0026amp;sched.lock) // ... nmp := mget() if nmp == nil { id := mReserveID() unlock(\u0026amp;sched.lock) var fn func() // ... newm(fn, _p_, id) // ... return } unlock(\u0026amp;sched.lock) // ... } 调度器的生命周期 # M0 M0是启动程序后的编号为0的主线程，这个M对应的实例会在全局变量runtime.m0中，不需要在heap上分配，M0负责执行初始化操作和启动第一个G， 在之后M0就和其他的M一样了。\nG0 G0是每次启动一个M都会第一个创建的gourtine，G0仅用于负责调度的G，G0不指向任何可执行的函数, 每个M都会有一个自己的G0。在调度或系统调用时会使用G0的栈空间, 全局变量的G0是M0的G0。\nGo 调度器场景过程全解析 # 场景1：G1 创建 G2 # P拥有G1，M1获取P后开始运行G1，G1使用go func()创建了G2，为了局部性G2优先加入到P的本地队列。\n场景2：G1 执行完毕 # G1运行完成后(函数：goexit)，M上运行的goroutine切换为G0，G0负责调度时协程的切换（函数：schedule）。从P的本地队列取G2，从G0切换到G2，并开始运行G2(函数：execute)。实现了线程M1的复用。\n场景3：G2 开辟过多的 G # 假设每个P的本地队列只能存4个G。G2要创建了6个G，前4个G（G3, G4, G5, G6）已经加入p1的本地队列，p1本地队列满了。\n场景4：G2 本地满再创建 G7 # G2在创建G7的时候，发现P1的本地队列已满，需要执行负载均衡(把P1中本地队列中前一半的G(G3, G4)，还有新创建G转移到全局队列)\n实现中并不一定是新的G，如果G是G2之后就执行的，会被保存在本地队列，利用某个老的G替换新G加入全局队列\n这些G被转移到全局队列时，会被打乱顺序。所以G3,G4,G7被转移到全局队列。\n场景5：G2 本地未满创建 G8 # G2创建G8时，P1的本地队列未满，所以G8会被加入到P1的本地队列。\nG8加入到P1点本地队列的原因还是因为P1此时在与M1绑定，而G2此时是M1在执行。所以G2创建的新的G会优先放置到自己的M绑定的P上。\n场景6：唤醒正在休眠的M # 规定：在创建G时，运行的G会尝试唤醒其他空闲的P和M组合去执行。\n假定G2唤醒了M2，M2绑定了P2，并运行G0，但P2本地队列没有G，M2此时为自旋线程（没有G但为运行状态的线程，不断寻找G）。\n场景7：被唤醒的M2从全局队列取批量G # M2尝试从全局队列(简称“GQ”)取一批G放到P2的本地队列（函数：findrunnable()）。M2从全局队列取的G数量符合下面的公式：\nn = min(len(GQ) / GOMAXPROCS + 1, cap(LQ) / 2 ) 至少从全局队列取1个g，但每次不要从全局队列移动太多的g到p本地队列，给其他p留点。这是从全局队列到P本地队列的负载均衡。\n假定我们场景中一共有4个P（GOMAXPROCS设置为4，那么我们允许最多就能用4个P来供M使用）。所以M2只从能从全局队列取1个G（即G3）移动P2本地队列，然后完成从G0到G3的切换，运行G3。\n场景8：M2从M1中偷取G # 假设G2一直在M1上运行，经过2轮后，M2已经把G7、G4从全局队列获取到了P2的本地队列并完成运行，全局队列和P2的本地队列都空了,如场景8图的左半部分。\n全局队列已经没有G，那M就要执行work stealing(偷取)：从其他有G的P哪里偷取一半G过来，放到自己的P本地队列。P2从P1的本地队列尾部取一半的G，本例中一半则只有1个G8，放到P2的本地队列并执行。\n场景9：自旋线程的最大限制 # P1本地队列中的G5、G6已经被其他M偷走并运行完成，当前M1和M2分别在运行G2和G8，M3和M4没有goroutine可以运行，M3和M4处于自旋状态，它们不断寻找goroutine。\n为什么要让m3和m4自旋，自旋本质是在运行，线程在运行却没有执行G，就变成了浪费CPU. 为什么不销毁现场，来节约CPU资源。因为创建和销毁CPU也会浪费时间，我们希望当有新goroutine创建时，立刻能有M运行它，如果销毁再新建就增加了时延，降低了效率。当然也考虑了过多的自旋线程是浪费CPU，所以系统中最多有GOMAXPROCS个自旋的线程(当前例子中的GOMAXPROCS=4，所以一共4个P)，多余的没事做线程会让他们休眠。\n场景10：G 发生系统调用/阻塞 # 假定当前除了M3和M4为自旋线程，还有M5和M6为空闲的线程(没有得到P的绑定，注意我们这里最多就只能够存在4个P，所以P的数量应该永远是M\u0026gt;=P, 大部分都是M在抢占需要运行的P)，G8创建了G9，G8进行了阻塞的系统调用，M2和P2立即解绑，P2会执行以下判断：如果P2本地队列有G、全局队列有G或有空闲的M，P2都会立马唤醒1个M和它自己绑定，否则P2则会加入到空闲P列表，等待M来获取可用的p。本场景中，P2本地队列有G9，可以和其他空闲的线程M5绑定。\n场景11：G 发生系统调用/非阻塞 # G8创建了G9，假如G8进行了非阻塞系统调用。\nM2和P2会解绑，但M2会记住P2，然后G8和M2进入系统调用状态。当G8和M2退出系统调用时，会尝试获取P2，如果无法获取，则获取空闲的P，如果依然没有，G8会被记为可运行状态，并加入到全局队列，M2因为没有P的绑定而变成休眠状态(长时间休眠等待GC回收销毁)。\n","date":"2026-08-22","externalUrl":null,"permalink":"/CodeLab/blog/go/gmp-model-analysis/","section":"知识","summary":"概念梳理 # 线程 # 通常语义中的线程，指的是内核级线程，核心点如下：\n","title":"GMP 模型分析","type":"blog"},{"content":"","date":"2026-08-22","externalUrl":null,"permalink":"/CodeLab/tags/gc/","section":"标签","summary":"","title":"GC","type":"tags"},{"content":" 垃圾回收算法 # 标记清除算法 # 分为两步走：\n标记：标记出当前还存活的对象\n清除：清扫掉未被标记到的垃圾对象\n缺点：会产生内存碎片。如果有大对象需要分配内存，可能会因为没有内存空间导致分配失败\n标记压缩算法 # 也是两步走：\n标记：标记出当前还存活的对象\n清除+压缩：在清除掉未被标记的垃圾对象的同时，对存活对象进行压缩整合，使得整体空间更为紧凑，解决内存空间问题\n缺点：实现复杂\n半空间复制算法 # 核心点：\n分配两个相等大小的空间，称为fromspace和tospace\n每轮只使用fromspace空间，以GC作为分水岭划分轮次\nGC时，将fromspace中的存活对象转移到tospace中，然后对tospace空间的对象进行压缩整合，同时清除fromspace空间中的垃圾对象\nGC后，交换fromspace和tospace，开启新的轮次\n优点：解决了内存碎片问题\n缺点：比较浪费空间\nGo的三色标记法 # 三色标记法的过程如下：\n每次新创建的对象，默认的颜色都是标记为【白色】\n每次GC回收开始，会从根节点开始遍历（非递归遍历，只遍历一次）所有对象，把遍历到的对象从【白色】集合放入【灰色】集合\n遍历【灰色】集合，将【灰色】对象引用的对象从【白色】集合放入【灰色】集合，之后将此【灰色】对象放入【黑色】集合\n重复第三步，直到【灰色】集合中没有对象为止\n此时全部内存的数据只有【黑色】和【白色】对象，回收所有【白色】标记的对象，因为它是不可达对象\nGo并发垃圾回收 # 在Go1.5之前，GC时需要停止全部的用户协程，专注完成GC工作后，再恢复用户协程。\nGo1.5之后，引入了并发垃圾回收机制，允许用户协程和GC协程同时运行。\n存在问题 # 可能存在漏标问题 # 漏标问题指的是在用户协程与GC协程并发执行的场景下，部分存活对象未被标记从而被误删的情况。这一问题产生的过程如下：\n条件：初始时刻，对象B持有对象C的引用\nmoment1：GC协程下，对象A被扫描完成，置黑；此时对象B是灰色，还未完成扫描\nmoment2：用户协程下，对象A建立指向对象C的引用\nmoment3：用户协程下，对象B删除指向对象C的引用\nmoment4：GC协程下，开始执行对对象B的扫描\n在上述场景中，由于GC协程在B删除C的引用后才开始扫描B，因此无法到达C. 又因为A已经被置黑，不会再重复扫描，因此从扫描结果上看，C是不可达的\n然而事实上，C应该是存活的（被A引用），而GC结束后会因为C仍为白色，因此被GC误删.\n漏标问题是无法接受，其引起的误删现象可能会导致程序出现致命的错误。针对漏标问题，Golang 给出的解决方案是屏障机制的使用。\n可能存在多标问题 # 多标问题指的是在用户协程与GC协程并发执行的场景下，部分垃圾对象被误标记从而导致GC未按时将其回收的问题。这一问题产生的过程如下：\n条件：初始时刻，对象A持有对象B的引用\nmoment1：GC协程下，对象A被扫描完成，置黑；由于对象B被对象A引用，因此被置灰\nmoment2：用户协程下，对象A删除指向对象B的引用\n上述场景引发的问题是，在事实上，B在被A删除引用后，已成为垃圾对象，但由于其事先已被置灰，因此最终会更新为黑色，不会被GC删除\n多标问题对比于漏标问题而言，是相对可以接受的。其导致本该被删但仍侥幸存活的对象被称为“浮动垃圾”，至多到下一轮GC，这部分对象就会被GC回收，因此错误可以得到弥补\n屏障机制 # 强弱三色不变式 # 漏标问题的本质就是，一个已经扫描完成的黑对象指向了一个被灰/白对象删除引用的白色对象。构成这一场景的要素拆分如下：\n黑色对象指向了白色对象\n灰、白对象删除了白色对象\n1、2 步中谈及的白色对象是同一个对象\n1 发生在 2 之前\n一套用于解决漏标问题的方法论称之为强弱三色不变式：\n强三色不变式：白色对象不能被黑色对象直接引用（直接破坏 1）\n弱三色不变式：白色对象可以被黑色对象引用，但要从某个灰对象出发仍然可达该白对象（间接破坏了 1、2 的联动）\n插入写屏障 # 屏障机制类似于一个回调保护机制，指的是在完成某个特定动作前，会先完成屏障成设置的内容。\n插入写屏障（Dijkstra）的目标是实现强三色不变式，保证当一个黑色对象指向一个白色对象前，会先触发屏障将白色对象置为灰色，再建立引用。\n删除写屏障 # 删除写屏障（Yuasa barrier）的目标是实现弱三色不变式，保证当一个白色对象即将被上游删除引用前，会触发屏障将其置灰，之后再删除上游指向其的引用。\n混合写屏障 # 插入写屏障、删除写屏障二者择其一，即可解决并发GC的漏标问题，至于错标问题，则采用容忍态度，放到下一轮GC中进行延后处理即可。\n然而真实场景中，需要补充一个新的设定——屏障机制无法作用于「栈对象」\n这是因为栈对象可能涉及频繁的轻量操作，倘若这些高频度操作都需要一一触发屏障机制，那么所带来的成本将是无法接受的。\n在这一背景下，单独看插入写屏障或删除写屏障，都无法真正解决漏标问题，除非我们引入额外的Stop the world（STW）阶段，对栈对象的处理进行兜底。\n为了消除这个额外的 STW 成本，Golang 1.8 引入了混合写屏障机制，可以视为糅合了插入写屏障+删除写屏障的加强版本，要点如下：\nGC 开始前，以栈为单位分批扫描，将栈中所有对象置黑\nGC 期间，栈上新创建对象直接置黑\n堆对象正常启用插入写屏障\n堆对象正常启用删除写屏障\n下面通过几个 case，来论证混合写屏障机制是否真的能解决并发GC下的各种极端场景问题。\nCase1 # 堆对象删除引用，栈对象建立引用\n背景：\n存在栈上对象A，黑色（扫描完）；\n存在堆上对象B，白色（未被扫描）；\n存在堆上对象C，白色（未被扫描），被堆上对象B引用\nmoment1：A建立对C的引用，由于栈无屏障机制，因此正常建立引用，无额外操作\nmoment2：B尝试删除对C的引用，删除写屏障被触发，C被置灰，因此不会漏标\nCase2 # 一个堆对象删除引用，成为另一个堆对象下游\n背景：\n存在堆上对象A，白色（未被扫描）；\n存在堆上对象B，黑色（已完成扫描）；\n存在堆上对象C，白色（未被扫描），被堆上对象B引用\nmoment1：B尝试建立对C的引用，插入写屏障被触发，C被置灰\nmoment2：A删除对C的引用，此时C已置灰，因此不会漏标\nCase3 # 栈对象删除引用，成为堆对象下游\n背景：\n存在栈上对象A，白色（未完成扫描，说明对应的栈未扫描）；\n存在堆上对象B，黑色（已完成扫描）；\n存在堆上对象C，白色（未被扫描），被栈上对象A引用\nmoment1：B尝试建立对C的引用，插入写屏障被触发，C被置灰\nmoment2：A删除对C的引用，此时C已置灰，因此不会漏标\nCase4 # 一个栈中对象删除引用，另一个栈中对象建立引用\n背景：\n存在栈上对象A，白色（未扫描，这是因为对应的栈还未开始扫描）；\n存在栈上对象B，黑色（已完成扫描，说明对应的栈均已完成扫描）；\n存在堆上对象C，白色（未被扫描），被栈上对象A引用\nmoment1：B建立对C的引用；\nmoment2：A删除对C的引用.\n结论：这种场景下，C要么已然被置灰，要么从某个灰对象触发仍然可达C.\n原因在于，对象的引用不是从天而降，一定要有个来处。当前 case 中，对象B能建立指向C的引用，至少需要满足如下三个条件之一：\n栈对象B原先就持有C的引用，如若如此，C就必然已处于置灰状态（因为B已是黑色）\n栈对象B持有A的引用，通过A间接找到C。然而这也是不可能的，因为倘若A能同时被另一个栈上的B引用到，那样A必然会升级到堆中，不再满足作为一个栈对象的前提；\nB同栈内存在其他对象X可达C，此时从X出发，必然存在一个灰色对象，从其出发存在可达C的路线.\n综上，我们得以证明混合写屏障是能够胜任并发GC场景的解决方案，并且满足栈无须添加屏障的前提。\n","date":"2026-08-22","externalUrl":null,"permalink":"/CodeLab/blog/go/gc-principles-analysis/","section":"知识","summary":"垃圾回收算法 # 标记清除算法 # 分为两步走：\n","title":"GC 原理分析","type":"blog"},{"content":"这里以后会放友情链接、常用工具、资源整理、联系方式以及其他与博客相关的内容。\n","date":"2026-08-22","externalUrl":null,"permalink":"/CodeLab/more/","section":"更多","summary":"这里以后会放友情链接、常用工具、资源整理、联系方式以及其他与博客相关的内容。\n","title":"更多","type":"more"},{"content":" 学习资料 # 这里专门整理与学习相关的资料，包括技术路线、书籍、视频、工具和文档等，方便随时查阅。\nGo # Go 语言相关书籍，可在线阅读，也可在 GitHub 仓库下载 PDF。\nGo 语言设计与实现 # 在线阅读 GitHub 下载 Go 程序员面试笔试宝典 # 在线阅读 GitHub 下载 Go 语言圣经 # 在线阅读 GitHub 下载 架构设计 # 架构设计相关书籍，可在 GitHub 仓库下载 PDF。\n亿级流量系统架构设计与实战 # GitHub 下载 分布式系统实战派：从简单系统到复杂系统 # GitHub 下载 Agent # AI Agent 相关书籍。\n《深入理解 AI Agent：设计原理与工程实践》 # GitHub 仓库 本文档会持续更新。\n","date":"2026-08-22","externalUrl":null,"permalink":"/CodeLab/more/learning/","section":"更多","summary":"学习资料 # 这里专门整理与学习相关的资料，包括技术路线、书籍、视频、工具和文档等，方便随时查阅。\n","title":"学习资料","type":"more"},{"content":"","date":"2026-08-22","externalUrl":null,"permalink":"/CodeLab/tags/%E5%AD%A6%E4%B9%A0%E8%B5%84%E6%96%99/","section":"标签","summary":"","title":"学习资料","type":"tags"},{"content":" 关于 CodeLab # CodeLab 是一个专注于后端开发与 AI Agent 实践的个人博客，主要分享技术知识、实战项目，以及我在工作中积累的经验与思考。\n我希望通过持续记录、复盘和整理，把零散的实践沉淀为可复用的方法，也和同样关注后端开发与 AI Agent 的朋友交流学习。\n作者 # 大家好，我是程序员羊肉，一名后端开发者，也是一位小红书创作者。我从在小红书分享学习经验开始，目前已经收获了 2500 多位粉丝。\nCodeLab 源于我创建个人博客的想法：记录平时的学习与实践，逐步沉淀出一套属于自己的方法论，也希望这些内容能帮助更多人。\n你也可以把 CodeLab 当作一个开放的知识社区。如果你愿意分享自己的经验，欢迎参与贡献、提交 PR，一起完善这里的内容。\n","date":"2026-08-13","externalUrl":null,"permalink":"/CodeLab/about/","section":"欢迎来到CodeLab","summary":"关于 CodeLab # CodeLab 是一个专注于后端开发与 AI Agent 实践的个人博客，主要分享技术知识、实战项目，以及我在工作中积累的经验与思考。\n","title":"关于","type":"page"},{"content":"","date":"2026-08-13","externalUrl":null,"permalink":"/CodeLab/tags/%E8%87%AA%E6%88%91%E4%BB%8B%E7%BB%8D/","section":"标签","summary":"","title":"自我介绍","type":"tags"},{"content":"","date":"2026-08-01","externalUrl":null,"permalink":"/CodeLab/blog/other/","section":"知识","summary":"","title":"其他","type":"blog"},{"content":"","externalUrl":null,"permalink":"/CodeLab/blog/agent/","section":"知识","summary":"","title":"Agent","type":"blog"},{"content":"","externalUrl":null,"permalink":"/CodeLab/authors/","section":"Authors","summary":"","title":"Authors","type":"authors"},{"content":"","externalUrl":null,"permalink":"/CodeLab/categories/","section":"Categories","summary":"","title":"Categories","type":"categories"},{"content":"","externalUrl":null,"permalink":"/CodeLab/blog/work/","section":"知识","summary":"","title":"工作沉淀","type":"blog"},{"content":" ","externalUrl":null,"permalink":"/CodeLab/series/","section":"系列","summary":"","title":"系列","type":"series"},{"content":"感谢每一位为 CodeLab 做出贡献的开发者，是你们的付出让这个网站不断成长。\npeakxy 25 个提交 SppamLite 1 个提交 ","externalUrl":null,"permalink":"/CodeLab/contributors/","section":"欢迎来到CodeLab","summary":"感谢每一位为 CodeLab 做出贡献的开发者，是你们的付出让这个网站不断成长。\n","title":"致谢","type":"page"}]