Go的线程模型

5,348 阅读11分钟

线程实现模型

  • 用户级线程模型
  • 内核级线程模型
  • 两级线程模型

Go线程模型核心元素 MPG

在操作系统提供的内核线程之上,Go搭建了一个特有的两级线程模型。

一个G的执行需要P和M的支持。P和M关联之后,就形成了一个有效的G运行环境(内核线程+上下文环境)。
每个P都会包含一个可运行的G的队列,该队列中的G会被依次传递给本地P关联的当前M,并获得运行时机。

  • M
  1. machine的缩写,一个M代表一个用户空间的内核线程,与内核线程(KSE)总是一一对应,在M的生命周期内,会且仅会与一个KSE产生关联。
  2. M刚创建之初,就会被加入到全局M列表
  3. 有些时候,M会被停止(比如找不到可运行G时等),M会被加入到调度器的空闲M列表,在需要一个未被使用的M时,调度器会先尝试从该列表中获取。
  4. 单个Go程序所使用的M的最大值可以设置,初始值是10000
  5. M的启动和停止:
    当M的因系统调用而阻塞(更确切的说,是他运行的G进入了系统调用),运行时系统会把M和关联的P分离开来,如果这个P的可运行队列中还有未被运行的G,那么运行时系统会去调度器的空闲M列表中找一个空闲的M,如果找不到就新建,然后这个p关联起来,使得剩下的这些G得以运行。

  • p
  1. processor的缩写,一个P代表执行一个Go代码片段所必需的资源(上下文环境)
  2. 调度器会适时的让P与不同的M建立或断开关联,使得P中的可运行G能够及时获得运行时机。
  3. P的数量默认等于当前cpu核数,可以利用函数runtime.GOMAXPROCS来改变P的数量。P的数量即为可运行G队列的数量。
  4. 当一个P不再与任何M关联时,且它的可运行G队列为空,会被放入调度器的空闲P列表,在需要一个空闲的P关联M时,会从此列表取出。
  5. P的包含一个自由G列表,存放一些已经运行完成的G。当增长到一定程度,运行时系统会把其中的部分G转移到调度器的自由G列表中。
  6. P的状态: - Pidel:初始化完成,当前p未与m关联,放入调度器的空闲P列表里。
    - Prunning:当前p已经和某个m关联,m在执行某个g。
    - Psyscall:当前p中的被运行的g正在被系统调用
    - Pgcstop:调度器停止调度。runtime正在进行GC(runtime会在gc时试图把全局P列表中的P都处于此种状态);有串行运行时任务正在等执行-->停止当前M-->释放本地P-->p为pgcstop
    - Pdead:当前P不再被使用(在调用runtime.GOMAXPROCS减少P数量时,多余的P就处于这种状态)
  • 任何非Pdead状态的p都会在运行时系统欲停止调度室被置于Pgcstop,其在启用调度的时候并不会恢复到原有状态,而是处在Pidle(同一起跑线上)
  • 任何非Pgcstop的P都可能因为全局P列表的缩小而被认为是多余的,被置于Pdead状态,其可运行G队列都会被转移到调度器的可运行G队列,它的自由G列表中的G也会被转移到调度器的自由G队列中。

  • G
  1. goroutine的缩写,一个G代表一个go代码片段,G是对go代码的一种封装.Go的编译器会把go语句变成对内部函数newproc的调用,并把go函数及其参数都作为参数传递给这个函数。
  2. 新建的G会被首先加入全局G列表,初始化之后,会被加入到本地P的可运行队列,还会被存储到本地P的runnext字段,该字段用户存放新鲜出炉的G,以便更早的运行它。如果runnext已经有一个G,那么这个已有G会被踢到该P的可运行G队列的末尾。如果该队列已满,那么这个G就只能追加到调度器的可运行G队列中。
  3. 当go语句欲启用一个G的时候,运行时系统会先试图从相应的P的自由G列表中获取一个现成的G,来封装这个go函数。仅当获取不到G才有可能创建一个新G。
  4. 运行时系统会在发现本地P自由G太少,会尝试从调度器的自由G列表中转移一些过来。如果发现本地P的自由G队列满了,会转移一些到调度器的自由G列表
  5. 所有的自由G列表 都是先进后出的。
  6. 一个G在被启用之后,会先被追加到某个P的可运行G队列中,以等待运行时机。
  7. G的状态: - Gidle(刚被分配,还未初始化) - Grunnable(初始化之后,一般在在p本地的可运行队列中等待运行;如果是退出系统调用且能运行的,放入调度器的可运行G队列,不能直接运行的G,则放入调度器的自由G列表等待被运行) - Grunning(正在运行) - Gsyscall(正在执行系统调用,转出系统调用状态且能运行的,放入调度器的可运行G队列;不能直接运行的G,则放入调度器的自由G列表等待被运行) - Gwaiting(正在阻塞,被 channel、IO操作、定时器(time.Timer)、time.Sleep等阻塞),在事件到来之后,G被唤醒,进入 - Grunnable,等待被调用,有些被放入本地p的可运行G队列,有些被放入调度器的本地可运行G队列,还有些被直接运行(如刚进行网络I/O)。 - Gdead(正在闲置,被放入本地p或者调度器的自由G队列) - Gcopystack(表示G的栈正被移动,为啥移动?因为栈的扩展或收缩) - Gscan(该状态不能单独存在,和其他状态组合在一起形成一个新状态,在GC扫描时发生)

