Golang学习笔记-调度器学习

Golang的调度器

  1. 谈到Golang的调度器,绕不开的是操作系统,进程和线程这些概念。多个线程是可以属于同一个进程的并共享内存空间,因为多线程
    不需要创建新的虚拟空间,所以不需要内存管理单元处理的上下文的切换,线程之间的通信也是基于共享内存进行的,同重量级的进程相比
    线程显得比较轻量

  2. 虽然线程比较轻量,但是线程每一次的切换需要耗时1us左右的时间,但是Golang调度器对于goroutine的切换只要在0.2us
    左右

  3. Go 语言的调度器通过使用与 CPU 数量相等的线程减少线程频繁切换的内存开销,同时在每一个线程上执行额外开销更低的 Goroutine 来降低操作系统和硬件的负载。

go语言中文文档:www.topgoer.com

转自:https://www.jianshu.com/p/9e1279c3d40e

调度器种类

  1. 单线程调度器:遵循如下调度过程

  2. 多线程调度器

  3. 任务窃取调度器

  4. 抢占式调度器

数据结构

G

  1. 表示Goroutine 是一个等待执行的任务

  2. 它只存在于Go语言的运行时,它是Go语言在用户态提供的线程,作为一种粒度更细的资源调度单元,如果使用得当能够在
    高并发的场景下更加高效的利用机器CPU.

  3. goroutine在运行的时候会使用私有结构体runtine.g表示,下面对具体的字段进行解释

    1. sched — 存储 Goroutine 的调度相关的数据;

    2. pc — 程序计数器(Program Counter);

    3. g — 持有 runtime.gobuf 的 Goroutine

    4. ret — 系统调用的返回值

    5. stack 字段描述了当前 Goroutine 的栈内存范围 [stack.lo, stack.hi)

    6. stackguard0 可以用于调度器抢占式调度

    7. m — 当前 Goroutine 占用的线程,可能为空

    8. atomicstatus — Goroutine 的状态;

    9. sched — 存储 Goroutine 的调度相关的数据;

  4. goroutine的状态:主要有三种状态:等待中,可运行,运行中

    1. 等待中:Goroutine 正在等待某些条件满足,例如:系统调用结束等,包括 _Gwaiting、_Gsyscall 和 _Gpreempted 几个状态

    2. 可运行:Goroutine 已经准备就绪,可以在线程运行,如果当前程序中有非常多的 Goroutine,每个 Goroutine 就可能会等待更多的时间,即 _Grunnable;

    3. 运行中:Goroutine 正在某个线程上运行,即 _Grunning;

M

Go 语言并发模型中的 M 是操作系统线程。调度器最多可以创建 10000 个线程,但是其中大多数的线程都不会执行用户代码(可能陷入系统调用),最多只会有 GOMAXPROCS 个活跃线程能够正常运行。
在默认情况下,运行时会将 GOMAXPROCS 设置成当前机器的核数,我们也可以使用 runtime.GOMAXPROCS 来改变程序中最大的线程数。
在默认情况下,一个四核机器上会创建四个活跃的操作系统线程,每一个线程都对应一个运行时中的 runtime.m 结构体。
在大多数情况下,我们都会使用 Go 的默认设置,也就是线程数等于 CPU 个数,在这种情况下不会触发操作系统的线程调度和上下文切换,所有的调度都会发生在用户态,由 Go 语言调度器触发,能够减少非常多的额外开销。

  1. g0 是持有调度栈的 Goroutine,curg 是在当前线程上运行的用户 Goroutine,这也是操作系统线程唯一关心的两个 Goroutine

    1. g0 是一个运行时中比较特殊的 Goroutine,它会深度参与运行时的调度过程,包括 Goroutine 的创建、大内存分配和 CGO 函数的执行

P

调度器中的处理器 P 是线程和 Goroutine 的中间层,它能提供线程需要的上下文环境,也会负责调度线程上的等待队列,通过处理器 P 的调度,每一个内核线程都能够执行多个 Goroutine,它能在 Goroutine 进行一些 I/O 操作时及时切换,提高线程的利用率。
因为调度器在启动时就会创建 GOMAXPROCS 个处理器,所以 Go 语言程序的处理器数量一定会等于 GOMAXPROCS,这些处理器会绑定到不同的内核线程上并利用线程的计算资源运行 Goroutine。

