进程、线程、协程的衍生关系及MPG模型
协程的英文名称叫做coroutine,在go中,叫goroutine。
一、用户栈和内核栈
32位linux系统上,进程的地址空间为4G,包括1G的内核地址空间——内核栈,和3G的用户地址空间——用户栈。
内核栈
内核栈,是各个进程在刚开始建立的时候通过内存映射共享的,但是每个进程拥有独立的4G的虚拟内存空间从这一点看又是独立的,互不干扰的(只是刚开始大家都是映射的同一份内存拷贝)。
用户栈
用户栈就是大家所熟悉的内存四区,包括:代码区、全局数据区、堆区、栈区
内存结构图
内核栈和用户栈的切换
当进程在用户空间运行时,CPU堆栈寄存器的内容是用户堆栈地址,使用用户栈。当进程在内核空间时,CPU堆栈寄存器的内容是内核栈地址空间,使用的是内核栈。
当进程因为中断或系统调用进入内核时,进程使用的堆栈也需要从用户栈到内核栈。进程陷入内核态后,先把用户堆栈的地址保存到内核堆栈中,然后设置CPU堆栈寄存器为内核栈的地址,这样就完成了用户栈到内核栈的转换。
当进程从内核态恢复到用户态时,把内核中保存的用户态堆栈的地址恢复到堆栈指针寄存器即可。这样就实现了内核栈到用户栈的转换。
二、进程的栈
内核在创建进程的时候,在创建task_struct的同时,也会为进程创建相应的堆、栈。每个进程会有两个栈,一个用户栈,存在于用户空间,一个内核栈,存在于内核空间。
当进程在用户空间运行时,cpu堆栈指针寄存器里面的内容是用户堆栈地址,使用用户栈;
当进程在内核空间时,cpu堆栈指针寄存器里面的内 容是内核栈空间地址,使用内核栈。
三、线程的栈
我们知道,线程之间是共享同一个进程堆空间,但是每个线程的栈空间是独立的。
线程栈的空间开辟在所属进程的堆区与共享内存区之间,线程与其所属的进程共享进程的用户空间,所以线程栈之间可以互访。
四、协程的栈
我们已经知道线程是进程中的执行体,拥有一个执行入口以及从进程虚拟地址空间分配的栈,包括用户栈和内核栈。由线程创建的执行体就是协程,因为用户程序不能操作内存空间,所以只能给协程分配用户栈,而操作系统对协程一无所知。所以协程又被称为“用户态线程”。
操作系统会记录线程控制信息,而线程获得CPU时间片以后才可以执行。
由线程创建的执行体,就是所谓的协程。
四、进程、线程与CPU核心数的关系
这个取决于不同语言的并发模型。如python就是1个内核线程对应一个用户进程。python虽然支持多线程,但是你可以发现cpu使用率无法超过100%,即使你是个八核计算机,它也只用到1个核,如果想在python中用到多核,就得开多进程。
像java和c++这种,就是一个内核线程对应一个用户线程。这样你在java中开了3个线程,理论上最多就能把cpu利用到300%。
像go的花,就是内核线程和用户线程没有固定的对应关系。
五、协程vs线程
| 协程 | 线程 | |
|---|---|---|
| 创建数量 | 轻松创建上百万个协程而不会导致系统资源衰竭 | 通常最多不能超过1万个 |
| 内存占用 | 初始分配4k堆栈,随着程序的增长自动增长删除 | 创建线程时必须制定堆栈且是固定的,通常以M为单位 |
| 切换成本 | 协程切换只需要保存3个寄存器,耗时约200ns | 线程的切换需要保存几十个寄存器,耗时约1000ns |
| 调度方式 | 非抢占式,由goroutine主动交出控制权 | 在时间片用完后,由CPU中断任务强行将其调度走,这时必须保存很多信息 |
| 创建销毁 | goroutine因为是由go runtime负责管理的,创建和销毁的消耗比较小,是用户级的 | 创建和开销巨大,因为要和操作系统打交道,是内核级的,通常解决办法是线程池 |
五、MPG并发模型
- M:Machine,对应一个内核线程
- P:Processor,虚拟处理器,代表M所需的上下文环境,是处理用户级代码逻辑的处理器。P的数量由环境变量中的GOMAXPROCS决定,默认情况下就是核数
-
G:Goroutine,本质上是轻量级的线程,G0正在执行,其他G在等待。
MPG并发模型
首先记得我们在上一节里面说的:go的线程跟内核线程没有严格的对应关系。
如图所示,M0(Machine)是用来绑定内核线程的,P(Processor)是负责执行G(Goroutine)的。从图里可以看出,G0正在执行,而其余3个G正在等待。
加入你是8核的计算机,则最多会存在8个我们上述图里的结构。
有以下的情景:
P本来在执行G0,然后G0触发了系统调用而我们知道系统调用是比较慢的,而P右边的等待队列里的3个G还亟待执行,这个时候,就会发生下面的情况:
G0触发系统调用的情景
这里P直接绑定了新的Machine——M1,M1即是对应的一个新的内核线程,这样等待队列的G就可以被执行了;原来的G0此刻在等待系统调用的返回。
当G0的系统调用返回之后,G0发现自己没有P了,而G0又必须依赖于P才能继续执行。此时G0就会被放到全局的可运行队列:runqueue中,这个队列就是那些找不到P的G。同时M0也被释放出来了,就会被放到线程缓存中去。
G0和M0的回收
此时全局队列runqueue中的G都在嗷嗷待哺,等待着P的执行。而P会周期地去扫描全局队列,如果发现里面有G的话,就会把G拿过来,挂到自己的局部队列里去,这样过一会儿,G0就会被P去执行了。如下图所示:
G0重新进入局部队列等待被P执行
整个过程中,G0本来绑定的是内核线程M0,最后绑定的是内核线程M2。这个可以表明,内核线程M和我们协程G的绑定关系是动态变化的。