2

goroutine调度器揭秘 2

 1 month ago
source link: https://colobu.com/2024/03/24/goroutine-scheduler-2/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

我翻译了Goroutine Scheduler Revealed: Never See Goroutines the Same Way Again, 这篇文章发表于1月2号,作者在文章最后说:

在接下来的部分,我们将更深入地探讨抢占式调度,并分析调度程序在运行期间所采取的每一步骤。

接下来快三个月了,也没有下文了,貌似鸽了。
为了不让读者等待,我接下来把相关的内容补上。

当然,Go的调度器也在演化之中,你看几年前的代码,或者几年后的代码,可能就没有办法一一对应了,我们以1.21.1版本讲解。

schedule 函数

函数schedule执行一次调度器循环,找到一个可运行的goroutine并执行它。这个函数永不返回。当一个 goroutine 执行完毕,它会再次调用 schedule 函数来选择下一个要执行的 goroutine。这就是为什么 schedule 函数看起来像是永不返回:它总是在选择和执行 goroutine,除非没有更多的 goroutine 可以执行。

schedule 函数并不是通过常规的函数调用栈来调度 goroutine 的。当一个 goroutine 结束执行,它并不是通过返回到 schedule 函数来调度下一个 goroutine,而是通过一种称为 "栈切换" 的技术。

在具体实现上,每个 goroutine 都有自己的栈,当它开始执行时,运行时系统会将当前的栈指针切换到这个 goroutine 的栈,当它结束执行时,运行时系统会将栈指针切换回原来的栈(通常是所谓的 "系统栈" 或 "调度器栈")。这样,每个 goroutine 的函数调用栈都是独立的,它们不会互相影响,也不会导致栈溢出。

因此,尽管从代码上看 schedule 函数像是在递归调用,但实际上它并不会导致栈溢出,因为每次调用 schedule 都是在一个新的 goroutine 的栈上,而不是在同一个函数调用栈上。

proc.go#schedule

以下是一些可能会调用 schedule 函数的函数和它们的使用场景:

  1. goexit(goexit1->goexit0):当一个 goroutine 结束执行时,它会调用 goexit 函数来清理资源并退出。在 goexit 函数中,会调用 schedule 函数来选择下一个要执行的 goroutine
  2. preemptParkpreemptPark 函数被用于实现抢占式调度。当运行时系统决定当前正在执行的 goroutine 需要被抢占时,它会设置 goroutine 的状态为 _Gpreempted,并调用 preemptPark 函数将 goroutine 挂起。
  3. park_m: park_m 函数被用于将当前的操作系统线程(M)挂起,直到有新的 goroutine 可以运行。比如没有goroutine可运行时,或者因为每个条件阻塞时。
  4. goschedImpl:调用runtime.Gosched 函数时,它被用于让当前的 goroutine 主动让出 CPU,让其他 goroutine 有机会执行。
  5. goyield_m: 类似runtime.Gosched 函数, goyield_m 函数也被用于让当前的 goroutine 主动让出 CPU,让其他 goroutine 有机会执行。但是它会把当前的goroutine放到local run queue,而不像Gosched放到global run queue。
  6. mstart1:创建的操作系统线程(M)开始执行时被调用的。b比如新创建M时,或者M不够用的时候。它的主要任务是初始化线程的环境,并开始执行调度循环。
  7. exitsyscall0: exitsyscall0 函数在一个 goroutine 完成系统调用并准备返回到 Go 空间时被调用。系统调用的goroutine返回后,会尝试找一个空闲的P去执行当前的G(和M), 否则会把当前的G放到global run queue中。

这些函数都是 Go 语言运行时系统中的内部函数,它们的具体实现可能会因 Go 语言的版本和运行时系统的配置而不同。

schedule函数中,重要的是调用了findRunnable, 找到一个可以执行的goroutine。必须要找到一个,否则会阻塞直到找到一个goroutine:

gp, inheritTime, tryWakeP := findRunnable() // blocks until work is available

那么,接下来我们跟踪findRunnable函数,了解它的每一个步骤,这是调度器工作的重点。

findRunnable 函数

findRunnable.svg

