Golang的GPM调度器

大家好,这里是Good Note,关注 公主号:Goodnote,专栏文章私信限时Free。本文详细介绍Golang的GPM调度器,包括底层源码及其实现,以及一些相关的补充知识。

在这里插入图片描述

前情提要

并发与并行

并行 (Parallel)
  • 定义:
    在同一时刻,多条指令在多个处理器上同时执行。
    • 宏观: 看起来是一起执行的。
    • 微观: 实际上多个任务同时进行。
并发 (Concurrency)
  • 定义:
    在同一时刻只能有一条指令执行,但多个进程指令快速轮换执行,宏观上看像是多个任务同时进行。
    • 宏观: 多个进程同时执行。
    • 微观: 单一时间段内交替执行。
关键区别
  • 并行强调在 同一时刻,多个任务在不同的处理器上执行。
  • 并发强调在 同一时间段内,多个任务交替执行,给人一种同时进行的假象。

进程和线程的区别

  1. 资源分配和调度单位

    • 进程: 是资源分配的最小单位。
    • 线程: 是程序执行的最小单位(资源调度的最小单位)。
  2. 地址空间

    • 进程: 有独立的地址空间。
      • 启动一个进程需要分配地址空间和维护段表,开销较大。
    • 线程: 共享进程的地址空间,切换和创建的开销更小。
  3. 通信方式

    • 线程: 共享地址空间,直接访问全局变量等数据。可以通过共享内存、同步机制(互斥锁(Mutex)、读写锁(RWMutex)、信号量(Semaphore))等进行通信。
    • 进程:
      • 同一进程内的线程可以直接共享全局变量,通信方便。
      • 不同进程需要使用 IPC(如管道、共享内存、消息队列、套接字(Socket))进行通信
  4. 健壮性

    • 进程: 独立运行,一个进程崩溃不会影响其他进程。
    • 线程: 多线程中任意一个线程崩溃会导致整个进程终止。

协程

协程是“用户态的轻量级线程”,协程的调度完全由用户控制,不为操作系统所知的,它由编程语言层面实现,上下文切换不需要经过内核态,再加上协程占用的内存空间极小,所以有着非常大的发展潜力。

解决的问题

主要用来解决操作系统线程太“重”的问题,所谓的太重,主要表现在以下两个方面:

  1. 创建和切换的高开销
    • 系统线程创建和切换需要进入内核,开销较大。
  2. 内存使用浪费
    • 系统线程栈空间较大,且一旦创建不可动态缩减或扩展。
协程的优势
  1. 轻量化

    • goroutine 是用户态线程,创建和切换无需进入内核。
    • 开销远小于系统线程。
  2. 灵活的栈内存管理

    • 启动时栈大小为 2KB
    • 栈可以根据需要自动扩展和收缩。
  3. 高并发能力

    • goroutine 支持创建成千上万甚至上百万的协程并发执行,性能和内存开销较低。

接下来讲解Go到并发调度——GPM调度器


Go的并发模型-CSP

常见的并发模型有七种:

  1. 线程与锁
  2. 函数式编程
  3. Clojure之道
  4. actor
  5. 通讯顺序进程(CSP)
  6. 数据级并行
  7. Lambda架构

Go 语言的并发模型是通信顺序进程(Communicating Sequential Processes,CSP) 的范型(paradigm),核心观念是将两个并发执行的实体通过通道channel连接起来,所有的消息都通过channel传输。CSP 是一种消息传递模型,通过在 goroutine 之间传递数据来传递消息,而不是对数据进行加锁来实现同步访问。用于在goroutine之间同步和传递数据的关键数据类型叫作通道(channel)。

channel详情请参考:【TODO】


Go的调度模型-GPM源码

GPM代表了三个角色,分别是Goroutine、Processor、Machine。下面详细介绍他们。

  • Goroutine:协程,用go关键字创建的执行体,它对应一个结构体g,结构体里保存了goroutine的堆栈信息。

  • Machine:表示操作系统的线程。

  • Processor:表示处理器,管理G和M的联系。

