1. Go的协程
理解协程,就回到了一道老生长谈的面试题,进程与线程的区别? 进程是资源分配的基本单位,线程是资源调度的基本单位。 结合Linux底层中进程与线程的实现,我们可以对资源的分配与调度有一个更加具象与感性的认识。
进程是在内存当中运行着的程序,每个进程都由其对应的PCB控制,即Linux中的task_struct结构体,task_struct就像进程的大脑,控制着进程的一切行为。我们提到的资源分配是最重要的功能之一,包括但不限于堆栈信息,Linux的进程的task_struct是借助mm_struct结构体进行资源调度的。
在Linux底层,线程也是由各自的task_struct控制的,同一线程组共享一个mm_struct,进而可以共享资源,然而要完成资源调度,线程就需要拥有自己独立的栈,所以,线程创建时,会在其task_struct中维护一个*thread的指针,指向维护栈私有信息的结构体(包括线程栈)。
对比进程与线程,在进行上下文切换时,由于线程的共享资源没必要进行切换,所以线程切换比进程切换轻松的多。然而,线程的切换本质上仍然是操作系统完成的,切换时需要陷入内核,这一步是很浪费时间的,其次,Linux的线程栈是固定大小8M,而很多时候,我们并不需要这么大的空间,这时,我们就需要能在用户态完成切换的更轻量级的线程模型,Go的协程是一种实现方式。
2. GMP模型
G是goroutine,每一个g结构对应了一个Go的协程,其中管理着我们协程运行所需的所有信息。
type g struct {
stack stack // g自己的栈
m *m // 执行当前g的m
sched gobuf // 保存了g的现场,goroutine切换时通过它来恢复
atomicstatus uint32 // g的状态Gidle,Grunnable,Grunning,Gsyscall,Gwaiting,Gdead
goid int64
schedlink guintptr // 下一个g, g链表
preempt bool //抢占标记
lockedm muintptr // 锁定的M,g中断恢复指定M执行
gopc uintptr // 创建该goroutine的指令地址
startpc uintptr // goroutine 函数的指令地址
}
P是调度器,g是由p来执行的,每个p都有一个g等待队列,来放置需要其执行的协程。
type p struct {
id int32
status uint32 // 状态
link puintptr // 下一个P, P链表
m muintptr // 拥有这个P的M
mcache *mcache
// P本地runnable状态的G队列
runqhead uint32
runqtail uint32
runq [256]guintptr
runnext guintptr // 一个比runq优先级更高的runnable G
// 状态为dead的G链表,在获取G时会从这里面获取
gFree struct {
gList
n int32
}
gcBgMarkWorker guintptr // (atomic)
gcw gcWork
}
M是内核线程,每个m对象对应一个内核线程,p对象需要挂载到m上才能运行,每个m都有一个g0协程负责进行调度。
type m struct {
g0 *g // g0, 每个M都有自己独有的g0
curg *g // 当前正在运行的g
p puintptr // 当前用于的p
nextp puintptr // 当m被唤醒时,首先拥有这个p
id int64
spinning bool // 是否处于自旋
park note
alllink *m // on allm
schedlink muintptr // 下一个m, m链表
mcache *mcache // 内存分配
lockedg guintptr // 和 G 的lockedm对应
freelink *m // on sched.freem
}
通过上图所示的这种方式,我们可以在用户态完成协程的切换,避免了线程上下文切换的开销。本质上,协程是借助GMP模型对操作系统的线程进行了复用,以减少切换。至此,我们已经了解的GMP模型的基本情况。
3. 协程调度
3.1 p对g的调度
Go中的p个数是默认的,与操作系统的核数相同。每个p都有一个对应的本地队列,p会优先消费自己本地队列中的g,之后会消费全局队列中的g,如果全局队列消费完,之后从网络轮询器(network poller)获取任务,如果以上任务都已经消费完成,从其他处理器的本地队列窃取,每次拿一半。
由于本地队列是私有的,所以对本地队列的调度过程是不需要加锁的,这样能有效的提高执行效率。另外,p创建的g会优先放在自己的本地队列中,满了的情况下,将新创建的与本地队列的前半部分打乱顺序放在全局队列中。
3.2 p的调度
如果p2对应的g8发生了系统调用,m会记住p2并且接收g8,之后p2去另一个m执行其他的g9,g8就绪后会尝试抢夺p2,如果失败的话,g8进入全局队列。