这是我参与「第五届青训营 」伴学笔记创作活动的第 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
// ...
}
每个操作系统线程启动后会执行一个循环,在这个循环中不断地执行每个被调度到的协程。
这个过程中,操作系统是感知不到协程的存在的。
下面两图的协程模型中,协程仍然是顺序执行的,没有实现协程的并发。第二张图的多线程循环模式仅仅是线程池-任务队列模式。
因此,存在两个问题:
- 全局协程队列的并发问题
- 实现协程并发执行
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 线程来发送这个信号给其他线程。