Golang GMP 并发模型笔记

Golang GMP 并发模型笔记

tk_sky 160 2024-03-04

Golang GMP 并发模型笔记

以前看过,但是居然没有记笔记,遂补

背景

线程与协程首先有所区别。一般语义中,线程是:

  • 操作系统的最小调度单元
  • 创建销毁和调度由内核完成,在此期间需要切换内核态

而一般语义中的协程是:

  • 又称为用户级线程
  • 与线程的映射关系是 M : 1
  • 创建销毁和调度在用户态完成
  • 从属于一个线程,无法并行,一个协程阻塞会导致同一进程的协程无法执行

于是,golang 经过优化,实现了 goroutine 这种特殊的协程:

  • 与线程的映射关系是 M : N
  • 创建销毁和调度在用户态完成
  • 可利用多个线程,实现并行
  • 通过调度器,可以实现线程和 goroutine 的动态绑定和调度
  • 栈空间大小可以动态扩展

GMP 模型

GMP = Goroutine + Machine + Processor,通过定义这三种概念然后把他们组合起来,针对减少加锁、避免资源分配不均等问题给出了解决方案。

G

  • g 就是 goroutine,是对协程的抽象
  • g 有自己的运行栈和状态,以及执行的任务函数
  • g 需要绑定到 p 才能运行,在 g 的视角中 p 是其 cpu

P

  • P 是 processor,是 golang 中的调度器
  • p 是 gmp 的中枢,承上启下,实现 g 和 m 的有机结合
  • g 只有被 p 调度才能执行
  • 对 m 而言,p 是 m 的执行代理,为其提供必要信息(如可执行的g等),隐藏调度细节
  • p 的数量决定了 g 最大并行数量,可以用 GOMAXPROCS 设定

M

  • m 是 machine,是对协程的抽象
  • m 不直接执行 g,而是先和 p 绑定,由其实现代理
  • 因为有 g,所以 m 不用和 g 绑死,g 在生命周期中可以跨 m 执行

GMP

一张经典的图:

image-20240304163726735

  • 全局有多个 M 和 P,但同时并行的 G 的最大数量等于 P 的数量
  • G 的存放队列包括本地队列、全局队列和 wait 队列(阻塞后又处于就绪态的队列)
  • M 调度 G 时,先取 P 的本地队列,然后再取全局队列,最后取 wait 队列。这样的好处是取本地队列时基本上无锁,减少全局锁竞争
  • 为防止 P 的闲忙差异太大,有一个 stealing 机制,本地队列为空的 P 可以从其他 P 的本地队列偷一半 G 到自己的队列

核心数据结构

g

定义如下:

type g struct {
    // ...
    m         *m      
    // ...
    sched     gobuf
    // ...
}


type gobuf struct {
    sp   uintptr
    pc   uintptr
    ret  uintptr
    bp   uintptr // for framepointer-enabled architectures
}
  • m:执行当前 g 的 m
  • sched.sp:指向函数调用栈栈顶( rsp 寄存器的值)
  • sched.pc:执行下一条指令地址(PC,RIP)
  • sched.ret:保存系统调用的返回值
  • sched.bp:指向函数栈帧的起始位置(RBP)

g 的生命周期有下面几种状态:

image-20240304171554871

m

type m struct {
    g0      *g     // goroutine with scheduling stack
    // ...
    tls           [tlsSlots]uintptr // thread-local storage (for x86 extern register)
    // ...
}
  • g0 是一种特殊的调度协程,执行 g 之间的切换调度
  • tls:线程本地存储

p

type p struct {
    // ...
    runqhead uint32
    runqtail uint32
    runq     [256]guintptr
    
    runnext guintptr
    // ...
}
  • runq:本地 goroutine 队列
  • runnext:下一个可执行的 goroutine

schedt

type schedt struct {
    // ...
    lock mutex
    // ...
    runq     gQueue
    runqsize int32
    // ...
}

是全局 goroutine 的封装:

  • lock:全局队列的锁
  • runq:全局 goroutine 队列
  • runqsize:全局队列容量

调度细节

g 与 g0

上面提到 goroutine 分为两类:

  • 负责调度普通 g 的 g0,执行固定的调度流程,与 m 一对一绑定
  • 执行用户函数的普通 g

m 通过 p 调度执行的 goroutine 永远在 g 和 g0 之间切换,当 g0 找到可执行的 g 时,会调用 gogo 方法,调度 g 执行用户定义的任务。当 g 需要主动让渡或被动调度时,触发 mcall 方法将执行权还给 g0。

调度类型

