golang异步调度和抢占

Go语言调度器的性能随着版本迭代表现的越来越优异,我们来了解一下调度器使用的G-M-P模型。先是一些概念:

  • G(Goroutine): goroutine,由关键字go创建
  • M(Machine): 在Go中称为Machine,可以理解为工作线程
  • P(Processor) : 处理器 P 是线程 M 和 Goroutine 之间的中间层(并不是CPU)

M必须持有P才能执行G中的代码,P有自己本地的一个运行队列runq,由可运行的G组成,下图展示了 线程 M、处理器 P 和 goroutine 的关系。

G-M-P调度模型

Go语言调度器的工作原理就是处理器P从本地队列中依次选择goroutine 放到线程 M 上调度执行,每个P维护的G可能是不均衡的,为此调度器维护了一个全局G队列,当P执行完本地的G任务后,会尝试从全局队列中获取G任务运行(需要加锁),当P本地队列和全局队列都没有可运行的任务时,会尝试偷取其他P中的G到本地队列运行(任务窃取)。

在Go1.3版本以前,调度器还不支持抢占式调度,只能依靠 goroutine 主动让出 CPU 资源,存在非常严重的调度问题:

  • 单独的 goroutine 可以一直占用线程运行,不会切换到其他的 goroutine,造成饥饿问题
  • 垃圾回收需要暂停整个程序(Stop-the-world,STW),如果没有抢占可能需要等待几分钟的时间,导致整个程序无法工作

Go1.14中编译器在特定时机插入函数,通过函数调用作为入口触发抢占,实现了协作式的抢占式调度。但是这种需要函数调用主动配合的调度方式存在一些边缘情况,就比如说下面的例子:

package main

import (
    "runtime"
    "time"
)

func main() {
    runtime.GOMAXPROCS(1)
    go func() {
        for {
        }
    }()

    time.Sleep(time.Millisecond)
    println("OK")
}

其中创建一个goroutine并挂起, main goroutine 优先调用了 休眠,此时唯一的 P 会转去执行 for 循环所创建的 goroutine,进而 main goroutine 永远不会再被调度。换一句话说在Go1.14之前,上边的代码永远不会输出OK,因为这种协作式的抢占式调度是不会使一个没有主动放弃执行权、且不参与任何函数调用的goroutine被抢占。

Go1.14 实现了基于信号的真抢占式调度解决了上述问题。Go1.14程序启动时,在 runtime.sighandler 函数中注册了 SIGURG 信号的处理函数 runtime.doSigPreempt,在触发垃圾回收的栈扫描时,调用函数挂起goroutine,并向M发送信号,M收到信号后,会让当前goroutine陷入休眠继续执行其他的goroutine。

资料参考原地址:https://golang.org/doc/go1.14

推荐阅读更多精彩内容