Goroutine

Goroutine就是代码中使用go关键词创建的执行单元,也是大家熟知的有“轻量级线程”之称的协程,协程是不为操作系统所知的,它由编程语言层面实现,上下文切换不需要经过内核态,再加上协程占用的内存空间极小,所以有着非常大的发展潜力。

  • goroutine建立在操作系统线程基础之上,协程与操作系统线程之间是多对多(M:N)的关系。
  • 这里的 M:N 是指M个goroutine运行在N个操作系统线程之上,内核负责调度这N个操作系统线程;N个系统线程又负责调度这M个goroutine。
  • 所谓的对goroutine的调度,是指程序代码按照一定的算法在适当的时候挑选出合适的goroutine并放到CPU上去运行的过程,这些负责对goroutine进行调度的程序代码我们称之为goroutine调度器。

在Go语言中,声明一个Goroutine很简单。如下:

go func() {}()

为了实现对goroutine的调度,使用 g 结构体来保存CPU寄存器的值以及goroutine的其它一些状态信息。

g 结构体保存了goroutine的所有信息,调度器代码可以通过 g 对象来对goroutine进行调度:

  • 当goroutine被调离CPU时,调度器代码负责把CPU寄存器的值保存在g对象的成员变量之中
  • 当goroutine被调度起来运行时,调度器代码又负责把g对象的成员变量所保存的寄存器的值恢复到CPU的寄存器。
g 结构体

g结构体用于代表一个goroutine,该结构体保存了goroutine的所有信息,包括栈,gobuf结构体和其它的一些状态信息:

// 前文所说的g结构体,它代表了一个goroutine
type g struct {
    // Stack parameters.
    // stack describes the actual stack memory: [stack.lo, stack.hi).
    // stackguard0 is the stack pointer compared in the Go stack growth prologue.
    // It is stack.lo+StackGuard normally, but can be StackPreempt to trigger a preemption.
    // stackguard1 is the stack pointer compared in the C stack growth prologue.
    // It is stack.lo+StackGuard on g0 and gsignal stacks.
    // It is ~0 on other goroutine stacks, to trigger a call to morestackc (and crash).
 
    // 记录该goroutine使用的栈
    stack       stack   // offset known to runtime/cgo
    // 下面两个成员用于栈溢出检查,实现栈的自动伸缩,抢占调度也会用到stackguard0
    stackguard0 uintptr // offset known to liblink
    stackguard1 uintptr // offset known to liblink

    ......

    // 此goroutine正在被哪个工作线程执行
    m              *m      // current m; offset known to arm liblink
    // 保存调度信息,主要是几个寄存器的值
    sched          gobuf
    stktopsp      uintptr  // 期望 sp 位于栈顶,用于回溯检查
    param         unsafe.Pointer // wakeup 唤醒时候传递的参数
 
    ......
    // schedlink字段指向全局运行队列中的下一个g,
    //所有位于全局运行队列中的g形成一个链表
    schedlink      guintptr

    ......
    // 抢占调度标志,如果需要抢占调度,设置preempt为true
    preempt        bool       // preemption signal, duplicates stackguard0 = stackpreempt

   ......

}

g结构体中两个主要的子结构体(stack和gobuf)介绍如下:

stack结构体

stack结构体主要用来记录goroutine所使用的栈的信息,包括栈顶和栈底位置:

// Stack describes a Go execution stack.
// The bounds of the stack are exactly [lo, hi),
// with no implicit data structures on either side.
//用于记录goroutine使用的栈的起始和结束位置
type stack struct {  
    lo uintptr   // 栈顶,指向内存低地址
    hi uintptr   // 栈底,指向内存高地址
}
gobuf结构体

gobuf结构体用于保存goroutine的调度信息,主要包括CPU的几个寄存器的值:

