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。
GM调度存在的问题:
- 单一全局互斥锁(SchedLock)和集中状态存储
- Goroutine传递问题(M经常在M之间传递“可运行”的goroutine)
- 每个M作内存缓存,导致内存占用过高,数据局部性较差
- 频繁syscall调用,导致严重的线程阻塞/解锁,加剧的性能损耗。
GMP模型
Go中,线程是运行goroutine的实体,调度器的功能是把可运行的goroutine分配到工作线程上。
- 全局队列:存放等待运行的G。
- P的本地队列:同全局队列类似,存放的也是等待运行的G,存的数量有限,不超过256个。新建G‘时,G’优先加入到P的本地队列,如果队列满了,则会把本地队列中一半的G移动到全局队列。
- P列表:所有的P都在程序启动时创建,并保存在数组中,最多有
GOMAXPROCS
(可配置)个。 - 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()调度流程
流程:
- 通过go func() 来创建一个goroutine;
- 有两个存储G的队列,一个是局部调度器P的本地队列、一个是全局G队列。新创建的G会先保存在P的本地队列中,如果P的本地队列已经满了就会保存在全局的队列中;
- G只能运行在M中,一个M必须持有一个P,M与P是1:1的关系。M会从P的本地队列弹出一个可执行状态的G来执行,如果P的本地队列为空,就会想其他的MP组合偷取一个可执行的G来执行。
- 一个M调度G执行的过程是一个循环机制;
- 当M执行某一个G时候如果发生了syscall或者其余阻塞操作,M会阻塞,如果当前有一些G在执行,runtime会把这个线程M从P中摘除(detach),然后再创建一个新的操作系统的线程(如果有空闲的线程可用就复用空闲线程)来服务于这个P;
- 当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 主动让出执行权)