跳到主要内容

GMP 模型-基于抢占的公平调度

在Go 1.14之前,如果一个 Goroutine 陷入一个不包含任何函数调用的无限循环,它会永久性地霸占这个 P 和其对应的M(内核线程),导致调度器无法抢占它。

在之前版本的“基于函数调用的抢占”机制依赖于 Goroutine 主动 在函数入口检查抢占标记。如果循环中没有任何函数调用,这个检查点就永远不会被触发。

历史上的问题:一个简单的“死循环”就能搞垮调度

我们可以用一段代码来模拟这个在旧版本Go中会产生问题的场景。请注意,在现代Go版本(1.14+)中,这段代码将按预期工作,不会阻塞。

package main

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

func main() {
// 关键:将P的数量限制为1,这样饿死现象会更容易复现和观察
runtime.GOMAXPROCS(1)

// 启动一个“霸道”的Goroutine,它会陷入一个纯计算的死循环
go func() {
fmt.Println("Hog (霸道) goroutine started.")
// 这个循环里没有任何函数调用,没有channel操作,没有IO
for {
}
}()

// 稍等一下,确保上面的goroutine已经开始运行
time.Sleep(10 * time.Millisecond)

// 启动另一个正常的Goroutine
go func() {
// 在Go 1.14之前的版本,这行打印永远不会执行!
// 因为调度权永远不会交到它手上。
fmt.Println("I am a starving goroutine!")
}()

// 主 Goroutine 等待,让其他 Goroutine 有机会运行
time.Sleep(time.Second)
fmt.Println("Main goroutine finished.")
}

在Go 1.13或更早的版本上运行此代码,你将只会看到:

Hog (霸道) goroutine started.
Main goroutine finished.

I am a starving goroutine! 这句话永远没有机会打印出来。

现代Go的解决方案:循环中的抢占(Loop Preemption)

Go团队意识到了这个严重的缺陷。因此,从 Go 1.14 开始,编译器和运行时被赋予了更强大的能力来处理这种情况。

除了在函数入口检查抢占标记外,Go编译器现在能够识别这种没有函数调用的“紧凑循环”(tight loop),并在循环内部自动插入抢占检查点。

这意味着,即使是 for {} 这样的代码,在编译后,其内部也可能被插入了类似这样的逻辑(伪代码):

loop_start:
// ... 循环体内的指令 ...

// 编译器自动插入的检查
// 比较当前栈指针和抢占标记
CMP g.stackguard0, SP
JNE loop_start // 如果没有抢占标记,继续循环

// 如果发现了抢占标记,则跳转到调度器
CALL runtime.morestack_noctxt // 主动调用调度器进行抢占

这样一来,sysmon设置的抢占标记就总有机会被检查到,即便是最顽固的死循环也会在至多几十毫秒后被强制中断,交出CPU控制权。

我们可以用一个时序图来对比这两种情况。

场景:一个Goroutine (G_Hog) 陷入 for {} 循环。

Go 1.13及以前 (抢占失败)

Go 1.14及以后 (抢占成功)

结论: 正是为了解决这个问题,Go 1.14引入了对紧凑循环的抢占支持,使得GMP调度模型变得更加健壮和公平,几乎杜绝了因代码问题导致Goroutine饿死的可能性。