type gobuf struct {
    // The offsets of sp, pc, and g are known to (hard-coded in) libmach.
    //
    // ctxt is unusual with respect to GC: it may be a
    // heap-allocated funcval, so GC needs to track it, but it
    // needs to be set and cleared from assembly, where it's
    // difficult to have write barriers. However, ctxt is really a
    // saved, live register, and we only ever exchange it between
    // the real register and the gobuf. Hence, we treat it as a
    // root during stack scanning, which means assembly that saves
    // and restores it doesn't need write barriers. It's still
    // typed as a pointer so that any other writes from Go get
    // write barriers.
    sp   uintptr  // 保存CPU的rsp寄存器的值
    pc   uintptr  // 保存CPU的rip寄存器的值
    g    guintptr // 记录当前这个gobuf对象属于哪个goroutine
    ctxt unsafe.Pointer
 
    // 保存系统调用的返回值,因为从系统调用返回之后如果p被其它工作线程抢占,
    // 则这个goroutine会被放入全局运行队列被其它工作线程调度,其它线程需要知道系统调用的返回值。
    ret  sys.Uintreg  
    lr   uintptr
 
    // 保存CPU的rip寄存器的值
    bp   uintptr  // for GOEXPERIMENT=framepointer
}

要实现对goroutine的调度,仅仅有g结构体对象是不够的,至少还需要一个存放所有(可运行)goroutine的容器,便于工作线程寻找需要被调度起来运行的goroutine。
Go调度器引入了schedt结构体,

  • 保存调度器自身的状态信息;
  • 保存goroutine的全局运行队列。

schedt结构体

schedt结构体用来保存调度器的状态信息和goroutine的全局运行队列

schedt结构体被定义成了一个共享的全局变量,这样每个工作线程都可以访问它以及它所拥有的goroutine运行队列,我们称这个运行队列为全局运行队列。

type schedt struct {
    // accessed atomically. keep at top to ensure alignment on 32-bit systems.
    goidgen  uint64
    lastpoll uint64

    lock mutex

    // When increasing nmidle, nmidlelocked, nmsys, or nmfreed, be
    // sure to call checkdead().

    // 由空闲的工作线程组成链表
    midle        muintptr // idle m's waiting for work
    // 空闲的工作线程的数量
    nmidle       int32    // number of idle m's waiting for work
    nmidlelocked int32    // number of locked m's waiting for work
    mnext        int64    // number of m's that have been created and next M ID
    // 最多只能创建maxmcount个工作线程
    maxmcount    int32    // maximum number of m's allowed (or die)
    nmsys        int32    // number of system m's not counted for deadlock
    nmfreed      int64    // cumulative number of freed m's

    ngsys uint32 // number of system goroutines; updated atomically

    // 由空闲的p结构体对象组成的链表
    pidle      puintptr // idle p's
    // 空闲的p结构体对象的数量
    npidle     uint32
    nmspinning uint32 // See "Worker thread parking/unparking" comment in proc.go.

    // Global runnable queue.
    // goroutine全局运行队列
    runq     gQueue
    runqsize int32

    ......

    // Global cache of dead G's.
    // gFree是所有已经退出的goroutine对应的g结构体对象组成的链表
    // 用于缓存g结构体对象,避免每次创建goroutine时都重新分配内存
    gFree struct {
        lock          mutex
        stack        gList // Gs with stacks
        noStack   gList // Gs without stacks
        n              int32
    }
 
    ......
}

因为全局运行队列是每个工作线程都可以读写的,因此访问它需要加锁,然而在一个繁忙的系统中,加锁会导致严重的性能问题。

  • 调度器为每个工作线程引入了一个私有的局部goroutine运行队列,工作线程优先使用自己的局部运行队列,只有必要时才会去访问全局运行队列,这大大减少了锁冲突,提高了工作线程的并发性。

  • 在Go调度器源代码中,局部运行队列被包含在p结构体的实例对象之中,参考下面Proccessor中的p结构体。

