Golang的调度器
- 谈到Golang的调度器,绕不开的是操作系统,进程和线程这些概念。多个线程是可以属于同一个进程的并共享内存空间,因为多线程
不需要创建新的虚拟空间,所以不需要内存管理单元处理的上下文的切换,线程之间的通信也是基于共享内存进行的,同重量级的进程相比
线程显得比较轻量 - 虽然线程比较轻量,但是线程每一次的切换需要耗时1us左右的时间,但是Golang调度器对于goroutine的切换只要在0.2us
左右 - Go 语言的调度器通过使用与 CPU 数量相等的线程减少线程频繁切换的内存开销,同时在每一个线程上执行额外开销更低的 Goroutine 来降低操作系统和硬件的负载。
调度器种类
- 单线程调度器:遵循如下调度过程
- 多线程调度器
- 任务窃取调度器
- 抢占式调度器
- 基于协作的抢占式调度器
- 基于信号的抢占式调度器:实现基于信号的抢占式调度器,垃圾回收在扫描栈时会触发抢占式调度;抢占的时间不够多,不能覆盖全部边缘情况;
- 挂起goroutine的过程是在垃圾回收的栈扫描时候来完成的
- 调用runtime.suspendG函数时会将处于运行状态的goroutine的preemptStop标记成为true
- 调用runtime.preemptPark函数可以挂起当前的goroutine 将其状态更新为_Gpreemted并触发调度器的重新调度,该函数能够交出线程控制权
- 在X86架构上增加异步抢占函数
- 支持通过向线程发送信号的方式暂停运行的 Goroutine;
- 在 runtime.sighandler 函数中注册 SIGURG 信号的处理函数 runtime.doSigPreempt
- 实现 runtime.preemptM 函数,它可以通过 SIGURG 信号向线程发送抢占请求;
- 修改 runtime.preemptone 函数的实现,加入异步抢占的逻辑;
- 目前的抢占式调度也只会在垃圾回收扫描任务时触发,我们可以梳理一下上述代码实现的抢占式调度过程
- 程序启动时,在 runtime.sighandler 函数中注册 SIGURG 信号的处理函数 runtime.doSigPreempt;
- 在触发垃圾回收的栈扫描时会调用 runtime.suspendG 挂起 Goroutine,该函数会执行下面的逻辑:
- 将 _Grunning 状态的 Goroutine 标记成可以被抢占,即将 preemptStop 设置成 true;
- 调用 runtime.preemptM 触发抢占;
- runtime.preemptM 会调用 runtime.signalM 向线程发送信号 SIGURG;
- 操作系统会中断正在运行的线程并执行预先注册的信号处理函数 runtime.doSigPreempt;
- runtime.doSigPreempt 函数会处理抢占信号,获取当前的 SP 和 PC 寄存器并调用
- runtime.sigctxt.pushCall 会修改寄存器并在程序回到用户态时执行
- 汇编指令 runtime.asyncPreempt 会调用运行时函数 runtime.asyncPreempt2;
- runtime.asyncPreempt2 会调用 runtime.preemptPark;
- runtime.preemptPark 会修改当前 Goroutine 的状态到 _Gpreempted 并调用
- runtime.schedule 让当前函数陷入休眠并让出线程,调度器会选择其它的 Goroutine 继续执行;
数据结构
G
- 表示Goroutine 是一个等待执行的任务
- 它只存在于Go语言的运行时,它是Go语言在用户态提供的线程,作为一种粒度更细的资源调度单元,如果使用得当能够在
高并发的场景下更加高效的利用机器CPU. - goroutine在运行的时候会使用私有结构体runtine.g表示,下面对具体的字段进行解释
- stack 字段描述了当前 Goroutine 的栈内存范围 [stack.lo, stack.hi)
- stackguard0 可以用于调度器抢占式调度
- m — 当前 Goroutine 占用的线程,可能为空
- atomicstatus — Goroutine 的状态;
- sched — 存储 Goroutine 的调度相关的数据;
- sched — 存储 Goroutine 的调度相关的数据;
- pc — 程序计数器(Program Counter);
- g — 持有 runtime.gobuf 的 Goroutine
- ret — 系统调用的返回值
- goroutine的状态:主要有三种状态:等待中,可运行,运行中
- 等待中:Goroutine 正在等待某些条件满足,例如:系统调用结束等,包括 _Gwaiting、_Gsyscall 和 _Gpreempted 几个状态
- 可运行:Goroutine 已经准备就绪,可以在线程运行,如果当前程序中有非常多的 Goroutine,每个 Goroutine 就可能会等待更多的时间,即 _Grunnable;
- 运行中:Goroutine 正在某个线程上运行,即 _Grunning;
M
Go 语言并发模型中的 M 是操作系统线程。调度器最多可以创建 10000 个线程,但是其中大多数的线程都不会执行用户代码(可能陷入系统调用),最多只会有 GOMAXPROCS 个活跃线程能够正常运行。
在默认情况下,运行时会将 GOMAXPROCS 设置成当前机器的核数,我们也可以使用 runtime.GOMAXPROCS 来改变程序中最大的线程数。
在默认情况下,一个四核机器上会创建四个活跃的操作系统线程,每一个线程都对应一个运行时中的 runtime.m 结构体。
在大多数情况下,我们都会使用 Go 的默认设置,也就是线程数等于 CPU 个数,在这种情况下不会触发操作系统的线程调度和上下文切换,所有的调度都会发生在用户态,由 Go 语言调度器触发,能够减少非常多的额外开销。
- g0 是持有调度栈的 Goroutine,curg 是在当前线程上运行的用户 Goroutine,这也是操作系统线程唯一关心的两个 Goroutine
- g0 是一个运行时中比较特殊的 Goroutine,它会深度参与运行时的调度过程,包括 Goroutine 的创建、大内存分配和 CGO 函数的执行
P
调度器中的处理器 P 是线程和 Goroutine 的中间层,它能提供线程需要的上下文环境,也会负责调度线程上的等待队列,通过处理器 P 的调度,每一个内核线程都能够执行多个 Goroutine,它能在 Goroutine 进行一些 I/O 操作时及时切换,提高线程的利用率。
因为调度器在启动时就会创建 GOMAXPROCS 个处理器,所以 Go 语言程序的处理器数量一定会等于 GOMAXPROCS,这些处理器会绑定到不同的内核线程上并利用线程的计算资源运行 Goroutine。
调度器启动
- 调度器通过 runtime.schedinit 函数初始化调度器:
- 在调度器初始函数执行的过程中会将 maxmcount 设置成 10000,这也就是一个 Go 语言程序能够创建的最大线程数,虽然最多可以创建 10000 个线程,但是可以同时运行的线程还是由 GOMAXPROCS 变量控制。
- 从环境变量 GOMAXPROCS 获取了程序能够同时运行的最大处理器数之后就会调用 runtime.procresize 更新程序中处理器的数量,在这时整个程序不会执行任何用户 Goroutine,调度器也会进入锁定状态,runtime.procresize 的执行过程如下:
- 如果全局变量 allp 切片中的处理器数量少于期望数量,就会对切片进行扩容;
- 使用 new 创建新的处理器结构体并调用 runtime.p.init 方法初始化刚刚扩容的处理器;
- 通过指针将线程m0同处理器allp[0]绑定到提起
- 调用runtime.p.destroy 方法释放不再使用的处理器结构;
- 通过截断改变全局变量 allp 的长度保证与期望处理器数量相等;
- 将除 allp[0] 之外的处理器 P 全部设置成 _Pidle 并加入到全局的空闲队列中;
- 调用 runtime.procresize 就是调度器启动的最后一步,在这一步过后调度器会完成相应数量处理器的启动,等待用户创建运行新的 Goroutine 并为 Goroutine 调度处理器资源。
创建Goroutine
想要启动一个新的goroutine来执行任务,我们需要将Go语言中的go关键字,这个关键字会在编译期间通过下面方法cmd/compile/internal/gc.state.stmt 和 cmd/compile/internal/gc.state.call 两个方法将该关键字转换成 runtime.newproc 函数调用:
- 编译器会将所有的go关键字转换为runtime.newproc 函数,该函数会接受大小和表示函数的指针funcval。在这个函数中我们还会
获取goroutine以及调用方的程序计数器,然后调用 runtime.newproc1 函数。runtime.newproc1 会根据传入参数初始化一个 g 结构体,我们可以将该函数分成以下几个部分介绍它的实现:- 获取或者创建新的Groutine结构体
- 将传入的参数移植到Goroutine的栈上
- 更新Goroutine的调度相关性
- 将Goroutine加入处理器队列
初始化结构体
- runtime.gfget通过两种不同的方式获取新的 runtime.g 结构体:
- 从Goroutine所在的处理器的gFree列表或者调度器的sched.gFree 列表中获取 runtime.g 结构体;
- 调用 runtime.malg 函数生成一个新的 runtime.g 函数并将当前结构体追加到全局的 Goroutine 列表 allgs 中。
- runtime.gfget 中包含两部分逻辑,它会根据处理器中 gFree 列表中 Goroutine 的数量做出不同的决策:
- 当处理器的 Goroutine 列表为空时,会将调度器持有的空闲 Goroutine 转移到当前处理器上,直到 gFree 列表中的 Goroutine 数量达到 32;
- 当处理器的 Goroutine 数量充足时,会从列表头部返回一个新的 Goroutine;
- runtime.newproc1 会从处理器或者调度器的缓存中获取新的结构体,也可以调用 runtime.malg 函数创建新的结构体。
运行队列
runtime.runqput 函数会将新创建的 Goroutine 运行队列上,这既可能是全局的运行队列,也可能是处理器本地的运行队列:
- 当 next 为 true 时,将 Goroutine 设置到处理器的 runnext 上作为下一个处理器执行的任务;
- 当 next 为 false 并且本地运行队列还有剩余空间时,将 Goroutine 加入处理器持有的本地运行队列;
- 当处理器的本地运行队列已经没有剩余空间时就会把本地队列中的
一部分 Goroutine 和待加入的 Goroutine 通过 runqputslow 添加到调度器持有的全局运行队列上; - Go 语言中有两个运行队列,其中一个是处理器本地的运行队列,另一个是调度器持有的全局运行队列,只有在本地运行队列没有剩余空间时才会使用全局队列
调度循环
调度器启动之后,Go 语言运行时会调用 runtime.mstart 以及 runtime.mstart1,前者会初始化 g0 的 stackguard0 和 stackguard1 字段,后者会初始化线程并调用 runtime.schedule 进入调度循环:
- 为了保证公平,当全局运行队列中有待执行的 Goroutine 时,通过 schedtick 保证有一定几率会从全局的运行队列中查找对应的 Goroutine;
- 从处理器本地的运行队列中查找待执行的 Goroutine;
- 如果前两种方法都没有找到 Goroutine,就会通过 runtime.findrunnable 进行阻塞地查找 Goroutine;
触发调度
运行时还会在线程启动 runtime.mstart 和 Goroutine 执行结束 runtime.goexit0 触发调度。我们在这里会重点介绍运行时触发调度的几个路径:
- 主动挂起 — runtime.gopark -> runtime.park_m
- 系统调用 — runtime.exitsyscall -> runtime.exitsyscall0
- 协作式调度 — runtime.Gosched -> runtime.gosched_m -> runtime.goschedImpl
- 系统监控 — runtime.sysmon -> runtime.retake -> runtime.preemptone
线程管理
Go 语言的运行时会通过调度器改变线程的所有权,它也提供了 runtime.LockOSThread 和 runtime.UnlockOSThread 让我们有能力绑定 Goroutine 和线程完成一些比较特殊的操作。Goroutine 应该在调用操作系统服务或者依赖线程状态的非 Go 语言库时调用 runtime.LockOSThread 函数11,例如:C 语言图形库等。
- runtime.dolockOSThread 会分别设置线程的 lockedg 字段和 Goroutine 的 lockedm 字段,这两行代码会绑定线程和 Goroutine。
- 当 Goroutine 完成了特定的操作之后,就会调用以下函数 runtime.UnlockOSThread 分离 Goroutine 和线程:
有疑问加站长微信联系(非本文作者)