调度器启动

  1. 调度器通过 runtime.schedinit 函数初始化调度器:

  2. 在调度器初始函数执行的过程中会将 maxmcount 设置成 10000,这也就是一个 Go 语言程序能够创建的最大线程数,虽然最多可以创建 10000 个线程,但是可以同时运行的线程还是由 GOMAXPROCS 变量控制。

  3. 从环境变量 GOMAXPROCS 获取了程序能够同时运行的最大处理器数之后就会调用 runtime.procresize 更新程序中处理器的数量,在这时整个程序不会执行任何用户 Goroutine,调度器也会进入锁定状态,runtime.procresize 的执行过程如下:

    1. 如果全局变量 allp 切片中的处理器数量少于期望数量,就会对切片进行扩容;

    2. 使用 new 创建新的处理器结构体并调用 runtime.p.init 方法初始化刚刚扩容的处理器;

    3. 通过指针将线程m0同处理器allp[0]绑定到提起

    4. 调用runtime.p.destroy 方法释放不再使用的处理器结构;

    5. 通过截断改变全局变量 allp 的长度保证与期望处理器数量相等;

    6. 将除 allp[0] 之外的处理器 P 全部设置成 _Pidle 并加入到全局的空闲队列中;

    7. 调用 runtime.procresize 就是调度器启动的最后一步,在这一步过后调度器会完成相应数量处理器的启动,等待用户创建运行新的 Goroutine 并为 Goroutine 调度处理器资源。

创建Goroutine

想要启动一个新的goroutine来执行任务,我们需要将Go语言中的go关键字,这个关键字会在编译期间通过下面方法cmd/compile/internal/gc.state.stmt 和 cmd/compile/internal/gc.state.call 两个方法将该关键字转换成 runtime.newproc 函数调用:

  1. 编译器会将所有的go关键字转换为runtime.newproc 函数,该函数会接受大小和表示函数的指针funcval。在这个函数中我们还会
    获取goroutine以及调用方的程序计数器,然后调用 runtime.newproc1 函数。runtime.newproc1 会根据传入参数初始化一个 g 结构体,我们可以将该函数分成以下几个部分介绍它的实现:

    1. 获取或者创建新的Groutine结构体

    2. 将传入的参数移植到Goroutine的栈上

    3. 更新Goroutine的调度相关性

    4. 将Goroutine加入处理器队列

初始化结构体

运行队列

runtime.runqput 函数会将新创建的 Goroutine 运行队列上,这既可能是全局的运行队列,也可能是处理器本地的运行队列:

  1. 当 next 为 true 时,将 Goroutine 设置到处理器的 runnext 上作为下一个处理器执行的任务;

  2. 当 next 为 false 并且本地运行队列还有剩余空间时,将 Goroutine 加入处理器持有的本地运行队列;

  3. 当处理器的本地运行队列已经没有剩余空间时就会把本地队列中的
    一部分 Goroutine 和待加入的 Goroutine 通过 runqputslow 添加到调度器持有的全局运行队列上;

  4. Go 语言中有两个运行队列,其中一个是处理器本地的运行队列,另一个是调度器持有的全局运行队列,只有在本地运行队列没有剩余空间时才会使用全局队列

调度循环

调度器启动之后,Go 语言运行时会调用 runtime.mstart 以及 runtime.mstart1,前者会初始化 g0 的 stackguard0 和 stackguard1 字段,后者会初始化线程并调用 runtime.schedule 进入调度循环:

  1. 为了保证公平,当全局运行队列中有待执行的 Goroutine 时,通过 schedtick 保证有一定几率会从全局的运行队列中查找对应的 Goroutine;

  2. 从处理器本地的运行队列中查找待执行的 Goroutine;

  3. 如果前两种方法都没有找到 Goroutine,就会通过 runtime.findrunnable 进行阻塞地查找 Goroutine;

触发调度

运行时还会在线程启动 runtime.mstart 和 Goroutine 执行结束 runtime.goexit0 触发调度。我们在这里会重点介绍运行时触发调度的几个路径:

线程管理

Go 语言的运行时会通过调度器改变线程的所有权,它也提供了 runtime.LockOSThread 和 runtime.UnlockOSThread 让我们有能力绑定 Goroutine 和线程完成一些比较特殊的操作。Goroutine 应该在调用操作系统服务或者依赖线程状态的非 Go 语言库时调用 runtime.LockOSThread 函数11,例如:C 语言图形库等。

  1. runtime.dolockOSThread 会分别设置线程的 lockedg 字段和 Goroutine 的 lockedm 字段,这两行代码会绑定线程和 Goroutine。

  2. 当 Goroutine 完成了特定的操作之后,就会调用以下函数 runtime.UnlockOSThread 分离 Goroutine 和线程:


-- --
  • 投诉或建议
评论