本文主要讨论 goroutine 调度的相关内容。
目录 Table of Contents
前情提要
Go中的并发性是以goroutine(独立活动)和channel(用于通信)的形式实现的,这是因为Go所信奉的“不要通过共享内存来通信,而应该通过通信来共享内存”。当然,Go也提供了对传统通信机制的支持,如Mutex(互斥锁) 和WaitGroup(信号量)。本文将讨论
Go并发编程中的基本单位goroutine的调度,如有错漏,欢迎指出 ;P
goroutine 调度
goroutines仅存在于go runtime中而不存在于OS中,因此需要 Go Runtime Scheduler 来管理它们的生命周期。
线程模型

M:machine,一个M代表一个用户线程P:processor,一个P代表一个Go协程所需的上下文环境G:goroutine,一个G代表一个Go协程KSE:kernel schedule entity,一个KSE代表一个内核线程
+ 共享任务队列(Global Queue)
- 流程:
- Step1. 先开一个线程池;
- Step2. 每个线程不断从
Global Queue中取G执行,亦即一个线程非同时刻可以执行多个协程; - Step3. 如果没有系统调用、I/O 中断或者 channel 阻塞,
goroutine对应的线程正常工作;如果有系统调用、I/O中断或者 channel 阻塞,goroutine对应的线程将被阻塞。
- 问题:
- 线程每次取
G时需要加锁,否则出现竞态危害; goroutine本身可以被阻塞,goroutine对应的线程不应该被阻塞。
- 线程每次取
- 解决:
- 引入独有任务队列,每个线程只读自己的任务队列,不存在资源竞争;
- 发生系统调用或I/O中断的
goroutine对应的线程要记录goroutine的上下文; - 发生系统调用或I/O中断的
goroutine对应的线程可以换出该goroutine,然后执行新goroutine。
+ 独有任务队列(Local Queue)
- 流程:
- Step2. 每个线程不断从
Local Queue中取G执行,亦即一个线程非同时刻可以执行多个协程;
- Step2. 每个线程不断从
- 问题:
- 线程既要负责调度又要负责执行,性能下降。
- 解决:
- 引入一个负责线程调度的抽象层,也就是
M。
- 引入一个负责线程调度的抽象层,也就是
+ 线程调度器(M)
- 流程:
- Step1. 线程池中每个线程对应一个
M,M将根据调度算法选择合适的G交由内核线程执行。
- Step1. 线程池中每个线程对应一个
- 问题:
- 到目前为止,
goroutine仍然是协作式的,这意味着一个G可以长时间占用 CPU,直到它完成任务或被阻塞。
- 到目前为止,
- 解决:
- 引入一个负责资源分配的抽象层,也就是
P。
- 引入一个负责资源分配的抽象层,也就是
+ 抢占式(P)
- 流程:
- Step3.当一个
G占用一定的 CPU 时间,又没有被调度过,那么它将被runtime抢占。
- Step3.当一个
- 问题:
- 保留了
Global Queue承载Local Queue溢出的G,此时如果多个P同时访问Global Queue,还是要加全局锁。
- 保留了
- 解决:
- 引入全局调度器
Sched。
- 引入全局调度器
+ 全局调度器(Sched)
P主要负责Local Queue的调度,Sched主要负责Global Queue的调度。
+ 非阻塞(netpoller)
- 阻塞的 IO 模型为:
G1被阻塞后,M1也被阻塞,但是M1的其它G#可以转移到空闲或新起的M#中执行。 - 非阻塞的 IO 模型为:
G1被阻塞后,M1不会阻塞,M1的其它G#仍然可以执行。 - 此外,
netpoller还抽象了epoll的多路复用,netpoller将对应的文件描述符注册到epoll实例中进行epoll_wait,就绪的文件描述符回调通知给阻塞的G,G更新为就绪状态等待调度继续执行。
调度场景

调度的本质就是
P将G合理地分配给某个M的过程,M只有和P绑定才能运行G。
负载均衡
- 从本地到全局:
Local Queue满了,会将前一半的G和新创建的G放到Global Queue并打乱顺序; - 从全局到本地:
Local Queue空了,会从Glocal Queue随机选取n = min(len(GQ)/GOMAXPROCS + 1, len(GQ/2))个G; - 为了调度公平:1/61 的几率在
Global Queue中找G,60/61 的几率在Local Queue找G,否则Global Queue中的G有可能永远没办法被调度。
任务窃取
出现空闲的
M,此时它绑定的P的Local Queue为空,并且Global Queue也为空,则从其它P中窃取后一半的G,这就是work-stealing算法。
线程自旋
- 在创建
G时,运行的G会尝试唤醒其他空闲的M执行,如果没有G,M将自旋而不阻塞,不停地找G; - 自旋的优点是降低了
M切换上下文的成本,缺点是 CPU 空转浪费资源; - 折中方案是:最多有
GOMAXPROCS个自旋的线程,多余的线程将休眠。
请求抢占
- 满足以下两个条件,
sysmon将会发送抢占请求,但是能否抢占成功不被保障:G进行系统调用超过 20 μsG运行 (非循环)超过 10 ms
network IO / channel 阻塞
- 当 network IO / channel 操作发生,被阻塞的
G放入等待队列,M选择其它的G; - 如果有就绪的
G,M正常执行;如果无就绪的G,M将解绑P并且休眠; - 当 network IO / channel 操作完成,被阻塞的
G从等待变成就绪,加入某个P中被新的M执行或加入到Global Queue,休眠的M将等待新创建的G唤醒。 - 在这个过程中,调度实际上是非阻塞的——
M不会因为G阻塞而休眠。
system call 阻塞
- 当 system call 操作发生,
G会阻塞,M会解绑P并且休眠; - 如果有空闲的
M,该空闲的M将绑定P,如果无空闲的M,将新建一个M来绑定P; - 当 system call 操作完成,被阻塞的
G从阻塞变成就绪,加入某个P中被新的M执行或加入到Global Queue,休眠的M将等待新创建的G唤醒。 - 在这个过程中,调度实际上是阻塞的——
M会因为G阻塞而休眠