在 Go 语言中,Goroutine 是并发模型的核心,而 P(Processor) 是 Go 调度器中的一个关键抽象。理解 Goroutine 调度模型 中的 G(Goroutine)、M(Machine,内核线程)、P(Processor,逻辑处理器) 的关系可以帮助我们理解 Go 的抢占式调度策略。
Go 调度器使用 G-M-P 模型:
- Goroutine (G):一个 Goroutine 代表一个 Go 协程。
- Processor (P):P 是逻辑处理器,负责调度和管理 Goroutine,最多有 GOMAXPROCS 个 P。每个 P 可以运行一个 Goroutine。
- Machine (M):M 是操作系统的内核线程。每个 M 需要绑定一个 P 来执行 Goroutine。
#Go 中的抢占式调度
Go 的调度器采用的是协作式调度为主,抢占式调度为辅。协作式调度意味着 Goroutine 需要主动放弃控制权来让其他 Goroutine 运行,比如调用系统调用或者 Goroutine 自己调用 runtime.Gosched()。
抢占式调度则是为了防止某些 Goroutine 占用 CPU 太久(比如某个 Goroutine 在长时间执行计算密集型任务),Go 1.14 引入了针对 计算密集型 Goroutine 的 抢占式调度。抢占式调度可以在以下场景下触发:
- Goroutine 执行时间过长,特别是没有主动进行系统调用、调度让出等行为时。
- Goroutine 执行在较长的函数调用链上,或者在一些函数的栈帧扩展时(例如深度递归调用或大数组操作时)。
#抢占 P 的时机
- 系统调用 (syscall) 后:当 Goroutine 执行系统调用后,Goroutine 会让出 P,此时调度器可能会选择调度其他的 Goroutine 来运行。
- 垃圾回收 (GC) 阶段:当触发垃圾回收时,调度器会在合适时机抢占 Goroutine,确保 GC 可以进行。
- 计算密集型任务被长时间运行:从 Go 1.14 开始,调度器会定期检查长时间运行的 Goroutine,并进行抢占。
#抢占式调度与长时间运行的 Goroutine
下面的例子展示了一个 Goroutine 在执行计算密集型任务时如何可能会被 Go 的抢占式调度机制打断。
package main
import (
"fmt"
"runtime"
"time"
)
// 模拟一个计算密集型任务
func busyLoop() {
for i := 0; i < 1e10; i++ {
// 占用 CPU,但没有主动让出调度权
}
fmt.Println("Finished busy loop")
}
func main() {
runtime.GOMAXPROCS(1) // 设置只有 1 个 P
go func() {
for {
fmt.Println("Running another goroutine...")
time.Sleep(500 * time.Millisecond) // 每 500 毫秒休息一次
}
}()
busyLoop() // 执行计算密集型任务
time.Sleep(2 * time.Second)
}
#代码解析:
- runtime.GOMAXPROCS(1):我们将 GOMAXPROCS 设置为 1,意味着整个程序中只有一个 P,这样所有 Goroutine 都只能在这个 P 上调度。
- busyLoop:这是一个计算密集型任务,在没有主动进行系统调用或让出调度权的情况下,循环执行大量的操作,耗尽 CPU 时间。
- 抢占:虽然 busyLoop 没有主动让出 CPU,但由于 Go 的抢占式调度机制,调度器可能会在合适的时间点打断 busyLoop,让其他 Goroutine(比如打印 "Running another goroutine..." 的那个 Goroutine)得到执行机会。
#输出示例:
Running another goroutine...
Running another goroutine...
...
Finished busy loop
我们可以看到,尽管 busyLoop 是一个计算密集型任务,其他的 Goroutine 仍然会间歇性地被调度并执行。这个就是 Go 抢占式调度的效果。
#抢占的实现机制
抢占式调度的核心机制是 定期检查 Goroutine 的执行时间。Go 调度器在后台维护一个时间戳,记录 Goroutine 上次被调度的时间。调度器每隔一段时间会检查当前运行的 Goroutine,如果 Goroutine 占用了 CPU 超过一定时间,调度器就会标记这个 Goroutine 需要被抢占,然后调度其他的 Goroutine 来执行。
抢占式调度通过以下方式触发:
- 函数调用边界:当 Goroutine 进行函数调用时,Go runtime 会在合适的时机插入抢占检查点。
- 栈增长:当 Goroutine 的栈增长(如递归调用导致栈内存增长)时,调度器也会插入抢占检查。
- GC 安全点:垃圾回收过程中,调度器也会尝试抢占。
#通过代码观察抢占效果
我们可以通过使用 GODEBUG 环境变量,启用抢占式调度的调试日志,观察抢占调度的具体行为。运行如下代码时,启用调试模式:
GODEBUG=schedtrace=1000,scheddetail=1 go run main.go
- schedtrace=1000 表示每隔 1000 毫秒输出一次调度器状态。
- scheddetail=1 表示输出详细的调度器信息。
#输出内容解释
在输出的调试信息中,我们可以看到调度器何时抢占了 Goroutine,何时让出了 P,以及具体的调度行为。调试信息会包括如下内容:
- idle M:表示某个 M(线程)变成空闲状态。
- new work:表示调度器找到了新的工作,分配给 P。
- steal work:表示调度器从其他 P 中窃取任务来运行。
#最后我们来总结一下
- Go 的调度器主要基于 协作式调度,但是对于计算密集型任务会通过 抢占式调度 机制防止长时间占用 CPU。
- 抢占调度在计算密集型 Goroutine、系统调用后、垃圾回收等场景下被触发。
- Go 1.14 引入了针对长时间运行的 Goroutine 的抢占式调度,使得 Goroutine 不会因为计算密集任务长时间阻塞 CPU。
这使得 Go 语言能更加高效地运行并发程序,避免单个 Goroutine 长时间霸占 CPU,影响其他 Goroutine 的执行。