Golang——GPM调度器

本文详细介绍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 复制代码
go func() {}()

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

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

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

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

go 复制代码
// 前文所说的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所使用的栈的信息,包括栈顶和栈底位置:

go 复制代码
// 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的几个寄存器的值:

go 复制代码
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运行队列,我们称这个运行队列为全局运行队列。

go 复制代码
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运行队列。
go 复制代码
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
    }

    ......
}
重要的全局变量
go 复制代码
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 的信息等

详见下面定义中的注释:

go 复制代码
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 核数即可。

相关推荐
秦老师Q13 分钟前
JavaWeb开发 - Filter过滤器详解
java·java-ee·web
Sword_Shi15 分钟前
服务器证书、数字证书和加解密算法
java·运维·服务器
周盛欢22 分钟前
Spring Boot 应用开发入门
java·spring boot·后端
灰色人生qwer30 分钟前
maven 项目怎么指定打包后名字
java·maven
nchu可乐百香果40 分钟前
sparkRDD教程之必会的题目
java·学习·spark·intellij-idea
wclass-zhengge42 分钟前
01基本介绍篇(D2_多线程问题)
开发语言·python
不知名美食探索家1 小时前
【9.1】Golang后端开发系列--Gin快速入门指南
开发语言·golang·gin
Yang-Never1 小时前
Shader -> BitmapShader贴图着色器详解
android·开发语言·kotlin·android studio·贴图·着色器
代码对我眨眼睛1 小时前
重回C语言之老兵重装上阵(一)vscode编译.C文件
c语言·开发语言·vscode
蒙娜丽宁1 小时前
【人工智能】用Python进行对象检测:从OpenCV到YOLO的全面指南
开发语言·python