容器元素

没特别标注的列表都是单向链表

作用域M列表P列表G队列/列表
运行时系统全局M列表全局P列表(一个数组)全局G列表(一个切片)
调度器空闲M列表空闲P列表可运行G队列、自由G列表(2个)
本地p可运行G队列、 自由G列表(1个)

调度器的一轮调度

两级线程模型中的一部分调度任务会由操作系统内核之外的程序承担,在Go中,调度器就负责这一部分的调度任务,调度的对象就是MPG。
一轮调度是Go调度器最核心的流程,在很多情况下都会被触发,在用户程序启动时的一系列初始化工作之后,一轮调度会首次启动并使封装main的那个G被调度运行。某个G的运行的阻塞、结束、退出系统调用,都会进行一轮调度。

  1. 调度器会判断当前M是否已被锁定,如果是,就会立即停止调度并停止当前M,也不会为当前M寻找可运行的G。当与它锁定的G处于可运行状态,M就会被唤醒并继续执行G。
  2. 如果未与任务G锁定,检查是否有运行时串行任务(需要停止Go调度器)正在等待执行,停止当前M-等待运行时串行任务执行完成。一但串行任务执行完成,该M会被唤醒,再次进行一轮调度。
  3. 无锁定、无运行时串行任务,开始寻找G,一旦找到G,就判断该G是否有与其他M锁定,有的话,会然与其锁定的M运行G;没有的话,直接运行G。

查找G

第一阶段

全力查找可运行的G:runtime.findrunnable函数,返回一个处于Grunnable状态的G。流程分为2个阶段,10个步骤

  • step1: 获取执行终结器的G,放入本地P的可运行G队列 终结器是一个其关联的对象呗垃圾回收器收集之前会执行的一个终结函数,该函数的执行会有个专门的G负责。 调度器会判断该G已经完成任务之后获取它,把它置为Grunable状态并放入本地P的可运行G队列。
  • step2: 从本地P的可执行G队列获取G,返回该G
  • step3: 从调度器的可执行G队列获取G,返回该G
  • step4: 从网络I/O轮询器(netpoller)处获取G,返回该G 调度器从netpoller那里获取一个G列表,把表头G返回,把其余的G都放入调度器的可运行G队列里。 当一个G试图在一个网络连接上进行读写操作时,底层程序会为此做准备,并把该G转为Gwaiting状态。 一旦准备就绪,底层程序就会返回相应的事件,这会让netpoller立即通知为此等待的G。从netpoller获取G的意思就是获取那些已经接收到通知的G,让他们转入Grunnable状态并等待运行。 这类型的G是不会被放入某个P的可运行G队列
  • step5: 从其他P的可运行G队列中获取G,返回G 利用一种伪随机算法从全局P列表中选取P,然后从它的可运行G队列中盗取一半的G到本地P的可运行G队列。并把盗取的第一个G作为结果返回。 选取P和盗取G的过程会重复多次。 前提条件:除了本地P还有非空闲的P

第一阶段结束

第二阶段

  • step6: 获取本地P中的可执行GC标记任务的G:返回该G 如果系统是正处在GC标记阶段,且本地P可用于GC标记任务,就会把本地P持有的GC标记专用的G置为Grunnable并返回该G
  • step7: 从调度器的可运行G队列中获取G,返回该G (为什么还要找一次?step3、4不是已经找不到了嘛,是3、4之后还有什么情况会有G新增到调度器的可运行G队列里吗?) 如果依然找不到可运行G,就会解除本地P与当前M的关联,并把P放入调度器的空闲P列表里。
  • step8: 从全局P列表中的每个P的可运行G队列获取G,返回该G 遍历全局P列表,只要发现某个P的可运行G队列不是空的,就从调度器的空闲p列表中取出一个P,并与当前M关联在一起,然后返回第一阶段去搜索G。
  • step9: 获取执行GC标记任务的G,关联持有GC标记专用G的P到当前M 如果系统正处于GC标记阶段,与GC标记任务相关的全局资源可用,调度器从其空闲P列表拿出一个P,如果这个P持有一个GC标记专用G,关联该P与当前M,然后再其次支持第二阶段
  • step10: 从网络I/O(netpoller)处获取G,并返回 调取器从netpoller那获取一个G列表,与step4不同的是,netpoller已被初始化,并且有I/O操作过,会阻塞等到有可用G出现。但是如果netpoller还未初始化,或者没有过I/O操作,这一步就会跳过。

名词简解

  • KSE: 内核调度实体,可以被内核的调度器调度的对象,也称为内核级线程,是操作系统内核最小调度单元。

  • 系统调用:是操作系统内核提供给用户空间程序的一套标准接口。通过这套接口,用户态程序可以受限地访问硬件设备,从而实现申请系统资源,读写设备,创建新进程等操作