16

GPM 模型

[TOC]

参考:深入Golang调度器之GMP模型

前言

在了解 Go 的 gorutine 时,我们还是得先复习下,并发和并行的区别:

  • 并发:同一段时间执行多个任务(你同时和两个女朋友聊天)。
  • 并行:同一时刻执行多个任务(你和你朋友都在和你女朋友聊天)。

在单核处理器上,通过多线程共享CPU时间片串行执行(并发非并行)。而并行则依赖于多核处理器等物理资源,让多个任务可以实现并行执行(并发且并行)。

一、GPM的基本流程

1.1 GPM的含义

  • G,表示一个 goroutine,即我需要分担出去的任务;
  • P,一个装满 G 的队列,用于维护一些任务;
  • M,一个操作器,用于将一个 G 搬到线程上执行;

1.2 Go调度器基本调度过程

  1. 创建一个 G 对象;
  2. 将 G 保存至 P中;
  3. P 去唤醒(告诉)一个 M,然后继续执行它的执行序(分配下一个 G);
  4. M 寻找空闲的 P,读取该 P 要分配的 G;
  5. 接下来 M 执行一个调度循环,调用 G → 执行 → 清理线程 → 继续找新的 G 执行。

简单叙述各自的任务

  • G,携带任务;
  • P,分配任务;
  • M,寻找任务;

二、全面的流程

2.1 各自携带的信息

  • G

    • 需执行函数的指令(指针)
    • 线程上下文的信息(goroutine切换时,用于保存 g 的上下文,例如,变量、相关信息等)
    • 现场保护和现场恢复(用于全局队列执行时的保护)
    • 所属的函数栈
    • 当前执行的 m
    • 被阻塞的时间
  • P,P/M需要进行绑定,构成一个执行单元。P决定了同时可以并发任务的数量,可通过GOMAXPROCS限制同时执行用户级任务的操作系统线程。可以通过runtime.GOMAXPROCS进行指定。

    • 状态(空闲、运行...)
    • 关联的 m
    • 可运行的 goroutine 的队列
    • 下一个 g
  • M,所有M是有线程栈的。如果不对该线程栈提供内存的话,系统会给该线程栈提供内存(不同操作系统提供的线程栈大小不同)。

    • 所属的调度栈
    • 当前运行的 g
    • 关联的 p
    • 状态

以上列举了三个结构各自的重要属性,现在我们来看下详细的运行流程。

2.2 准备知识

2.2.1 栈

普通栈:普通栈指的是需要调度的 goroutine 组成的函数栈,是可增长的栈,因为 goroutine 可以越开越多。

线程栈:线程栈是由需要将 goroutine 放置线程上的 m 们组成,实质上 m 也是由 goroutine 生成的,线程栈大小固定(设置了 m 的数量)。所有调度相关的代码,会先切换到该goroutine的栈中再执行。也就是说线程的栈也是用的g实现,而不是使用的OS的。

2.2.2 队列

全局队列

  • 该队列存储的 G 将被所有的 M 全局共享,为保证数据竞争问题,需加锁处理。
  • M 自旋(自己队列上没有 G)会偷取其他队列的一半 G,放置在自己绑定的本地队列上,先从该队列偷取,如果没有则从其他本地队列偷取。
  • sysmon 再次扫描发现上次任然在运行的 G 时(sysmon 起初扫描间隔为 10ns,每次扫描间隔翻倍,最高为 10ms),会将其停止与 M 解绑,放置该队列中;

本地队列:该队列存储数据资源相同的任务,每个本地队列都会绑定一个 M ,指定其完成任务,没有数据竞争,无需加锁处理,处理速度远高于全局队列。

2.2.3 上下文切换

简单理解为当时的环境即可,环境可以包括当时程序状态以及变量状态。

对于代码中某个值说,上下文是指这个值所在的局部(全局)作用域对象。相对于进程而言,上下文就是进程执行时的环境,具体来说就是各个变量和数据,包括所有的寄存器变量、进程打开的文件、内存(堆栈)信息等。