Processor

Proccessor负责Machine与Goroutine的连接,它的作用如下:

  • 它能提供线程需要的上下文环境,
  • 分配G到它应该去的线程上执行。(局部goroutine运行队列被包含在p结构体)
p结构体

同样的,处理器的数量也是默认按照GOMAXPROCS来设置的,与线程的数量一一对应。主要存储

  • 性能追踪、垃圾回收、计时器等相关的字段外
  • 处理器的待待执行的局部goroutine运行队列。
type p struct {
    lock mutex

    status       uint32 // one of pidle/prunning/...
    link            puintptr
    schedtick   uint32     // incremented on every scheduler call
    syscalltick  uint32     // incremented on every system call
    sysmontick  sysmontick // last tick observed by sysmon
    m                muintptr   // back-link to associated m (nil if idle)

    ......

    // Queue of runnable goroutines. Accessed without lock.
    //本地goroutine运行队列
    runqhead uint32  // 队列头
    runqtail uint32     // 队列尾
    runq     [256]guintptr  //使用数组实现的循环队列
    // runnext, if non-nil, is a runnable G that was ready'd by
    // the current G and should be run next instead of what's in
    // runq if there's time remaining in the running G's time
    // slice. It will inherit the time left in the current time
    // slice. If a set of goroutines is locked in a
    // communicate-and-wait pattern, this schedules that set as a
    // unit and eliminates the (potentially large) scheduling
    // latency that otherwise arises from adding the ready'd
    // goroutines to the end of the run queue.
    runnext guintptr

    // Available G's (status == Gdead)
    gFree struct {
        gList
        n int32
    }

    ......
}
重要的全局变量
allgs     []*g     // 保存所有的g
allm       *m    // 所有的m构成的一个链表,包括下面的m0
allp       []*p    // 保存所有的p,len(allp) == gomaxprocs

ncpu             int32   // 系统中cpu核的数量,程序启动时由runtime代码初始化
gomaxprocs int32   // p的最大值,默认等于ncpu,但可以通过GOMAXPROCS修改

sched      schedt     // 调度器结构体对象,记录了调度器的工作状态

m0  m       // 代表进程的主线程
g0   g        // m0的g0,也就是m0.g0 = &g0
  • 全局变量的初始状态:
    • 切片(allgsallp)初始化为空。
    • 指针(allm)初始化为 nil
    • 整数变量(如 ncpugomaxprocs)初始化为 0
    • 结构体(如 sched)的所有成员初始化为对应类型的零值。
  • 程序启动时:
    • 这些全局变量逐步被赋值和初始化,表示当前程序的调度器状态、线程信息和可用的处理器。

Machine

M就是对应操作系统的线程

  • 最多有 GOMAXPROCS 个活跃线程(M)同时运行,默认情况下 GOMAXPROCS 的值等于 CPU 核心数。
  • 每个 M 对应一个 runtime.m 结构体实例。

为什么线程数等于 CPU 核数?
每个线程分配到一个 CPU 核心,可以避免线程的上下文切换,从而减少系统开销,提高性能。

m 结构体用来代表工作线程,它保存了g p m 三方的信息:

  1. m 自身使用的栈信息
  2. 当前 m 上正在运行的 goroutine
  3. m 绑定的 p 的信息等

详见下面定义中的注释:

