风雨雾凇 风雨雾凇
首页
  • 服务端

    • golang
  • 其他

    • leetcode
  • 学习
  • 面试
  • 心情杂货
  • 实用技巧
  • 友情链接
关于
收藏
  • 分类
  • 标签
  • 归档
GitHub (opens new window)

风雨雾凇

技术小渣渣
首页
  • 服务端

    • golang
  • 其他

    • leetcode
  • 学习
  • 面试
  • 心情杂货
  • 实用技巧
  • 友情链接
关于
收藏
  • 分类
  • 标签
  • 归档
GitHub (opens new window)
  • golang

    • 源码阅读

      • 导读
      • builtin
    • go应用问题排查常用方法
    • Golang包管理笔记
    • Golang的协程调度器原理及GMP设计思想
      • Golang垃圾回收
      • Golang逃逸分析
    • 技术文档

    • GitHub技巧

    • Nodejs

    • 博客搭建

    • leetcode

    • 机器学习

    • 技术
    • golang
    风雨雾凇
    2022-05-07
    目录

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

    # GMP设计思想

    # 调度器的由来

    # 单进程

    一个进程只能处理一个任务,故不需要调度器

    # 多进程/线程

    进程阻塞时,可以切换其他进程来执行其他任务

    • 优点
      • 可以并发执行任务
    • 缺点
      • 高内存占用
      • 调度的高消耗CPU
      • 锁&竞争冲突
    # 协程
    • 解决多进程&线程的缺点而被设计
    • 线程和协程关系
      • N:1
        • 缺点:无法使用硬件多核能力、协程阻塞则无法并行
      • 1:1
        • 缺点:协程创建、删除和切换的代价都依赖CPU线程完成,代价昂贵
      • M:N
        • 克服了以上缺点
    • 线程&协程区别
      • 占用空间
      • 调度:抢占式vs协作式(协程让出CPU后才执行下一个协程)

    # Goroutine调度器的GMP模型设计思想

    # Go语言的协程goroutine
    • goroutine&channel
    • 特点
      • 占用内存更小
      • 调度更灵活
    # 旧版goroutine调度器

    Go目前使用的调度器是2012年重新设计的,因为之前的调度器性能存在问题,所以使用4年就被废弃了,那么我们先来分析一下被废弃的调度器是如何运作的? GPM模型 旧版调度流程

    • 缺点
      1. 创建、销毁、调度G都需要每个M获取锁,这就形成了激烈的锁竞争。
      2. M转移G会造成延迟和额外的系统负载。比如当G中包含创建新协程的时候,M创建了G’,为了继续执行G,需要把G’交给M’执行,也造成了很差的局部性,因为G’和G是相关的,最好放在M上执行,而不是其他M'。
      3. 系统调用(CPU在M之间的切换)导致频繁的线程阻塞和取消阻塞操作增加了系统开销。
    # GMP模型

    GPM模型

    • 模型相关
      1. 全局队列(Global Queue):存放等待运行的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,不断重复下去。
    • P和M的个数问题
      • P的数量
        • 由启动时环境变量$GOMAXPROCS或者是由runtime的方法GOMAXPROCS()决定。这意味着在程序执行的任意时刻都只有$GOMAXPROCS个goroutine在同时运行。
        • M的数量
          • go语言本身的限制:go程序启动时,会设置M的最大数量,默认10000.但是内核很难支持这么多的线程数,所以这个限制可以忽略。
          • runtime/debug中的SetMaxThreads函数,设置M的最大数量
          • 一个M阻塞了,会创建新的M。
        • P和M的关系
          • M与P的数量没有绝对关系,一个M阻塞,P就会去创建或者切换另一个M,所以,即使P的默认数量是1,也有可能会创建很多个M出来。
        • P和M何时被创建
          • P:在确定P的最大数量n后,运行时系统会根据这个数量创建n个P
          • M:没有足够的M来关联P并运行其中可运行的G。比如所有的M此时都阻塞住了,而P中还有很多就绪任务,就会去寻找空闲的M,而没有空闲的,就会去创建新的M。
    # 调度器的设计策略
    • 复用线程
      • work stealing机制:当本线程无可运行的G时,尝试从其他线程绑定的P偷取G,而不是销毁线程。
      • hand off机制:当本线程因为G进行系统调用阻塞时,线程释放绑定的P,把P转移给其他空闲的线程执行。
    • 利用并行
      • GOMAXPROCS设置P的数量,最多有GOMAXPROCS个线程分布在多个CPU上同时运行。GOMAXPROCS也限制了并发的程度,比如GOMAXPROCS = 核数/2,则最多利用了一半的CPU核进行并行。
    • 抢占
      • 在coroutine中要等待一个协程主动让出CPU才执行下一个协程,在Go中,一个goroutine最多占用CPU 10ms,防止其他goroutine被饿死,这就是goroutine不同于coroutine的一个地方。
    • 全局G队列
      • 在新的调度器中依然有全局G队列,但功能已经被弱化了,当M执行work stealing从其他P偷不到G时,它可以从全局G队列获取G。
    # go func()调度流程

    go func()调度流程

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

    # 调度器的生命周期

    调度器的声生命周期

    package main
    
    import "fmt"
    
    func main() {
        fmt.Println("Hello world")
    }
    
    1
    2
    3
    4
    5
    6
    7
    • M0&G0
      • M0是启动程序后的编号为0的主线程,这个M对应的实例会在全局变量runtime.m0中,不需要在heap上分配,M0负责执行初始化操作和启动第一个G, 在之后M0就和其他的M一样了。
      • G0是每次启动一个M都会第一个创建的gourtine,G0仅用于负责调度的G,G0不指向任何可执行的函数, 每个M都会有一个自己的G0。在调度或系统调用时会使用G0的栈空间, 全局变量的G0是M0的G0。
    • 生命周期流程
      1. runtime创建最初的线程m0和goroutine g0,并把2者关联。
      2. 调度器初始化:初始化m0、栈、垃圾回收,以及创建和初始化由GOMAXPROCS个P构成的P列表。
      3. 示例代码中的main函数是main.main,runtime中也有1个main函数——runtime.main,代码经过编译后,runtime.main会调用main.main,程序启动时会为runtime.main创建goroutine,称它为main goroutine吧,然后把main goroutine加入到P的本地队列。
      4. 启动m0,m0已经绑定了P,会从P的本地队列获取G,获取到main goroutine。
      5. G拥有栈,M根据G中的栈信息和调度信息设置运行环境
      6. M运行G
      7. G退出,再次回到M获取可运行的G,这样重复下去,直到main.main退出,runtime.main执行Defer和Panic处理,或调用runtime.exit退出程序。
    # 可视化GMP编程
    • go tool trace

    trace记录了运行时的信息,能提供可视化的Web页面。

    package main
    
    import (
        "os"
        "fmt"
        "runtime/trace"
    )
    
    func main() {
    
        //创建trace文件
        f, err := os.Create("trace.out")
        if err != nil {
            panic(err)
        }
    
        defer f.Close()
    
        //启动trace goroutine
        err = trace.Start(f)
        if err != nil {
            panic(err)
        }
        defer trace.Stop()
    
        //main
        fmt.Println("Hello World")
    }
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    • Debug trace

    编译二进制包后使用以下方式执行

      $ GODEBUG=schedtrace=1000 ./trace2
      SCHED 0ms: gomaxprocs=2 idleprocs=0 threads=4 spinningthreads=1 idlethreads=1 runqueue=0 [0 0]
      Hello World
      SCHED 1003ms: gomaxprocs=2 idleprocs=2 threads=4 spinningthreads=0 idlethreads=2 runqueue=0 [0 0]
      Hello World
      SCHED 2014ms: gomaxprocs=2 idleprocs=2 threads=4 spinningthreads=0 idlethreads=2 runqueue=0 [0 0]
      Hello World
      SCHED 3015ms: gomaxprocs=2 idleprocs=2 threads=4 spinningthreads=0 idlethreads=2 runqueue=0 [0 0]
      Hello World
      SCHED 4023ms: gomaxprocs=2 idleprocs=2 threads=4 spinningthreads=0 idlethreads=2 runqueue=0 [0 0]
      Hello World
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    • 执行方式:GODEBUG=schedtrace=1000 ./trace2
    • 说明
      • SCHED:调试信息输出标志字符串,代表本行是goroutine调度器的输出;
      • 0ms:即从程序启动到输出这行日志的时间;
      • gomaxprocs: P的数量,本例有2个P, 因为默认的P的属性是和cpu核心数量默认一致,当然也可以通过GOMAXPROCS来设置;
      • idleprocs: 处于idle状态的P的数量;通过gomaxprocs和idleprocs的差值,我们就可知道执行go代码的P的数量;
      • threads: os threads/M的数量,包含scheduler使用的m数量,加上runtime自用的类似sysmon这样的thread的数量;
      • spinningthreads: 处于自旋状态的os thread数量;
      • idlethread: 处于idle状态的os thread的数量;
      • runqueue=0: Scheduler全局队列中G的数量;
      • [0 0]: 分别为2个P的local queue中的G的数量。

    # Go调度器调度场景过程全解析

    具体参考下列文章,重点如下:

    • 新建G会优先放到当前的P本地队列
    • M执行完G时,会先切换G0负责协程的调度切换,从而执行下一个G
    • 当开辟过多G,P本地队列装不下的时候,则会执行负载均衡(把P本地队列前一半的G和新建的G打乱顺序转移到全局队列【新建的G不一定会转移,需视是否需立即执行决定】)
    • 创建G时,运行的G会尝试唤醒其他空闲的P和M组合去执行
    • 空闲M从全局队列GQ获取G的数量符合公式:n = min(len(GQ) / GOMAXPROCS + 1, cap(LQ) / 2 )
    • 如全局队列已经没有G,则m需要自行work stealing: 从其他P的本地队列中偷取一半的G,放到自己的P队列
    • 空闲线程如没有G执行,则会让其处于自旋状态【由于创建销毁本身也需消耗资源,故保存自旋当有新的G时可立马执行】,最多有GOMAXPROCS个自旋线程
    • 当G进行了系统调用时,则M和P会立即解绑,此时如P本地队列存在G、全局队列有G或有空闲的M,P都会立马唤醒一个M和它绑定,否则P会加入空闲P列表,等待M来获取【即当前M用于执行阻塞系统调用的G,此时M无P绑定】
    • 假设G进行的系统调用并非阻塞,则M执行完毕系统后,G会重新投入G全局队列中并标记为可运行状态,同时M会重新尝试获取之前绑定的P,如该P已被其他M绑定,则从空闲队列中获取P,如仍获取失败则M因为没有P的绑定而变成休眠状态(长时间休眠等待GC回收销毁)。

    # 总结

    Go调度本质是把大量的goroutine分配到少量线程上去执行,并利用多核并行,实现更强大的并发。

    # 参考

    • Golang的协程调度器原理及GMP设计思想 (opens new window)
    编辑 (opens new window)
    #golang#学习笔记#GMP
    上次更新: 2023/02/17, 16:53:03
    Golang包管理笔记
    Golang垃圾回收

    ← Golang包管理笔记 Golang垃圾回收→

    最近更新
    01
    builtin
    02-12
    02
    导读
    02-12
    03
    13-罗马数字转整数
    01-30
    更多文章>
    Theme by Vdoing | Copyright © 2017-2023 风雨雾凇 | 粤ICP备16018321号-2
    博客内容遵循署名-非商业性使用-相同方式共享 4.0 国际 (CC BY-NC-SA 4.0) 协议
    • 跟随系统
    • 浅色模式
    • 深色模式
    • 阅读模式