文章目录

  • 一、操作系统的进程和线程模型
    • 1.1、基础知识
    • 1.2、KST/ULT
  • 二、Golang的GPM协程调度模型
  • 三、M的结构及对应关系
  • 四、P的结构及状态转换
  • 五、G的结构及状态转换
  • 六、GPM调度器的结构
  • 七、GPM核心容器汇总

一、操作系统的进程和线程模型

1.1、基础知识

在学习了解Golang的GPM协程调度模型之前,首先先回顾一下操作系统的进程和线程模型。

进程从字面意思理解就是运行中的程序,是对应用程序运行状态的封装,一个应用程序的启动到关闭过程对应着一个进程的出生到死亡的过程,从进程中可以获取到应用程序运行的相关信息。进程是操作系统调度和执行的基本单位。而线程是存在于进程中一条执行路径,是CPU进行调度和资源分配的最小单位。

线程和进程的区别在于:

  1. 线程只拥有启动所需的最小资源,一个进程中至少有一个以上的线程,线程又被称为轻量级进程。
  2. 线程的资源和地址空间都取自进程的进程映象。
  3. 线程拥有线程上下文,线程的上下文保存了当前线程所指向代码的PC计数器、一个数据栈、处理器状态和私有的一些数据。
  4. 线程是CPU调度的最小单位,是进程中的一条执行路径,是资源分配的最小单位。

在现代操作系统中,线程通常以CPU时间片轮转的方式进行调度,CPU将一个连续的时间划分为多个时间片,指定线程在特定时间片内运行,并且进行轮转,使得多个线程可以在一个CPU核心的调度下,在一个连续的时间并发执行。通常一个操作系统最大的线程并行数为CPU核数总和,也就是一个CPU核心同一时刻只能调度一个线程。

在这种线程调度方式中,需要进行频繁地线程上下文切换,保存线程执行现场以及状态、堆栈信息和计数器,所以使用线程时,如果线程过多调度的性能损耗也会加大,甚至很多时候由于上下文切换开销过大,导致线程并发执行效率不如串行执行效率高,这就是传统的内核态线程调度的缺点。

1.2、KST/ULT

线程按照其调度器所在空间,可分为内核级线程及用户级线程。

  • 内核级线程(KST,kernel support thread)
    内核级线程依赖于操作系统的线程实现,每个内核级线程都对应着操作系统进程内部的线程实现,线程的调度和控制依赖于操作系统内核的线程,通常操作系统对外提供相应的内核线程操作API供程序使用。操作系统内核可以感知到线程的存在和操作。

内核级线程的优点是:

  1. 借助操作系统的实现,可利用CPU多核处理器的优势实现并发执行
  2. 一个进程内的线程被阻塞后,其他线程仍然可以继续执行
    内核级线程的缺点是线程上下文切换需要借助于操作系统内核,存在两次用户态和内核态的转化,效率较低。

通常各大语言的多线程类库都是对操作系统的内核级线程进行封装,以供开发者方便地使用线程,但本质上操作的仍为操作系统内核线程,比如Java、C++等语言,所以能够开启的线程数是有限的,通常不可多过服务器的CPU核心数,如果超过这个数量,那么上下文切换带来的开销就会很大。

  • 用户级线程(ULT,user level thread)
    用户级线程指的是通过线程库来实现线程的调度,线程库运行在用户空间中,不依赖于内核的实现,所以用户级线程(又被称为协程)可以做到对内核无感知,内核不会参与用户级线程的调度和控制,操作系统仍对进程进行直接控制。

用户级线程的优点:

  1. 用户级线程上下文切换在用户空间完成,无需借助内核,所以不用进行内核态转化,效率高
  2. 用户级线程与具体操作系统无关,只依赖于线程库的实现
  3. 用户级线程可以根据自身需要实现相应的调度算法,而无需受操作系统控制
    用户级线程的缺点:
  4. 操作系统侧以进程为调度单位,当线程阻塞时,该进程内所有线程都阻塞
  5. 由于不依赖于操作系统实现,无法直接利用多核CPU的优势

