goroutine是goland的超级重点。

一、前置知识:进程、协程

单进程操作系统

原理

早期的操作系统为单进程操作系统,所以执行进程的顺序为线性执行(执行线程的时候也是如此,如linux操作系统的cpu本身无法区别进程和线程),因此总结下来早期单进程操作系统以时间顺序来执行进程,同一时刻只能执行一个进程(只有一个CPU核心)。
image.png

单进程操作系统的缺陷

  1. 单一执行流程、计算机只能逐个按顺序处理任务。
  2. 进程阻塞带来的CPU浪费时间,当一个进程阻塞时只能等待进程执行完成之后再去执行后面的任务。

如何宏观执行多个任务?这里就引出了多线程/多进程操作系统

多线程/多进程操作系统

原理

这里使用了轮询调度,操作系统将某段时间划分成N个时间片,例如第一个时间片运行进程A,当该时间片执行完之后进入下一个时间片,如果没有执行完,此时会强制切换运行进程B,依次执行,当所有进程执行完成一遍之后会重头执行进程A,循环往复。

image.png
因为每个时间片时间很短很短,所以从宏观来看,三个进程就像是同时在运行,从而达到并发的效果。

这样就解决了单进程时代进程阻塞时无效的等待,多线程/多进程在遇到某一个进程阻塞时,依旧可以去执行其他进程。

多线程/多进程操作系统的缺陷

高消耗调度CPU

在线程或者进程切换的时候,需要保存上一个线程或者进程的当前的一些状态,中间会进行上下文切换,之后再切换执行下一个线程或者进程,所以会有切换成本,这个成本也属于CPU的浪费时间成本。
image.png

进程切换和线程切换的区别:

  • 进程切换:

      1. 换页表
      1. 换内核和硬件上下文
  • 线程切换:

    • 只有换内核和硬件上下文

所以多线程/多进程的切换成本,也会导致进程/线程的数量越多,切换成本就越大,资源也就浪费的越多。

所以100%的CPU占用率,可能60%在执行程序,另外的40%在切换中,所以CPU利用率只有60%。
如何提高CPU利用率是软件层面进行系统优化和架构的研究方向。

高内存占用

除了高消耗调度CPU的弊端,多线程/多进程还有高内存占用的弊端。

线程和进程占用的内存大小:

  • 一个进程占用的虚拟内存大小为4GB(32位操作系统上)
  • 一个线程占用内存4MB

进程和线程占用的内存也是比较高的。(进程有着独立的资源管理,所以占用内存要比线程大很多)

多线程同步竞争,资源冲突造成的开发难度变复杂

多线程随着同步竞争(如锁、竞争资源冲突等),开发设计程序,执行程序的过程也会变得越来越复杂。

协程的诞生

之前介绍了多线程/多进程的一些缺陷,接下来看看工程师们如何解决上述的缺陷。

线程中,操作系统分成了用户态和内核态。

  • 内核态:操作系统底层,分配物理地址资源,进程开辟,分配磁盘资源等等。
  • 用户态:业务逻辑、调接口等等

image.png

接着提出了以下规则制定:

  • 将线程中两个态分成两个单独的线程:用户线程、内核线程。

    • 内核线程单独处理硬件方面的东西
    • 用户线程用来实现业务层并发的效果
  • 内核线程和用户线程进行绑定
  • 不管是进程还是线程,CPU只能接触和获取到内核线程(CPU的视野里只有内核线程,并不知道内核线程还绑定了用户线程,所以对于硬件而言,处理线程还是按照以前逻辑,而且在操作系统层面不需要修改任何代码,只需要修改用户态即可)

image.png

1对1协程模型

经过上述的改进,协程的概念也就诞生了,协程其实就是上述图中的用户线程。(这里假设绑定是1对1)
image.png

1对1模型弊端

如果想实现多任务的并发,1:1协程模式中协程的创建、删除和切换时的消耗代价还是都由CPU承担,依旧浪费资源。

N对1协程模型

之前假设的是用户线程(协程)和内核态是一一对应的(1对1),如果多个协程绑定同一个内核线程(N:1的情况),这时候就需要一个协程调度器
image.png
因为CPU对用户态(用户空间无感,只对内核态的线程有感知,认为当前只有一个线程),上层通过协程调度器绑定了三个协程,每个协程中绑定一个任务,这样保证了用户态的并发,但是CPU本身只维护一个线程,不需要进行切换,也就解决了多线程/多进程中的CPU高消耗和高占用内存的问题。

N对1模型弊端

当协程调度器轮询执行协程的时候,中间某个协程阻塞了,可能就会造成该协程之后的协程也处于等待执行的状态:
image.png

M对N协程模型

为了解决1:1和N:1的模型的问题,这里提出了M:N的协程模型:
image.png
内核空间中的每个线程绑定一个CPU的核,如上图,两个内核态的线程通过协程调度器同时完成上层用户态的三个协程的工作。

所以通过如何提高内核态更高效的处理用户态的协程,其瓶颈就在协程调度器上,协程调度器做得越好,CPU的利用率也就越高(底层的内核态也就是CPU硬件核是固定的数量)。所以不同的遍程语言想要实现支持协程,就需要实现协程调度器,谁做得好谁就对协程的支持就好。

二、GO对协程的处理

GO中的协程介绍