对于调度器 p 实现从一个 g 切换到另一个 g 的过程,可以分为几种调度类型:

  • 主动调度:用户在执行代码中调研会了 runtime.Gosched 方法,当前 g 会主动让出执行权,主动调用 mcall 加入队列等待下次被执行

  • 被动调度:g 可能会因为执行条件不满足而陷入阻塞,直到条件满足后 g 才从阻塞中被唤醒,重新进入可执行队列。这种情况下会调用 gopark 方法进入阻塞、通过 goready 方法从阻塞中恢复。

  • 正常调度:g 中任务执行完成,g0 会将当前 g 标记为死亡状态,发起新一轮调度

  • 交接调度:又叫对 p 的抢占。如果 g 执行系统调度的时间超过指定的时长,且全局的 p 资源比较紧缺,此时会将 p 和 g 解绑,交接出来用于其他 g 的调度。等 g 完成系统调用后会重新进入可执行队列中等待被调度。交接调度时因为是系统调用进入内核态,所以此时 m 也会阻塞在内核态,不能再运行其他 g 了,于是就需要把 p 和这个 g 及其阻塞的 m 断开。此时会由一个全局的第三方 monitor g 来对 p 进行监控和介入。

调度策略

  • 为防止全局队列中的协程等太久,p 每执行 61 次本地队列中的协程调度就会从全局队列中拉一个 G 放到 P 的本地队列中
  • 准备就绪的网络协程加入全局队列中
  • 如果本地队列空了,尝试去从其他 p 里偷一半的 p 到自己的队列中执行

调度时机

调度的时机包括:

  • m 启动
  • g 退出
  • g 主动让出
  • gopark 被动等待
  • 退出系统调用
  • start the world

基于信号的抢占调度

在 1.14 版本 golang 增加了基于信号的抢占式调度,避免一个 goroutine 在 for 死循环等情况下一直占用 cpu 而不主动让出。

M 会注册一个 SIGURG 信号的处理函数,上面提到的全局监控函数会间隔性地进行监控 P,如果发现某 g 独占 p 超过 10ms,就会给M发送抢占信号。M 收到信号以后,会通过先前注册的信号处理函数保存上下文,然后让出线程,最后调用 schedule 函数继续继续执行调度循环。

一些问题

如何理解 P?

P 可以理解为调度器,也可以理解为 M 的上下文,主要负责维护可运行的 goroutine 队列。它把本应分给一个线程的任务和 M 分离开来,这样可以实现一种动态的负载均衡,可以将一个 p 转移给另一个 m 来执行。

P 和 M 何时被创建?

在确定了 P 的最大数量后,runtime 会创建相同数量的 P。对于M,如果没有足够的 M 来关联 P,就会创建一个新的 M。这这情况一般发生在所有的 M 都阻塞时。

启动了大量 goroutine,如何分配?

这些 G 会根据 P 的设定个数,尽量平均地分配到每个 P 的本地队列。如果本地队列都满了,就会把剩下的 G 分配到全局队列。接下来执行 GMP 模型的调度策略:

  • 本地队列轮转:每个P维护一个G的队列,P周期性的将G调度到M中执行,执行一小段时间将上下文保存,然后将其放到队尾,再从队首重新取一个出来调度
  • 系统调用:一般情况下M个数会略大于P的个数。在G带着M进入系统调用阻塞后,将释放P,让空闲的M获取P,继续执行P中剩下的G。
  • 工作量窃取:空闲的P将其他P的G偷一半过来执行,以保持工作量均匀

goroutine 内存泄露的原因和解决方法?

原因:

goroutine 也要维护上下文信息,这些信息会放在 runtime 的堆上,且在 goroutine 销毁前不会释放。如果一个 goroutine 无法结束,同时又在创建新的 goroutine 复用这部分内存,就会造成内存泄露。主要情况有:

  • goroutine 正在进行 channel、mutex 等阻塞操作,但是由于逻辑问题一直阻塞
  • goroutine 里的业务逻辑进入死循环,资源无法释放
  • goroutine 里的业务逻辑进入长时间等待,同时有不断新增的goroutine 进入等待

解决方法:

  • 使用 channel ,同时引入超时控制
  • 排查:使用 pprof
  • 使用 context 做超时控制

总结

GMP 模型是 go 语言的并发模型,通过定义 goroutine G、逻辑处理器 P 和内核线程 M 来实现了高效的并发使用方法。其中 G 是 goroutine,是 go语言中的轻量级并发执行单元,比线程更小更灵活;P 是虚拟的执行单元,负责 goroutine 的调度,维护可运行的 goroutine 队列,并可以在内核线程上转移;M 是内核线程,依赖 P 执行,负责运行其中的 G。在遇到系统调用或网络等阻塞、占用超时等情况下,G 和 P 可以被灵活抢占或重新分配以提高效率。