GO基础学习(8) GMP模型--GO Scheduler
写在开头
非原创,知识搬运工/整合工,仅用于自己学习,众所周知GO在并发编程方面有强大的能力,而这能力主要是协程和通道这两大利器作为基石构建的。本文主要介绍协程g和g的调度器 go scheduler,理论知识比较多
往期回顾
带着问题去阅读
- 区分进程、线程和协程
- CPU核数与线程的关系
- 操作系统调度器OS Scheduler的目的
- 协程的调度方式
- 协程有什么更好的地方
- M:N模型是什么
- 协程和线程有什么关系
- runtime在程序中充当什么角色
- GO调度器的核心思想是什么
- 什么是g/m/p
1.什么是协程
1.1进程和协程
一个程序(可执行文件)变为进程(运行文件)是通过加上运行状态实现的,每个进程都有独立的内存空间来存放资源(主要分为代码区、数据区、栈和堆),一个CPU任何时间只能运行一个进程,一个进程可以有多个线程。进程是拥有和分配资源的基本单位,线程是进程的一部分,是进程任务的执行单元,是进程指令执行流的最小单元(二进制指令一行行执行下来),即CPU调度的基本单位,进程负责给线程分配资源。
CPU的核心数量与线程的没什么必然的关系
一般情况下,服务请求线程会“相对公平”地分配到核上运行,并且时间片上轮流使用,即并发执行(不一定是并行执行)
比如系统有4个核,如果:
1.有3个线程,就分配到3个核上运行
2.有8个线程,平均分配每个核两个线程进行运行
3.10个线程,有些核跑3个线程,有些核跑2线程
这也不是绝对的,要看操作系统的调度策略,OS是尽量让每个线程平均等待时间最小化,且保证如果有可以执行的线程时,不会让CPU空闲,即操作系统调度器OS SCheduler,实际上每个程序启动都会创建一个初始进程,并启动一个线程,而线程去创建线程,CPU在这一层调度,而非进程
每个线程也有自己的资源,主要分三类:共享内存中的全局变量、线程本地数据、线程私有数据。每个线程都有自己的栈空间,线程之间还会有小块区域来保护隔离自己的栈空间
1.2协程
进程的创建非常麻烦,开销大(申请资源、内存独占),且进程之间的通信不方便,因此完成一个任务会使用多个线程,而不是多个进程(线程的切换负担要比进程小,这也被称为轻量级进程,进程切换时需要转换内存地址空间,将不用的代码切入到外存)
为什么内存地址空间转换这么慢?Linux 实现中,每个进程的地址空间都是虚拟的,虚拟地址空间转换到物理地址空间需要查页表,这个查询是很慢的过程,因此会用一种叫做 TLB 的 cache 来加速,当进程切换后,TLB 也随之失效了,所以会变慢。
但是线程的创建和销毁都需要调用系统调用,系统调用涉及到用户态和内核态的转换,在高并发情况下开销还是很大,那么我们能不能创建一个没有系统调用的线程(当然这也是夸张的说法),那么就意味着这个线程的调度不再由OS控制,而是我们用户,这就引入了GO的大利器协程g,协程实际上是一个特殊的数据结构
一般而言,线程切换会消耗 1000-1500 纳秒,一个纳秒平均可以执行 12-18 条指令。所以由于线程切换,执行指令的条数会减少 12000-18000。
Goroutine 的切换约为 200 ns,相当于 2400-3600 条指令
1.3 OS Scheduler
上文提过了,对于操作系统调度器,最重要的是不让一个CPU现在,尽量让每个核心有任务可做
2.协程调度
协程的调度方式是non-preemptive,即一个gorutine干了一段时间干累了,主动告诉GO Scheduler切换为另一个gorutine(主动放弃执行机会,协作完成任务),其中这个告诉操作被隐藏在GO标准函数库函数,以及一些语法操作如 go 和 channel io里
image.png
2.1函数调用过程
理解函数调用过程是理解GO Scheduler的重要一步,在之前的函数章节我们学习过,现在复习一下
image.png
func a(b int) int {
renturn b
}
func main(){
var b int = 1
a(b)
}
在main调用子函数a的时候,会进行一个压栈操作,子函数a的参数会压在栈顶,再执行Call指令,Call指令还会将main的基栈压栈(return address)来让子函数执行完后,继续执行后续代码,实际上address是函数调用完后即将执行的下一条指令位置。然后就会进入被调用者的栈帧(新的一个栈),首先压入caller bp即栈基址,也就是栈底
2.3 M:N模型
协程g的创建到销毁都由runtime负责,runtime会在程序启动时创建M个线程thread,之后创建N个g,g会依附到M个线程上执行的,这也解释了协程和线程的关系,g还是依赖thread执行,这也是M:N模型
image.png
同一时刻,一个线程只能跑一个协程,当g发生阻塞或休眠,runtime会把g调度走,让其他g来执行,这是不是跟那个OS scheduler调度线程很像,保证不让线程闲着,即CPU闲着,榨干为止。同时也明白了上文所说的主动放弃执行机会,实际是g告诉runtime,让他把自己调度走,g的切换也不意味着线程一定切换
2.4 runtime充当的角色
image.png
GO程序执行由两层组成:GO Program(用户程序代码)和Runtime(运行时)。runtime直接与操作系统内核打交道,用户程序进行的系统调用都被它拦截处理,以此来帮助用户程序进行调度以及垃圾回收的相关工作。或者说用户程序和运行时通过函数调用来实现channel通信,g创建,内存管理等功能,它就是我们的保姆,管家呀
2.5scheduler
GO scheduler是GO运行时的最重要一个部分,runtime维护所有g,通过scheduler进行调度,GO程序执行的高效和调度是分不开的。但是将g调度到线程上执行仅仅是runtime层面的一个概念,实际是使用GMP模型(三个基础结构体)调度的:
- g,一个协程
- m,内核线程
- p, 代表一个虚拟的处理器/核Processor
- sched,三大天王有4个不是很正常吗,这是另一个核心结构体,总览全局
p维护一个可运行状态Runable的g的队列,m需要获取p才能运行g,在GO版本早期是没有p的,m必须从全局队列里获取g,因此需要加全局锁,锁的开销很大,因此后续加上了p。
Runtime起始也会启动一些G:负责垃圾回收、执行调度、运行用户代码,并且也会创建一个M用来运行开始的G,之后更多的G被创建,更多的M也被创建
GO scheduler的核心思想:
- 榨干线程
- 限制同时运行的线程数N,N为CPU核心数量
- 线程私有运行g的队列,并且可以从其他线程偷取g运行,线程阻塞后,将运行队列传递给其他线程
关于第三条的阻塞后交给其他线程,指GO调度器会启动一个后台线程sysmon,用来检测p上长时间(超过10ms)运行的g,将其调度到全局队列中(调度优先级低)