Golang Scheduler 调度器

发布于 2022-01-18 12:56:03 字数 4429 浏览 1208 评论 0

Go 的运行时(Runtime)管理着调度、垃圾回收以及 goroutine 的运行环境,本次主要介绍调度器(scheduler)。

为什么需要调度器?主要是为了方便高并发程序的编写。线程是 CPU 调度的实体,但线程切换还是有一定代价的。Goroutine 更加轻量,程序员只需要面对 Goroutine,由 scheduler 将 Goroutine 调度到线程上执行。

所谓 M:N 模型就是指,N 个 goroutine 在 M 个线程上执行。

goroutine 和线程的区别

内存占用

创建一个 goroutine 的栈内存消耗为 2 KB,实际运行过程中,如果栈空间不够用,会自动进行扩容。创建一个 thread 则需要消耗 1 MB 栈内存,而且还需要一个被称为 “a guard page” 的区域用于和其他 thread 的栈空间进行隔离。

对于一个用 Go 构建的 HTTP Server 而言,对到来的每个请求,创建一个 goroutine 用来处理是非常轻松的一件事。而如果用一个使用线程作为并发原语的语言构建的服务,例如 Java 来说,每个请求对应一个线程则太浪费资源了,很快就会出 OOM 错误(OutOfMermoryError)。

创建和销毀

Thread 创建和销毀都会有巨大的消耗,因为要和操作系统打交道,是内核级的,通常解决的办法就是线程池。而 goroutine 因为是由 Go runtime 负责管理的,创建和销毁的消耗非常小,是用户级。

切换

当 threads 切换时,需要保存各种寄存器,以便将来恢复:

16 general purpose registers, PC (Program Counter), SP (Stack Pointer), segment registers, 16 XMM registers, FP coprocessor state, 16 AVX registers, all MSRs etc.

而 goroutines 切换只需保存三个寄存器:Program Counter, Stack Pointer and BP。

一般而言,线程切换会消耗 1000-1500 纳秒,一个纳秒平均可以执行 12-18 条指令。所以由于线程切换,执行指令的条数会减少 12000-18000。

Goroutine 的切换约为 200 ns,相当于 2400-3600 条指令。

因此,goroutines 切换成本比 threads 要小得多。

调度器:M,P 和 G

调度器的底层实现主要有三个数据结构:m, p, g

  • g:一个 g 表示了一个 goroutine,主要包含了当前 goroutine 栈的一些字段
  • m:代表一个操作系统的线程,goroutine 需要调度到m上运行。m可以理解为“machine”
  • p:一个抽象的处理器,可以理解为Logical Processor,通常P的数量等于CPU核数(GOMAXPROCS)。m需要获得p才能运行g

早期版本的Golang是没有P的,调度是由G与M完成。 这样的问题在于每当创建、终止Goroutine或者需要调度时,需要一个全局的锁来保护调度的相关对象。 全局锁严重影响Goroutine的并发性能。

先看一张图理解 gmp 之间的交互关系:

每个 p 都维护了一个自己的LocalQueue,里面是一个g的队列,除此之外还有一个全局的GlobalQueue,存储全局可运行的goroutine,这些g还没有被分配到具体的p。当一个goroutine被创建,或者变为可执行状态(runnable)时,就会被放到LocalQueue或者GlobalQueue中。如果LocalQueue还有剩余空间就会放到LocalQueue中,否则就会把LocalQueue中的一部分goroutine和待加入的goroutine放入GlobalQueue中。

当一个 g 执行结束时,p 会将其从队列中取出,从队列中选择下一个可运行的 g 放到 m 上执行,选择的顺序如下:

  1. 有一定概率先从GlobalQueue中寻找(每61次找一次)
  2. 从LocalQueue中寻找
  3. 如果前面都没有找到,会通过runtime.fundrunnable()进行阻塞查找:
    1. 从LocalQueue、GlobalQueue中查找
    2. 从网络轮询器(network poller)中查找(之后会介绍)
    3. 如果没有找到,尝试从其他p的LocalQueue中进行工作偷窃(work stealing),会随机选择一个p,并“偷”过来这个p的LocalQueue中一半的g

系统调用

g 需要进行系统调用时,分两种情况,如果是阻塞的系统调用(syscall),那么运行当前gm就会从依附的p上摘除(detach),然后创建一个新的m来服务于这个p。当系统调用完成之后,这个 g 就会被重新放入之前 p 的 LocalQueue 等待调度,而之前 detach 的 m 则会休眠,加入到空闲线程中(不会销毁,以避免频繁的创建和销毁线程,损失性能)。

对于非阻塞的情况,当前 g 会被绑定到网络轮询器(network poller)上,等系统调用结束,当前 g 才会回到之前的 p 上等待调度。

抢占式调度

在 Go1.14 之前,是基于协作的抢占式调度。 runtime 在程序启动时,会自动创建一个系统线程,运行 sysmon() 函数,在整个程序生命周期中一直执行。sysmon()会调用retake()函数,retake()函数会遍历所有的P,如果一个P处于执行状态, 且已经连续执行了较长时间,就会设置它的抢占标志位,这将导致该P中正在执行的G进行下一次函数调用时,会通过调用dropg()将G与M解除绑定;再调用globrunqput()将G加入全局runnable队列中。最后调用schedule() 来用为当前P设置新的可执行的G。

因此,如果在 goroutine 内部没有一些 time.Sleep(),channel,函数调用之类的触发调度的抢占点,那么有可能该 goroutine 就会一直执行,如果是无限循环,也不会被调度走,就有可能会导致其他 goroutine 不能得到调度。Go 1.14 引入基于信号的抢占之后,这个问题得到了解决。

推荐阅读

如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。

扫码二维码加入Web技术交流群

发布评论

需要 登录 才能够评论, 你可以免费 注册 一个本站的账号。
列表为空,暂无数据

关于作者

JSmiles

生命进入颠沛而奔忙的本质状态,并将以不断告别和相遇的陈旧方式继续下去。

0 文章
0 评论
84960 人气
更多

推荐作者

lorenzathorton8

文章 0 评论 0

Zero

文章 0 评论 0

萧瑟寒风

文章 0 评论 0

mylayout

文章 0 评论 0

tkewei

文章 0 评论 0

17818769742

文章 0 评论 0

    我们使用 Cookies 和其他技术来定制您的体验包括您的登录状态等。通过阅读我们的 隐私政策 了解更多相关信息。 单击 接受 或继续使用网站,即表示您同意使用 Cookies 和您的相关数据。
    原文