前言

看了很多网上对goroutine的描写,感觉写的好的不多,这里想稍微根据自己的情况来针对性的聊聊goroutine,其中很多部分的知识我已经比较熟悉或者前面的博客已经涉及到了的就不再赘述,对于我不够了解或者深入的对花更多的篇幅去描述,所以这篇Blog更多的是我的一个感想和杂谈吧,没有成体系。

正文

基本概念

谈到Goroutine,那么我不得不谈到的就是另外基本概念,进程和线程,当然这里我也不想浪费笔墨说我们看烂的东西,简单过一下重点。

进程:资源管理的基本单位,一个CPU

线程:在同一进程下,共享一些类似堆,数据段等信息,拥有独立的程序计数器,栈,一些寄存器。

协程(coroutine):python中是利用yield来使当前协程跳出,然后到某个时机再重新拿回控制权,对于c++的coroutine库我不太熟悉,这里暂且不表。

对于Linux,其实进程线程没有严格的区分,从我前面做ucore的时候也能看到其实就是一些寄存器和地址的区别,也就是内核线程和用户线程,对于用户线程来说因为存在内核栈的上下文切换(例如相关的寄存器),所以效率是不高的(相对于后面说的goroutine)。

那么回归正题,什么是goroutine?和我们上文所说的有什么区别呢?goroutine的优势又是什么呢?

从我的理解来看:Goroutine就是对线程的更轻量级抽象,大部分操作都存在于用户态(如切换goroutine),让coder只关注于goroutine层面,向下封装让操作系统对goroutine无感,一切的操作调度由语言级来进行调度(runtime来进行操作),也是因为go对于程序比起操作系统更加了解,所以可以在例如goroutine切换时就可以减少消耗,更小的内存占用,更合理的创建分配与销毁,也因为goroutine看起来是这么的美好。

GMP

谈到Goroutine,不得不谈的就是GMP模型,简单来说就是这张图

三角形表示OS线程它是由OS管理的可执行程序的一个线程,而且工作起来特别像你的标准POSIX线程。在运行时代码里,它被成为M,即机器(machine)。

圆形表示一个goroutine。它包括栈、指令指针以及对于调用goroutines很重要的其它信息,比如阻塞它的任何channel。在可执行代码里,它被称为G。

矩形表示用于调用的上下文。你可以把它看作在一个单线程上运行代码的调度器的一个本地化版本。它是让我们从N:1调度器转到M:N调度器的重要部分。在运行时代码里,它被叫做P,即处理器(processor)。

灰色的g表示就绪的goroutine,被安排在runqueue上,每个p上下文有一个本地的runqueue,同时有一个全局的runqueue来提供给本地,因为有了p,所以当我们的goroutine需要系统调用的时候,就可以吧上下文切换到另一个线程上,然后为了保持每个p的runqueue数量合适也可以从全局runqueue拿,如果没有可以从其他p偷g,除了这些随着go的版本更新,goroutine也在不断的优化调度逻辑。

一开始的老版本中goroutine只有一个G-M模型,没有中间P层来协助goroutine的调度,导致很严重的锁竞争,所以我们同样利用添加了一层抽象,让G把P当作类似CPU的存在,而M则与P进行绑定,由P来调度维护Goroutine队列。

调度

Goroutinede的调度随着版本改进也一直在改进,看一下大佬总结的历程

  • 单线程调度器 ·0.x
    • 只包含 40 多行代码;
    • 程序中只能存在一个活跃线程,由 G-M 模型组成;
  • 多线程调度器 ·1.0
    • 允许运行多线程的程序;
    • 全局锁导致竞争严重;
  • 任务窃取调度器 ·1.1
    • 引入了处理器 P,构成了目前的 G-M-P 模型;
    • 在处理器 P 的基础上实现了基于工作窃取的调度器;
    • 在某些情况下,Goroutine 不会让出线程,进而造成饥饿问题;
    • 时间过长的垃圾回收(Stop-the-world,STW)会导致程序长时间无法工作;
  • 抢占式调度器 ·1.2~ 至今
    • 基于协作的抢占式调度器 - 1.2 ~ 1.13
    • 通过编译器在函数调用时插入抢占检查指令,在函数调用时检查当前 Goroutine 是否发起了抢占请求,实现基于协作的抢占式调度;
    • Goroutine 可能会因为垃圾回收和循环长时间占用资源导致程序暂停;
    • 基于信号的抢占式调度器 - 1.14 ~ 至今
    • 实现基于信号的真抢占式调度
    • 垃圾回收在扫描栈时会触发抢占调度;
    • 抢占的时间点不够多,还不能覆盖全部的边缘情况;

我们可以看到从G-M模型到GMP模型的最终定义;Goroutine窃取从其他P的local队列中窃取,从全局队列中窃取;从协作式抢占到基于信号的真实抢占。

这里想简单提一下存在几种调度方式:

  1. 类似原子操作,等待channal等导致goroutine阻塞:

    P调度local的其他goroutine,如果没有就进行任务偷取(任务偷取不想这里展开后面有空再说吧)

  2. 当是系统调用时的调度:

    当 G 进行系统调用的时候,对应的 M 和 P 也阻塞在系统调用,并不会立刻发生抢占,只有当阻塞持续时间过长时,才会将 P(及之上的其他 G)抢占并分配到空闲的 M 上,然后M-G单独挂起,知道G恢复后将G加回全局队列中,而M则空闲挂起,也有可能进入自旋来寻找可用的G。

  3. 当是类似网络IO的操作时:

    其实这里Go实现了一个网络轮训器,类似实现了IO多路复用,将阻塞的G脱离交给轮训器做管理,直到文件描述符可用就是IO完成。注意网络轮训器可能会被守护进程出触发,也有可能会被其他活跃Goroutine触发,当文件描述符可用后将G加回原来的P继续操作

  4. 当前Goroutine陷入Sleep:

    守护进程可能会剥夺那些占用过长的goroutine

然后对于抢占1.14之前都是协作式抢占,1.14才正式实现了部分情况下的真实抢占

类似这个在1.14之前是会陷入死循环中出不来的,上面的例子会启动和机器的 CPU 核心数相等的 goroutine,每个 goroutine 都会执行一个无限循环。然后主goroutine中sleep了,此时调度器将执行权限给了其他goroutine,这些goroutine无限循环,并且没有什么类似操作channal,sleep的操作导致了卡死主goroutine。如下图

但是在1.14中这段代码是可以继续运行的,但是1.14的抢占式调度暂时只在垃圾回收扫描任务时触发,以及在栈扫描的时候(STW),见下图

总结

这里还有很多很多的点没有涉及或者讲明了,比如Go的内存分配,栈空间分配,网络轮训器的具体实现,守护Goroutine的逻辑,以及Goroutine之间的通信(CSP模型,推崇使用Channal来做,之后会仔细写一些研究下)