2.5 说说你对 Go 里的抢占式调度的理解

Go 从 v1.1 发现展到目前的 v1.16,协程调度策略也在不断的完善优化。

下面我将从 v.1.1 开始讲讲 协程调度策略中抢占式调度的发展历程。

v1.1 的非抢占式调用

在最初的 v1.1 版本中,只有当一个协程主动让出 CPU 资源(可以是运行结束,也可以是发生了系统调用或其他阻塞性操作),才能触发调度,进行下一个协程。

而如果一个协程运行了很久,也没有主动让出的动作发生,就会自私的一个人占用整个线程,该线程无法再去运行其他的 goroutine 了。

这种策略会让 Go 的并发性大打折扣,名不符实。

v1.2 基于协作的抢占式调用

由于 v1.1 的非抢占式调用,以程序的并发效率影响实在太大。因为在下一个版本 v1.2 就紧急地对调度策略进行了临时的优化。经过优化后,go 从 v1.2 开始支持抢占式的调用:

  1. 如果 sysmon 监控线程发现有个协程 A 执行之间太长了(或者 gc 场景,或者 stw 场景),那么会友好的在这个 A 协程的某个字段设置一个抢占标记 ;

  2. 协程 A 在 call 一个函数的时候,会复用到扩容栈(morestack)的部分逻辑,检查到抢占标记之后,让出 cpu,切到调度主协程里;

之所以说 v1.2 的抢占式调用是临时的优化方案,是因为这种抢占式调度是基于协作的。在一些的边缘场景下,协程还是在会独自占用整个线程无法让出。

从上面的流程中,你应该可以注意到,A 调度权被抢占有个前提:A 必须主动 call 函数,这样才能有走到 morestack 的机会。

反面案例可以看下面这个程序,当运行到 time.Sleep 后,线程上的 goroutine 会从 main 切换到前面的匿名函数协程,而这个匿名函数协程并是在作for 死循环,并没有任何可以让出 cpu 运行权的操作,因为该程序在 go 1.14 之前的 go版本中,运行后会一直卡住,而不会打印 I got scheduled!

package main

import (
    "fmt"
    "runtime"
    "time"
)

func main() {
    runtime.GOMAXPROCS(1)

    fmt.Println("The program starts ...")

    go func() {
        for {
        }
    }()

    time.Sleep(time.Second)
    fmt.Println("I got scheduled!")
}

v1.14 基于信号的抢占式调用

基于协作的抢占式调用,伴随着 Go 走过了12个版本,终于在 v1.14 迎来了真正的抢占式调用。

为什么说是真正的抢占式调用呢?

因为 v1.14 的这种抢占式调用是基于信号的,不管你的协程有没有意愿主动让出 cpu 运行权,只要你这个协程超过某个时间,就会发送信号强行夺取 cpu 运行权。

那么这个时间具体是多少呢? 20ms