你觉得 Go 在什么时候会抢占 P?

开发 前端
Go 的调度器采用的是协作式调度为主,抢占式调度为辅。协作式调度意味着 Goroutine 需要主动放弃控制权来让其他 Goroutine 运行,比如调用系统调用或者 Goroutine 自己调用 runtime.Gosched()。

在 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 的时机

  1. 系统调用 (syscall) 后:当 Goroutine 执行系统调用后,Goroutine 会让出 P,此时调度器可能会选择调度其他的 Goroutine 来运行。
  2. 垃圾回收 (GC) 阶段:当触发垃圾回收时,调度器会在合适时机抢占 Goroutine,确保 GC 可以进行。
  3. 计算密集型任务被长时间运行:从 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)
}

#代码解析:

  1. runtime.GOMAXPROCS(1):我们将 GOMAXPROCS 设置为 1,意味着整个程序中只有一个 P,这样所有 Goroutine 都只能在这个 P 上调度。
  2. busyLoop:这是一个计算密集型任务,在没有主动进行系统调用或让出调度权的情况下,循环执行大量的操作,耗尽 CPU 时间。
  3. 抢占:虽然 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 来执行。

抢占式调度通过以下方式触发:

  1. 函数调用边界:当 Goroutine 进行函数调用时,Go runtime 会在合适的时机插入抢占检查点。
  2. 栈增长:当 Goroutine 的栈增长(如递归调用导致栈内存增长)时,调度器也会插入抢占检查。
  3. 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 的执行。

责任编辑:武晓燕 来源: Go语言圈
相关推荐

2021-04-19 09:20:01

Go 抢占 P语言

2021-09-29 09:24:21

GCGo STW

2023-06-06 16:54:00

2015-03-02 14:44:48

AngularJS jQuery超越

2021-03-23 10:08:02

编程互联网数据科学

2023-02-01 15:49:51

人工智能AI

2015-07-08 15:55:01

NSStringcopystrong

2017-05-15 09:55:07

2020-05-12 11:25:50

MySQLES数据库

2013-11-28 16:03:24

2012-09-24 10:20:39

JavaScriptJS

2009-06-19 16:29:47

EJBXML

2019-04-16 13:27:36

隐私数据信息保护

2017-06-28 15:06:51

PythonLambda函数

2021-08-13 11:31:23

HTTP

2024-08-05 01:22:16

2022-05-19 10:27:34

机器学习人工智能

2022-09-08 09:42:26

JavaScripMapObject

2021-01-30 19:59:37

性能项目开源

2012-07-26 10:27:31

PHP
点赞
收藏

51CTO技术栈公众号