进程的演变
早期的单进程系统
每一个程序就是一个进程,直到程序运行完才能执行下一个进程
缺点:
进程阻塞带来的 CPU 时间浪费
单一的执行流程
多进程时代
当一个进程阻塞的时候,切换到另外等待执行的进程,能够尽量利用 CPU,提高工作效率
缺点:
进程的切换调度开销太大
如何提高 CPU 的利用率
当进入多核时代之后,每台电脑上面都有多个 CPU,我们可以在电脑上面同时执行多个进程,不仅可以一边听音乐一遍打游戏,甚至还可以同时挂着微信和 QQ。
那么如何能够最大化的利用 CPU,同时又能够尽量减小线程切换所带来的开销呢?
Golang 采用了一种经典思想(加一层)把线程分成内核态与用户态,也就是线程和协程。线程切换开销不是大嘛,我们在线程之下分割出更小的处理单元就可以了。
线程与协程的绑定关系
1:N
缺点:
某个程序用不了硬件的多核加速能力,一个线程无法利用多核的能力
当一个协程阻塞,会造成线程阻塞,其他协程无法工作,导致没有并发能力
1:1
缺点:
协程的创建、删除和切换都由 CPU 完成,开销太大。M:N 线程由 CPU 调度是抢占式的,协程由用户调度是协作式的需要针对。
M:N
M:N 模式既能够利用多核能力,当协程阻塞的时候,线程也可以去处理其他的协程。但是它们中间的关系如何进行维护呢?
一层不行再加一层(我们在线程与协程之间添加一个调度器负责维护它们之间的绑定关系)。
模型在中间层设计一个调度器 Goroutine(只占几 KB、动态的):让一组可以复用的函数运行在一组线程之上,即使协程阻塞,该线程的其他协程也可以被 runtime 调度,从而转移到其他可运行的线程上。
被废弃的 Goroutine 调度器:略
GMP 模型
GMP 模型的组成
G:代表 Goroutine
P:代表 Processor(处理器)
M:代表 Machine(内核线程)
全局队列: 存放等待运行的 Goroutine
P 的本地队列: 每个 P 单独维护一个自己的本地队列,不超过 256 个。新建 G 的时候,如果 P 的本地队列已经满了,就会把 P 的本地队列的一半的 G 转移到全局队列
P 列表: 所有的 P 都在程序启动时创建,并保存在数组中,最多有 GOMAXPROCS 个
M: 由 Go 语言本身的限制决定,默认是 10000 个
M 的休眠队列: 如果有休眠的 M 放在这里
GMP 的执行
每一个 M 想要执行 G 都要先与 P 进行绑定
由 M 从 P 的本地队列中弹出一个 Goroutine 来执行
如果 P 的本地队列没有 G 了,M 会尝试从全局队列进行获取;如果全局队列中也没有可供执行的 G,会从其他 P 维护的本地队列中偷一个 G 来执行
如果本线程因为 G 进行系统调用阻塞时,线程会释放绑定的 P,把 P 转移给其他空闲的线程执行,能够提高资源利用率
如果 M 不够的话,会尝试从休眠队列中唤醒一个 M 来与 P 绑定,如果休眠队列中没有 M,就会重新创建一个 M
Hand Off 机制
如果本线程因为 G 进行系统调用阻塞时,线程会释放绑定的 P,把 P 转移给其他空闲的线程执行,能够提高资源利用率。
Work Stealing
如果 P 的本地队列没有 G 了,M 会尝试从全局队列进行获取,如果全局队列中也没有可供执行的 G,会从其他 P 维护的本地队列中偷一个 G 来执行。
Go 语言中的 Goroutine 是抢占式的,在 Go 中一个 Goroutine 最多占用 CPU 10ms。
G0 与 M0
M0:
启动程序后编号为 0 的主线程,在全局命令 runtime.M0 中,不需要在 Heap 堆上分配。负责执行初始化操作和启动第一个 G,启动第一个 G 之后,M0 就和其他的 M 一样了。
G0:
每次启动一个 M,创建的第一个 Goroutine 就是 G0。G0 只用于负责调度 G,G0 不指向任何可执行的函数。每个 M 都会有一个自己的 G0,在调度过程中,会使用 M 切换到 G0 再通过 G0 调度。M0 的 G0 会放在全局空间。