进程用来分配内存空间,是操作系统分配资源的最小单位;线程用来分配 CPU 时间,多个线程共享内存空间,是操作系统或 CPU 调度的最小单位;协程用来精细利用线程。协程就是将一段程序的运行状态打包,可以在线程之间调度。或者说将一段生产流程打包,使流程不固定在生产线上。协程不是被操作系统内核所管理,而完全是由程序所控制。
Go 中协程的本质
协程在 Go 内部的表示如下:
type g struct {
stack stack // 协程栈
sched gobuf // 目前程序运行现场
atomicstatus atomic.Uint32 // 协程状态
goid uint64 // 协程 id
// 。。。省略一些其他属性
}
type stack struct {
lo uintptr
hi uintptr
}
type gobuf struct {
sp uintptr // 栈指针,指向当前协程运行到哪个地方
pc uintptr // 程序计数器,记录运行到了哪行代码
g guintptr
ctxt unsafe.Pointer
ret uintptr
lr uintptr
bp uintptr // for framepointer-enabled architectures
}
而线程的描述为一个 m 结构体:
type m struct {
g0 *g // goroutine with scheduling stack
curg *g // current running goroutine
mOS
// 。。。省略一些其他属性
}
- runtime 中将操作系统线程抽象为 m 结构体
- g0 协程,操作调度器
- curg 记录当前运行的协程
- mOS 记录操作系统线程信息
单线程循环 Go 0.x
首先进入 g0 stack,执行 schedule()
方法,在 schedule 方法中调用 execute()
方法,execute 中再调用 gogo()
方法,gogo 方法为汇编语言编写,针对不同平台提供不同处理方法,接着通过 gogo 方法,从全局队列 runnable queue 中获取任务协程 g
,进入到用户自定义的业务方法中。此时使用业务协程 g
自己的栈记录调用、跳转关系、本地变量等信息。
业务逻辑方法执行完成后会回退到 goexit()
的栈帧,goexit 会进行栈的切换,切换到 g0 stack,继续执行 schedule 方法链,不停地将 runnable queue 中的业务方法协程 g
取出执行。
多线程循环 Go 1.0
在单线程的基础上,多个线程的标准调度循环同时从 runnable queue 中取出协程 g
执行。注意,为保证协程安全,runnable queue 需要加锁。
存在的问题
- 协程串行执行,无法并发,会有阻塞现象。
- 多线程并发时,会抢夺全局队列的全局锁。
G-M-P 调度模型
前面说到,多线程从全局队列中取协程出来执行时,需要对这个队列加锁,如果每个线程 m
每次获取锁后,只从中取一个 g
,则会造成很大开销,存在极大的性能问题。一个朴素的思想就是,每次取多个,将这些协程维护在一个自己的本地队列 p
中,本地队列中的全部 g
执行完后,再去全局队列抓取一堆。
这个本地队列 p
在 Go 中的表示如下:
type p struct {
// 指向服务的线程
m muintptr // back-link to associated m (nil if idle)
// Queue of runnable goroutines. Accessed without lock.
runqhead uint32
runqtail uint32
// 队列,存放 g
runq [256]guintptr
// 下一个可用协程指针
runnext guintptr
// 。。。省略其他属性
}
- P 作为 M 与 G 的中介,承担’送料’的作用。
- P 持有一些 G,使得每次获取 G 不用去全局找。
- P 大大减少了并发冲突状况。
新建协程
创建一个新协程时,会随机查找一个 P,将该协程放到 P 的 runnext(优先执行),如果本地队列已经满了,则将新协程放到全局队列。
协程阻塞-触发切换
P 中本地队列里存在长耗时任务时,会阻塞后续协程,造成饥饿现象,一种做法是内部调用 runtime.gopark()
方法,让当前大任务进入等待状态,回到 execute()
继续执行后续操作。另外也可以完成系统调时后挂起或主动挂起,这里的主动的含义是用户自己的方法去触发 runtime.gopark()。
在极端情况下,本地队列中的协程全部为耗时任务,则会造成全局队列 runnable queue 饥饿问题,那么此时需要同时调度本地队列和全局队列:
runtime 中的具体做法是:
func findRunnable() (gp *g, inheritTime, tryWakeP bool) {
// 。。。
// 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 pp.schedtick%61 == 0 && sched.runqsize > 0 {
lock(&sched.lock)
gp := globrunqget(pp, 1)
unlock(&sched.lock)
if gp != nil {
return gp, false, false
}
}
// 。。。
}
执行 61 次线程循环后,gp := globrunqget(pp, 1)
去全局协程队列里拿 1 个协程进本地队列。