并发的演进
单进程
单进程时代,一切只能串行。
问题:
- one by one
- 阻塞了怎么办?效率太低
多进程&多线程
多进程/多线程解决了阻塞问题,让进程阻塞时空闲的CPU可以很好的利用起来,这样,似乎多个进程是在同时被运行。
问题:
- 进程间创建、切换、销毁需要占用很长时间,调度大量时间
- 进程/线程占用更多的内存资源
协程
线程分为内核态线程和用户态线程,一个用户态线程必须绑定一个内核态线程。这样的用户态线程可以成为协程。多协程中协程与内核线程之间的关系可以是N:1、1:1、N:M。
优劣:
N:1线程切换完全在用户态,非常轻量。无法利用硬件多核优势1:1实现简单,但将协程调度交由CPU实现,过于浪费N:M既能利用好硬件加速又能避免CPU直接调度浪费,但调度器实现相对复杂。Golang基于此实现GPM模型。
并发与并行
并发 ≠ 并行
并发
并发是在单个CPU核上,线程通过时间片或者出让控制权来实现任务切换,达到同时运行多个任务的目的。任何时刻都只有一个任务被执行,其他任务通过某种算法来排队执行。例如,可以一边听讲一边刷微博,但是某个时间点,你的一个大脑只能处理一件事情;整体时间线上看,任何一个时刻都只有一件事情正在被处理。
并行
并行是基于多核CPU场景下可以在同一时间处理多个任务,并非编程语言可以带来的特性,需要硬件的支持。例如,可以通过扩展一个电脑,你来听课,而电脑一边爬取微博信息。
关联
并发是并行的前提,通过并发的构造方法,使得一个程序可以达到(趋近于)并行化。
CPU密集 & I/O密集
CPU密集
对于CPU密集的计算过程,单核场景下的并发并不能达到理想效果,反而可能因为过度的上下文切换和线程的创建/销毁,使得性能进一步变差
I/O密集型
对于I/O密集型的计算过程,单核场景下的并发,可以使得某个线程在I/O等待的时候继续处理其他线程的事情,从而提高性能。
Goroutine
进程、线程、协程
进程
进程是系统进行资源分配的基本单位,有独立内存空间。多进程模型相对简单,但是存在资源开销大和进程间通信的问题。
线程
线程是CPU调度和分派的基本单位,线程依附于进程存在。多线程模型相对复杂,会有死锁,线程安全,模型复杂等问题,但却因为资源开销及易于管理等优点适用于对于性能要求较高的应用。
协程
协程是一种用户态轻量级线程,协程调度完全由用户控制,协程切换只需要保存任务上下文,没有内核开销。
上下文切换
由于中断处理、多任务处理、用户态切换等原因,会导致CPU在线程之间切换,切换的过程就需要保存当前线程状态,并恢复另一个线程状态,这就是上下文切换。
上下文切换代价是很高的,会占用大量程序指令时间
- 如果存在跨核上下文切换,可能导致CPU缓存失效,带来更多的切换耗时
Goroutine 之轻
Golang实现了特殊的两级线程模型(GPM模型),对系统线程(内核级线程)进行了封装,暴露了一个轻量级的协程goroutine(用户级线程)供用户使用。而用户级线程到内核级线程的调度由golang的runtime负责,调度逻辑对外透明。优势在于上下文切换在完全用户态进行,无需像线程一样频繁在用户态与内核态之间切换,节约了资源消耗。
- 上下文切换代价小,只涉及3个寄存器 PC/SP/DX;对比线程,需要模式切换、更多寄存器刷新
- 内存占用少,线程栈空间通常为2M,Goroutine最小2K,由runtime伸缩分配;Golang程序中可轻松支持10W级别goroutine运行,线程到达1k时内存就一件达到2G
Goroutine历史
目前使用的goroutine调度器是2012年重新设计的,原先的goroutine调度器实现要点如下:
- 全局存在一个go协程队列,线程通过锁访问队列,获取、执行goroutine
- 存在多个线程可对队列中goroutine执行,阻塞放回
优劣:
- 基本实现多个线程的调度,并发执行go协程
- 但是,对go协程队列的访问都需要获取锁,形成激烈锁竞争
- 因为只有全局队列,所以在进程中创建的相关进程,仍然放到全局队列中等待执行,不能落在同一个M上,局部性较差
- CPU在M之间切换导致M频繁阻塞和取消阻塞,增加系统开销
GPM 模型
参考博客
核心概念
G
- G即Goroutine,就是我们所说的协程,为用户级线程,通过
sched变量保存上下文信息。 - goroutine的创建、休眠、恢复、停止都受到go runtime的管理
- goroutine执行异步操作时进入休眠状态,操作完成后恢复,无需占用系统线程
- goroutine创建或恢复时会添加到运行队列(先找某个P的Local队列全满则入Global队列),等待M取出运行
M
- M即Machine,对内核级线程的封装,相当于内核级线程。
- M的数量是GO Runtime调整的,为防止过对OS线程导致系统调度不过来,默认限制为1W个;runtime/debug 中的SetMaxThreads函数可以设置M的最大数量
- M可以执行两种代码
- go代码,即goroutine,需要P
- 原生代码,即系统调用之类的代码,无需P
- 若G需要执行无法避免阻塞的原生代码,M会释放持有的P并进入阻塞状态,P将被其他M获取使用,这就是hand off 机制
P
- P即Processor,即G和M的调度对象,用来调度G和M之间的关联关系,可通过GOMAXPROCS设置,默认为核心数;GOMAXPROCS最大为256
- P可以认为是go代码控制并行度的机制,P决定了G的最大并行数,对于M拥有P后才可以执行G
- 在P中的数据是锁自由(lock free,不使用锁的情况下使得线程之间的变量同步)的,读写这些数据的效率会非常高
sched
- Go 调度器,维护M队列、G队列以及调度器的一些状态信息
三者关系
- 每个P对象拥有一个LRQ(Local Run Queue),为分配的G保存在GRQ(Global Run Queue)中,等待分配给一个P的LRQ,对于G来说P相当于CPU核
- 通过P的连接,对M和G解耦,即G希望使用M必须绑定一个P,通过多个M和多个P对应,实现并行处理多个G,对 M 来说,P 提供了相关的执行环境(Context),如内存分配状态(mcache),任务队列(G)等
数据结构
G的状态
空闲:刚创建,仍未初始化
待运行:在运行队列中,等待被M取出运行
运行中:M正在运行这个G,这个M拥有一个P
- 系统调用中:M正在运行G发起的系统调用,此时不占有P
- 等待中:正在等待某些条件完成,此时不在运行也不在运行队列中
- 已中止:G未被使用,可能已经执行完毕,在freelist中等待下次复用
- 栈复制中:G正在获取一个新的栈空间,把原来的内容复制过去,用于防治GC扫描
M的动作
M无明确的状态标记,可认为有如下过程
- 自旋:获取到了P资源但是没有G可执行,寻找G的一种运行状态
- 执行G:执行G的代码,此时拥有P
- 执行原生代码:执行原生代码或阻塞的syscall,此时不拥有P
- 休眠:无G可运行时,休眠,加入空闲M链表中
P的状态
- 空闲:M休眠后,若拥有P,P也会加入空闲链表
- 运行中:M拥有P后,P的状态会变成运行中
- 系统调用中:go调用原生代码,原生代码有反过来调用go代码是,P进入系统调用状态
- GC停止中:当gc stop the world时,P进入此状态
- 已中止:当P的数量在运行时改变,数量减少时,多余的P会变为此状态
本地运行队列 LRQ
本地队列为P的运行队列,G会优先加入本地队列,M获取运行的G时也会优先从拥有的P的本地队列获取
环形队列,由256长度数组和两个序号head、tail组成
- 出入本地队列无需线程锁,当本地队列满时,会将本地队列的一半放入全局队列中。
- 当M从P的本地运行队列获取G时,发现队列为空,会从全局队列中拿一批放入本地队列或者从其他P中盗取一半G,此为Work Stealing机制
全局运行队列 GRQ
- 通过全局变量
sched保存,出入队列使用线程锁 - 全局队列使用链表和两个指针head、tail组成
空闲M链表
- 发现无待运行的G,M开始休眠,通过全局
sched保存空闲M链表 - 进入休眠的M会等待一个信号量
m.park,获取后会唤醒 - M阻塞,有就绪G和P时,又没有空闲M则会创建新的M
- 通过如下机制保证入队列的G可以有足够的M运行
- 入队列的G进入待运行状态,若无自旋的M但是有空闲P,就唤醒或新建M
- 当M离开自旋状态,准备运行G时,如果当前无自旋的M但是有空闲P,唤醒或新建M,保证下一个G来时大概率会有M资源配合运行
- 当M离开自旋状态,并准备休眠时,会在离开自旋状态后检查运行队列,如果有待运行的G则重新进入自旋状态
- 当全部G已经消耗完毕时,M进入自旋状态,此时会保持GOMAXPROCS个自旋M多余M进入休眠状态
空闲P列表
- 空闲P链表通过全局变量
sched保存;在确定P的最大数量后,runtime就会把P创建好 - 当P本地队列中不包含G且盗取不到G时,M会释放P然后进入休眠状态,并将P加到空闲P链表中
- 下次G入队时,若发现有空闲P但是没有自旋M时,会唤醒或新建M,M拥有这个P,使得P重新进入运行状态
调度策略
- 通过work stealing机制和hand off机制使得G和M对P有更好的复用
- 通过GOMAXPROCS制定P数量来控制并发程度
- 一般情况下一个协程需要等待前一个协程让出CPU才可以进入执行,goroutine通过限制最多占用10msCPU来防止其他goroutine被饿死
- 对全局队列进行一定程度的弱化,只有当P使用work stealing也获取不到G时才尝试从全局队列获取G
go func{}发生了什么
M0 和 G0
- M0是启动程序后编号为0的主线程,这个M对应实例存在全局变量runtime.m0中,不需要在heap上分配。M0执行初始化操作和启动第一个G后就和其他M一样了
- G0时每次启动一个M第一个创建的goroutine,G0仅负责调度G,不指向任何可执行函数,每个M都有自己的G0。在调度或者系统调用时会使用G0的栈空间,全局变量的G0是M0的G0。
流程
- 创建一个G,首先查看P的LRQ,若满再考虑GRQ;入队列等待被调度
- M与P绑定,从P中获取G;若P本地队列为空,则从全局队列中获取G,获取失败则从其他P中盗取
- 执行G,过程中
- 若发生系统调用,则M带着G进行系统调用,释放当前绑定的P
- 若执行完成,则返回结果
- 执行过程中创建新的G’,则优先加入P本地队列
- 创建G’,若发现本地队列已满,则将本地队列的一半放入全局队列中
- 创建G’后,G会唤醒M和现有空闲P组合进入自旋状态
- 自旋的新M-P组合会将现有全局队列根据P最大数量均分(至少为1)批量获取G入本地队列
- G执行完毕,M切换G0进行调度,M-P进入自旋状态寻找下一个G执行
总结
对golang协程调度模型的初步理解暂时告一段落,时间线拉的很长,并且还只是在懂得基本原理的层面,希望未来还能看到更深度的好文可以观摩学习。同时,也不排除这个年轻的语言在未来的某个版本中舍弃GPM模,但其设计方法和理念依然值得学习。