GMP调度模型

一、Goroutine

1. 定义

goroutine 是 Go语言中的轻量级线程实现,由 Go 运行时(runtime)管理。Go 程序会智能地将 goroutine中的任务合理地分配给每个 CPU。

goroutine是一个与其他goroutines并行运行在同一地址空间的go函数或者方法。一个运行的程序由一个或者更多个goroutine组成。它与线程、协程、进程等不同。它是一个goroutine。

2.goroutine 和 thread的区别

  1. 内存占用。goroutine的内存开销小,一个goroutine内存开销在2k左右,而thread的开销在1-8M,并且为了防止栈溢出导致污染其他线程,线程间还有guard page的存在,内存开销大。
  2. 创建和销毁。thread是内核级的,内核调用消耗的性能代价比较高,开销较大。而goroutine是一种用户态线程,由go runtime管理,创建和销毁的销毁代价小。
  3. 调度切换。抛开陷入内核,线程切换大概是1000-1500纳秒,而goroutine切换为200ns(用户态,三个寄存器),一个纳秒平均可以执行12-18个指令,所以可以少执行12000-18000条指令,所以更快。
  4. 复杂性。线程创建和退出复杂,多个thread间通讯间复杂,不能大量创建多线程。

3.启动

1
2
3
go func(){

}

二、M:N模型

image-20230321175048601

go创建了M个线程,之后创建的N个goroutine都会依附在这M个线程上执行,即M:N模型。在运行时goroutine数量是远大于线程数量的,同一时刻,一个线程执行一个goroutine,而当goroutine阻塞时,go runtime会把这个goroutine调度走,让其他goroutine继续执行,而不是让线程休眠阻塞,尽可能的让CPU忙起来。

Tips:线程是CPU执行调度的单元,内核的结构是task_struct,他和进程用同一个结构体,区别是有一个字段决定了地址空间是否共享。

三、GMP模型

G: goroutine,使用struct runtime.g,包含了当前goroutine的堆栈、状态、上下文。

M: Machine,也称为工作线程,使用struct runtime.m,所有M是有线程栈的。如果不对该线程栈提供内存提供内存,系统会给该线程栈提供内存。当指定了内存,则M.stack->G.stack,M的PC寄存器指向G提供的函数,然后去执行。

1. GM调度器

go 1.2之前的调度器,限制了go并发的伸缩性,尤其是有高吞吐或并行运算需求的服务程序。

当goroutine调用了一个阻塞的系统调用,运行这个goroutine的线程就会被阻塞,这时候就应该创建或者唤醒一个线程来运行别的没有阻塞的goroutine。线程在这里可以创建不止一个,可以按需不断创建,而活跃的线程最大个数存储在变量GOMAXPROCS中。

问题:单一全局互斥锁和集中的状态存储;goroutine传递问题;Per-M持有一个内存缓存;严重的线程阻塞。

2. GMP概念

P:Processor G和M的调度对象,用来调度G和M之间的关系,数量可以通过GOMAXPROCS()来设置。

它代表了M所需的上下文环境,也是处理用户级代码逻辑的处理器,mcache/stackalloc从M移到了P,而G队列也被分为两类,保留全局G队列,同时每个P都会有一个本地的G队列。由于引入了本地队列,runtime不需要去进行一个集中式的调度,每一个M都会在P的本地队列,全局队列,或者其他P的队列中找G执行,减少全局锁对性能的影响。

注意:P的本地队列还是可能面临一个并发访问的场景,为了避免加锁,这里P的本地队列是使用了一个叫LockFree的队列,窃取G时使用CAS原子操作来完成。

3. work stealing

当一个P执行完本地所有的G,并且全局队列为空,会尝试挑选一个P,从它的队列中窃取一半的G,否则会从全局队列获取(当前个数/GOMAXPROCS)个G。为了保证公平性,遍历的顺序也随机化了。

光窃取失败时获取是不够的,还是有可能就导致全局队列饥饿。P的调度算法中,会每N轮调度后就去全局队列中获取一个G。

谁放入的全局队列呢?新建G时,本地队列的G放不下已满并达到256时,会放半数的G到全局队列中,阻塞的系统调用如果没有空闲的P也会把G放到全局队列。

4. Syscall