在Go中,将协程(co-routine)称为Goroutine

  • 将Goroutine的内存大小修改为几KB,普通的线程内存大小为几MB,进程为几GB,Goroutine将大部分不必要的内存都砍掉

    • 因为Goroutine内存仅为几KB,因此可以执行大量的Goroutine
  • Goroutine可以经常切换,得益于其GO的协程调度器。

GO中早期的协程调度器

Go中早期的旧协程调度器执行流程:

  1. 协程模型是M对N,所有的协程都会在GO的全局协程队列中,全局队列由一个锁来保护,如果一个内核态要去执行一个Goroutine,首先获取全局队列的锁。

image.png

  1. 获取到锁之后,thread会把goroutine1执行,其余的Goroutine队列排序一次前移,当Goroutine1执行完成之后,thread还锁,之后Goroutine1进入全局Go协程队列的末尾排队。

image.png

旧调度器缺点

  1. 创建、销毁、调度Goroutine需要每个线程去获取锁,形成激烈的锁竞争,有锁就会有竞争,有阻塞,造成性能影响
  2. 线程执行一个Goroutine时,有可能该Goroutine会生成一个新的Goroutine-2并且也需要执行,原本希望同一个线程执行这两个Groutine,但是此时只能将Goroutine-2交给另一个thread去执行,这样Groutine-2转移到另一个线程执行时,会造成延迟和额外的系统负载
  3. 系统调用(CPU在thread之间的切换)导致频繁的线程阻塞和取消阻塞操作增加了系统开销

    GMP模型

    为了解决旧调度器的缺陷,GoLang提出了GMP的全局解释器,其中G和M分别对应早期调度模型中的goroutine和内核线程。

  • G是goroutine,基于协程建立的用户态线程
  • M是machine,它直接关联一个os内核线程,用于执行G。
  • P是processor,P里面一般会存当前goroutine运行的上下文环境(函数指针,堆栈地址及地址边界,goroutine运行队列),P会对自己管理的goroutine队列做一些调度。

    • processor中包含了Groutine运行的资源,如果要运行goroutine需要获取Processor。

GMP模型图:
image.png

  • P的本地队列都由对应的某个P来管理。
  • 内核线程M要执行某个P的本地队列中的Goroutine,需要去先获取对应的P,接着由P将本地队列中的Goroutine取出运行。
  • 新创建的Goroutine优先存放到P的本地队列中,如果P的本地队列满的话,则存放到全局队列中。
  • 一个Processor同一时刻只能运行一个Goroutine,所以Go最大并行数量为GOMAXPROCS的数量,也就是Processor的数量。

Golang调度器的设计策略

介绍golang设计调度器的宏观方针。

  • 复用线程
  • 利用并行
  • 抢占
  • 全局Goroutine队列

    复用线程

    work-stealing机制

work-stealing机制图:
image.png

  • 新创建的Goroutine优先存放到P的本地队列中,如果P的本地队列满的话,则存放到全局队列中。
  • 如图M1正在运行G1,此时对应的P的本地队列还有G2,G3等待运行,M2此时是空闲的,此时M2将M1对应的P的本地队列中的G3"窃取"过来执行。(类似于Java中的forkjoin机制)

    hand off机制

    当执行Processor执行Goroutine时遇到阻塞事件,并且本地队列中还有其他Goroutine等待执行,如下图:
    image.png
    此时可以发现P1是执行调度的单元也进入阻塞状态,导致G2也只能等待G1阻塞运行结束(本地队列和Processor是绑定的,但是阻塞需要线程来进行,所以P1不能跳过G1执行G2),为了提高CPU利用率,这里就用到了Hand off机制,如图:
    image.png

  • Hand off机制将P1和M1进行分离,P1切换绑定到新创建/唤醒的线程M3,原本的G1绑定M1,M1此时进入睡眠状态,所以M3相当于接管了M1原本绑定P1的操作,继续执行P1的本地队列中的G2,这样的效果就是不耽误G2的执行,提高CPU利用率。
  • 当G1的阻塞操作执行结束,如果G1还有其他操作,则加入到其他的队列中等待执行,此时M1进入睡眠或者销毁。

利用并行

可以使用GOMAXPROCS限定processor的个数,例如CPU核数/2,这样就只用一般数量的CPU处理程序,剩下的CPU处理其他的进程。

抢占

  • 以前的协程:co-routine协程和cpu绑定运行,此时有另外一个co-routine需要执行,只能等待原co-routine主动释放,才能解绑CPU资源给新的co-routine运行。
  • goroutine:当有其他goroutine需要运行,则规定CPU和每个goroutine最多绑定运行10ms,当10ms过后新的goroutine会抢占CPU资源,这样就保证了多个goroutine运行的时候处于并发的特点。

全局G队列

全局G队列是基于原先的work stealing机制做的补充。
image.png
从work-stealing机制中可以知晓,M1正在执行G1,M2此时处于空闲,所以M2此时优先"窃取"其他P的本地队列中的goroutine,但是当其他本地队列也为空的情况下,则会去全局队列中去获取goroutine(图中为M2获取G3)执行,获取的过程中需要加锁解锁,所以过程还是比较慢的,但是Go的调度器支持了这种机制。

补充

Golang深入理解:https://www.jianshu.com/p/559b0a0959dc


最后修改:2024 年 03 月 13 日
如果觉得我的文章对你有用,请随意赞赏