Go协程调度
cuishuangtodo
go夜读-12 goroutine 的调度
相关博客:
init()的执行顺序 && Go scheduler的初始化过程
Linux的I/O调度算法
Go调度器GPM
四种常用的磁盘调度算法
当程序开始执行时,操作系统调度程序会选择在进程中创建的线程,并安排线程在 CPU 内核上运行。核心越多,操作系统可以调度的线程就越多。要做的算法比较复杂,但是已经足够成熟,值得信赖。
这个想法是当一个 CPU 内核空闲时,任何可执行线程都不应该等待。
线程的生命周期是Created -> Runnable -> Running -> Waiting on Resource(blocked) -> Terminated。
线程状态:
新线程已创建
Runnable——线程已准备好运行或被调度
Running- 线程正在 CPU 中运行
阻塞——线程正在等待资源
Terminated- 线程关闭/终止
那么当线程数高于核心数时会发生什么呢?上下文切换。
操作系统更改线程以让其他线程有机会运行,这样操作系统就可以一次处理多个任务。
想象一个厨师煮面条,他往容器里倒水煮沸,他不会在那里等到水沸腾,而是利用这段时间来切菜或掰面条,或两者兼而有之。
这里的过程是煮面,线是烧水、切菜、掰面。厨师想要同时处理多项任务,但他一次只能考虑一件事,因此他开始在等待特定任务时切换操作。
支票可能会去煮面条或开水切菜,这是不可预测的。就像操作系统可以在上下文切换时选择任何线程一样,这是不可预测的。它也可能取决于线程的优先级。
当从一个线程切换到另一个线程时,操作系统必须从 CPU 内核替换线程及其支持信息(如内存/缓存),并且在 CPU 处理额外指令的情况下需要几纳秒。因此,如果有大量线程,操作系统会为了公平而不断更改线程,让每个线程都有自己的 CPU 时间,并且大部分时间会在上下文切换中丢失。
所以,尽量减少进程中可运行线程的数量。
Golang 提供了一种轻量级的并发方式,Goroutines。这些 Go 例程由 Go 运行时管理,而不是由操作系统管理。Goroutines 有类似线程的状态——可运行、运行、终止/完成。与 OS 线程一样,goroutines 将用于并发。
Go 例程将被安排在操作系统线程中运行。
所以操作系统上下文切换操作系统线程,Go 运行时上下文切换 Goroutines。
为什么使用 Go 例程:
轻的。Goroutines 比 2KB 的线程小很多,它们会在需要时增加堆栈大小(可增长的分段堆栈),其中线程的大小≥1,与 Goroutines 相比要高得多。
OS 线程的创建和拆除可能比 goroutine 花费更多的时间。
线程运行时和调度由操作系统维护,线程的上下文切换会花费更多时间。并且线程的调度是抢占式的(线程调度将基于时间和优先级,这可能导致在特定线程上运行的任务可能需要更多时间,因为它在线程函数结束时与另一个线程进行了上下文切换)
在 Go 例程中,调度由遵循协作调度的 Go 运行时管理。在协作调度中,只有当当前 goroutine 被阻塞或完成时,当前 goroutine 才会进行上下文切换。(也支持抢占式调度了~)
Go程序时可以通过设置环境变量来控制分配给Go程序的OS线程数GOMAXPROCS
调度过程中使用的结构:
- G struct - 它包含有关 Go Routines 的信息(id、堆栈和缓存以及程序计数器),基本上是 goroutine 的详细信息。
- M struct-指向操作系统线程。它有一个指向可运行 Go 例程的全局队列的指针,运行 Go 例程和调度程序。
- Sched struct-包含所有空闲队列,等待Go例程和线程
调度过程中使用的队列:
当 Go 程序在机器上启动时,作为逻辑处理器的 OS 硬件线程将变得可用。即:如果计算机有 8 个硬件线程,则 Golang 程序可用的线程数将为 8。这可以通过设置“GOMAXPROCS”环境变量来更改。
线程可以被称为“P”。该机器将被称为“M”。Go例程将被称为“G”
现在,每当 Go 程序生成一个新的 Goroutine 时,goroutine 结构将被创建并放置在任何可用线程“P”的本地运行队列中,当操作系统线程从操作系统获得 CPU 时间时,它将开始执行 Go来自队列的例程。当Queue中存在多个Goroutines时,会一个一个地挑选goroutines进行处理。
当’P’的一个Local Run Queue被大量使用时,可以使用其他’P’的Queue。所以 goroutines 被放置在多个 OS 线程中,只要 OS 线程获得 CPU 时间,Go routines 就会被执行。哪个 goroutine 放置在哪个处理器中的映射将由“M Struct”查看。当 Go 运行时想要上下文切换 goroutine 时,这会派上用场,因为运行时可以简单地更改操作系统线程“P”的 M 结构和 LRQ。
当 OS 线程的 LRQ Queue goroutines 执行完毕后,OS 线程不能一直空闲,所以线程不得不偷工。
它可以从
GRQ — 全局运行时队列,所有未分配给操作系统线程的 Goroutine 都在那里。
其他处理器或线程的 LRQ - 空闲线程可以从其他线程的 LRQ 窃取一半的工作。
每个 OS 线程都会有一个队列,用于它负责运行的 Go 例程,该队列将在 M 结构中维护,M 结构中的每个条目都将是 G 结构。Local队列中的OS线程本地维护的队列。
新创建或未调度的 Go routines 将在 CPU 维护的 Global queue 中,如果 Local 队列中的所有 Goroutines 已完成或都处于阻塞状态,则 OS 线程可以从 Global queue 中选择 Go routines。
为执行用户代码而创建的 GO 例程将开始运行用户程序。与线程一样,goroutines 可以进一步产生一个新的 goroutine。
为调度而创建的 goroutine 将进行监视,它会在 OS 线程中对 Goroutines 进行上下文切换。
就像在 OS 线程中为并发进行上下文切换一样,Goroutines 也可以完成。而且它会更快,因为它不涉及从处理器切换整个线程的上下文,而且它很简单,因为 Goroutine 更小,上下文切换只发生在 Goroutine 的地方。由于 Go 运行时调度器是协作的,我们可以预测接下来会发生什么,这与抢占式的 OS 调度器不同。
我们可以预测是基于:
如果 Go 例程是
等待任何同步系统调用
任何文件 IO 操作
等待频道
垃圾收集正在运行,所有 Goroutine 都将停止,以确保 Goroutine 不会使用新数据或旧数据。
在上述场景中,调度程序会将当前 Go 例程视为已阻塞,并与来自 M-struct (LRQ)(指向具有可运行 Go 例程的操作系统线程)的其他可运行 Go 例程进行上下文切换。
调度程序做出决定的另一个地方是 goroutine 何时生成新的 goroutine。调度器不会让这个新的 goroutine 成为可运行的,并将其添加到队列中,并在找到合适的时间时执行它。
在程序开始时会创建很少的 goroutine 来进行调度和垃圾收集活动。
垃圾收集:
垃圾收集是一项活动,其中已使用的当前不需要的内存空间(基本上是堆中的变量)将从内存中清除,因此可以重复使用。如果任何运行时都没有正确安排垃圾收集,那么内存就会累积,很快应用程序可能会崩溃,因为没有足够的内存可供使用。
不同的语言根据使用目的有不同的垃圾回收实现。
考虑“语言 A”,如果它需要更快的处理和内存分配,那么垃圾收集将很少运行,因为它需要时间来查找和清除不需要的内存。
考虑“语言 B”,如果它需要良好的内存管理(因为它必须处理大量数据)并权衡性能,那么垃圾收集将经常运行,以便可以清理和重用内存再次。
Golang 中的垃圾收集:
Golang 使用“非分代并发三色标记和清除收集器”。垃圾收集的 Go 例程将在 Go 程序启动时启动。
此实施分为四个阶段:
Marking Setup:在这个阶段,write barrier 被打开,所以正在运行的应用程序goroutines不能创建任何新的变量,这可以通过停止所有应用程序goroutines来实现。这种情况发生的时间很短,感觉应用程序停止工作了。(让世界停止)
标记:此阶段在打开写屏障后开始。用于 GC 的 goroutines 将占用 25% 的 OS 线程用于 GC 活动。它将标记所有已在使用的内存。Go 例程通过遍历已经存在的 Go 例程的堆栈来找到堆的根,并使用堆的根来解析堆图并标记所有正在使用的内存。
标记终止:在这个阶段,写屏障被关闭,清理活动和正在使用的内存的收集将发生,并将在下一步中使用。在此阶段,分配给垃圾收集的 25% 操作系统线程将被回收,所有操作系统线程现在都可以用于应用程序线程。在此阶段,goroutine 将再次停止,因为执行上述停止所有 Go 例程(停止世界)中提到的所有操作既简单又干净
清扫:利用前面步骤计算出来的数据/集合,可以找到没有被使用的内存,重新回收。
为了避免在代码中不必要地使用更多内存,请避免编写闭环代码,因为它不会给垃圾收集 goroutines 运行的机会,因此内存积累会使程序崩溃。垃圾收集的 Go 例程仅在存在安全点(如函数调用)时才开始。
标记/收集时发生延迟:将 25% 的操作系统线程分配给 GC 并停止世界,这会在运行标记设置和标记终止时停止所有应用程序 goroutine。
收集器有一个定步算法,用于确定何时开始收集。该算法取决于收集器用来收集有关正在运行的应用程序和应用程序对堆施加压力的信息的反馈循环。压力可以定义为应用程序在给定时间内分配堆内存的速度。正是这种压力决定了收集器需要运行的速度。
目标是:
不要在紧密循环中累积内存而是进行批处理(所以会有一个函数调用来为GC运行做安全点)
并且不要让 GC 运行得非常频繁,这会影响其他 Goroutine 和应用程序的性能。
您可以通过设置环境变量“GOGC”来禁用 GC 并控制何时必须在运行时调用 GC。例如:GOGC=off 禁用垃圾收集。
要在代码中的某个位置手动触发垃圾收集,请使用runtime.GC()
有关 GC 如何执行的更多调试,请使用GODEBUG=gctrace=1 ./goapp
原文作者: fliter
原文链接:
https://dashen.tech/2023/04/26/Go协程调度/版权声明: 转载请注明出处