Golang GMP调度

技术 · 2023-12-18

GMP模型 Reference

基本概念

Go的GMP是指它的调度器(scheduler)使用的一种并发模型,也称为M:N调度器。在这种模型中,M个goroutine(Go协程)被映射到N个操作系统线程(OS threads)上,其中M和N都是可配置的。

GMP的工作原理如下:

  • G:代表Goroutine,是Go语言中轻量级的执行单元;
  • M:代表Machine,是Go语言中的执行线程,负责执行Goroutine;
  • P:代表Processor,是Go语言中的处理器,负责调度Goroutine。

在GMP模型中,所有的Goroutine都被放在一个全局的队列中。当一个M空闲时,它会从队列中取出一个Goroutine并执行它。如果一个Goroutine需要进行阻塞操作(如等待I/O完成),它会将自己从M上解绑,并将M还给调度器。此时,调度器会找到另一个空闲的M,并将其绑定到这个Goroutine上。当阻塞操作完成后,这个Goroutine会重新被放回队列中等待执行。

每个M都有一个本地队列,用于存放它当前正在执行的Goroutine。当一个M执行完当前的Goroutine后,它会从本地队列中取出下一个Goroutine执行。如果本地队列为空,它会从全局队列中获取Goroutine并执行。如果全局队列也为空,它会进入休眠状态等待新的Goroutine加入全局队列。

P是调度器用来管理M和Goroutine之间关系的数据结构。P的数量可以动态调整,调度器会根据当前系统负载情况动态增加或减少P的数量。每个P都有一个本地队列和一个全局队列。当一个Goroutine需要执行时,调度器会将其放入某个P的本地队列中。如果本地队列已满,它会将Goroutine放入全局队列中等待其他P来处理。

总的来说,GMP模型通过将大量的Goroutine映射到少量的OS线程上,实现了高效的并发处理。同时,调度器可以动态调整M和P的数量,适应不同的系统负载情况,从而提高了系统的性能和可伸缩性。

此外,GMP模型还具有以下优点:

  • 轻量级:Goroutine是非常轻量级的执行单元,可以快速地创建和销毁,不会占用太多的系统资源。
  • 高效:GMP模型中的调度器采用了一些高效的算法,如自适应调度、工作窃取等,可以尽可能地利用系统资源,提高系统的吞吐量和响应速度。
  • 安全:GMP模型中的调度器可以保证多个Goroutine之间的安全性,避免了一些常见的并发问题,如死锁、竞态条件等。

当然,GMP模型也存在一些缺点。例如,由于每个M都需要维护自己的本地队列,这可能会导致一些负载不均衡的情况。此外,由于M和P的数量是可配置的,如果配置不当可能会导致性能下降。因此,在使用GMP模型时需要根据具体情况进行调整和优化。

image.png
调度器整体梳理:

  • 将协程放入每个处理器的本地队列中,满了放了全局队列。
  • 然后处理器会和空闲的内核线程做绑定,运行处理器中的协程。
  • 如果某个线程阻塞了,处理器会和当前线程解除绑定,去和其他空闲的线程绑定。如果没有线程了,处理器可以创建线程。

调度器的设计策略

复用线程:避免频繁的创建、销毁线程,而是对线程的复用。

  • 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。

M运行G的时候:

  • 正常结束
  • 阻塞:M与P接触绑定,P与其他空闲M解绑。
  • 执行时间到:重新放入队列。

go func调度流程

image.png
image 1.png

M0

M0 是启动程序后的编号为 0 的主线程,这个 M 对应的实例会在全局变量 runtime.m0 中,不需要在 heap 上分配,M0 负责执行初始化操作和启动第一个 G, 在之后 M0 就和其他的 M 一样了。

G0

G0 是每次启动一个 M 都会第一个创建的 goroutine,G0 仅用于负责调度的 G,G0 不指向任何可执行的函数,每个 M 都会有一个自己的 G0。在调度或系统调用时会使用 G0 的栈空间,全局变量的 G0 是 M0 的 G0。

代码分析

package main

import "fmt"

func main() {
    fmt.Println("Hello world")
}

M0是一般是和main goroutine绑定,因为它第一个获取到P

  1. runtime 创建线程 m0 和 goroutine g0,并关联。
  2. 调度器初始化:初始化 m0、栈、垃圾回收。创建和初始化调度器 P。
  3. runtime.main 调用 main.main,为 runtime.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 退出程序。

image.png

GMP可视化

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")
}

分析:

go run trace.go 
go tool trace trace.out 

trace.out

main函数为trace.Start.func1函数创建了一个协程G6,放入P1中,然后P1创建M2来运行。

image.png

image 1.png

Golang
Theme Jasmine