调用syscall后会解绑P,然后M和G进入阻塞,而P此时状态就是syscall,表明这个P的G正在syscall中,这时的P是不能被调度给别的M的。如果短时间内阻塞的M就唤醒了,那么M会优先来重新获取这个P,就能继续获取并绑定回去,这样有利于数据的局部性。

系统监视器(system monitor),称为sysmon会定时扫描。在执行syscall时,如果一个P的G执行时间超过一个syscall tick(10ms),就会把他设为idle,重新调度给需要的M,强制解绑。

而syscall结束后,M按照一下规则知道满足其中一个条件:

  1. 尝试获取同一个P,恢复执行G。
  2. 尝试获取idle list中其他空闲的P,恢复执行G。
  3. 找不到空闲的P,把G放回global queue,M放回到idle list。

5. Spining thread

线程自旋是相对于线程阻塞而言的,表象就是循环执行一个指定的逻辑(调度逻辑,目的是为了不停地寻找G)。

有两个地方引入了自旋:

  1. 类型1:M不带P的寻找P挂载,一有P就释放
  2. 类型2:M带P的寻找G运行,一有runable的G就会执行

自旋的M最多只允许GOMAXPROCS(busy P)个。

在新G被创建、M进入系统调用,M从空闲被激活这三种状态变化前,调度器至少确保至少有一个自旋M存在(唤醒或者创建一个M),除非没有空闲的P。

当新G被创建,如果有可用的P,就意味着新G可以被立即执行,即便不在同一个P也无妨,所以我们保留一个自旋的M就可以很快被运行。

当M进入系统调用时,这个M不知道何时可以被唤醒,所以需要一个自旋M来确保执行剩下的G。

当M从空闲被激活时,意味着一个M从空闲状态开始工作了,这时要检查并确保还有一个自旋M存在,以防还有G或者P空着。

6. GMP问题总结

6.1 单一全局互斥锁和集中状态存储

G被分成了全局队列和P的本地队列,全局队列依然是全局锁,但是使用场景变少,P本地队列是无锁队列,使用原子操作来面对可能得并发场景。

6.2 goroutine传递问题

G创建时就在本地队列,避免在G之间的传递,而且G对P的数据局部性好;当G开始运行了,系统调用返回后M会尝试获取可用P,获取到了的话可以避免在M之间的传递,而且优先获取调用阻塞签的P,所以G对M和P的数据。

6.3 Per-M 持有内存缓存

内存mcache只存在P结构中,P最多只有GOMAXPROCS个,远小于M的个数,所以内存没有过多的消耗。

6.4 严重的线程阻塞/解锁

通过引入自旋,保证任何时候都有处于等待状态的自旋M,避免在等待可用的P和G时频繁地阻塞和唤醒。

三、Sysmon

监控线程,它无需P也可以运行,他是一个死循环,每20us-10ms执行一次,循环一次之后sleep一会,为什么是动态周期呢,主要是避免空转,如果每次循环没有要做的事,就是加大sleep时间。

  • 释放闲置超过5分钟的span内存。
  • 如果超过2分钟没有垃圾回收,强制执行
  • 将长时间未处理的netpoll添加到全局队列
  • 向长时间执行的G任务发出抢占调度
  • 收回因syscall长时间阻塞的P

当P在M上执行时间超过10ms,sysmon 调用 preemptone 将 G 标记为 stackPreempt 。因此需要在某个地方触发检测逻辑,Go 当前是在检查栈是否溢出的地方判定(morestack()),M 会保存当前 G 的上下文,重新进入调度逻辑。

异步抢占,注册 sigurg 信号,通过 sysmon 检测,对M对应的线程发送信号,触发注册的handler,它往当前G的PC中插入一条指令(调用某个方法),在处理完handler,G恢复后,自己把自己推到了global queue中。

四、协程泄漏

排查方式:

  1. 使用火焰图确定是哪一段代码内存消耗高
  2. 使用pprof top 监控是不是有地方发生了协程泄漏
  3. 使用list命令确定协程是在哪一行代码阻塞

五、Network epoller

