98·进阶专题高级

运行时与调度器

runtimegmpschedulerpreemption

第98课 - 运行时与调度器

所属模块:进阶专题 难度:高级 标签runtime, gmp, scheduler, preemption


1. 学习目标

完成本课学习后,你将能够:

  • 解释 Go 运行时(Runtime)的核心组成部分及其作用。
  • 阐述经典的 GMP 模型(Goroutine, Machine, Processor)的完整工作原理。
  • 理解协作式抢占异步抢占的区别及其对程序行为的影响。
  • 通过代码和工具(如 GODEBUG)观察和分析调度器的行为。
  • 知道在何种情况下可以(以及何时不应)通过 runtime 包函数与调度器交互。

2. 核心概念

什么是 Go 运行时?

你可以把 Go 的运行时想象成一个“操作系统中的操作系统”或者一个庞大的支持库。当你写下 go func() 创建一个新的 Goroutine 时,并不是直接创建一个操作系统线程。所有这些复杂的调度、内存管理、垃圾回收(GC)、网络 I/O 多路复用等幕后工作,都是由 Go 运行时在用户态(User Space)悄悄完成的。它赋予了 Go 卓越的并发性能和简单的编程体验。

GMP 模型:调度器的心脏

Go 的调度器是运行时中最核心的部分之一,其核心设计被称为 GMP 模型。我们用一个生动的比喻来理解它:

  • G (Goroutine):你的任务,就像一辆待组装的汽车。它包含了栈、指令指针和其他状态。
  • M (Machine):真正干活的工人,对应一个操作系统线程(OS Thread)。它是执行代码的实体。
  • P (Processor):工人的工作台和工具包。它代表了执行 Go 代码所需的资源,最核心的是一个本地运行队列(Local Run Queue)。GOMAXPROCS 的值决定了系统中 P 的数量。

它们如何协同工作?

  1. 创建任务:你使用 go 关键字创建一个 G。这个 G 会尝试放入当前 P 的本地队列中。
  2. 获取工作台:一个 M(线程)必须先获取并绑定一个 P,才能执行 G。就像工人需要一个工作台才能开始造车。
  3. 执行任务M 从其绑定的 P 的本地队列中取出一个 G,开始执行它的代码。
  4. 寻找任务:如果 P 的本地队列空了,M 不会闲着,它会尝试:
    • 全局运行队列(Global Run Queue)中获取 G
    • 工作窃取(Work Stealing):从其他 P 的本地队列中“偷”一半的 G 过来执行。这是负载均衡的关键。
    • 如果所有队列都空了,M 可能与 P 解绑,进入休眠或阻塞在系统调用上。

抢占式调度

早期的 Go 只支持协作式抢占:一个 G 只会在特定的检查点(如函数调用)让出 CPU。如果一个 G 是一个死循环且没有函数调用,它将会长时间占用 M,导致其他 G 饿死。

从 Go 1.14 开始,引入了基于信号的异步抢占。运行时会向 M 发送一个信号(如 SIGURG),强制中断正在执行的 G,使其切换到其他任务。这解决了长时间运行的纯计算循环的问题,使调度更加公平和健壮。

3. 代码示例

以下代码将演示 GMP 模型的基本交互和抢占式调度的一个侧面。

package main

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

func main() {
	// 设置最大可同时执行的CPU核数,这决定了P的数量。
	// 在此示例中,我们设置为1,以简化观察。
	// 在实际应用中,通常默认为机器的逻辑CPU数。
	runtime.GOMAXPROCS(1)

	var wg sync.WaitGroup
	wg.Add(2)

	fmt.Println("开始演示调度器行为...")

	// 一个会执行较长(但会释放CPU)的任务
	go func() {
		defer wg.Done()
		fmt.Println("[任务A] 开始运行,并会主动休眠")
		time.Sleep(time.Second) // 模拟I/O操作,让出当前M
		fmt.Println("[任务A] 休眠结束,继续运行")
	}()

	// 一个计算密集型任务,展示协作式抢占点
	go func() {
		defer wg.Done()
		fmt.Println("[任务B] 开始运行,进行计算循环")
		sum := 0
		for i := 0; i < 1e8; i++ {
			sum += i
			// 在Go 1.14之前,如果没有类似下面的函数调用或控制流语句,
			// 这个循环可能会‘饿死’其他任务。但现在,运行时会进行抢占。
			if i%1e7 == 0 {
				// 循环体中的函数调用是一个协作式抢占点。
				// 在现代Go版本中,即使没有这个,循环也可能被异步抢占。
				runtime.Gosched() // 主动让出当前G的执行权
			}
		}
		fmt.Printf("[任务B] 计算完成,结果: %d\n", sum)
	}()

	// 主Goroutine也在等待
	wg.Wait()
	fmt.Println("所有任务完成。")
}