type m struct {
    // g0主要用来记录工作线程使用的栈信息,在执行调度代码时需要使用这个栈
    // 执行用户goroutine代码时,使用用户goroutine自己的栈,调度时会发生栈的切换
    g0      *g     // goroutine with scheduling stack

    // 通过TLS实现m结构体对象与工作线程之间的绑定
    tls           [6]uintptr   // thread-local storage (for x86 extern register)
    mstartfn      func()
    // 指向工作线程正在运行的goroutine的g结构体对象
    curg          *g       // current running goroutine
 
    // 记录与当前工作线程绑定的p结构体对象
    p             puintptr // attached p for executing go code (nil if not executing go code)
    nextp         puintptr
    oldp          puintptr // the p that was attached before executing a syscall
   
    // spinning状态:表示当前工作线程正在试图从其它工作线程的本地运行队列偷取goroutine
    spinning      bool // m is out of work and is actively looking for work
    blocked       bool // m is blocked on a note
   
    // 没有goroutine需要运行时,工作线程睡眠在这个park成员上,
    // 其它线程通过这个park唤醒该工作线程
    park          note
    // 记录所有工作线程的一个链表
    alllink       *m // on allm
    schedlink     muintptr

    // Linux平台thread的值就是操作系统线程ID
    thread        uintptr // thread handle
    freelink      *m      // on sched.freem

    ......
}

M里面存了两个比较重要的东西,一个是g0,一个是curg。

M 和 G 的关系
  1. g0
  • 保存 m 使用的调度栈,负责运行时任务的管理,与用户 goroutine 栈分离。
  • 调度代码运行时使用 g0 的栈,而用户代码运行时使用用户 goroutine 的栈。
  • 主要用于 goroutine 的创建、内存分配、任务切换等运行时调度操作。
  1. curg
    • 当前线程正在运行的用户 goroutine
    • 每个线程在同一时刻只能运行一个 goroutinecurg 指向该任务。

M 和 P 的关系
  1. p

    • 当前线程正在运行的处理器。
    • 提供执行 Go 代码所需的上下文资源,比如本地运行队列和内存分配缓存。
  2. nextp

    • 暂存处理器,通常用于 M 在需要切换任务时暂存 P。
  3. oldp

    • 系统调用之前的处理器,用于在系统调用结束后恢复原处理器环境。

Go的调度模型-流程

PM绑定

默认启动GOMAXPROCS(四)个线程GOMAXPROCS(四)个处理器,然后互相绑定。
在这里插入图片描述

创建G并加入P的私有队列

一旦G被创建,在进行栈信息和寄存器等信息以及调度相关属性更新之后,它就要进到一个P的队列等待发车。
在这里插入图片描述

创建多个G,轮流往其他P的私有队列里面放。

在这里插入图片描述

P的私有队列满,加入G对应的全局队列

假如有很多G,都塞满了,那就把G塞到全局队列里(候车大厅)。

在这里插入图片描述

M通过P消费G

除了往里塞之外,M这边还要疯狂往外取:

  • 首先去处理器的私有队列里取G执行;
  • 如果取完的话就去全局队列取;
  • 如果全局队列里也没有的话,就去其他处理器队列里偷。

在这里插入图片描述

没有可执行的G,M和P断开绑定

如果哪里都没找到要执行的G,那M就会和P断开关系,然后去睡觉(idle)了。
在这里插入图片描述

其他情况

G被阻塞

如果两个Goroutine正在通过channel执行任务时阻塞,PM会与G立刻断开,找新的G继续执行。

在这里插入图片描述

P随M进入系统调用

如果G进行了系统调用syscall,M也会跟着进入系统调用状态,那么这个P留在这里就浪费了,P不会等待G和M系统调用完成,而是P与GM立刻断开,找其他比较闲的M执行其他的G。
在这里插入图片描述

当G完成了系统调用,因为要继续往下执行,所以必须要再找一个空闲的处理器发车。
在这里插入图片描述

如果没有空闲的处理器了,那就只能把G放回全局队列当中等待分配。
在这里插入图片描述

监控线程sysmon