2.2.4 线程清理

由于每个P都需要绑定一个 M 进行任务执行,所以当清理线程的时候,只需要将 P 释放(解除绑定)(M就没有任务),即可。P 被释放主要由两种情况:

  • 主动释放:最典型的例子是,当执行G任务时有系统调用,当发生系统调用时M会处于阻塞状态。调度器会设置一个超时时间,当超时时会将P释放。
  • 被动释放:如果发生系统调用,有一个专门监控程序,进行扫描当前处于阻塞的P/M组合。当超过系统程序设置的超时时间,会自动将P资源抢走。去执行队列的其它G任务。
阻塞是正在运行的线程没有运行结束,暂时让出 CPU。
2.2.5 抢占式调度

runtime.main中会创建一个额外m运行sysmon函数,抢占就是在sysmon中实现的。

sysmon会进入一个无限循环, 第一轮回休眠20us, 之后每次休眠时间倍增, 最终每一轮都会休眠10ms. sysmon中有netpool(获取fd事件), retake(抢占), forcegc(按时间强制执行gc), scavenge heap(释放自由列表中多余的项减少内存占用)等处理。

抢占条件

  1. 如果 P 在系统调用中,且时长已经过一次 sysmon 后,则抢占;

调用 handoffp 解除 M 和 P 的关联。

  1. 如果 P 在运行,且时长经过一次 sysmon 后,并且时长超过设置的阻塞时长,则抢占;

设置标识,标识该函数可以被中止,当调用栈识别到这个标识时,就知道这是抢占触发的, 这时会再检查一遍是否要抢占。

2.3 详细流程

基本流程和上面一样。每创建出一个 g,优先创建一个 p 进行存储,当 p 达到限制后,则加入状态为 waiting 的队列中。

如果 g 执行时需要被阻塞,则会进行上下文切换,系统归还资源后,再返回继续执行。

当一个G长久阻塞在一个M上时,runtime会新建一个M,阻塞G所在的P会把其他的G 挂载在新建的M上。当旧的G阻塞完成或者认为其已经死掉时 回收旧的M(抢占式调度)。

P会对自己管理的goroutine队列做一些调度(比如把占用CPU时间较长的goroutine暂停、运行后续的goroutine等等)当自己的队列消费完了就去全局队列里取,如果全局队列里也消费完了会去其他P的队列里抢任务(所以需要单独存储下一个 g 的地址,而不是从队列里获取)。

三、总结

相比大多数并行设计模型,Go比较优势的设计就是P上下文这个概念的出现,如果只有G和M的对应关系,那么当G阻塞在IO上的时候,M是没有实际在工作的,这样造成了资源的浪费,没有了P,那么所有G的列表都放在全局,这样导致临界区太大,对多核调度造成极大影响。

而goroutine在使用上面的特点,感觉既可以用来做密集的多核计算,又可以做高并发的IO应用,做IO应用的时候,写起来感觉和对程序员最友好的同步阻塞一样,而实际上由于runtime的调度,底层是以同步非阻塞的方式在运行(即IO多路复用)。

所以说保护现场的抢占式调度和G被阻塞后传递给其他m调用的核心思想,使得goroutine的产生。

单从线程调度讲,Go语言相比起其他语言的优势在于OS线程是由OS内核来调度的,goroutine则是由Go运行时(runtime)自己的调度器调度的,这个调度器使用一个称为m:n调度的技术(复用/调度m个goroutine到n个OS线程)。 其一大特点是goroutine的调度是在用户态下完成的, 不涉及内核态与用户态之间的频繁切换,包括内存的分配与释放,都是在用户态维护着一块大的内存池, 不直接调用系统的malloc函数(除非内存池需要改变),成本比调度OS线程低很多。 另一方面充分利用了多核的硬件资源,近似的把若干goroutine均分在物理线程上, 再加上本身goroutine的超轻量,以上种种保证了go调度方面的性能。


Diuyon
67 声望148 粉丝

« 上一篇
go module 使用
下一篇 »
B树和B+树