欢迎光临
我们一直在努力

如何通过抢占式调度原理理解 Go 1.14 之后死循环不再阻塞线程的本质

在 Go 1.14 版本之前,Go 语言的调度器主要依赖于“协作式抢占”(Cooperative Preemption)。这意味着 Goroutine 只有在执行函数调用、系统调用或特定的运行时检查点时,才会主动或被动地交出控制权,让调度器有机会切换到其他 Goroutine。

这种机制在遇到纯计算型的死循环(即 for {} 循环中不包含任何函数调用)时,会导致灾难性的后果:该 Goroutine 会独占分配给它的 M(操作系统线程)直到永远,从而导致在该 M 上等待执行的其他 Goroutine 永远无法获得运行机会,即发生了 CPU 饥饿(Starvation)。

Go 1.14 引入了非协作式抢占(Asynchronous Preemption)机制,彻底解决了这一问题。

1. 协作式调度的局限性(Go < 1.14)

在 Go 1.14 以前,调度器主要通过以下两种方式确保 Goroutine 切换:

  1. 函数序言检查 (Stack Check): 当 Goroutine 调用函数时,函数序言会检查栈是否需要增长,并在此时顺带检查是否需要被抢占。
  2. 系统调用/I/O操作: 当 Goroutine 阻塞于系统调用时,M 会被释放,P 上的其他 Goroutine 可以被调度。

如果一个 Goroutine 执行的代码是这样的:

func busyLoop() {
    i := 0
    for {
        i++ // 纯粹的计算,无函数调用
    }
}

因为它从未调用任何函数,它永远不会到达协作式的“安全点”,因此它会独占 M,直到进程被杀死。

2. Go 1.14 的本质:异步抢占(Signal-based Preemption)

Go 1.14 之后的调度器通过使用操作系统信号实现了抢占:

  1. 定时器与检查: Go 运行时会设置定时器。当一个 Goroutine 运行时间过长(通常是 10 毫秒或更久)时,调度器会标记它为可抢占。
  2. 发送信号: 调度器会向正在运行该 Goroutine 的 OS 线程(M)发送一个异步信号(例如,在 Linux/Unix 上通常使用 SIGURG)。
  3. 中断执行流: OS 线程接收到信号后,会立即暂停当前的执行,跳转到 Go 运行时注册的信号处理函数。
  4. 栈检查与注入: 在信号处理函数中,Go 运行时会检查被中断的 Goroutine 的 PC(程序计数器)和 SP(栈指针)。如果发现它处于一个非安全状态(例如,持有锁),则等待。如果处于安全状态(即处于一个可中断的循环中),运行时会修改该 Goroutine 的寄存器状态,强制将执行流跳转到一个特殊的运行时函数(runtime.asyncPreempt)。
  5. 主动让出: runtime.asyncPreempt 会强制该 Goroutine 挂起,将控制权交还给 P(逻辑处理器),从而允许 P 调度队列中的下一个等待中的 Goroutine。

通过这种机制,即使是纯计算的死循环,也会被周期性地通过 OS 信号中断,从而实现真正的抢占式调度,确保了 Goroutine 之间的公平性。

3. 实践演示:死循环不再饥饿其他 Goroutine

以下代码展示了 Go 1.14+ 环境中,一个计算密集型的死循环是如何被抢占,从而允许另一个 Goroutine 周期性地打印消息。

package main

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

func busyLoop() {
    // 这是一个不包含任何运行时函数调用的纯计算死循环
    fmt.Println("Goroutine 1: 启动高强度计算(死循环)")
    i := 0
    for {
        i++
        // 确保 i 被使用,防止编译器完全优化掉循环体
        if i % 100000000 == 0 {
            // 仅进行计算,无函数调用
        }
    }
}

func checker() {
    fmt.Println("Goroutine 2: 启动定时检查器")
    for j := 1; j <= 5; j++ {
        time.Sleep(500 * time.Millisecond)
        // 如果 Goroutine 1 独占了线程,这里将永远不会被执行!
        // 由于 Go 1.14+ 的抢占,我们能看到此消息周期性出现。
        fmt.Printf("Goroutine 2: 成功运行检查 %d 次\n", j)
    }
    fmt.Println("Goroutine 2: 任务完成。")
}

func main() {
    // 确认我们使用的 CPU 资源至少是 1
    fmt.Printf("当前 GOMAXPROCS: %d\n", runtime.GOMAXPROCS(0))

    // 启动两个 Goroutine
    go busyLoop()
    go checker()

    // 保持主线程存活,直到 checker 完成
    time.Sleep(3 * time.Second)
    fmt.Println("程序结束。")
}

运行结果预期(Go 1.14+):

当前 GOMAXPROCS: 8 # (取决于你的系统配置)
Goroutine 1: 启动高强度计算(死循环)
Goroutine 2: 启动定时检查器
Goroutine 2: 成功运行检查 1 次
Goroutine 2: 成功运行检查 2 次
Goroutine 2: 成功运行检查 3 次
Goroutine 2: 成功运行检查 4 次
Goroutine 2: 成功运行检查 5 次
Goroutine 2: 任务完成。
程序结束。

如果没有 Go 1.14+ 的异步抢占,一旦 busyLoop 启动并被分配给唯一的 P/M,checker 将永远无法运行(除非 GOMAXPROCS > 1,且 checker 被调度到了另一个 M)。但由于异步抢占的存在,即使在 GOMAXPROCS=1 的情况下,busyLoop 也会被强制中断,从而让 checker 有机会运行,确保了公平调度。

【本站文章皆为原创,未经允许不得转载】:汤不热吧 » 如何通过抢占式调度原理理解 Go 1.14 之后死循环不再阻塞线程的本质
分享到: 更多 (0)

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址