✍个人博客:Pandaconda-CSDN博客
📣专栏地址:http://t.csdnimg.cn/UWz06
📚专栏简介:在这个专栏中,我将会分享 Golang 面试中常见的面试题给大家~
❤️如果有收获的话,欢迎点赞👍收藏📁,您的支持就是我创作的最大动力💪
37. GM P 中 hand off 机制
GMP 中的 hand off 机制是指在某个 M 线程需要将当前正在执行的 Goroutine 交给另一个 M 线程时,使用的一种机制。
具体地,hand off 机制的实现过程如下:
当一个 M 线程需要将当前正在执行的 Goroutine 交给另一个 M 线程时,它会将该 Goroutine 和一个指向目标 M 线程的指针打包成一个结构体,称为 hand off 对象。
当目标 M 线程的本地队列中没有 Goroutine 可供执行时,它会从全局队列中获取一个 hand off 对象,并尝试将其中的 Goroutine 从原来的 M 线程中获取出来,添加到自己的本地队列中执行。在此期间,当前 M 线程会不断尝试从全局队列中获取 Goroutine 并将其调度到本地队列中执行。
当目标 M 线程成功获取到 hand off 对象后,它会将其中的 Goroutine 添加到自己的本地队列中,并将它们调度到绑定的 P 上执行。
hand off 机制的好处是可以避免线程饥饿,提高 Goroutine 的调度效率。当一个 M 线程需要将当前正在执行的 Goroutine 交给另一个 M 线程时,可以使用 hand off 机制来尽快地将 Goroutine 交给目标 M 线程,从而避免线程饥饿的问题。同时,由于 hand off 机制只在需要将当前正在执行的 Goroutine 交给另一个 M 线程时才会被使用,因此相对于 work stealing 机制来说,它的实现比较简单,不会增加太多额外的开销。
示例
由于 hand off 机制的使用场景比较特殊,且需要涉及到多个 Goroutine 之间的交互,因此比较难以直接演示。
不过,我们可以通过一个简单的示例来说明 hand off 机制的基本使用方法和效果。
假设我们有一个生产者-消费者模型,其中有多个生产者 Goroutine 和多个消费者 Goroutine,它们都需要不断地从一个共享的队列中获取任务进行处理。为了提高并发效率,我们可以使用 GMP 模型来对任务进行调度。
在这个示例中,我们使用一个全局队列来存储任务,并使用 hand off 机制来将任务从一个 M 线程转移到另一个 M 线程。每个生产者 Goroutine 和消费者 Goroutine 都会不断地尝试从全局队列中获取任务,并将其添加到自己的本地队列中执行。当某个 Goroutine 的本地队列为空时,它会从全局队列中获取一个 hand off 对象,并将其中的 Goroutine 从原来的 M 线程中获取出来,添加到自己的本地队列中执行。在此期间,其他 Goroutine 也可以从全局队列中获取任务,并将其添加到自己的本地队列中执行。
示例代码如下:
package main import ( "fmt" "sync" "time" ) // 全局变量,用于保存正在处理的任务 var currentTask int func producer(tasks chan<- int, wg *sync.WaitGroup) { defer wg.Done() // 生产 10 个任务 for i := 1; i <= 10; i++ { fmt.Printf("producer producing task %d\n", i) tasks <- i time.Sleep(time.Second) } // 关闭任务通道 close(tasks) } func consumer(id int, tasks <-chan int, done chan<- bool, wg *sync.WaitGroup) { defer wg.Done() for task := range tasks { fmt.Printf("consumer %d processing task %d\n", id, task) // 模拟处理任务的耗时 time.Sleep(time.Second) // 交出任务,使用 hand off 机制 currentTask = task done <- true } fmt.Printf("consumer %d has processed all tasks\n", id) } func main() { var wg sync.WaitGroup // 任务通道 tasks := make(chan int) // done 通道,用于实现 hand off 机制 done := make(chan bool) // 启动 3 个 consumer goroutine for i := 1; i <= 3; i++ { wg.Add(1) go consumer(i, tasks, done, &wg) } // 启动 producer goroutine wg.Add(1) go producer(tasks, &wg) // 等待所有 goroutine 执行完毕 wg.Wait() // 所有任务处理完毕后,输出最后一个交出任务的 consumer ID 和任务 ID fmt.Printf("last consumer to hand off task: %d, task ID: %d\n", currentTask%3+1, currentTask) }
在这个示例中,我们定义了一个全局变量 currentTask,用于保存当前正在处理的任务。在 consumer goroutine 中,当处理完一个任务后,使用 hand off 机制将任务交出,并更新 currentTask 的值。在程序结束时,我们可以通过输出 currentTask 的值来查看最后一个交出任务的 consumer ID 和任务 ID。
38. Go 抢 占式调度?
在 1.2 版本之前,Go 的调度器仍然不支持抢占式调度,程序只能依靠 Goroutine 主动让出 CPU 资源才能触发调度,这会引发一些问题,比如:
某些 Goroutine 可以长时间占用线程,造成其它 Goroutine 的饥饿。
垃圾回收器是需要 stop the world 的,如果垃圾回收器想要运行了,那么它必须先通知其它的 goroutine 停下来,这会造成较长时间的等待时间。
为解决这个问题:
Go 1.2 中实现了基于协作的“抢占式”调度。
Go 1.14 中实现了基于信号的“抢占式”调度。
基于协作的抢占式调度
协作式:大家都按事先定义好的规则来,比如:一个 goroutine 执行完后,退出,让出 p,然后下一个 goroutine 被调度到 p 上运行。这样做的缺点就在于是否让出 p 的决定权在 groutine 自身。一旦某个 g 不主动让出 p 或执行时间较长,那么后面的 goroutine 只能等着,没有方法让前者让出 p,导致延迟甚至饿死。
非协作式: 就是由 runtime 来决定一个 goroutine 运行多长时间,如果你不主动让出,对不起,我有手段可以抢占你,把你踢出去,让后面的 goroutine 进来运行。
基于协作的抢占式调度流程:
编译器会在调用函数前插入 runtime.morestack,让运行时有机会在这段代码中检查是否需要执行抢占调度。
Go 语言运行时会在垃圾回收暂停程序、系统监控发现 Goroutine 运行超过 10ms,那么会在这个协程设置一个抢占标记。
当发生函数调用时,可能会执行编译器插入的 runtime.morestack,它调用的 runtime.newstack 会检查抢占标记,如果有抢占标记就会触发抢占让出 cpu,切到调度主协程里。
这种解决方案只能说局部解决了 “饿死” 问题,只在有函数调用的地方才能插入 “抢占” 代码(埋点),对于没有函数调用而是纯算法循环计算的 G,Go 调度器依然无法抢占。
比如,死循环等并没有给编译器插入抢占代码的机会,以下程序在 go 1.14 之前的 go 版本中,运行后会一直卡住,而不会打印 I got scheduled!
。
package main import ( "fmt" "runtime" "time" ) func main() { runtime.GOMAXPROCS(1) go func() { for { } }() time.Sleep(time.Second) fmt.Println("I got scheduled!") }
为了解决这些问题,Go 在 1.14 版本中增加了对非协作的抢占式调度的支持,这种抢占式调度是基于系统信号的,也就是通过向线程发送信号的方式来抢占正在运行的 Goroutine。
基于信号的抢占式调度
真正的抢占式调度是基于信号完成的,所以也称为 “异步抢占”。不管协程有没有意愿主动让出 cpu 运行权,只要某个协程执行时间过长,就会发送信号强行夺取 cpu 运行权。
M 注册一个 SIGURG 信号的处理函数:sighandler。
sysmon 启动后会间隔性的进行监控,最长间隔 10ms,最短间隔 20us。如果发现某协程独占 P 超过 10ms,会给 M 发送抢占信号。
M 收到信号后,内核执行 sighandler 函数把当前协程的状态从 _Grunning 正在执行改成 _Grunnable 可执行,把抢占的协程放到全局队列里,M 继续寻找其他 goroutine 来运行。
被抢占的 G 再次调度过来执行时,会继续原来的执行流。
抢占分为 _Prunning
和 _Psyscall
,_Psyscall
抢占通常是由于阻塞性系统调用引起的,比如磁盘 io、cgo。_Prunning
抢占通常是由于一些类似死循环的计算逻辑引起的。
39. 协作式 的抢占式调度
在 Go 语言中,Goroutine 调度器采用的是协作式调度,也就是说,在一个 Goroutine 执行过程中,如果没有主动交出控制权(比如调用 time.Sleep()、channel 操作等),其他 Goroutine 是无法抢占执行的。这样可以避免出现线程安全的问题,但也会导致某个 Goroutine 长时间占用 CPU 时间,从而降低程序整体的并发性能。
为了解决这个问题,Go 语言在 1.14 版本引入了抢占式调度。抢占式调度的主要思想是,在 Goroutine 执行过程中,如果某个 Goroutine 执行时间过长,会被强制抢占,让其他 Goroutine 有机会执行。这样可以保证所有 Goroutine 公平地获得 CPU 时间,从而提高程序的并发性能。
在抢占式调度中,Go 语言采用了基于信号的抢占方式。具体来说,当一个 Goroutine 执行时间过长时,会在指定时间内收到一个抢占信号,然后在信号处理程序中暂停当前 Goroutine 的执行,并将控制权交给调度器,让调度器决定下一个要执行的 Goroutine。当下一个 Goroutine 开始执行时,之前被暂停的 Goroutine 就被称为 “被抢占” 的 Goroutine。
需要注意的是,抢占式调度只在 Go 语言的系统线程中生效,而在非系统线程中,仍然采用协作式调度。这是因为非系统线程是由 Go 语言运行时管理的,无法被操作系统直接抢占,因此只能采用协作式调度。另外,抢占式调度对于需要实现低延迟的应用程序可能不太适合,因为抢占操作需要额外的 CPU 时间,从而增加了系统的响应时间。
实例演示
下面是一个简单的抢占式调度的示例代码:
package main import ( "fmt" "time" ) func main() { go func() { for { fmt.Println("Goroutine 1 is running") time.Sleep(time.Second) } }() go func() { for { fmt.Println("Goroutine 2 is running") time.Sleep(time.Second) } }() for { fmt.Println("Main Goroutine is running") time.Sleep(time.Second) } }
在这个示例代码中,我们定义了三个 Goroutine,分别是 “Goroutine 1”、“Goroutine 2” 和 “Main Goroutine”。其中,“Goroutine 1” 和 “Goroutine 2” 分别每秒输出一次自己的名称,而 “Main Goroutine” 每秒输出一次自己的名称。
我们可以运行这个程序并观察输出结果。在协作式调度下,我们会发现 “Main Goroutine” 总是先输出,而 “Goroutine 1” 和 “Goroutine 2” 则交替输出。而在抢占式调度下,由于 Goroutine 执行时间被限制,我们会发现 “Main Goroutine”、“Goroutine 1” 和 “Goroutine 2” 三个 Goroutine 的输出基本上是随机的,每个 Goroutine 每秒只能输出一次。
需要注意的是,抢占式调度并不是默认启用的,如果要启用抢占式调度,可以通过设置 GOMAXPROCS 环境变量或调用 runtime.GOMAXPROCS() 函数来指定使用的系统线程数。当 GOMAXPROCS 的值大于 1 时,Go 语言会自动启用抢占式调度。