传统并发的方式,其实是多线程。但是多线程除了每个线程的占用较大栈空间外(至少 2M),最大的问题就是线程的调度是由内核控制。

即线程上下文切换(同一个进程中)会从 用户态 -> 内核态 -> 用户态,很多上线文信息都是保存在内存中的,一次切换就是一次 I/O (内存的读写),这明显这是一个耗性能杀手的操作。

Golang 中有一个协程的概念,其实简单理解就是更轻量级的调度单元。它有两个最大的特点:

  • 占用栈空间小(2KB ~ 2GB)
  • 上下文都在用户态切换,不会涉及到内核态

虽然上下文切换也会涉及到内存 I/O 操作,但包含的信息极少,从下文 G 的结构体中 gobuf类型字段可以看出,就 7 个字段的信息。

这里列出一些测试过的量化指标:

协程切换的速度大约为 1~2微秒,而golang中协程的切换速度为 0.2微秒左右


(下文说明:本文使用的版本:go version 1.17.9; 下文会交替出现: 协程、g 、goroutine,都是同一个意思(主要是博主懒,不想改了-_-)

接下来就进入正文。

(文章内容较多,建议大家配合目录看,更容易框架性/体系性 地去理解)


GMP

他们的关系图很容易在网上搜索到,这里就不放了。先介绍一下它们的结构体

M

M即操作系统的线程,一个M代表一个线程。

(一核cpu(P) 绑定一个 golang 线程,再利用协程自身的调度,就可发挥出很好的并发效果)

m 只关心 curg 和 g0 两个 Goroutine。

curg 是在当前线程上运行的用户 Goroutine。

g0 是一个比较特殊的 Goroutine(简单理解成处于用户态 golang 的系统协程),它深度参与运行时的调度过程。每当用户协程需要重新调度的时候(退出或被抢占),m 上的 运行的 curg 就会切换成 g0,完成调度后,再切换成 curg。

P

GOMAXPROCS

同时又可以把 p 理解为 执行 goroutine 的执行环境(context)。

我们看看 p 结构体中,与调度相关的字段:

runq
runqrunqheadrunqtail
runqheadrunqtail
runnext
status
_Pidleidle p list
_Prunning
_Psyscall
_Pgcstop
_Pdead
gFree
runtime.malg


G

即 gorouting。它的结构体中有将近100个字段,这里不会全部介绍完,只介绍其中比较重要的几个字段:

stack
stackguard0
preemptpreemptStop

我们再来看看 sched 的类型 gobuf,当调度器保存或者恢复上下文的时候,会用到其中的信息:

atomicstatus 表示 goroutine所处状态,状态解释如下:

_Gidle
_Grunnable
_Grunning

可以执行代码,拥有栈的所有权。被赋予了内核线程 M 和处理器 P,不在运行队列中

_Gsyscall
_Gwaiting
_Gmoribund_unused
_Gdead

表示 goroutine 并没有被使用到。

可能刚刚退出,或在一个freelist,也或者刚刚被初始化;

可能有栈也可能没有

_Genqueue_unused
_Gcopystack

发现需要扩容或者缩小栈空间,将协程的栈转移到新栈时的状态。

没有执行用户代码,不在运行队列上。

_Gpreempted
_Gscanxxx

这里看起来比较头疼不要紧,在最后我会附上一张状态扭转图。

schedt

runqgFree


调度

这是本章的重点,分为 调度策略调度时机

go

而创建goroutine的底层方法是:

它干的事儿就是创建一个 goroutine,然后放入到 p 的本地协程队列中。

newproc1()malg()

然后就是调度执行了。

调度策略

即从哪里获取 协程?下面是调度策略函数:

主要的调度逻辑就是这三段。

runqput

所以一开始的入口是第二段, 直接从本地协程队列获取协程。

findrunnable


P 本地协程队列

findrunnablerunqget

它的处理逻辑也很简单,流程是:

next==0
head == tail


全局协程队列

globrunqget方法len(_p_.runq) / 2runqput


获取准备就绪的网络协程

然后就是 获取准备就绪的网络协程:

injectglist函数globrunqputbatch方法


窃取其他P中的G

最后从其他 P中窃取G

stealWorkallp []*p
allp
runqsteal() 调用 runqgrab()


执行

schedule() 方法execute()方法

将 g 状态改为 _Grunnable -> _Grunning

gogogogogoexit0() 方法

其中主要的逻辑就是:

gfput

我们可以认为调度循环永远都不会返回。

gogogoexit0()
gfput
sched.gFree


调度时机

schedule

(借用 goland 的调用调转,很容易找到)

整理如下:

mstart1()
Goexit()goexit1()goexit0()
Gosched()`gosched_m()goschedImpl()
gopark()park_m()
exitsyscall()exitsyscall0()
preemptPark()
startTheWorld()semrelease1()goyield()goyield_m()

协程退出 在上一节 执行 中已经说了,这里就不重复。

线程启动 主要是初始化 P,比较简单,这里就不叙述了,感兴趣的朋友可以自己了解一下。

剩下的,我们再一个一个来看。

抢占 P 咱们单独拿一节来说。


主动让出

runtime.Gosched()

然后我们再看看源码逻辑:

逻辑也比较简单:

_Grunning_Grunnabledropg


被动等待

这是最常见的,比如 网络I/O、chan 阻塞、定时器 等都会进入这里面。

源码也比较简单:

总的逻辑也比较简单:

_Grunning_Gwaitingdropg

但中间有个插曲 :

gopark()
!oknetpollblockcommit

如果这个 网络事件解锁成功(goroutine准备好了),就恢复这个 goroutine,进入 执行


等待被唤醒

goready()
ready()
  1. 修改 g 的状态 _Gwaiting -> _Grunnable
  2. 放入本地队列


退出系统调用

reentersyscall()
  1. 保存当前 PC 和栈指针 SP 中的内容
  2. 修改 g 的状态 _Grunning -> _Gsyscall;修改 p 的状态为 _Psyscall
  3. 并解除 p 与 m 之间的绑定
  4. 将 P 放入 oldp 中

然后就是进入系统调用。

当系统调用结束后,会调用退出系统调用的函数,将 g 重新执行:

这里有一个快速路径和一个慢的路径。

exitsyscallfast()


exitsyscall0()

执行从注释就能看出来:

如果获取到空闲 p,就直接绑定 p 和 m, 然后执行当前。

_Grunnable


startTheWorld

goyield_m

逻辑也非常简单:

_Grunning_Grunnable


抢占P


就像我们在 linux 上跑的守护进程,或者 k8s 的 DaemonSet 资源一样。这个系统协程就是一个跑在后台的协程。

在程序启动的时候,会创建一个新的线程 m,同时创建一个 协程 g,这个 g 不需要 p,直接绑定与 m 绑定。

sysmonretake()
_Prunning_Psyscall
  1. 如果 p 距离上次调度已经过去 10us (pd.schedwhen+forcePreemptNS <= now)
  2. 如果系统调用超过了一个 sysmon tick (20us)
  3. 系统调用情况下,p 中的本地 goroutine 队列中有等待运行的G(runqempty(p))。 这时候抢占只是为了让本地队列中的 goroutine有执行的机会。
  4. 没有空闲 p 和 自旋的 m ( atomic.Load(&sched.nmspinning)+atomic.Load(&sched.npidle) > 0 )。有的话,说们很闲,抢占了也没有意义
  5. 当系统调用时间超过了 10us (pd.syscallwhen+1010001000)

情况1

preemptone()
gp.stackguard0 = stackPreempt
检查栈溢出
morestack_noctxt()
newstack()
gopreempt_m()goschedImpl()goschedImpl


其他情况

_Pidle
handoffp()startm()
  1. p 中本地协程队列不为空
  2. 处理gc
  3. 没有 自选/空闲 m
  4. 全局协程队列不为空
  5. 如果这是最后一个运行的 p,同时需要处理网络 socket 事件
pidleput()
releasem(mp)_g_.stackguard0 = stackPreemptpreemptone()

最终 被抢占的 p 当前执行的 g,都会被放入到全局队列中


信号抢占

在go1.14版本之前没有抢占机制,这就会出现一个问题,如下:

一个这样的协程会永远占着 cpu,1.14版本之后,加入了信号强制抢占 p 的机制。类似于 Linux 里面的 sighandler 注册信号的方式:

doSigPreempt(gp, c)asyncPreempt()asyncPreempt2()preemptParkgopreempt_m


总结

一开始我们介绍了 GMP 结构体中重要字段的意思。

然后就是重点 调度 ,调度可以看作是一个死循环,分为 调度策略调度时机

调度策略逻辑如下:

  1. 先从 p 的 本地协程队列 获取 g,如果没有,就从全局协程队列里,获取第一个,然后拿一部分协程(最多拿走 256/2 个) 到本地中;

2. 如果 全局队列 中没有, 就获取将网络协程中的第一个,然后将剩下的所有网络协程都加入到全局协程队列中。

3. 如果还是没有,就从其他 p 的本地协程队列中 窃取一半 到自己本地队列中。


然后就是调度时机,常见的就是:

  1. 主动让出:
runtime.Gosched()_Grunnable_Grunning

2. 被动等待:

_Grunning_Gwaiting
_Gwaiting_Grunnable

3.退出系统调用

退出后就要要么马上执行当前 goroutine(快路径);

要么就是慢路径: 将协程状态从 _Gwaiting 变成 _Grunnable,然后放入当前 p 的全局协程队列。。

4. gc 的 stop the world 后的 start the world:

_Grunning_Grunnable

最后就是,就是监控线程的抢占 p ,它的目的主要是为了更公平是实现调度,防止其他 协程出现饥饿的情况。主要是出现下面两种情况会发生抢占:

  1. 超过了系统 tick(即20微秒)
  2. 当前执行超过了 10 微秒(系统执行或者用户协程执行)

然后将当前的 g 放入全局队列


g 的状态转换图可以参考下面这张图:

下班后加班加点耗时半个月,终于把复杂的 GMP 调度写完了。

喜欢的小伙伴不要只收藏不点赞啊 T_T。