Golang Scheduler 调度器

发布于 2022-01-18 12:56:03 字数 4429 浏览 1218 评论 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

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

文章
评论
84963 人气
更多

推荐作者

微信用户

文章 0 评论 0

小情绪

文章 0 评论 0

ゞ记忆︶ㄣ

文章 0 评论 0

笨死的猪

文章 0 评论 0

彭明超

文章 0 评论 0

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