/* 可能的输出顺序(会因调度而异):
开始演示调度器行为...
[任务A] 开始运行,并会主动休眠
[任务B] 开始运行,进行计算循环
[任务A] 休眠结束,继续运行
[任务B] 计算完成,结果: 4999999950000000
所有任务完成。
*/

如何观察? 你可以使用 GODEBUG 环境变量来窥探调度器的内部状态。 运行命令:GODEBUG=schedtrace=100 go run main.go 输出会显示每100ms的调度快照,包括 gomaxprocsidle(空闲的M/P数量)、runqueue(全局队列长度)等信息。

4. 实践练习

  1. 基础练习: 修改上面的代码,将 GOMAXPROCS 设置为你机器的核心数(例如 runtime.GOMAXPROCS(runtime.NumCPU()))。使用 GODEBUG=schedtrace=500 运行程序。观察输出中 p 字段的数量是否与 GOMAXPROCS 一致,以及 runqueueidle 线程数量的变化。

  2. 理解工作窃取: 创建一个场景:启动1个 GOMAXPROCS(1),然后迅速启动10个都会立即休眠2秒的 G。接着再启动10个需要1秒计算的 G。预测一下这20个 G 大致会在何时全部完成?实际运行并使用 GODEBUG 验证你的猜想。

  3. 探究抢占: 创建一个没有 runtime.Gosched() 或任何函数调用的无限 for 循环。在 Go 1.14+ 的版本上运行它。观察它是否会导致程序卡死?如果会,为什么?(提示:主 G 也可能需要执行)。尝试在另一个 G 中打印一条信息,看它是否有机会执行。

5. 常见错误

  • 错误认为 GOMAXPROCS 是线程数P 不是线程。M 是线程。P 限制了可以真正并行执行用户代码的上下文数量,但 M 的数量可以远大于 P(例如在系统调用阻塞时)。
  • 过度依赖 runtime.Gosched():现代Go调度器(1.14+)的异步抢占已能处理大多数情况。滥用 Gosched 会降低性能,因为切换 G 有成本。应仅在需要明确释放CPU给其他G的特定协作式场景使用。
  • 假设固定的调度顺序:永远不要假设你的 G 会按照你启动的顺序执行。调度是不确定的。通过通道(channel)和同步原语(WaitGroupMutex)来控制依赖关系和顺序。
  • initG 创建前调整 GOMAXPROCS:虽然 runtime.GOMAXPROCS 是并发安全的,但在程序启动时设置它是最佳实践,通常由运行时默认设置。

6. 小结

  • Go 运行时是支撑语言特性的底层引擎,负责调度、内存管理和GC。
  • GMP 模型是调度器核心:G 是任务,M 是执行线程,P 是资源上下文(本地队列)。GOMAXPROCS 决定了 P 的数量。
  • 调度器通过工作窃取机制实现负载均衡。
  • Go 1.14 引入了异步抢占,解决了纯计算循环可能阻塞调度的问题,使得调度更公平。
  • 可以通过 GODEBUG=schedtrace 环境变量观察调度器行为。
  • 通常无需直接与调度器交互,应依赖 Go 的并发原语(gochannelsync 包)来编写程序。

理解运行时和调度器,能让你在编写高性能并发程序时更有信心,在遇到棘手的性能或调度问题时,也能进行更深入的诊断。下一课,我们将探索一个激动人心的领域:WebAssembly 与 Go

练习编辑器

go
Loading...

继续学习

完成本课后,建议继续学习下一课「WebAssembly 与 Go」 以巩固所学知识。