Go 协程的实现原理 | 青训营笔记

176 阅读5分钟

这是我参与「第五届青训营 」伴学笔记创作活动的第 9 天

1. 协程的底层结构

type g struct {
    // Stack parameters.
    // stack describes the actual stack memory: [stack.lo, stack.hi).
    // stackguard0 is the stack pointer compared in the Go stack growth prologue.
    // It is stack.lo+StackGuard normally, but can be StackPreempt to trigger a preemption.
    // stackguard1 is the stack pointer compared in the C stack growth prologue.
    // It is stack.lo+StackGuard on g0 and gsignal stacks.
    // It is ~0 on other goroutine stacks, to trigger a call to morestackc (and crash).
    stack       stack   // offset known to runtime/cgo
    sched     gobuf

    atomicstatus uint32
    goid         int64

    // ......
}

// Stack describes a Go execution stack.
// The bounds of the stack are exactly [lo, hi),
// with no implicit data structures on either side.
type stack struct {
    lo uintptr
    hi uintptr
}

// The offsets of sp, pc, and g are known to (hard-coded in) libmach.
//
// ctxt is unusual with respect to GC: it may be a
// heap-allocated funcval, so GC needs to track it, but it
// needs to be set and cleared from assembly, where it's
// difficult to have write barriers. However, ctxt is really a
// saved, live register, and we only ever exchange it between
// the real register and the gobuf. Hence, we treat it as a
// root during stack scanning, which means assembly that saves
// and restores it doesn't need write barriers. It's still
// typed as a pointer so that any other writes from Go get
// write barriers.
type gobuf struct {
    sp   uintptr
    pc   uintptr
    g    guintptr
    ctxt unsafe.Pointer
    ret  uintptr
    lr   uintptr
    bp   uintptr // for framepointer-enabled architectures
}

协程本质是一个 g 类型的结构体

  • stack stack 是堆栈地址
  • sched gobuf 记录当前协程运行的现场信息
  • atomicstatus uint32 记录协程状态

Go 将操作系统的线程抽象为一个 m 类型的结构体,其记录了 g0 协程和当前正在运行的协程等信息。

// runtime\runtime2.go

type m struct {
    g0      *g     // goroutine with scheduling stack
    curg    *g     // current running goroutine

    mOS
    // ...
}

每个操作系统线程启动后会执行一个循环,在这个循环中不断地执行每个被调度到的协程。
这个过程中,操作系统是感知不到协程的存在的。
下面两图的协程模型中,协程仍然是顺序执行的,没有实现协程的并发。第二张图的多线程循环模式仅仅是线程池-任务队列模式

因此,存在两个问题:

  1. 全局协程队列的并发问题
  2. 实现协程并发执行

2. GMP 调度模型

GMP 调度模型是用于解决,多线程循环模型中全局协程队列的并发问题的。

其思路是:每个线程 m 维护一个仅改线程自己能范围的本地协程队列,这样在获取下一个要执行协程时就无需加锁。当本地协程队列空时,就从全局协程队列中获取若干个协程放入本地协程队列中,访问全局协程队列时会先加锁,以减少并发冲突的次数。
如果当前线程的本地协程队列和全局协程队列都空时,当前线程会尝试去其他线程的本地协程队列中偷一些协程来执行(任务窃取)。 这个规则增加了线程的利用率。
并且新建的协程会优先挑选一个线程的本地协程队列来存放,所有线程的本地队列都满了,才会放入全局协程队列。

3. 协程的并发

协程如果仅仅是顺序的执行的话,如果某个线程正在执行一个比较耗时的协程,那么其他协程就会出现饥饿现象。

其思路是:**在线程执行某个协程的过程中,可以中断当前协程的执行,保存协程现场信息。重新从本地协程队列中拿一个来执行。**这样就形成了该线程的本地队列中的协程并发小循环。
但还存在全局协程队列中的饥饿现象。解决方法是,每隔一段时间就从全局协程队列中取来一个协程。

// Check the global runnable queue once in a while to ensure fairness.
// Otherwise two goroutines can completely occupy the local runqueue
// by constantly respawning each other.
if _p_.schedtick%61 == 0 && sched.runqsize > 0 {
    lock(&sched.lock)
    gp = globrunqget(_p_, 1)
    unlock(&sched.lock)
    if gp != nil {
        return gp, false, false
    }
}
// 每 61 个本地协程小循环后, 从全局队列取 1 个协程进来

4. 如何实现协程的切换

  • 主动挂起 runtime.gopark
  • 系统调用 exitsyscall
  • 函数调用 runtime.morestack
  • 线程收到信号 doSigPreempt

4.1. 主动挂起和系统调用完成时被切换

协程的切换有两种方式:主动挂起、系统调用完成时

主动挂起:在协程的代码中调用 runtime.gopark(),则当前协程会被切换掉。(注意:gopark 函数不是公开的,因此不能直接调用,但一些系统函数会调用它,例如 time.Sleep、锁、管道等)

系统调用完成时:一些系统调用的函数或方法(如 IO 操作、网络状态),在结束后会调用 exitsyscall 来挂起当前协程。

也就是说,如果某个协程没有主动挂起,也没有任何系统调用,它将永远不会被切换掉。因此需要引入抢占式调度。

4.2. 函数调用时被切换

虽然可能会有协程永远不会执行主动挂起和系统调用的代码,但是所有的协程都会执行 runtime.morestack() 函数。
runtime.morestack() 在每次函数调用的都会执行的,它是用来检查协程栈中是否有足够的空间来存放待调用函数的栈帧
因此 Go 语言在 runtime.morestack() 函数中添加了抢占式调度的处理代码,最终执行 schedule 来切换掉当前协程。

但仍存在问题:没有函数调用的协程不会被切换掉

4.3. 基于信号的抢占式调度机制

Go1.14 引入了基于信号的抢占式调度机制。

在操作系统中,一个线程可以注册一些信号,当线程接收到这个信号时它就会停止当前执行的代码,转而去指定对应的处理函数。

根据信号机制,如果某个线程一直在不断执行一个协程,就可以给这个线程发送信号,让线程停止执行改协程,并切换到其他协程。

Go 语言用了 SIGURG 信号,并让 GC 线程来发送这个信号给其他线程。