GO基础学习(8) GMP模型--GO Scheduler

2023-04-28  本文已影响0人  温岭夹糕

写在开头

非原创,知识搬运工/整合工,仅用于自己学习,众所周知GO在并发编程方面有强大的能力,而这能力主要是协程和通道这两大利器作为基石构建的。本文主要介绍协程g和g的调度器 go scheduler,理论知识比较多

往期回顾

  1. 基本数据类型
  2. slice/map/array
  3. 结构体
  4. 接口
  5. nil
  6. 函数
  7. 程序到进程
  8. 进程到线程再到协程

带着问题去阅读

  1. 区分进程、线程和协程
  2. CPU核数与线程的关系
  3. 操作系统调度器OS Scheduler的目的
  4. 协程的调度方式
  5. 协程有什么更好的地方
  6. M:N模型是什么
  7. 协程和线程有什么关系
  8. runtime在程序中充当什么角色
  9. GO调度器的核心思想是什么
  10. 什么是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模型(三个基础结构体)调度的:

p维护一个可运行状态Runable的g的队列,m需要获取p才能运行g,在GO版本早期是没有p的,m必须从全局队列里获取g,因此需要加全局锁,锁的开销很大,因此后续加上了p。
Runtime起始也会启动一些G:负责垃圾回收、执行调度、运行用户代码,并且也会创建一个M用来运行开始的G,之后更多的G被创建,更多的M也被创建
GO scheduler的核心思想:

  1. 榨干线程
  2. 限制同时运行的线程数N,N为CPU核心数量
  3. 线程私有运行g的队列,并且可以从其他线程偷取g运行,线程阻塞后,将运行队列传递给其他线程
    关于第三条的阻塞后交给其他线程,指GO调度器会启动一个后台线程sysmon,用来检测p上长时间(超过10ms)运行的g,将其调度到全局队列中(调度优先级低)

参考

1.GO SCheduler

上一篇 下一篇

猜你喜欢

热点阅读