目录
一、前言
二、从多进程,多线程角度来分析
三、Goroutine 调度器的 GMP 模型的设计思想
(1)Goroutine主要概念如下:
(2)调度器的设计策略
(3)Go 调度器调度场景过程全解析
四、使用协程的注意事项
现在网上关于协程的博文已经不在少数了,但我通过阅读并没有觉得真正的了解什么是协程,所以我决定自己写一篇博客,来帮助大家了解并学习协程的运作方式。
首先,我们从上下文切换的角度来分析进程,线程以及协程的区别,来对协程有一个初步的了解。
进程的上下文切换涉及到从【用户态】->【内核态】->【用户态】的过程,并且上下文中包含非常多的数据,如下图所示:
线程的上下文切换涉及到从【用户态】->【内核态】->【用户态】的过程,上下文中包含的数据虽然不像进程中的那么多,但整个过程也非常耗时,具体包含的数据如下图所示:
相较而言,协程的上下文切换则快了很多, 它只需在【用户态】即可完成上下文的切换,并且需要切换的上下文信息也较少
也许大家有些疑惑,为什么 【用户态】->【内核态】->【用户态】这一过程比较耗时,耗资源呢?
我们知道,操作系统保持跟踪进程运行所需的所有状态信息,这种状态,也就是上下文。
进程的上下文包括许多信息,比如PC和寄存器文件的当前值,以及主存的内容。
为什么会有上下文的切换?
在任何一个时刻,单处理器系统都只能执行一个进程的代码。当操作系统决定要把控制权从当前进程转移到某个新进程时,就会进行上下文切换,即保存当前进程的上下文、恢复新进程的上下文,然后将控制权传递到新进程。新进程就会从它上次停止的地方开始。
上下文切换为什么要陷入内核?
假设现在有两个并发的进程:shel进程和hello进程。最开始,只有 shell进程在运行,即等待命令行上的输人。当我们让它运行hello程序时, shell通过调用一个专门的函数,即系统调用,来执行我们的请求,系统调用会将控制权传递给操作系统。操作系统保存 shell进程的上下文,创建一个新的hello进程及其上下文,然后将控制权传给新的hello进程。hello进程终止后,操作系统恢复shll进程的上下文,并将控制权传回给它, shell进程会继续等待下一个命令行输入。
从上面这个实例我们可以得出结论:
(1)上一个进程的上下文信息还在内存和处理器当中,我们要保存这些信息的话,就必须陷入到内核态才可以。
(2)创建一个新的进程,以及它的上下文信息,并且将控制权交给这个新进程,这些都只有在内核态才能实现。
综上,我们可以得出结论,进程和线程的上下文切换相较于协程比较“耗时耗力”。
那么协程的上下文切换相较线程有哪些提升?
协程上下文切换只涉及CPU上下文切换,而所谓的CPU上下文切换是指少量寄存器(PC / SP / DX)的值修改,协程切换非常简单,就是把当前协程的 CPU 寄存器状态保存起来,然后将需要切换进来的协程的 CPU 寄存器状态加载的 CPU 寄存器上就 ok 了。而对比线程的上下文切换则需要涉及模式切换(从用户态切换到内核态)、以及 16 个寄存器、PC、SP…等寄存器的刷新;
PC程序计数器:保存下一条指令的地址
SP:保存栈顶的地址
线程栈空间通常是 2M, 协程栈空间最小 2K。
我们知道,一切的软件都是跑在操作系统上,真正用来干活 (计算) 的是 CPU。早期的操作系统每个程序就是一个进程,知道一个程序运行完,才能进行下一个进程,就是 “单进程时代”。一切的程序只能串行发生。
早期的单进程操作系统,面临 2 个问题:
单一的执行流程,计算机只能一个任务一个任务处理。
进程阻塞所带来的 CPU 时间浪费。
那么能不能有多个进程来宏观一起来执行多个任务呢?
后来操作系统就具有了最早的并发能力:多进程并发,当一个进程阻塞的时候,切换到另外等待执行的进程,这样就能尽量把 CPU 利用起来,CPU 就不浪费了。
在多进程 / 多线程的操作系统中,就解决了阻塞的问题,因为一个进程阻塞 cpu 可以立刻切换到其他进程中去执行,而且调度 cpu 的算法可以保证在运行的进程都可以被分配到 cpu 的运行时间片。这样从宏观来看,似乎多个进程是在同时被运行。
但新的问题就又出现了,进程拥有太多的资源,进程的创建、切换、销毁,都会占用很长的时间,CPU 虽然利用起来了,但如果进程过多,CPU 有很大的一部分都被用来进行进程调度了。
怎么才能提高 CPU 的利用率呢?
但是对于 Linux 操作系统来讲,cpu 对进程的态度和线程的态度是一样的。
很明显,CPU 调度切换的是进程和线程。尽管线程看起来很美好,但实际上多线程开发设计会变得更加复杂,要考虑很多同步竞争等问题,如锁、竞争冲突等。
多进程、多线程已经提高了系统的并发能力,但是在当今互联网高并发场景下,为每个任务都创建一个线程是不现实的,因为会消耗大量的内存 (进程虚拟内存会占用 4GB [32 位操作系统],而线程也要大约 4MB)。
大量的进程 / 线程出现了新的问题
而协程刚好可以解决上述2个问题。协程运行在线程之上,当一个协程执行完成后,可以选择主动让出,让另一个协程运行在当前线程之上。并且,协程并没有增加线程数量,只是在线程的基础之上通过分时复用的方式运行多个协程,而且协程的切换在用户态完成,切换的代价比线程从用户态到内核态的代价小很多。
因此我们可以将线程分为 “内核态 “线程和” 用户态 “线程。
一个 “用户态线程” 必须要绑定一个 “内核态线程”,但是 CPU 并不知道有 “用户态线程” 的存在,它只知道它运行的是一个 “内核态线程”。
这样,我们再去细化去分类一下,内核线程依然叫 “线程 ”,用户线程叫 “协程”。
既然一个协程可以绑定一个线程,那么能不能多个协程绑定一个或者多个线程上呢。
于是,Go 为了提供更容易使用的并发方法,使用了 goroutine。goroutine 来自协程的概念,让一组可复用的函数运行在一组线程之上,即使有协程阻塞,该线程的其他协程也可以被 runtime 调度,转移到其他可运行的线程上。最关键的是,程序员看不到这些底层的细节,这就降低了编程的难度,提供了更容易的并发。
在 Go 中,线程是运行 goroutine 的实体,调度器的功能是把可运行的 goroutine 分配到工作线程上。
Goroutine 调度器和 OS 调度器是通过 M 结合起来的,每个 M 都代表了 1 个内核线程,OS 调度器负责把内核线程分配到 CPU 的核上执行。
P和M的个数的问题
1、P 的数量:
由启动时环境变量 $GOMAXPROCS 或者是由 runtime 的方法 GOMAXPROCS() 决定。这意味着在程序执行的任意时刻都只有 $GOMAXPROCS 个 goroutine 在同时运行。
2、M 的数量:
- go 语言本身的限制:go 程序启动时,会设置 M 的最大数量,默认 10000. 但是内核很难支持这么多的线程数,所以这个限制可以忽略。
- runtime/debug 中的 SetMaxThreads 函数,设置 M 的最大数量。
- 一个 M 阻塞了,会创建新的 M。
M 与 P 的数量没有绝对关系,一个 M 阻塞,P 就会去创建或者切换另一个 M,所以,即使 P 的默认数量是 1,也有可能会创建很多个 M 出来。
P 和 M 何时会被创建
1、P 何时创建:在确定了 P 的最大数量 n 后,运行时系统会根据这个数量创建 n 个 P。
2、M 何时创建:没有足够的 M 来关联 P 并运行其中的可运行的 G。比如所有的 M 此时都阻塞住了,而 P 中还有很多就绪任务,就会去寻找空闲的 M,而没有空闲的,就会去创建新的 M。
复用线程:避免频繁的创建、销毁线程,而是对线程的复用。
一个协程的具体调度流程
从上图我们可以分析出几个结论:
1、我们通过 go func () 来创建一个 goroutine;
2、有两个存储 G 的队列,一个是局部调度器 P 的本地队列、一个是全局 G 队列。新创建的 G 会先保存在 P 的本地队列中,如果 P 的本地队列已经满了就会保存在全局的队列中;
3、G 只能运行在 M 中,一个 M 必须持有一个 P,M 与 P 是 1:1 的关系。M 会从 P 的本地队列弹出一个可执行状态的 G 来执行,如果 P 的本地队列为空,就会想其他的 MP 组合偷取一个可执行的 G 来执行;
4、一个 M 调度 G 执行的过程是一个循环机制;
5、当 M 执行某一个 G 时候如果发生了 syscall 或则其余阻塞操作,M 会阻塞,如果当前有一些 G 在执行,runtime 会把这个线程 M 从 P 中摘除 (detach),然后再创建一个新的操作系统的线程 (如果有空闲的线程可用就复用空闲线程) 来服务于这个 P;
6、当 M 系统调用结束时候,这个 G 会尝试获取一个空闲的 P 执行,并放入到这个 P 的本地队列。如果获取不到 P,那么这个线程 M 变成休眠状态, 加入到空闲线程中,然后这个 G 会被放入全局队列中。
(1)P 拥有 G1,M1 获取 P 后开始运行 G1,G1 使用 go func() 创建了 G2,为了局部性 G2 优先加入到 P1 的本地队列。
(2)G1 运行完成后,M 上运行的 goroutine 切换为 G0,G0 负责调度时协程的切换。从 P 的本地队列取 G2,从 G0 切换到 G2,并开始运行 G2 。实现了线程 M1 的复用。
(3)假设每个 P 的本地队列只能存 3 个 G。G2 要创建了 6 个 G,前 3 个 G(G3, G4, G5, G6)已经加入 p1 的本地队列,p1 本地队列满了。
(4) G2 在创建 G7 的时候,发现 P1 的本地队列已满,需要执行负载均衡 (把 P1 中本地队列中前一半的 G,还有新创建 G 转移到全局队列)
(5)G2 创建 G8 时,P1 的本地队列未满,所以 G8 会被加入到 P1 的本地队列。G8 加入到 P1 点本地队列的原因还是因为:G2 创建的新的 G 会优先放置到自己的 M 绑定的 P 上。
(6)规定:在创建 G 时,运行的 G 会尝试唤醒其他空闲的 P 和 M 组合去执行。
假定 G2 唤醒了 M2,M2 绑定了 P2,并运行 G0,但 P2 本地队列没有 G,M2 此时为自旋线程(没有 G 但为运行状态的线程,不断寻找 G)。
(7)M2 尝试从全局队列取一批 G 放到 P2 的本地队列。至少从全局队列取 1 个 G,但每次不要从全局队列移动太多的 G 到 P 本地队列,给其他 P 留点。这是从全局队列到 P 本地队列的负载均衡。
(8)假设 G2 一直在 M1 上运行,经过 2 轮后,M2 已经把 G7、G4 从全局队列获取到了 P2 的本地队列并完成运行,全局队列和 P2 的本地队列都空了,如场景 8 图的左半部分。
全局队列已经没有 G,那 M 就要执行 work stealing (偷取):从其他有 G 的 P 哪里偷取一半 G 过来,放到自己的 P 本地队列。P2 从 P1 的本地队列尾部取一半的 G,本例中一半则只有 1 个 G8,放到 P2 的本地队列并执行。
(9)如果一个M在长时间内分配不到G的话就会进入休眠状态。
(10)G8 创建了 G9,G8 进行了阻塞的系统调用,M2 和 P2 立即解绑,P2 会执行以下判断:如果 P2 本地队列有 G、全局队列有 G 或有空闲的 M,P2 都会立马唤醒 1 个 M 和它绑定,否则 P2 则会加入到空闲 P 列表,等待 M 来获取可用的 p。本场景中,P2 本地队列有 G9,可以和其他空闲的线程 M5 绑定。
(11)G8 创建了 G9,假如 G8 进行了非阻塞系统调用。 M2 和 P2 会解绑,但 M2 会记住 P2,然后 G8 和 M2 进入系统调用状态。当 G8 和 M2 退出系统调用时,会尝试获取 P2,如果无法获取,则获取空闲的 P,如果依然没有,G8 会被记为可运行状态,并加入到全局队列,M2 因为没有 P 的绑定而变成休眠状态 (长时间休眠等待 GC 回收销毁)。
P存在的意义:
假如没有P,不同的G在不同的M上并发运行时可能都需向系统申请资源(如堆内存),由于资源是全局的,将会由于资源竞争造成很多系统性能损耗。
让P去管理G对象,M要想运行G必须先与一个P绑定,然后才能运行该P管理的G。P对象中预先申请一些系统资源作为本地资源,G需要的时候先向自己的P申请(无需锁保护),如果不够用或没有,再向全局申请。而且从全局拿的时候会多拿一部分,以供后面高效的使用。
P的存在解耦了G和M,当M执行的G被阻塞时,P可以绑定到其他M上继续执行其管理的G,提升并发性能。
假设协程运行在线程之上,并且协程调用了一个阻塞IO操作,这时候会发生什么?实际上操作系统并不知道协程的存在,它只知道线程,因此在协程调用阻塞IO操作的时候,操作系统会让线程进入阻塞状态,当前的协程和其它绑定在该线程之上的协程都会陷入阻塞而得不到调度。
所以,在协程中尽量不要调用阻塞IO的方法,比如打印,读取文件,Socket接口等,除非改为异步调用的方式,并且协程只有在IO密集型的任务中才会发挥作用。
要牢记这句话:协程只有和异步IO结合起来才能发挥出最大的威力