Go GMP model

87

Machine

  • Go 对系统级线程的抽象,不直接执行 g,而是先和 p 绑定,由其实现代理

Processor

  • Go 中的调度器,是 m 的执行代理,p 的数量决定了 g 的最大并行数量(通过 runtime.GOMAXPROC(int) 设定)

  • 关联了本地可运行 G 的队列,队列最多可存放 256 个 G

Goroutine

  • 参与调度与执行的最小单位

  • 需要绑定到 p 才能执行,M 个 Goroutine 运行在 N 个内核线程之上,实现并行

  • 创建、销毁、调度在用户态完成,切换成本比线程小

  • 每个 Goroutine 有自己的运行栈,状态以及执行函数,能动态伸缩栈空间大小

// goroutine 状态
const (
	_Gidle = iota // 0 协程开始创建的状态
	_Grunnable // 1 协程在待执行队列
	_Grunning // 2 协程正在执行,同一时刻一个 P 中只有一个 G 处于此状态
	_Gsyscall // 3 协程正在执行系统调用
	_Gwaiting // 4 协程处于挂起状态,等待被唤醒。GC、channel 通信或锁操作时经常会进入此状态
	_Gdead // 6 协程刚初始化完成/已经被销毁
	_Gcopystack // 8 协程正在栈扩容流程中
	_Gpreempted // 9 协程被抢占后的状态
)

数据结构定义

GMP模型的数据结构定义在 runtime/runtime2.go 中。

type m struct {
    g0      *g     // 特殊的调度协程,负责执行 g 之间的切换调度
    // thread-local storage, 线程本地存储,存储内容只对当前线程可见. 
    // 线程本地存储的是 m.tls 的地址,m.tls[0] 存储的是当前运行的 g,
    // 因此线程可以通过 g 找到当前的 m、p、g0 等信息.
    tls           [tlsSlots]uintptr
    // ...
}

type p struct {
    runqhead uint32 // 队列头部
    runqtail uint32 // 队列尾部
    runq     [256]guintptr // 本地 g 队列,最大长度为 256
    runnext guintptr // 下一个可执行的 g
    // ...
}

// schedt 全局 g 队列
type schedt struct {
    lock mutex // 操作全局队列使用的锁
    runq     gQueue // 全局 g 队列
    runqsize int32 // 全局 g 队列的容量
    // ...
}

g0 和 g 的切换

m 通过 p 调度执行的 goroutine 永远在 g 和 g0 之间进行切换。当 g0 找到可执行的 g 时,会调用 gogo 方法,调度 g 执行用户定义的任务。当 g 需要主动让渡或被动调度时,会触发 mcall 方法,将执行权重新交还给 g0。

func gogo(buf *gobuf)
// ...
func mcall(fn func(*g))

宏观调度流程

  • g0 执行 schedule() ,寻找到用于执行的 g findRunnable()

  • g0 执行 execute(),更新当前 g、p 的状态信息,并调用 gogo() ,将执行权交给 g

  • g 因主动让渡(gosche_m())、被动调度(park_m())、正常结束( goexit0())等原因,调用 m_call() ,执行权重新回到 g0 手中

  • g0 执行 schedule() 函数,开启新一轮循环

p 每 61 次调度,会去全局队列里取出一个 g(如果全局队列有g)并执行

// g0 为 m 寻找到可执行的 g 之后,接下来就开始执行 g
func execute(gp *g, inheritTime bool) {
    _g_ := getg()

    _g_.m.curg = gp
    gp.m = _g_.m
    casgstatus(gp, _Grunnable, _Grunning)
    gp.waitsince = 0
    gp.preempt = false
    gp.stackguard0 = gp.stack.lo + _StackGuard
    if !inheritTime {
        _g_.m.p.ptr().schedtick++
    }

	// 调用 gogo,执行 goroutine 中的任务
    gogo(&gp.sched)
}

调度类型

这里是调度器 p 实现从执行一个 g 切换到另一个 g。

正常调度:g 执行任务完成,g0 将当前 g 置为 _Gdead 状态,发起新一轮调度。

抢占调度:在 Go 进程中有一个全局监控协程 monitor g(由于发起系统调用时需要打破用户态的边界进入内核态,此时 m 也会因系统调用而陷入僵直,无法主动完成抢占调度的行为),这个 g 会越过 p 直接与一个 m 进行绑定,不断轮询对所有 p 的执行状况进行监控,如果发现满足抢占调度条件(g 执行系统调用超过指定的时长,且 p 资源比较紧缺),则将 p 和 g 解绑,抢占出来用来于其他 g 的调度,等 g 完成系统调度后,会重新进入队列中等待被调度。

主动调度:用户主动让渡(即在代码中调用 runtime.Gosched()),此时当前 g 会让出执行权,主动进入全局队列,等待下次被调度执行。

// g 执行主动让渡时,会调用 mcall 方法将执行权归还给 g0,并由 g0 调用 gosched_m 方法
func Gosched() {
    // ...
    mcall(gosched_m)
}

func gosched_m(gp *g) {
    goschedImpl(gp)
}

func goschedImpl(gp *g) {
    status := readgstatus(gp)
    if status&^_Gscan != _Grunning {
        dumpgstatus(gp)
        throw("bad g status")
    }
		// 将当前 g 的状态由执行中切换为待执行 _Grunnable
    casgstatus(gp, _Grunning, _Grunnable)
		// dropg(),将当前的 m 和 g 解绑
    dropg()
		// 将 g 添加到全局队列当中
    lock(&sched.lock)
    globrunqput(gp)
    unlock(&sched.lock)

		// 开启新一轮的调度
    schedule()

被动调度:由于当前不满足某种执行条件,g 陷入阻塞态无法被调度,直到条件达成后,g 才从阻塞中被唤醒,重新进入可执行队列等待被调度(触发原因:channel 通信或互斥锁操作等,底层会进入 gopark 方法)。

// g 需要被动调度时,会调用 mcall 方法切换至 g0,并调用 park_m 方法将 g 置为阻塞态. goready 方法通常与 gopark 方法成对出现,能够将 g 从阻塞态中恢复,重新进入等待执行的状态.
func gopark(unlockf func(*g, unsafe.Pointer) bool, lock unsafe.Pointer, reason waitReason, traceEv byte, traceskip int) {
    // ...
    mcall(park_m)
}

func goready(gp *g, traceskip int) {
    systemstack(func() {
        ready(gp, traceskip, true)
    })
}