Go 调度模型(二)
Posted by 付辉 on Saturday, March 30, 2019 共2193字
真的猛士,敢于直面惨淡的人生,敢于正视淋漓的鲜血。这是怎样的哀痛者和幸福者?然而造化又常常为庸人设计,以时间的流驶,来洗涤旧迹,仅使留下淡红的血色和微漠的悲哀。在这淡红的血色和微漠的悲哀中,又给人暂得偷生,维持着这似人非人的世界。我不知道这样的世界何时是一个尽头!
有一种感觉,Go
调度模型可能还得再来几篇博客,才能真正读出感觉来。现在还是一个门外汉。越是深入了解,越觉得知之甚少,后背发凉。
OS
调度结构
Go Runtime
实现了自己的调度策略,从OS
调度结构的演变来看,调度思想都是相似的。当然,我始终觉得设计背后的思想才是整个系统的核心。思想从无到有、从中心化到去中心化、从单任务到并行多任务,当然这肯定不是调度策略的专利,在很多场景都有这种思想的体现,比如分布式设计。
那为什么我还是没有一眼看出来呢,可能是阅历太少,思考的深度不够,积极接受现状,懒于了解历史吧。不过,越是去了解,就会发现不会的太多,思维发散的太广,难以为继。
single scheduler
存在一个全局的任务队列和一个全局的调度器,因为整个过程不需要加锁,所以单核吞吐量很高,但无法充分利用多核资源。
有点类似于:给一个数据表中的所有用户PUSH
消息,虽然我们有10台服务器,但我们只在其中一台服务上执行该任务。优点是设计开发简单,缺点是没有充分利用资源,效率不高。
multi scheduler with global queue
多个调度器共享一个全局的任务队列,该模型需要频繁的对任务队列进行加锁,并发性能存在明显的瓶颈。这同时让我想起了Go的并发问题
中介绍的例子,加锁保证了计算的正确性,但却牺牲了效率。当然,这不仅仅是调度系统会面临的问题,比如本地缓存BigCache也遇到了同样的问题。
还接着上面的例子说,当在多台服务器上都启动Task
执行任务时,为了避免同一个用户不被重复PUSH
多次,势必也面临着对单条记录加锁的问题。
multi scheduler with local queue
给每个调度器分配一个本地的任务队列,这样调度器就可以无锁的操作本地任务队列,显著减少锁竞争,提高多核下的调度效率。同时还要保证让各个调度器随时都有事情可做,所以也存在一些任务迁移或者任务窃取的方案。到了这里,我们就已经看到了Go Scheduler
的雏形。其实思维很简单,将全局的任务队列划分成多个小的任务队列,各个调度器处理自己的任务队列,跟Database Sharding异曲同工。
继续上面的例子,我们只需要给各个服务器分配用户表的小块数据,当Task
执行完分配的数据块后,再去请求新的数据块就可以了。
GPM
现在提到Go Scheduler
就会直接想到GPM
,但之前的设计里Scalable Go Scheduler Design Doc
其实并不存在P
。P
的引入直接将调度模型由multi scheduler with global queue
跨越到multi scheduler with local queue
。
- 每个
Goroutine
需要对应一个G
结构体,而G
保存了当前Goroutine
的运行堆栈和状态信息。Goroutine
通过G
中保存的信息可以执行或恢复执行。 P
是专门被引入用来优化原始Go
调度系统所抽象的逻辑对象,操作系统并不知道P
的存在。对M
而言,P
提供了其执行的相关环境、以及Goroutine
的任务队列等。M
是OS
线程的抽象,是物理存在的。M
只有和P
绑定之后,才可以执行G
代码。M
本身也不会保存G
的状态,在需要任务切换时,M
会将堆栈状态保存回G
中,任何M
都可以根据G
中的信息恢复执行。
M
阻塞
当M
准备执行Goroutine
时,首选需要关联一个P
,然后从P
的队列中取出一个G
来执行。如果G
中执行的代码使M
发生阻塞,比如唤起系统调用。那么M
将会一直阻塞,直到系统调用返回。此时全局空闲M
队列的另一个M
会被唤醒,同时,阻塞状态的M
会与P
解绑。这样做也是为了保证其他G
不会因为缺少M
而被阻塞执行。
但如果Goroutine
在channel
通讯过程中发生阻塞,M
并不会展示相似的阻塞行为。因为OS
并不了解channel
的执行机制,channel
是被Go Runtime
来处理的。如果一个Goroutine
在channel
通讯上发生了阻塞,那没有任何理由让运行它的M
也阻塞。这种情况下,G
的状态会被设置为等待,M
会继续执行别的Goroutine
。当G
重新变成可运行状态时,等待别的M
去执行。
P
的改进
原始Go
的调度并没有P
,仅有G
、M
以及Sched
。当时系统只存在一个全局的G
队列,通过Sched
锁来进行并发控制。存在的问题有:
- 调度的执行依赖全局的
Sched
锁,修改全局的M
队列和G
队列、或者其他全局的Sched
字段都需要加锁 M
的内存问题,执行的内存是跟M
相关联的。但即使M
并不执行G
代码,它也会申请2MB
的MCache
空间,而这些空间只有M
在执行G
时才需要。同时,一个阻塞中的M
也是不需要MCache
的。- 系统调用不够清晰,
M
在执行中会频繁阻塞和恢复,浪费CPU
时间 M
之间频繁的传递G
,而不是选择自己执行它,这增加了系统的额外负载。每个M
必须能够执行任何可运行的G
,特别是刚刚创建了G
的M
。
P
引入之后,从之前的M
和Sched
中抽取了部分字段,这样做带来了很多好处:
MCache
就被移动到了P
中,而系统最多存在GOMAXPROCES
个P
,解决了不必要的内存浪费问题G freelist
被移动到P
中,每个P
都有了一个可运行的本地G
队列。本地G
队列缓解了全局Sched
锁的问题。- 当一个
G
被M
创建,它被追加到对应P
的本地队列末尾,以保证每个G
都能被执行。
参考文章: