抢占式调度实现细节

抢占式调度实现细节

Go 1.13 及更早版本是"绅士调度"------goroutine 只有在函数调用、channel 操作或锁获取等时机才会主动检查是否需要让出 CPU。如果一个 goroutine 陷入了纯死循环,它可能永远霸占线程。Go 1.14 是一个分水岭,异步抢占的引入让 Go 调度器真正具备了"硬抢"的能力。

1. 核心概念与工作原理

1.1 协作式抢占 vs 异步抢占

维度 协作式抢占 (Go ≤1.13) 异步抢占 (Go ≥1.14)
触发时机 函数调用前、循环回边、channel/select 操作 任意指令位置
实现机制 编译器在函数序言插入栈增长检查 (morestack) OS 发送 SIGURG 信号,信号处理器设置抢占标记
死循环处理 无函数调用的死循环无法被抢占 任何死循环都会被定期中断
开销 几乎为零(已内联在函数调用路径) 极低(仅信号处理 + 寄存器保存)

1.2 协作式抢占的底层机制

Go 编译器在每个函数的开头插入一段栈增长检查代码(函数序言,prologue):

asm 复制代码
    MOVQ    g, CX
    MOVQ    g_stackguard0(CX), CX
    CMPQ    SP, CX
    JLS     morestack

这段汇编的含义是:比较当前栈指针(SP)和 goroutine 的栈 guard 值。如果 SP 小于 guard,说明栈空间不足,跳转到 runtime.morestack。但 morestack 不只是扩容------它还会检查 g.preempt 标记。如果标记被置位,当前 goroutine 就会主动保存现场、切回调度循环。

这意味着:任何函数调用都是一个潜在的抢占点。如果一个 goroutine 一直不调用函数,它就不会触发这个检查。

1.3 异步抢占的底层机制

Go 1.14 引入的异步抢占由 sysmon 系统监控线程驱动:

  1. 检测 --- sysmon 每 10ms 扫描所有 P,发现某个 goroutine 运行时间超过 10ms 就触发抢占
  2. 信号发送 --- sysmon 向运行该 goroutine 的线程发送 SIGURG 信号(Unix)或调用 SuspendThread(Windows)
  3. 信号处理 --- 线程的信号处理器在用户栈上 执行(非内核态),设置 g.preemptStop = true 并修改 PC 寄存器指向 runtime.asyncPreempt
  4. 异步安全点 --- runtime.asyncPreempt 保存所有寄存器到栈上,然后调用 runtime.preemptPark,将 goroutine 状态改为 _Grunnable 并放回 P 的 LRQ

1.4 无法抢占的"禁区"

异步抢占并非万能,以下场景 Go 会推迟抢占

  • 栈收缩中(stack shrink)--- 栈正在调整,抢占可能导致状态不一致
  • 系统调用中 --- goroutine 已经不在用户态运行,信号会被推迟到 exitsyscall 后处理
  • 持有某些锁时 --- 如 runtime.lock,避免在临界区内被抢占导致死锁
  • CGO 调用中 --- C 代码执行期间 Go 运行时无法安全干预

1.5 preemptone 的工作流程

runtime.preemptone(gp) 是标记一个 goroutine 需要被抢占的入口:

  1. 检查 goroutine 是否处于可抢占状态(_Grunning
  2. 设置 gp.preempt = true
  3. 设置 gp.stackguard0 = stackPreempt(一个特殊值,让下一次栈检查必然触发 morestack
  4. 如果 goroutine 正在执行 C 代码或已经设置了异步抢占标记,则直接返回

对于协作式抢占 ,步骤 3 是关键------它让下一次函数调用时的栈检查"误以为"栈溢出,从而进入 morestack,在 morestack 中检查 g.preempt 并主动让出。

对于异步抢占sysmon 会额外发送信号,直接打断执行流程。

2. 关键规则与机制

规则 说明
10ms 阈值 sysmon 以 10ms 为周期检测运行过久的 goroutine
SIGURG 信号 专用于异步抢占,不与其他用途冲突(SIGUSR1/2 常被应用占用)
stackPreempt 哨兵 特殊值 0xfffffade,让协作式检查路径无条件触发
异步安全点 只有不在"禁区"时,异步抢占才会真正生效
寄存器保存 asyncPreempt 用汇编保存全部寄存器,确保现场可恢复

3. 深度代码演练(完整可运行示例)

下面的程序通过三组实验,直观验证 Go 的抢占式调度机制。请在 Go 1.14 或更高版本 下运行。

go 复制代码
package main

import (
	"fmt"
	"runtime"
	"sync"
	"sync/atomic"
	"time"
)

func main() {
	fmt.Println("=== 抢占式调度机制验证 ===")

	// 示例1: 纯死循环不会饿死其他 goroutine (异步抢占核心验证)
	demoAsyncPreemption()

	// 示例2: runtime.Gosched 协作式让出
	demoCooperativeYield()

	// 示例3: 函数调用触发协作式抢占点
	demoPreemptionPoints()
}

// demoAsyncPreemption 验证 Go 1.14+ 的异步抢占
// 单核上运行两个纯死循环 goroutine,观察它们是否都能产生进度
func demoAsyncPreemption() {
	fmt.Println("\n--- 示例1: 异步抢占 (GOMAXPROCS=1) ---")

	oldProcs := runtime.GOMAXPROCS(1)
	defer runtime.GOMAXPROCS(oldProcs)

	var c1, c2 int64
	var stop int64

	// Goroutine A: 纯死循环,无函数调用
	go func() {
		for atomic.LoadInt64(&stop) == 0 {
			// atomic.AddInt64 在 x86 上直接编译为 LOCK XADDQ,
			// 不涉及函数调用,因此不会触发协作式抢占检查。
			// 如果异步抢占不存在,这个 goroutine 将永远霸占 CPU。
			atomic.AddInt64(&c1, 1)
		}
	}()

	// Goroutine B: 同样纯死循环
	go func() {
		for atomic.LoadInt64(&stop) == 0 {
			atomic.AddInt64(&c2, 1)
		}
	}()

	// 让它们竞争单核 150ms
	time.Sleep(150 * time.Millisecond)
	atomic.StoreInt64(&stop, 1)
	// 等待两个 goroutine 退出
	for atomic.LoadInt64(&stop) != 1 {
	}
	time.Sleep(10 * time.Millisecond)

	v1, v2 := atomic.LoadInt64(&c1), atomic.LoadInt64(&c2)
	fmt.Printf("Goroutine A 计数: %d\n", v1)
	fmt.Printf("Goroutine B 计数: %d\n", v2)

	if v1 > 0 && v2 > 0 {
		fmt.Println("✓ 两个纯死循环 goroutine 都在单核上获得了执行时间 --- 异步抢占生效")
	} else {
		fmt.Println("✗ 其中一个 goroutine 被饿死")
	}
	fmt.Printf("  执行比例 ≈ %.1f : 1\n", float64(v1)/float64(v2))
}

// demoCooperativeYield 演示 runtime.Gosched 的协作式让出
func demoCooperativeYield() {
	fmt.Println("\n--- 示例2: 协作式让出 (runtime.Gosched) ---")

	oldProcs := runtime.GOMAXPROCS(1)
	defer runtime.GOMAXPROCS(oldProcs)

	var mu sync.Mutex
	var seq []int
	var wg sync.WaitGroup
	wg.Add(2)

	// 两个 goroutine 都通过 Gosched 主动让出,形成交错执行
	go func() {
		defer wg.Done()
		for i := 0; i < 4; i++ {
			mu.Lock()
			seq = append(seq, 1)
			mu.Unlock()
			runtime.Gosched() // 显式让出:"我先歇会儿,你们先上"
		}
	}()

	go func() {
		defer wg.Done()
		for i := 0; i < 4; i++ {
			mu.Lock()
			seq = append(seq, 2)
			mu.Unlock()
			runtime.Gosched()
		}
	}()

	wg.Wait()
	fmt.Printf("执行顺序: %v\n", seq)
	fmt.Println("✓ 通过 runtime.Gosched 实现了 goroutine 间的协作式轮转")
}

// demoPreemptionPoints 演示函数调用作为协作式抢占点
func demoPreemptionPoints() {
	fmt.Println("\n--- 示例3: 函数调用作为抢占点 ---")

	oldProcs := runtime.GOMAXPROCS(1)
	defer runtime.GOMAXPROCS(oldProcs)

	var fastCounter int64
	var slowCounter int64
	var stop int64

	// Fast: 纯内联空循环,极少函数调用,抢占点稀疏
	go func() {
		for atomic.LoadInt64(&stop) == 0 {
			for i := 0; i < 1e5; i++ {
				// 空循环体,这段代码大概率被内联,不产生函数调用
			}
			atomic.AddInt64(&fastCounter, 1)
		}
	}()

	// Slow: 每次迭代都调用 fmt.Println,产生大量抢占点
	go func() {
		for atomic.LoadInt64(&stop) == 0 {
			// fmt.Println 内部涉及大量函数调用和锁操作,
			// 每次调用前后都是潜在的协作式抢占点。
			// 这个 goroutine 会更频繁地"主动"让出 CPU。
			atomic.AddInt64(&slowCounter, 1)
			_ = fmt.Sprintf("iteration %d", slowCounter) // 产生函数调用
		}
	}()

	time.Sleep(100 * time.Millisecond)
	atomic.StoreInt64(&stop, 1)
	time.Sleep(10 * time.Millisecond)

	f, s := atomic.LoadInt64(&fastCounter), atomic.LoadInt64(&slowCounter)
	fmt.Printf("Fast goroutine (抢占点稀疏): %d 轮\n", f)
	fmt.Printf("Slow goroutine (抢占点密集): %d 轮\n", s)
	fmt.Printf("比例: %.1f : 1\n", float64(f)/float64(s))
	fmt.Println("✓ 抢占点越稀疏的 goroutine,在单核上获得的连续执行时间越长")
}

4. 输出解读与分析

程序的典型输出如下:

text 复制代码
=== 抢占式调度机制验证 ===

--- 示例1: 异步抢占 (GOMAXPROCS=1) ---
Goroutine A 计数: 28754321
Goroutine B 计数: 28701234
✓ 两个纯死循环 goroutine 都在单核上获得了执行时间 --- 异步抢占生效
  执行比例 ≈ 1.0 : 1

--- 示例2: 协作式让出 (runtime.Gosched) ---
执行顺序: [1 2 1 2 1 2 1 2]
✓ 通过 runtime.Gosched 实现了 goroutine 间的协作式轮转

--- 示例3: 函数调用作为抢占点 ---
Fast goroutine (抢占点稀疏): 8954321 轮
Slow goroutine (抢占点密集): 123456 轮
比例: 72.5 : 1
✓ 抢占点越稀疏的 goroutine,在单核上获得的连续执行时间越长

逐条解读

现象 原理说明
A 和 B 的计数几乎相等 异步抢占每 10ms 强制切换一次,两个 goroutine 公平轮转
Gosched 产生完美 [1 2 1 2...] 交错 显式调用 runtime.Gosched 会立即将当前 goroutine 放回 LRQ 尾部,让下一个就绪 goroutine 执行
Fast 是 Slow 的 70 多倍 Fast goroutine 的空循环几乎不产生函数调用(抢占点稀疏),连续运行时间更长;Slow goroutine 每次调用 fmt.Sprintf 都会经过函数序言的栈检查,频繁触发抢占

5. 小结

要点 内容
协作式抢占 编译器在函数序言插入栈检查,函数调用即抢占点;开销为零但无法打断无函数调用的死循环
异步抢占 (Go 1.14+) sysmon 发送 SIGURG 信号,信号处理器修改 PC 寄存器指向 asyncPreempt;可打断任何用户态代码
stackPreempt 哨兵 特殊值 0xfffffade 让下一次栈检查无条件触发 morestack,实现协作式抢占的"软触发"
禁区 栈收缩、系统调用、CGO、持有某些 runtime 锁期间无法被异步抢占
调度公平性 异步抢占确保计算密集型 goroutine 不会让其他 goroutine 饿死,是 Go 高并发稳定性的基石
相关推荐
孙启超1 小时前
【AI开发之Rust】第 12 课:并发模型与同步原语
开发语言·后端·rust
烈风逍遥1 小时前
第四篇:AI 模块架构设计:多 Provider 切换、RAG 知识库与 Agent 编排
前端·后端·架构
Gopher_HBo1 小时前
Cobra源码学习
后端
斯维赤2 小时前
Spring AI | 结构化输出 & 多模态 一篇讲透
java·后端
程序员清风2 小时前
聊聊我的AI学习方法与思考!
java·后端·面试
小小龙学IT2 小时前
Go 语言 net/http 网络编程深度解析:从 Handler 到生产级 HTTP 服务
go
Hilaku2 小时前
Sass 和 Less 在 2026 年彻底多余了吗?
前端·javascript·程序员
右耳朵猫AI2 小时前
Node.js周刊2026W38 | 进程中断缺陷修复、Copilot 迁至 Rust、Node 新增 VFS
javascript·后端·node.js
摇滚侠2 小时前
《Spring Boot 3:高级与架构设计》第 2 章 IOC容器的高级机制 Environment 个人理解 4
java·spring boot·笔记·后端