findRunnable 函数是调度器的核心函数之一,它的主要任务是找到一个可以执行的 goroutine

findRunnable 函数的实现非常复杂,因为它需要考虑很多因素,比如当前的 goroutine 是否需要被抢占,当前的 goroutine 是否需要让出 CPU,当前的 goroutine 是否需要被挂起,当前的 goroutine 是否需要被唤醒,当前的 goroutine 是否需要被移动到其他的 P,等等。我们暂时不考虑这些复杂的情况,只关注最简单的情况:怎么找到一个可以执行的 goroutine

可以寻找的goroutine

重点它寻找一个可以执行的goroutine,这些goroutine都是存在什么地方内?

  • local run queue: 每个P都有一个local run queue,用于存放当前P上的goroutine。
  • global run queue: 所有的P共享一个global run queue,用于存放所有的goroutine。
  • netpoll: 用于处理网络事件的goroutine。
  • timer: 用于处理定时器事件的goroutine。
  • syscall: 用于处理系统调用的goroutine。

处于性能优化的目的,每个处理器(P)确实包含一个四叉堆(heap),用于保存定时器(timer)。。
每个定时器都有一个到期时间,当到达这个时间时,定时器关联的函数就会被调用。四叉堆的结构使得运行时系统可以快速地找到最早到期的定时器,这对于定时器的处理非常重要。
当你在 Go 代码中使用 time.After,time.Sleep 或 time.AfterFunc 等函数时,就会创建一个定时器,并将其添加到当前 P 的 timers 四叉堆中。

全局还有一个网络轮询器(netpoller),它用于处理网络事件。每个findRunnable调用的时候也可能去访问它。

findRunnable 寻找goroutine的步骤

findRunnable-flowchart.svg

findRunnable函数寻找goroutine过程既复杂又简单。
说它复杂,是因为它需要考虑很多因素,做各种检查,执行不同的策略去寻找goroutine。
说它简单,是因为它的逻辑很清晰,只是一步一步地检查,直到找到一个可以执行的goroutine。一旦找到goroutine,就直接返回。

根据这个函数的实现,我把大致的逻辑画了一个流程图,如上图所示,下面我们一步一步地解释这个流程图,忽略其中的一些条件检查,我们主要关注它的大的逻辑。

  1. 首先定义了一个top标签,因为在后面的代码中,需要从头开始重新检查,类似一个for循环,但是这里使用goto语句更清晰。
  2. 检查Timer,如果有到期的timer,则执行timer相关的goroutine。如果程序中有大量的Timer也不太好,如果Timer大量的增删或者日期变化,会导致性能问题。
  3. 检查tracer, 跟踪调用的goroutine,如果符合条件,执行。
  4. 检查是否有gc worker要执行,如果有,执行。
  5. 如果tick(嘀嗒)计数器到了61次,并且global run queue中有goroutine,那么执行global run queue中的goroutine。
  6. 如有必要,唤醒finalizer
  7. 如有必要,执行cgo_yield,让执行cgo系统调用的goroutine让出CPU。
  8. 检查local run queue, 如果有goroutine,执行。
  9. 再次检查global run queue, 如果有goroutine,执行。
  10. 检查netpoll, 如果网络事件需要处理,唤醒相关的goroutine。
  11. 从其他P的local run queue中偷取goroutine,如果有goroutine,执行。
  12. 如有必要,执行idle-time marking, 执行GC的标记阶段。
  13. wasm相关的逻辑处理
  14. 再次检查global run queue, 如果有goroutine,执行。
  15. 如有需要,再次从头检查
  16. 再次检查netpoll
  17. 如果没有找到可以执行的goroutine,阻塞等待

总体上看, findRunnable 函数实现了一个通用的goroutine调度逻辑。

也许,针对不同的场景,你可以在这个调度器上微调,尝试获取更高性能调度:

  1. 调整寻找goroutine的顺序、频次等
  2. 进行goroutine的优先级调度、进行NUMA等CPU架构的优化

这样,我们把原作者的许诺的分享中的调度步骤的内容补上了,接下来补上最后一部分的内容:抢占式调度。请关注公众号 “鸟窝聊技术” 获取最新文章。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK