Golang-goroutine的调度

谈及这个话题,最初的出发点是朋友问我:“goroutine的调度是抢占式的还是非抢占式的”?所以就有了以下的总结。

什么是goroutine

要知道什么是goroutine,那么就要对以下几个定义有所了解:

  • 进程(Process):在内存中的程序。有自己独立的独占的虚拟 CPU 、虚拟的 Memory、虚拟的 IO devices。

  • 线程(Thread):轻量级进程。在现代操作系统中,是进程中程序执行流的最小单元。一个标准的线程由线程ID,当前指令指针(PC),寄存器集合和堆栈组成。

  • 协程(coroutine/fiber):轻量级线程。 是可以并发执行的函数,由编译或用户指定位置将控制权交给协程调度程序执行的方式。它是非抢占式的,可以避免反复系统调用,还有进程切换造成的开销,给你上几千个逻辑流,也称用户级别线程。

而goroutine,可以认为是协程的go语言实现,也可以说是轻量级线程(即协程coroutine),但作者Rob Pike并不这么说:“一个Goroutine是一个与其他goroutines 并发运行在同一地址空间的Go函数或方法。一个运行的程序由一个或更多个goroutine组成。它与线程、协程、进程等不同。它是一个goroutine。”

抢占式和非抢占式

非抢占式(Nonpreemptive)

让进程运行直到结束或阻塞的调度方式,容易实现,适合专用系统,不适合通用系统

抢占式(Preemptive)

允许将逻辑上可继续运行的在运行过程暂停的调度方式,可防止单一进程长时间独占CPU,系统开销大

goroutine的进化史

G-M模型(Go 1.0)

在这个调度器中,每个goroutine对应于runtime中的一个抽象结构:G,而os thread作为“物理CPU”的存在而被抽象为一个结构:M(machine)。

G-P-M模型(Go 1.1)

P是一个“逻辑Proccessor”,每个G要想真正运行起来,首先需要被分配一个P(进入到P的local runq中,这里暂忽略global runq那个环节)。对于G来说,P就是运行它的“CPU”,可以说:G的眼里只有P。但从Go scheduler视角来看,真正的“CPU”是M,只有将P和M绑定才能让P的runq中G得以真实运行起来。这样的P与M的关系,就好比Linux操作系统调度层面用户线程(user thread)与核心线程(kernel thread)的对应关系那样(N x M)。

“抢占式”调度(Go 1.2)

这个抢占式调度的原理则是在每个函数或方法的入口,加上一段额外的代码,让runtime有机会检查是否需要执行抢占调度。这种解决方案只能说局部解决了“饿死”问题,对于没有函数调用,纯算法循环计算的G,scheduler依然无法抢占。

e.g:

package main

import (
"fmt"
"runtime"
)

func main() {
// 设置为只有一个P
runtime.GOMAXPROCS(1)
go func() {
for {
fmt.Println("hello world")
}
}()
// 由于没有函数方法调用,P只会堵死在这个G上,而上一个G会被“饿死”。
go func() {
for {
//fmt.Println("hello")
}
}()

select {}
}

参考链接:

1.goroutine的调度

2.five-things-that-make-go-fast

结论:

根据参考文章,可以知道goroutine的调度是基于G-P-M模型的,他本身就是一种非抢占式调度。文章中提到Go 1.2的版本优化,通过runtime检查执行抢占,实现了“抢占调度”,但仍然会有无法运行runtime检查的情况(即文章中提到的各种阻塞)。综上所述,goroutine是非抢占式的,通过协作来进行调度。

END