一、引言:为什么要单独写 sudog?
技术认知的提升往往是一个螺旋上升的过程。回看之前写的《深度解密 Go 语言调度器:GMP 模型精讲》以及更早前的《聊聊 golang 中 channel》,虽然涵盖了主干,但对 sudog 这个关键结点的处理总觉得欠些火候。
时过境迁,当我对 Go 运行时的理解不再停留在表面时,我发现 sudog 才是连接 GMP 模型与 Channel 同步机制的那枚"定海神针"。
在 Go 中,当一个 Goroutine (G) 因为 Channel 操作而阻塞时,它必须"挂起"。直接用 g 结构体来承载阻塞信息是不够的,原因有三:
- 职责单一性:
g描述的是执行体的状态(栈、寄存器等),而sudog描述的是"等待的行为"(等哪个 chan、发还是收、数据存哪)。 - 多重等待(select 难题): 一个 G 在执行
select时,可能同时在 5 个 Channel 的等待队列里。我们无法让一个g结构体同时出现在 5 个链表中,但可以创建 5 个sudog指向同一个g。 - 解耦与性能:
g结构体非常庞大,而sudog极度轻量且通过对象池(cache)复用,能极大减少内存压力。
本篇,我们就来揭开这个"代理人"------sudog 的神秘面纱。
二、从 Channel 阻塞说起:G 是如何"睡着"的?
为了看清 sudog 的由来,我们先脱离复杂的理论,看一段再普通不过的代码:
go
func main() {
ch := make(chan string)
go func() {
// G1: 尝试从 channel 接收数据,由于此时没有发送者,G1 将进入阻塞
data := <-ch
fmt.Println("接收到数据:", data)
}()
// 为了演示阻塞,主协程先睡一会,不给 ch 发数据
time.Sleep(time.Hour)
}
在这段代码中,子协程(我们称之为 G1 )在执行到 data := <-ch 时,由于 Channel 缓冲区为空且没有发送者,它会被立刻"挂起"。
从宏观上看,程序只是停在了这一行;但如果我们把视角切换到 Go Runtime 的底层,这其实是一场极其细腻的**"交接仪式"**。
1. 寻找归宿:recvq 队列
当 G1 发现自己无法从 ch 中读到数据时,它并不能直接原地消失。它需要把自己"登记"在 ch 的等待名单上。
在 Channel 的底层结构 hchan 中,有两个双向链表:recvq(接收等待队列)和 sendq(发送等待队列)。此时,G1 必须进入 recvq 排队,等待某一个发送者(Sender)来唤醒它。
go
// runtime/chan.go
type hchan struct {
// ...
recvq waitq // 等待接收的 goroutine 队列
sendq waitq // 等待发送的 goroutine 队列
// ...
}
当你写下 data := <-ch 时,底层的执行链路如下:
go
// src/runtime/chan.go
func chanrecv(c *hchan, ep unsafe.Pointer, block bool) (selected, received bool) {
// ... 前面省略了对非阻塞判断、Channel 关闭判断、以及从缓冲区取数据的逻辑 ...
// 1. 加锁:准备操作等待队列
lock(&c.lock)
// 2. 再次检查:如果没有发送者在等待,且缓冲区也没有数据
if c.sendq.dequeue() == nil && c.qcount == 0 {
if !block { // 如果是非阻塞请求(如 select 中的 default),直接返回
unlock(&c.lock)
return false, false
}
// 3. 核心阻塞逻辑开始:获取当前 G
gp := getg()
// 4. 从当前 P 的缓存中获取一个轻量级的 sudog 结构
// 这是 sudog 第一次登场:它是 G 的"替身"
mysg := acquireSudog()
mysg.releasetime = 0
mysg.elem = ep // 重点:告诉 sudog 醒来后把数据拷贝到哪个内存地址
mysg.g = gp // 重点:绑定当前的 Goroutine
mysg.c = c // 绑定当前的 Channel
gp.waiting = mysg // G 也记录一下自己正在哪个 sudog 上等
// 5. 将 sudog 放入 Channel 的接收等待队列 (recvq)
c.recvq.enqueue(mysg)
// 6. 关键的一步:调用 gopark 挂起当前 G
// chanparkcommit 是一个回调函数,用于在 G 挂起后释放 c.lock
gopark(chanparkcommit, unsafe.Pointer(&c.lock), waitReasonChanReceive, traceEvGoBlockRecv, 2)
// ---------------------------------------------------------
// G 被唤醒后,会从这里继续往下执行!
// ---------------------------------------------------------
// 7. 醒来后的扫尾工作
if mysg != gp.waiting {
throw("G waiting list is corrupted")
}
gp.waiting = nil
success := mysg.success // 记录是否成功接收到了数据
// 释放 sudog 回对象池
releaseSudog(mysg)
return true, success
}
// ... 后续逻辑 ...
}
src/runtime/chan.go: 调用chanrecv函数。- 在
chanrecv内部,如果发现没有数据可读,会执行以下逻辑:- 创建一个
sudog。 - 将
sudog放入hchan.recvq。 - 重点: 调用
gopark。 - 在调用时,会传入一个
waitReason(等待原因),对于 Channel 接收,这个原因是waitReasonChanReceive。
- 创建一个
这里在说说 chanrecv 源码中关于 sugog 的几个细节
-
mysg.elem = ep的深意:ep是接收变量(如data := <-ch中的&data)的地址。- 深度点: 为什么不直接把地址存给 G?因为 G 可能会参与
select,同时监听多个 Channel。每个 Channel 都需要知道数据往哪拷贝,这个信息必须存在特定于该次等待操作的sudog里。
-
acquireSudog()的性能考量:- Go Runtime 为了极致性能,并没有每次阻塞都从堆上分配
sudog。 - 深度点: 每个 P(调度器处理器)都有一个
sudogcache本地缓存池。这样在高并发 Channel 通信时,sudog的分配几乎是零开销的。【这在上一篇《深度解密 Go 语言调度器:GMP 模型精讲》关于 P 中也提到过】
- Go Runtime 为了极致性能,并没有每次阻塞都从堆上分配
-
chanparkcommit的锁切换:- 这是一个非常精妙的设计。
gopark必须在 G 彻底挂起之后 ,才能把 Channel 的锁(c.lock)给解开。 - 深度点: 如果先解开锁再挂起,可能会出现"唤醒丢失":发送者在 G 还没进入
_Gwaiting状态前就尝试去唤醒它。chanparkcommit保证了整个挂起过程的原子性。
- 这是一个非常精妙的设计。
-
releaseSudog(mysg):- 当 G 醒来时,任务已完成(数据已被发送者直接拷贝到了
ep指向的地址)。 - 深度点: 此时
sudog的使命结束,重新回到 P 的缓存池中,等待下一次阻塞。
- 当 G 醒来时,任务已完成(数据已被发送者直接拷贝到了
2. 核心动作:gopark
登记完信息后,G1 会调用 Runtime 的核心函数 gopark。
gopark 是 Goroutine 从运行到停止的转折点,它的任务是:让出 CPU。
go
// src/runtime/proc.go
func gopark(unlockf func(*g, unsafe.Pointer) bool, lock unsafe.Pointer, reason waitReason, traceEv byte, nframes int) {
// ...
mp := acquirem()
gp := mp.curg
// ...
mp.waitlock = lock
mp.waitunlockf = unlockf
// ...
// 真正的切换动作在这里
mcall(park_m)
}
mcall(park_m):这是一个汇编实现的函数,它的作用是从当前 Goroutine 的栈切换到 g0 的栈 (g0 负责调度,每个 M 中都有一个 g0,具体可以参考上一篇《深度解密 Go 语言调度器:GMP 模型精讲》)。- 状态切换 :在
park_m内部,会将当前 G 的状态从_Grunning改为_Gwaiting。 - 解除关联:将当前 M(物理线程)与 G 解绑。
- 重新调度 :调用
schedule()。这时,M 会去 P 的本地队列或全局队列里找下一个可运行的 G 来执行。
至此,G1 就像进入了深度睡眠,不再占用任何 CPU 资源。
3. 阻塞的 G 去了哪里?
很多同学认为阻塞的 G 会被扔进全局队列。答案是否定的。
一旦进入阻塞状态,G1 会从所有运行队列中'彻底消失'。调度器(P)不再理会它,工作线程(M)也转头去跑别的 G 了。此时的 G1,就像断了线的风筝,唯一的线头攥在 Channel 的手中。
确切地说,是 Channel 的 recvq 链表里挂着一个 sudog,而这个 sudog 紧紧拉住了 G1。如果这个 Channel 永远没有人发数据,且没有其他地方引用这个 Channel,那么 G1 就会随着 Channel 一起被 GC 掉,或者造成永久的死锁。
这种'不在调度器内,而在同步组件内'的设计,正是 Go 实现高并发同步的精妙之处。"
4. 悬念:为什么不能直接用 g??
既然 sudog 已经带着 G 成功在 Channel 里"挂了号",我们不禁要问:
为什么非要多此一举搞个 sudog,而不是直接把 g 扔进队列?
在上面的分析中,我们已经窥见了一些端倪(如 elem 地址的存储)。但更深层的矛盾在于:
- 一个 G 如果在执行 select,它可能同时在 5 个 Channel 的等待队列里,难道我们要给 g 结构体内置一个 5 长度的数组吗?
- 数据拷贝时,发送方如何优雅地跨越栈界限找到接收方的地址?
sudog 就像是 G 在阻塞世界里的"替身"或"代理人"。它比 g 更轻量,比 g 更灵活。
下一章,我们就来剥开 sudog 的外壳,看看这个"代理人"内部到底藏了哪些绝密数据。
三、深入 sudog 内部:这张"名片"印了什么?
waiting (持有首个sudog)
串联同一个 select 中的所有 sudog
反向指向宿主
反向指向宿主
挂载在 Ch1 的等待队列
挂载在 Ch2 的等待队列
标识归属
标识归属
1 waitlink g g recvq recvq c c 1 Goroutine_G1
+uintptr stack
+sudog* waiting
+uintptr param
sudog_A
+g: *G1
+c: *Channel_1
+waitlink: *sudog_B
+isSelect: true
+next: *sudog(Ch1 队列后继)
+prev: *sudog(Ch1 队列前驱)
+elem: *data(数据接收地址)
sudog_B
+g: *G1
+c: *Channel_2
+next: nil
+prev: nil
+waitlink: nil
+isSelect: true
+elem: *data(数据接收地址)
Channel_1
+lock mutex
+waitq recvq
Channel_2
+lock mutex
+waitq recvq
在 runtime/runtime2.go 中,sudog 的定义虽然不像 g 那样动辄几百行,但每一个字段都承载着跨协程通信的关键信息。
go
type sudog struct {
// -------------------------------------------
// 核心关联信息
// -------------------------------------------
g *g
next *sudog
prev *sudog
elem unsafe.Pointer // 指向数据的指针(如:x := <-ch 中 x 的地址)
// -------------------------------------------
// 状态与同步信息
// -------------------------------------------
acquiretime int64
releasetime int64
ticket uint32
// isSelect 表示这个 G 是否正在参与 select 操作
// 这决定了唤醒时的逻辑(是否需要原子性地抢占"完成权")
isSelect bool
// success 表示通信是否成功
// 如果是因为 channel 关闭而唤醒,这里为 false
success bool
// -------------------------------------------
// 逻辑链表与树(用于 select)
// -------------------------------------------
parent *sudog // semaRoot 二叉树节点
waitlink *sudog // g.waiting 列表或 select 列表
c *hchan // 关联的 channel
}
我们来深度剖析其中最重要的几个设计点:
1. g *g:谁在等?
这是 sudog 最核心的使命------它必须牢牢指向那个"睡着"的 Goroutine。
当另一个协程(如 Sender)来到 Channel 时,它通过遍历 recvq 拿到这个 sudog,就能通过 g 指针迅速找到目标 G,从而完成"唤醒"动作。
2. elem:数据往哪拷贝?(手递手的秘密)
这是 sudog 性能优化的精髓所在。
在普通的并发模型中,如果 G1 把数据发给 G2,通常需要先把数据拷贝到一个缓冲区,等 G2 醒了再去读。
但 Go 的 Channel 在有等待者时,实现了"零拷贝":
- 当 G1 因
data := <-ch阻塞时,它会把data的地址存在mysg.elem里。 - 当 G2 执行
ch <- "hello"来到现场时,它直接从recvq弹出mysg。 - G2 发现
mysg.elem不为空,于是直接将 "hello" 拷贝到elem指向的地址(即 G1 的栈内存)。
这就是为什么 sudog 必须携带 elem: 它让数据交换跨越了 Goroutine 的界限,实现了真正的"手递手"。
这也体验了 Go 的设计哲学
Do not communicate by sharing memory; instead, share memory by communicating
3. next 与 prev:有序排队
hchan 里的 recvq 和 sendq 本质上就是由 sudog 组成的双向链表。
通过这两个指针,Go 保证了 Channel 等待队列的 FIFO(先入先出) 特性。谁先调用 <-ch,谁的 sudog 就在链表头部,谁就优先被唤醒。这体现了并发竞争中的公平性。
4. isSelect 与 waitlink:应对"多路复用"
这是 sudog 存在的最强理由。
想象一下这个场景:
go
select {
case <-ch1:
case <-ch2:
}
此时 G1 必须同时在 ch1 和 ch2 的 recvq 队列里排队。
- 如果直接用
g结构体,g内部只有一套next/prev指针,它只能待在一个队列里。 - 有了
sudog: Runtime 为 G1 创建两个sudog。sg1挂在ch1,sg2挂在ch2。 waitlink的作用: 这些属于同一个 G 的sudog会通过waitlink串联起来。- 一处唤醒,处处清理: 一旦
ch1有了数据,G1 被唤醒。它会顺着自己的waitlink找到sg2,并从ch2的队列中将其"撤单"。
深度进阶:为何 sudog 的入队需要地址排序?
在 select 场景下,一个 G 会通过多个 sudog 代理人同时潜伏在多个 Channel 的队列中。这引发了一个致命问题:如何同时锁住这些 Channel 而不发生死锁?
Go Runtime 的解法堪称教科书:地址排序加锁。
即使你在代码中先写 case <-chA 后写 case <-chB,运行时在创建 sudog 并挂载它们之前,会先对比 chA 和 chB 的内存地址,永远先锁地址小的那个。
这种设计确保了无论多少个 Goroutine、以什么样乱序的 select 组合在一起,它们加锁的路径永远是"单行道",从根本上杜绝了循环等待导致的死锁。这也再次印证了 sudog 不仅仅是数据的载体,更是复杂并发控制下的重要筹码。
若上面这种说法部分读者看不懂的话,我再用个比较书面的写法解释下。
- 什么时候需要加锁?
当你执行一个普通的 ch1 <- 1 时,底层只需要锁住 ch1 这一个 Channel 就能保证安全。
但是,当你执行 select 时:
go
select {
case <-ch1:
case <-ch2:
}
Go 运行时的 selectgo 函数需要一口气处理多个 Channel 。它需要检查 ch1 是否有数据,同时也要检查 ch2 是否有数据。如果都没数据,它还要把 sudog 分别挂到 ch1 和 ch2 的等待队列里。
为了保证这个"检查 + 挂载"的过程是原子的 (即:不能在我检查 ch1 的时候,别的协程偷偷把 ch2 的数据取走了),select 必须把涉及到的所有 Channel 全部锁住。
- 为什么会有"加锁顺序"?(死锁的由来)
假设我们有两个协程 G1 和 G2,代码如下:
- G1:
select { case <-ch1: case <-ch2: } - G2:
select { case <-ch2: case <-ch1: }(注意顺序换了)
如果 Go 只是简单地按代码里的顺序加锁:
- G1 运行,先锁住了
ch1。 - 此时 G2 运行,先锁住了
ch2。 - 接下来,G1 尝试去锁
ch2,发现被 G2 占着,于是 G1 阻塞等待。 - 接着,G2 尝试去锁
ch1,发现被 G1 占着,于是 G2 阻塞等待。
死锁发生了! 两个协程互相拿着对方想要的锁,谁也不撒手,程序卡死。
- Go 是如何解决的?(地址排序)
为了规避上面的"AB-BA"型死锁,Go 运行时采用了一个非常简单但有效的策略:按内存地址排序。
不管你的代码里 case 是怎么写的,select 在底层加锁前,会先做一个操作:
- 拿到所有
case涉及到的 Channel 指针。 - 根据 Channel 的内存地址(十六进制地址)从小到大排序。
- 严格按照这个排序后的顺序依次加锁。
回到刚才的例子:
假设 ch1 的地址是 0x123,ch2 的地址是 0x456。
- G1 加锁顺序:
ch1(0x123)->ch2(0x456) - G2 加锁顺序:也是
ch1(0x123)->ch2(0x456)(虽然它代码里先写的 ch2,但排序后会变回来)
这样,G1 和 G2 就会去竞争同一个锁(ch1)。谁先抢到 ch1,谁就能继续抢 ch2;抢不到的那个会乖乖等第一个人全部处理完释放。竞争变成了排队,死锁消失了。
selectgo
为什么要单开一个小节来讲 runtime.selectgo,简单来说,它就是 Go 语言中 select 语句在运行时的"大脑"。
当你写下:
go
select {
case <-ch1:
// ...
case ch2 <- 1:
// ...
default:
// ...
}
编译器在编译阶段会把这个复杂的语法结构,转换成对运行时函数 runtime.selectgo 的调用。它是 Go Runtime 处理多路复用逻辑的核心入口。
它在哪?
-
源码位置:
src/runtime/select.go -
函数签名:
gofunc selectgo(cas0 *scase, order0 *uint16, pc0 *uintptr, nsends, nrecvs int, block bool) (int, bool)cas0: 所有的case数组。nsends/nrecvs: 发送和接收 case 的数量。block: 是否需要阻塞(如果没写default,这个值就是true)。- 返回值: 选中的 case 索引和是否接收成功的布尔值。
它和 sudog 的关系是什么?
如果说 chanrecv 是 sudog 的"单笔订单客户",那么 selectgo 就是 sudog 的 "大批发商"。
在 selectgo 内部,当发现所有的 Channel 都没有准备好,且必须进入阻塞状态时,它会循环遍历所有的 case,为每一个 case 都通过 acquireSudog() 拿一个 sudog 出来。
selectgo 的"五部曲"流程
你可以把 selectgo 的执行过程想象成一个严密的算法流程:
第一步:洗牌(Poll Order)
为了公平,它会生成一个随机序列。比如你代码写的是 case 1, 2, 3,它处理的顺序可能是 2, 3, 1。这样可以防止排在前面的 case 永远被优先执行(导致饥饿)。
第二步:定序(Lock Order)
正如我们之前讨论的,为了防止死锁,它会把所有涉及到的 Channel 按内存地址从小到大排序,得到一个加锁顺序。
第三步:快查(Fast Path)
它会先按随机顺序把所有 Channel 扫一遍。如果发现某个 Channel 已经准备好了(比如缓冲区有数,或者有对端在等待),那就直接成交。
- 注意: 如果在这一步就成交了,
selectgo根本不会去碰sudog。
第四步:入队(这里是 sudog 的主场)
如果快查失败,且没有 default 分支,那么 G 必须睡觉。
此时 selectgo 会:
- 按"加锁顺序"锁住所有 Channel。
- 为每个 case 创建一个
sudog。 - 把这些
sudog挂进对应 Channel 的等待队列(recvq或sendq)。 - 将这些
sudog通过waitlink串起来,挂在 G 的waiting字段下。 - 调用
gopark睡觉。
第五步:收尾(Dequeue)
当 G 被某个 Channel 唤醒醒来后,selectgo 会执行"撤单"操作:
- 它会再次锁定所有 Channel。
- 顺着
waitlink链表,把还没成交的sudog从其他 Channel 的队列里一个个拔出来。 - 最后把所有
sudog释放回缓存池。
5. c *hchan:我在等哪个 Channel?
虽然 sudog 挂在 Channel 的队列里,但 sudog 内部也反向持有了 Channel 的引用。这主要用于在复杂的调度逻辑中进行校验,确保唤醒行为的准确性。
6. ticket:select 唤醒的"入场券"
在 select 场景下,一个 Goroutine (G) 会对应多个 sudog(每个 case 一个)。这时会产生一个极端竞争:如果两个 Channel 同时准备好了,谁来唤醒 G?
如果没有 ticket,两个 Channel 可能会同时尝试唤醒同一个 G,导致逻辑混乱或重复唤醒。
- 设计原理:ticket 是一个用于 CAS(Compare And Swap) 原子操作的标志位。
- 竞争机制:当某个 Channel 准备好数据时,它不会直接唤醒 G,而是先尝试通过原子操作"抢夺"这个 G 关联的 sudog 中的 ticket。
- 胜者为王 :
- 第一个成功修改 ticket 的 Channel 赢得了"唤醒权"。它负责将 G 状态改为运行中,并把数据交给 G。
- 其他迟到的 Channel 在尝试修改 ticket 时会失败,于是它们知道这个 G 已经被领走了,便默默放弃。
8. 实例分析
go
func TestSudog(t *testing.T) {
ch1 := make(chan string)
ch2 := make(chan string)
// 我们重点关注这个 Goroutine (G1)
go func() {
var data1, data2 string
fmt.Println("G1: 进入 select,准备派发代理人(sudog)...")
/* 底层逻辑拆解:
1. Runtime 发现 ch1 和 ch2 都没数据。
2. 调用 acquireSudog() 为 ch1 创建 sg1,elem 指向 &data1。
3. 调用 acquireSudog() 为 ch2 创建 sg2,elem 指向 &data2。
4. sg1.waitlink = sg2 (将代理人串联起来)。
5. g.waiting = sg1 (G 记住第一个代理人)。
6. 分别将 sg1 放入 ch1.recvq,sg2 放入 ch2.recvq。
7. 调用 gopark(),G1 挂起。
*/
select {
case data1 = <-ch1:
fmt.Println("G1: ch1 唤醒了我,收到:", data1)
case data2 = <-ch2:
fmt.Println("G1: ch2 唤醒了我,收到:", data2)
}
// 唤醒后,未成交的 sudog 会被从另一个 channel 的队列中移除
}()
// 保证 G1 已经完成"挂号"排队
time.Sleep(time.Second)
// 主协程作为 Sender 发送数据
ch2 <- "hello sudog"
time.Sleep(time.Second)
}
为了更加直观的看这段代码的内存,可以看看下图,用心多看几篇,sudug 应该就掌握了。
Channel 等待队列层
Sudog 代理层
Goroutine 层
waiting
g
g
waitlink
enqueue
next/prev
enqueue
next/prev
c
c
Goroutine (G1)
状态: _Gwaiting
sudog (sg1)
elem: &data1
isSelect: true
ticket: 1
sudog (sg2)
elem: &data2
isSelect: true
ticket: 0
hchan (ch1)
recvq 链表
hchan (ch2)
recvq 链表
其他 sudog
其他 sudog
深度 Tips:顺序的艺术
很多同学会问,waitlink 里的 sudog 顺序是随机的吗?
实验证明,waitlink 串联 sudog 的顺序严格遵循 代码中的 case 顺序。然而,这仅仅是"静态"的连接。在 Runtime 的世界里,为了绝对的公平与安全,还存在另外两个顺序:
- Poll Order (轮询顺序): 它是随机的,确保每个 case 都有公平的机会被执行,不让上方的代码"霸榜"。
- Lock Order (加锁顺序): 它是按 Channel 地址排序的,这是一种典型的死锁预防机制(Dining Philosophers Problem 的变种解法)。
所以,sudog 虽然在链表里乖乖按代码顺序排队,但它们被处理的优先级和加锁的先后,都是经过 Runtime 精心计算的。
7. 深度复盘:sudog 到底是什么?
到这里,我们可以给 sudog 下一个最终定义了:
sudog 是 Goroutine 在阻塞状态下的"上下文快照"。
它像是一个专门设计的 "连接器":
- 向上,它通过
g勾住 Goroutine,保证执行体不丢失; - 向下,它通过
c和next/prev嵌入 Channel 的等待队列; - 中间,它通过
elem预留了数据传输的"停机坪"。
正是因为有了这个轻量级的中间层,Go 才能在复杂的 select 场景下,依然保持 Channel 操作的原子性和高效的数据同步。
既然 sudog 承载了 Goroutine 阻塞时的所有希望,而且在高并发场景下,Channel 的读写频率极高,如果每次阻塞都去堆上 new 一个 sudog,那 GC(垃圾回收)恐怕要累得"罢工"了。
Go 官方当然不会允许这种情况发生。为了追求极致性能,Go 在运行时为 sudog 量身定制了一套分级缓存机制。
四、极致性能:sudog 的分级缓存池
在 Go Runtime 的设计哲学里,频繁使用的轻量级对象通常不会直接从堆分配,而是通过 对象池(Pool) 复用。sudog 就是这一哲学的深度实践者。
1. 缓存存哪了?(P 的百宝袋)
还记得我们在《深度解密 Go 语言调度器:GMP 模型精讲》中提到的 P(Processor) 吗?P 不仅负责调度,还存放了很多本地资源,其中就包括 sudog 的缓存。
在 runtime2.go 中,P 的定义里有两个关键字段:
go
type p struct {
// ...
sudogcache []*sudog // 本地缓存池(切片实现)
// ...
}
每个 P 都有一个本地的 sudogcache。这意味着:当一个协程 G 需要 sudog 时,它所在的 P 可以直接从自己的口袋里掏出来,不需要加锁,性能极高。
2. 获取 sudog:acquireSudog()
我们在第二章的源码中看到了这个函数。它的内部逻辑其实是一个"三级跳":
go
// src/runtime/proc.go
func acquireSudog() *sudog {
// 1. 获取当前 P
pp := getg().m.p.ptr()
// 2. 如果 P 的本地缓存池没空
if len(pp.sudogcache) == 0 {
lock(&sched.sudoglock)
// 3. 本地没货,去"全局缓存池"里批量搬运一批过来
for len(pp.sudogcache) < cap(pp.sudogcache)/2 && sched.sudogcache != nil {
s := sched.sudogcache
sched.sudogcache = s.next
s.next = nil
pp.sudogcache = append(pp.sudogcache, s)
}
unlock(&sched.sudoglock)
// 4. 如果全局池也是空的,那没办法,只能真的 new 一个出来了
if len(pp.sudogcache) == 0 {
pp.sudogcache = append(pp.sudogcache, new(sudog))
}
}
// 5. 从本地池里弹出一个使用
n := len(pp.sudogcache)
s := pp.sudogcache[n-1]
pp.sudogcache = pp.sudogcache[:n-1]
// 初始化清空字段
if s.elem != nil { throw("acquireSudog: elem not nil") }
return s
}
获取策略:(本地 --》 全局 --》 堆)
- 优先本地: 无锁操作,快如闪电。
- 其次全局: 多个 P 共享,需要加锁,作为中转站。
- 最后兜底: 实在没存货了再调用
new(sudog)从堆分配。
Q1:为什么本地缓存用切片(Slice),全局缓存用单链表(Linked List)?
- 本地用切片: 主要是为了利用切片的
append和pop操作。在一个线程(M)内部操作本地 P 的切片是不需要加锁的,这非常像一个 LIFO 栈,最后存进去的立刻被取出来,对 CPU 缓存也非常友好。 - 全局用单链表: 因为全局缓存是在锁(
sudoglock)的保护下操作的。链表结构非常适合"一个一个地摘取"或者"一个一个地插入",不需要像切片那样考虑扩容或内存连续性的问题。
Q2:这个切片的容量是多少?
- 在 Go 的实现中,
p的本地sudogcache初始容量和最大容量通常被限制在 128。当超过 128 时,就会触发"上缴"逻辑;当本地为 0 时,就会触发"批发"逻辑。
Q3:清理 s.elem = nil 为什么这么重要?
- 这是最容易产生 Bug 的地方。
sudog.elem指向的是接收数据的变量地址(通常在 G 的栈上)。如果回收时不置为nil,即便这个 G 已经退出了,这个sudog依然持有着那个地址的引用,会导致 GC 认为那块内存还在使用,从而引发内存泄漏。
总结一个形象的例子:
- P 的本地
sudogcache是你桌子上的笔筒(切片),里面插着 128 支笔,随手就能拿。 - 全局
sched.sudogcache是公司的行政仓库(单链表),大家共用。 - 当你笔筒空了,你去仓库领 64 支回来(批发);当你笔筒插不下了,你送 64 支去仓库(上缴)。
- 这样既保证了你干活快(无锁),也保证了大家都有笔用(负载均衡)。
3. 回收 sudog:releaseSudog() 的"大扫除"
当 G 被唤醒、任务完成后,sudog 并不销毁,而是被"洗干净"后放回池子里。
go
// src/runtime/proc.go
func releaseSudog(s *sudog) {
// 1. 现场清理:必须把所有指针字段置为 nil,否则会引起内存泄漏(GC 无法回收相关对象)
s.elem = nil
s.g = nil
s.c = nil
// ... (清空其他指针)
pp := getg().m.p.ptr()
// 2. 如果本地池子满了,就把本地池的一半"上缴"到全局池
if len(pp.sudogcache) == cap(pp.sudogcache) {
lock(&sched.sudoglock)
// 搬运一半去全局
for len(pp.sudogcache) > cap(pp.sudogcache)/2 {
n := len(pp.sudogcache)
s1 := pp.sudogcache[n-1]
pp.sudogcache = pp.sudogcache[:n-1]
s1.next = sched.sudogcache
sched.sudogcache = s1
}
unlock(&sched.sudoglock)
}
// 3. 放入本地池
pp.sudogcache = append(pp.sudogcache, s)
}
回收策略:
- 物归原主: 优先放回当前 P 的本地池。
- 溢出上缴: 本地池满了(默认容量通常是 128),就匀出一半给全局池,供其他 P 使用。
4. 深度思考:为什么要分级?
如果你读过相关关于内存分配或栈管理的文章,你会发现 Go 处处都在用这种 "本地 P 缓存 + 全局缓存" 的设计。
- 避开锁竞争: 高并发下,如果所有 P 都去抢一个全局锁来拿
sudog,Channel 就会变成程序的性能瓶颈。 - 平衡负载: 有的 P 可能很忙,一直在阻塞/唤醒,
sudog需求量大;有的 P 可能很闲。通过全局池,忙碌的 P 可以从空闲的 P 手里"白嫖"旧的sudog,而不需要反复向系统申请内存。
章节小结
sudog 的缓存机制揭示了 Go Runtime 性能强大的真相:不放过任何一个小对象的复用机会。
通过本地 P 的无锁缓存,Go 将 Channel 阻塞的开销降到了最低。当你写下 ch <- data 时,你可能想不到,Runtime 只是从 P 的"口袋"里拿了一个现成的盒子,装上你的 G,用完后又迅速擦洗干净放了回去。
至此,我们已经看清了 sudog 的由来(阻塞) 、长相(结构)以及身世(缓存池)。
在结束这一系列深度解密之前,我们还有最后一个"硬骨头"要啃:在 select 这种极端复杂的场景下,sudog 究竟是如何配合 selectgo 完成那一套复杂的加锁、入队、撤单逻辑的?
下一章,我们将迎来终极挑战:sudog 与 select 的华丽圆舞曲。
五、终极挑战:sudog 与 select 的华丽圆舞曲
如果说普通的 Channel 读写是"一对一"的单调约会,那么 select 就是一场多人的"假面舞会"。一个 Goroutine 需要同时观察多个 Channel,谁先有空就跟谁跳,而且一旦选定了一个,就必须立刻拒绝其他所有人。
在这场复杂的博弈中,sudog 扮演了极其关键的"多重代理人"角色。
1. 舞会前的准备:加锁与排序
当 select 进入阻塞分支(即所有 case 都不就绪,且没有 default)时,系统会调用 runtime.selectgo。
在正式派发 sudog 之前,Runtime 会做两件非常硬核的事:
- Poll Order (轮询顺序): 随机打乱 case 的顺序,保证公平性。
- Lock Order (加锁顺序): 按照 Channel 的内存地址从小到大排序,并依次锁定。
- 为什么要锁住所有 Channel? 因为在 G 挂起的一瞬间,必须保证所有相关的 Channel 状态不再发生变化,否则就会漏掉刚好进来的数据。
2. 派发"分身":sudog 链表的构建
此时,主角 G 终于要动用它的 sudog 大军了。
对于 select 中的每一个 case,G 都会通过 acquireSudog() 从 P 的口袋里拿出一个 sudog。这些 sudog 会做两件事:
- 对外挂号: 每一个
sudog进入对应 Channel 的recvq(或sendq)中排队。 - 对内结盟: 所有的
sudog通过waitlink字段串成一条单向链表,由gp.waiting指向表头。
这时,神奇的一幕出现了: 一个 G 依然只是一份"肉身",但它通过 N 个 sudog 代理人,同时"潜伏"在了 N 个不同的 Channel 等待队列里。
3. 深度睡眠与"一处唤醒"
当一切就绪,G 调用 gopark 进入沉睡。
想象一下:此时 ch1 突然来了一个 Sender。
- 原子竞争:Sender 发现 ch1.recvq 里的 SG_A。为了防止其他 Channel 同时也醒了,Sender 会利用原子操作(CAS)去抢夺 SG_A 的 ticket。
- 数据交付:抢到 ticket 后,Sender 将数据直接拷贝给 SG_A 的 elem 变量。
- 激活主角:Sender 调用 goready(SG_A.g) 唤醒 G。
注意! 此时 G 虽然醒了,但它在 ch2、ch3 的队列里还挂着其他的 sudog(SG_B, SG_C...)。如果不处理,这些过时的 sudog 就会变成孤魂野鬼,甚至导致 G 被二次错误唤醒。
4. 撤单仪式:清理战场
G 醒来后的第一件事,就是执行 "撤单(Dequeue)" 逻辑。
它会沿着自己的 gp.waiting 链表(也就是 waitlink 串起来的那条线),挨个访问所有的代理人:
- 如果这个
sudog就是唤醒我的那个(比如 SG_A),跳过。 - 如果是其他还没被触发的
sudog(比如 SG_B, SG_C),则将它们从各自 Channel 的等待队列中强制踢出。
最后,所有的 sudog 被送回 releaseSudog() 回收。舞会结束,现场被打扫得干干净净。
六、 总结:为什么说 sudog 是定海神针?
通过这个系列,我们从引言的"为什么要写",一直聊到了 select 的底层战场。现在回头看,sudog 的设计精髓可以概括为:
- 空间换逻辑: 它通过轻量级的代理结构,解决了
g结构体无法同时存在于多个队列的物理限制。 - 跨栈的桥梁: 它通过
elem指针,让两个原本隔离的 Goroutine 栈空间实现了直接的、零拷贝的数据交互。 - 极低的成本: 借助 P 的分级缓存池,它的创建和销毁几乎不产生性能损耗。
写在最后:
在 Go 的世界里,Goroutine 是我们的士兵,Channel 是沟通的管道,而 sudog 则是那些在管道间奔走、在黑暗中守望的"契约文书"。它虽隐于幕后,却撑起了 Go 语言最引以为傲的并发模型。
当你下次写下 select { case <-ch: } 时,希望你能想到,在那个微妙的瞬间,有一群名为 sudog 的代理人正有序地在内存地址间排队、加锁,为你守护着那份精准的同步。