Go 所有的 I/O 都是阻塞的。然后通过 goroutine + channel 来处理并发。因此所有的 IO 逻辑都是直来直去的,你不再需要回调,不再需要 future,要的仅仅是 step by step。这对于代码的可读性是很有帮助的。
G 发起网络 I/O 操作也不会导致 M 被阻塞(仅阻塞G),从而不会导致大量 M 被创建出来。将异步 I/O 转换为阻塞 I/O 的部分称为 netpoller。打开或接受连接都被设置为非阻塞模式。如果你试图对其进行 I/O 操作,并且文件描述符数据还没有准备好,G 会进入 gopark 函数,将当前正在执行的 G 状态保存起来,然后切换到新的堆栈上执行新的 G。

那什么时候 G 被调度回来呢?

  • sysmon

  • schedule():M 找 G 的调度函数

  • GC:start the world

调用 netpoll() 在某一次调度 G 的过程中,处于就绪状态的 fd 对应的 G 就会被调度回来。

G 的 gopark 状态:G 置为 waiting 状态,等待显示 goready 唤醒,在 poller 中用得较多,还有锁、chan 等。

六、goroutine生命周期

main方法的执行

整段程序始于一个runtime.rt0_go中,会执行很多初始工作。

image-20230322143825932

  1. 启动并绑定一个m0和g0,m0就是程序的主线程,g0负责调度,即shedule()函数。
  2. 创建 P,绑定 m0 和 p0,首先会创建 GOMAXPROCS 个 P ,存储在 sched 的 空闲链表(pidle)。
  3. 新建任务 g 到 p0 本地队列,m0 的 g0 会创建一个 指向 runtime.main() 的 g ,并放到 p0 的本地队列。
  4. runtime.main(): 启动 sysmon 线程;启动 GC 协程;执行 init,即代码中的各种 init 函数;执行 main.main 函数。

注意:一开始是runtime的main函数,然后才是用户的main函数。

wakeup机制

准备运行的新 goroutine 将唤醒 P 以更好地分发工作。这个 P 将创建一个与之关联的 M 绑定到一个 OS thread。
go func() 中 触发 Wakeup 唤醒机制:
有空闲的 P 而没有在 spinning 状态的 M 时候, 需要去唤醒一个空闲(睡眠)的 M 或者新建一个。当线程首次创建时,会执行一个特殊的 G,即 g0,它负责管理和调度 G。

Go 基于两种断点将 G 调度到线程上:

  • 当 G 阻塞时:系统调用、互斥锁或 chan。阻塞的 G 进入睡眠模式/进入队列,并允许 Go 安排和运行等待其他的 G。
  • 在函数调用期间,如果 G 必须扩展其堆栈。这个断点允许 Go 调度另一个 G 并避免运行 G 占用 CPU。
  • 在这两种情况下,运行调度程序的 g0 将当前 G 替换为另一个 G,即 ready to run。然后,选择的 G 替换 g0 并在线程上运行。与常规 G 相反,g0 有一个固定和更大的栈。
  • Defer 函数的分配
  • GC 收集,比如 STW、扫描 G 的堆栈和标记、清除操作
  • 栈扩容,当需要的时候,由 g0 进行扩栈操作

Goroutine Recycle

G 很容易创建,栈很小以及快速的上下文切换。基于这些原因,开发人员非常喜欢并使用它们。然而,一个产生许多 shortlive 的 G 的程序将花费相当长的时间来创建和销毁它们。
每个 P 维护一个 freelist G,保持这个列表是本地的,这样做的好处是不使用任何锁来 push/get 一个空闲的 G。当 G 退出当前工作时,它将被 push 到这个空闲列表中。

image-20230322145008140为了更好地分发空闲的 G ,调度器也有自己的列表。它实际上有两个列表:一个包含已分配栈的 G,另一个包含释放过堆栈的 G(无栈)。
锁保护 central list,因为任何 M 都可以访问它。当本地列表长度超过64时,调度程序持有的列表从 P 获取 G。然后一半的 G 将移动到中心列表。需求回收 G 是一种节省分配成本的好方法。但是,由于堆栈是动态增长的,现有的G 最终可能会有一个大栈。因此,当堆栈增长(即超过2K)时,Go 不会保留这些栈。


GMP调度模型
https://www.zengzx.xyz/2022/11/02/01.知识架构/01.Go/01.基础/08.GMP调度模型/
作者
Eden
发布于
2022年11月2日
许可协议