二、Golang的GPM协程调度模型

接下来进入正题,Golang为了减少操作系统内核级线程上下文切换的开销以及提升调度效率,提出了GPM协程调度模型,GPM模型借助了用户级线程的实现思路,通过用户态的协程调度,能够在线程上实现多个协程的并发执行。

GPM三个字母分别表示的是Goroutine、Processor及Machine。

Goroutine代表着Golang中的协程,通过Goroutine封装的代码片段将以协程方式并发执行,是GPM调度器调度的基本单位。

Processor代表执行Goroutine的上下文环境及资源,是GPM调度器中关联内核级线程与协程的中间调度器。

Machine是内核线程的封装,一个M与一个内核级线程一一对应,为Goroutine的执行提供了底层线程能力支持。

GPM三大核心组成结构如下:

GPM中,M与内核线程一一对应,M可以关联多个P,而P也可以调度多个G。

三、M的结构及对应关系

M在Golang的实现中对应着操作系统的一个内核级线程,其包含了需要执行的Goroutine函数以及G的信息,需要注意的,M是无状态的,它的存在是为了执行Goroutine函数。源码位于runtime/runtime2.go中,该结构体核心的字段如下:

type m struct {g0      *g    mstartfn      func()curg          *g      p             puintptr nextp         puintptroldp          puintptr lockedg       guintptrspinning      boolincgo         boolncgo          int32// 忽略}

各个核心字段的含义如下:

  • g0(m0):g结构体指针,g0是一个G的特殊实例,g0存在于m0这个特殊的M实例之中。m0是在调度程序启动时,由运行时系统创建的第一个M实例,该实例对应该程序拥有的第一个内核线程,而g0则为该内核线程的线程栈,用于执行调度、垃圾回收、栈管理等特殊的任务。除了该g0之外的所有G都为调度系统所创建的用户级G。
  • mstartfn:函数类型,对应着当前内核线程需要执行的Goroutine函数片段。
  • curg:g结构体指针,对应着当前该M相关联的G。
  • p:地址类型,对应着当前该M关联的P。
  • nextp:地址类型,标识有可能与该M存在关联的P。
  • oldp:地址类型,记录上一个与该M关联的P。
  • lockedg:地址类型,标识当前正在锁定该M的G,通过LockOSThread进行G和M的锁定,一旦G和M锁定后,该G只可由该M执行。
  • spinning:布尔类型,表示当前是否正在自旋,自旋则代表当前M正在寻找可执行的G。
  • incgo:布尔类型,表示当前是否正在执行cgo调用。
  • ncgo:int32类型,表示当前正在执行的cgo调用数目。
    所以通过curg、mstartfn、p就能够体现GPM调度的核心执行链路了:

四、P的结构及状态转换

P在Golang的实现中对应着一个调度队列,其中存储着多个G用于调度,需要注意的是P具备状态的,当其达到特定状态时,其含有的G才可被调度,并且P的数量也代表着实际上的最大Goroutine并行执行数(因为一个P需要在运行时取出一个G与M关联,所以当有N个P时最多可同时取出N个G关联M执行)。

P的数量可通过runtime.GOMAXPROCS函数进行设定,默认为当前系统的CPU核数。

首先看一个P对应的结构体,其源码也位于runtime/runtime2.go中,核心的字段及状态定义如下:

const (_Pidle = iota_Prunning_Psyscall _Pgcstop_Pdead
)type p struct {status      uint32 schedtick   uint32 syscalltick uint32 m           muintptrrunqhead uint32runqtail uint32runq     [256]guintptrrunnext guintptrgFree struct {gListn int32}
}

p的五个状态如下:

  • Pidle:当前p尚未与任何m关联,处于空闲状态
  • Prunning:当前p已经和m关联,并且正在运行g代码
  • Psyscall:当前p正在执行系统调用
  • Pgcstop:当前p需要停止调度,一般在GC前或者刚被创建时
  • Pdead:当前p已死亡,不会再被调度
    P的状态流转图如下:

在P创建之初,会被置为Pgcstop状态,在完成初始化之后,会马上进入Pidel状态,进入该状态后的P可被调度器调度,当P与某个M相关联时,会进入到Prunning状态,当其执行系统调用时,会进入到Psyscall状态,当P应为全局P列表的缩小而被删除时会进入Pdead状态,不会再进行状态流转和调度。当正在执行的P由于某些原因停止调度时,会统一流转成Pidle空闲状态,等待调度,避免线程饥饿。

P结构体中,重要的字段如下:

  • status:表示当前P的状态,为上述五个状态之一
  • schedtick :调度计数器,每被调度一次则自增1
  • syscalltick:系统调用计数器,每进行一次系统调用则自增1
  • m:即将要关联的m,M的nextp字段对应着该P
  • runq:可运行的G队列,默认容量为256个G
  • runqhead:可运行G队列头,标识目前正在运行的G
  • runnext:下一个将要运行的G
  • gFree:空闲G列表,存储着状态为Gdead的G,当其数目过多时,将会被转移到调度器全局G列表,用于被其他P再次使用(相当于一个G缓存池)

五、G的结构及状态转换

一个 G 就代表一个 goroutine,也与 go 函数对应。我们使用 go 语句时,实际上是向 Go 调度器提交了一个并发任务。Go 的编译器会把 go 语句变成内部函数 newproc 的调用,并把 go 函数以及其参数部分传递给这个函数,G和P一样具有着多个状态进行转换,其状态及结构体源码如下:

const (_Gidle = iota_Grunnable_Grunning _Gsyscall _Gwaiting _Gmoribund_unused_Gdead_Genqueue_unused_Gcopystack_Gscan         = 0x1000_Gscanrunnable = _Gscan + _Grunnable_Gscanrunning  = _Gscan + _Grunning_Gscansyscall  = _Gscan + _Gsyscall_Gscanwaiting  = _Gscan + _Gwaiting
)type g struct {stack       stack   // offset known to runtime/cgostackguard0 uintptr // offset known to liblinkstackguard1 uintptrm              *m      // current m; offset known to arm liblinksched          gobuf atomicstatus   uint32waitreason     waitReason // if status==Gwaitingpreempt        bool       // preemption signal, duplicates stackguard0 = ststartpc        uintptr         // pc of goroutine function
}

先从G的状态看起,G有如下状态可进行转换:

  • Gidle:当前 G 刚被分配,还未初始化
  • Grunable:正在可运行队列等待运行
  • Gruning:正在运行中,执行G函数
  • Gsyscall:正在执行系统调用
  • Gwaiting:正在被阻塞,一般是该G正在执行网络I/O操作,或正在执行time.Timer、time.Sleep
  • Gdead:已经使用完正在闲置,放入空闲G列表中,可被再次使用(和P不同,P处于Pdead状态则无法被再次调度)
  • Gcopystack:表示当前 G 的栈正在被移动,可能是因为栈的收缩或扩容
  • Gscan:表明当前正在进行GC扫描,由于在GC扫描的过程中肯定会处于某个前置状态,所以又有以下组合
  • Gscanrunable :代表当前 G 正等待运行,同时栈正被 GC 扫描
  • Gscanrunning :表示正处于 Grunning状态,同时栈在被 GC 扫描
  • Gscanwaiting:表示正处于 Gwaiting状态,同时栈在被 GC 扫描
  • Gscansyscall:表示正处于 Gsyscall状态,同时栈在被 GC 扫描

其状态流转图如下:

G结构体中重要字段的含义:

  • stack:当前G所被分配的栈内存空间,由lo及hi两个内存指针组成
  • stackguard0:g0的最大栈内存地址,当超过了这个数值则需要进行栈扩张
  • stackguard1:普通用户G的最大栈内存地址,当超过了这个数值则需要进行栈扩张
  • m:当前关联该G实例的M实例
  • sched:记录G上下文环境,用于上下文切换
  • atomicstatus:G的状态值,表示上述几个状态
  • waitreason:处于Gwaiting的原因
  • preempt:当前G是否可抢占
  • startpc:当前G所绑定的函数内存地址

六、GPM调度器的结构

GPM调度器负责协调G、P、M三者具体的调度工作,每个GO程序中只存在一个GPM调度器,其源码位于runtime/runtime2.go之中,结构体名称为schedt,对应着的全局唯一实例为sched,结构体核心字段如下,直接在代码中注释出来:

type schedt struct {// 全局唯一idgoidgen  uint64// 记录的最后一次从i/o中查询G的时间lastpoll uint64// 互斥锁 lock mutex// M的空闲链表,通过m.schedlink组成一个M空闲链表midle        muintptr// 正处于自旋状态的M数量nmidle       int32// 已经被锁定且正在自旋的M数量nmidlelocked int32// 下一个M的id,或者是目前已存在的M数量mnext        int64// M数量的最大值maxmcount    int32// 已被释放掉的M数量nmfreed      int64// 系统所开启的协程数量(非用户协程)ngsys uint32// 空闲P列表pidle      puintptr// 空闲的P数量npidle     uint32// 全局的G队列// 根据runqhead可以获取队列头的G及g.schedlink形成G链表runqhead guintptrrunqtail guintptr// 全局G队列大小runqsize int32// 等待释放的M列表freem *m// 是否需要暂停调度(通常因为GC带来的STW)gcwaiting  uint32// 需要停止但是仍为停止的P数量stopwait   int32// 实现stopwait事件通知stopnote   note// 停止调度期间是否进行系统监控任务sysmonwait uint32// 实现sysmonwait事件通知sysmonnote note
}

七、GPM核心容器汇总

  • 任何 G 都会存在于全局 G 列表中,其余4个容器只存放当前作用域内具有某个状态的 G
  • 从 Gsyscall 状态转出的 G 都会被放到调度器的可运行 G 队列
  • 刚被运行时系统初始化的 G 都会被放入本地 P 的可运行 G 队列
  • 从 Gwaiting 状态转出的 G,有的会被放入本地 P 的可运行 G 队列,有的会被放到调度器的可运行 G 队列,还有的会被直接运行(比如刚完成网络 I/O)
  • 如果本地 P 的可运行队列 G 已满,其中的一半 G 会被转移到调度器的可运行 G 队列
  • 调度器可运行 G 队列遵循 FIFO(先进先出)

需要注意的是runtime.sched.gfreeStack和gfreeNoStack都代表着可运行G列表,但不同的是gfreeNoStack中存储着栈大小不等与默认栈大小的G,在放入该队列前会被释放空间,调度器无论是从gfreeStack还是gfreeNoStack中拿到的G都会进行栈空间检查,如果为0则会进行栈空间初始化。

Golang并发编程-GPM协程调度模型原理及组成分析相关推荐

  1. Golang的协程调度器原理及GMP设计思想

    一.Golang"调度器"的由来? (1) 单进程时代不需要调度器 我们知道,一切的软件都是跑在操作系统上,真正用来干活(计算)的是CPU.早期的操作系统每个程序就是一个进程,知道 ...

  2. python并发编程:协程asyncio、多线程threading、多进程multiprocessing

    python并发编程:协程.多线程.多进程 CPU密集型计算与IO密集型计算 多线程.多进程与协程的对比 多线程 创建多线程的方法 多线程实现的生产者-消费者爬虫 Lock解决线程安全问题 使用线程池 ...

  3. python并发编程之协程

    python并发编程之协程 1.协程: 单线程实现并发 在应用程序里控制多个任务的切换+保存状态 优点: 应用程序级别速度要远远高于操作系统的切换 缺点: 多个任务一旦有一个阻塞没有切,整个线程都阻塞 ...

  4. Go 分布式学习利器(17)-- Go并发编程之协程机制:Grountine 原理及使用

    文章目录 1. Thread VS Groutine 2. Groutine 调度原理 3. Groutine 示例代码 关于Go的底层实现还需要后续持续研究,文中如有一些原理描述有误,欢迎指证. 1 ...

  5. Java并发编程实战~协程

    Golang 是一门号称从语言层面支持并发的编程语言,支持并发是 Golang 一个非常重要的特性.在上一篇文章<44 | 协程:更轻量级的线程>中我们介绍过,Golang 支持协程,协程 ...

  6. 3-Go并发编程与协程Goroutine

    目录 一.并发编程 1 - 并行和并发 2 - 程序.进程.线程 3 - 进程并发 4 - 线程并发 5 - 线程同步 二.协程Coroutine 1 - 协程概念 2 - Go并发 3 - go程创 ...

  7. python并发之协程_python并发编程之协程

    一 引子 本节的主题是基于单线程来实现并发,即只用一个主线程(很明显可利用的cpu只有一个)情况下实现并发,为此我们需要先回顾下并发的本质:切换+保存状态 cpu正在运行一个任务,会在两种情况下切走去 ...

  8. skynet源码阅读5--协程调度模型

    注:为方便理解,本文贴出的代码部分经过了缩减或展开,与实际skynet代码可能会有所出入.     作为一个skynet actor,在启动脚本被加载的过程中,总是要调用skynet.start和sk ...

  9. Kotlin 协程调度切换线程是时候解开真相了

    前言 协程系列文章: 一个小故事讲明白进程.线程.Kotlin 协程到底啥关系? 少年,你可知 Kotlin 协程最初的样子? 讲真,Kotlin 协程的挂起/恢复没那么神秘(故事篇) 讲真,Kotl ...

最新文章

  1. spark项目实战:电商分析平台之项目概述
  2. sqlserver tds协议学习_数据安全交换协议来了,或将推动AI大步迈向3.0时代
  3. [JS-BOM]BOM_Location地址栏对象
  4. layui 分页ajax,实现Ajax异步的layui分页
  5. 聊聊 Jmeter 如何并发执行 Python 脚本
  6. 文本处理工具grep、egrep的具体用法
  7. 【Webcam设计】总结与代码仓库
  8. 什么是压力测试,如何做压力测试?
  9. mysql测评作业指导书_测评作业指导书
  10. 项目管理之项目章程和三个重要说明书
  11. 北京邮电大学砸彩蛋大作业
  12. 宥马运动服务器正在维护,宥马运动ios版
  13. win10自带磁盘测速工具
  14. asp.net 设计音乐网站
  15. CF Round 192
  16. 教你用Python爬取妹子图APP
  17. html制作炸金花,微信小程序怎么制作炸金花?微信小程序制作炸金花的方法
  18. 前端性能优化之“离线缓存manifest”
  19. 学报格式和论文格式一样吗_学报论文格式要求
  20. aes128 cmac java,C语言实现AES-128 CMAC算法

热门文章

  1. 吴晓波:预见2021(跨年演讲 —— 06 购物中心即将消亡)
  2. Android 9.0 版本以上,多进程访问对WebView的影响
  3. 一秒上手一学就会,做自媒体短视频如何快速变现?
  4. 【51nod P3047】位移运算【位运算】
  5. Pytorch打怪路(一)pytorch进行CIFAR-10分类(4)训练
  6. Unity Scroll View 滑动边界透明度渐变效果
  7. 简单记录—vue 用js方法实现侧边导航栏联动选择
  8. proftpd启动失败提示unable to determine IP address of “xxx.com”
  9. 甲骨文发布低代码平台,轻松扩展SaaS应用程序
  10. C++ 文件输出与输入