sysmon 是 Go 运行时中的一个特殊线程(M),也被称为 监控线程。就像 保洁阿姨,定时清理系统中“不干活”的资源,确保调度器的正常运行和系统的高效运转。

  • 特点:

    • 不需要 P(处理器)即可独立运行。
    • 20 微秒到 10 毫秒 被唤醒一次,频率动态调整,执行系统级的维护任务。
  • 主要职责

    1. 垃圾回收

      • 执行垃圾回收相关的辅助任务,释放不再使用的内存资源。
    2. 回收长时间阻塞的 P

      • 当某个 P 长时间处于系统调用或其他阻塞状态时,sysmon 会将其从 M 中解绑,使资源得到重新利用。
    3. 发起抢占调度

      • 监控长时间运行的 Ggoroutine),如果某个 G 运行时间过长,sysmon 会发出抢占信号,强制切换到其他 G
      • 确保调度的公平性,防止某个 goroutine 独占资源。

      想在程序中实现抢占,可以使用: runtime.Gosched()

runtime 包及相关功能

runtime.Gosched()

  • 作用:
    • 将当前的 goroutine 暂停,让出 CPU 时间片,让调度器切换其他的 goroutine 执行。
    • 当前的 goroutine 会在稍后重新进入运行状态。
  • 使用场景:
    • 实现协作式的任务切换,避免单个 goroutine 长时间占用 CPU。

runtime.Goexit()

  • 作用:
    • 终止当前 goroutine,并执行该 goroutinedefer 语句。
  • 注意事项:
    • 不会影响其他 goroutine 或整个程序的运行,仅结束调用它的 goroutine
    • 用于提前退出某些任务。

runtime.GOMAXPROCS(n int)

  • 作用:
    • 设置 Go 运行时使用的最大 CPU 核心数。
    • 返回之前设置的值。
  • 默认值:
    • Go 1.5 之前,默认使用单核。
    • Go 1.5 及之后,默认使用所有 CPU 核心数。
  • 作用解释:
    • 决定同时运行的系统线程(M)的数量,对调度性能有直接影响。
  • 使用场景:
    • 限制程序对 CPU 核心的使用,避免过度竞争。

runtime.NumGoroutine()
  • 作用:
    • 返回当前程序启动的 goroutine 数量。
  • 使用场景:
    • 用于监控 goroutine 的数量,帮助判断程序是否有潜在的内存或调度问题。

GMP 限制分析

M 的限制

  • M 是什么:
    • M 代表操作系统线程,最终执行所有的任务。
  • 限制:
    • 默认最大数量为 10000
    • 超过限制会报错:GO: runtime: program exceeds 10000-thread limit
  • 异常场景:
    • 通常只有当大量 goroutine 阻塞时,才会触发这种限制。
  • 调整方法:
    • 使用 debug.SetMaxThreads 增加限制。

G 的限制

  • G 是什么:
    • G 代表 goroutine,是 Go 的轻量级线程。
  • 限制:
    • 没有硬性限制,理论上可以创建任意数量的 goroutine
    • 实际数量受 内存大小 的限制。
  • 内存占用:
    • 每个 goroutine 大约占用 2~4 KB 的连续内存块。
  • 注意事项:
    • 申请的 goroutine过多,如果内存不足,可能会导致创建失败或系统崩溃。当然 这种情况出现在 Goroutine 数量非常庞大(如数百万)。几十个或几百个 Goroutine: 一般是合理的,具体需根据任务性质决定。

P 的限制

  • P 是什么:
    • P 代表调度器中的处理器,负责连接 G 和 M。
  • 限制:
    • 数量由 GOMAXPROCS 参数决定。
    • 默认值等于 CPU 核心数。
  • 影响:
    • P 的数量影响任务并行执行的程度,但不影响 goroutine 的创建数量。
  • 合理设置:
    • 一般情况下,设置为机器的 CPU 核数即可。

历史文章

MySQL数据库

MySQL数据库

Redis

Redis数据库笔记合集

Golang

  1. Golang笔记——语言基础知识
  2. Golang笔记——切片与数组
  3. Golang笔记——hashmap
  4. Golang笔记——rune和byte
  5. Golang笔记——channel
  6. Golang笔记——Interface类型
  7. Golang笔记——数组、Slice、Map、Channel的并发安全性
  8. Golang笔记——协程同步
  9. Golang笔记——并发控制
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值