【golang】深入理解GMP调度模型

Goroutine

Go中,协程被称为goroutine,它非常轻量,一个goroutine只占几KB,并且这几KB就足够goroutine运行完,这就能在有限的内存空间内支持大量goroutine,支持了更多的并发,虽然一个goroutine的栈只占几KB(Go语言官方说明为4~5KB),但实际是可伸缩的,如果需要更多内容,runtime会自动为goroutine分配。

Goroutine特点:

  • 占用内存更小(几kb)
  • 调度更灵活(runtime调度)

GMP指的是什么

G(Goroutine):我们在Go语言中所说的协程,为用户级的轻量级线程,每个Goroutine对象中的sched保存着其上下文信息。
M(Machine):对内核级线程的封装,数量对应真实的CPU数(真正干活的对象,默认数量为10000)。
P(Processor):即为G和M的调度对象,用来调度G和M之间的关联关系,其数量可通过,GOMAXPROCS()来设置,默认为核心数。

版本1.0之前GM调度模型

调度器把G都分配M上,不同的G在不同的M并发运行时,都需要向系统申请资源,比如堆栈内存等,因为资源是全局的,就会因为资源竞争造成很多性能损耗。为了解决这样的问题go从1.1版本引入,在运行时系统的时候加入p对象,让P去管理这个G对象,M想要运行G,必须绑定P,才能运行P所管理的G。

【golang】深入理解GMP调度模型_第1张图片

GM调度存在的问题:

  1. 单一全局互斥锁(SchedLock)和集中状态存储
  2. Goroutine传递问题(M经常在M之间传递“可运行”的goroutine)
  3. 每个M作内存缓存,导致内存占用过高,数据局部性较差
  4. 频繁syscall调用,导致严重的线程阻塞/解锁,加剧的性能损耗。

GMP模型

Go中,线程是运行goroutine的实体,调度器的功能是把可运行的goroutine分配到工作线程上。

【golang】深入理解GMP调度模型_第2张图片

  1. 全局队列:存放等待运行的G。
  2. P的本地队列:同全局队列类似,存放的也是等待运行的G,存的数量有限,不超过256个。新建G‘时,G’优先加入到P的本地队列,如果队列满了,则会把本地队列中一半的G移动到全局队列。
  3. P列表:所有的P都在程序启动时创建,并保存在数组中,最多有GOMAXPROCS(可配置)个。
  4. M:线程想运行任务就得获得P,从P的本地队列获取G,P队列为空时,M也会尝试从全局队列拿一批G放到P的本地队列,或从其他P的本地队列偷一半放到自己P的本地队列。M运行G,G执行后,M会从P获取下一个G,不断重复下去。

Goroutine调度器和OS调度器是通过M结合起来的,每个M都代表了1个内核线程,OS调度器负责把内核线程分配到CPU的核上执行。

go func()调度流程

【golang】深入理解GMP调度模型_第3张图片
流程:

  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会被放入全局队列中。

M0
M0是启动程序后的编号为0的主线程,这个M对应的实例会在全局变量runtime.m0中,不需要在heap上分配,M0负责执行初始化操作和启动第一个G,在之后M0就和其他的M一样了。
G0
G0是每次启动一个M都会第一个创建的gourtine,G0仅用于负责调度的G,G0不指向任何可执行的函数,每个M都会有一个自己的G0。在调度或系统调用时会使用G0的栈空间,全局变量的G0是M0的G0

work stealing机制和hand off机制

  • work stealing机制:获取 P 本地队列,当从绑定 P 本地 runq 上找不到可执行的 g,尝试从全局链表中拿,再拿不到从 netpoll 和事件池里拿,最后会从别的 P 里偷任务。
  • hand off机制:当本线程M因为G进行的系统调用阻塞时,线程释放绑定的P,把P转移给其他空闲的M执行。

版本1.2 ~ 1.13 基于协作的抢占式调度

版本1.2~1.13,程序只能依靠Goroutine主动让出CPU资源才能触发调度。

问题:

  • 某些Goroutine可以长时间占用线程,造成其它Goroutine的饥饿
  • 垃圾回收需要暂停整个程序(Stop-the-world,STW),最长可能需要几分钟的时间,导致整个程序无法工作。

版本1.14~至今 基于信号的抢占式调度

在任何情况下,Go 运行时并行执行(注意,不是并发)的 goroutines 数量是小于等于 P 的数量的。为了提高系统的性能,P 的数量肯定不是越小越好,所以官方默认值就是 CPU 的核心数,设置的过小的话,如果一个持有 P 的 M, 由于 P 当前执行的 G 调用了 syscall 而导致 M 被阻塞,那么此时关键点: GO 的调度器是迟钝的,它很可能什么都没做,直到 M 阻塞了相当长时间以后,才会发现有一个 P/M 被 syscall 阻塞了。然后,才会用空闲的 M 来强这个P。通过 sysmon 监控实现的抢占式调度,最快在 20us,最慢在10-20ms才会发现有一个 M 持有 P 并阻塞了。操作系统在 1ms 内可以完成很多次线程调度(一般情况1ms 可以完成几十次线程调度),Go 发起 IO/syscall 的时候执行该 G 的 M会阻塞然后被 OS 调度走,P 什么也不干,sysmon 最慢要10-20ms才能发现这个阻塞,说不定那时候阻塞已经结束了,这样宝贵的 P 资源就这么被阻塞的M 浪费了。

Sysmon 有什么作用?

Sysmon 也叫监控线程,会变动的周期性检查

好处:

  • 释放闲置超过 5 分钟的 span 物理内存;
  • 如果超过 2 分钟没有垃圾回收,强制执行;
  • 将长时间未处理的 netpoll 添加到全局队列;
  • 向长时间运行的 G 任务发出抢占调度(超过10ms的g,会进行retake);
  • 收回因 syscall 长时间阻塞的 P;

GMP调度过程中存在哪些阻塞

  • I/O,select
  • block on syscall(系统调用(syscall)过程中发生了阻塞)
  • channel
  • 等待锁
  • runtime.Gosched()(手动让当前 Goroutine 主动让出执行权)

你可能感兴趣的:(golang,golang)