翻译至:[Scalable Go Scheduler Design Doc]--DmitryVyukov (https://docs.google.com/document/d/1TTj4T2JO42uD5ID9e89oa0sLKhJYD0Y_kqxDv3I3XMw/edit#heading=h.mmq8lm48qfcw)
当前调度器的问题
当前的goroutine调度器限制了用go编写编发程序的可伸缩性,特别是高吞吐量服务和并行计算程序。Vtocc (https://github.com/vitessio/vitess]服务在8核机子上的最大CPU消耗为70%,profile显示在runtime.futex()
函数花费了14%。通常,调度器会禁止用户使用惯用的细颗粒度的并发,这对性能至关重要。
目前的实现存在以下问题:
1.单个全局互斥锁(
Schd.Lock
)和集中的状态。此锁保护所有与goroutine有关的操作(创建,完成,重新调度等)2.
Goroutine(G)
间的交替 (G.nextg
)。工作线程(M
)频繁地切换可运行的goroutine
,这可能导致延迟增加和额外的开销。每个M
必须能够执行任务可运行的G
,特别是刚刚创建G
的M
。- Per-M 内存缓存(
M.mcache
)。内存缓存与其他缓存(堆栈分配)都与所有M
相关联,而其实它们只需要与运行Go代码的M
相关联(在syscall内部阻塞的M其实并不需要mcache)。 运行Go代码的M与所有M的比率高达1:100。这导致过多的资源消耗(每个MCache
最多可以到2M)和槽糕的数据局部性。
- Per-M 内存缓存(
- 过于积极的线程阻塞/解除阻塞。在系统调度时,工作线程经常被阻塞和解除阻塞。这增加了很多开销。
设计
Processors
普遍的想法是将P(Processors处理器)
的概念引入运行时,并在处理器智之上实现work-stealing scheduler(工作窃取调度)
http://supertech.csail.mit.edu/papers/steal.pdf程序
M
表示OS线程。P
表示执行Go代码所需的资源。当M
执行Go代码时,它有一个关联的P
。
当M
空闲或在系统调用时,它需要获取P
。
我们拥有与GOMAXPROCS
相同数量的P
。所有的P
都被组织成一个数组,这是为了实现work-stealing工作窃取
的要求。GOMAXPROCS 更改设计 stop/start the world 来重新调整P
的数组。来自sched
的一些变量被分散并移动到P
,来自M
的一些变量也被移动到P
(与Go代码的主动执行相关的变量)
struct P
{
Lock;
G *gfree; // freelist, moved from sched
G *ghead; // runnable, moved from sched
G *gtail;
MCache *mcache; // moved from M
FixAlloc *stackalloc; // moved from M
uint64 ncgocall;
GCStats gcstats;
// etc
...
};
P *allp; // [GOMAXPROCS]
还有一个无锁的空闲P列表:
P *idlep; // lock-free list
当M
开始执行Go代码时,必须先从列表中弹出P
。当M
结结束执行Go代码时,它将P
塞回列表中。因此,当M
执行Go代码时,它必须具有关联的P
。这种机制渠道了sched.atomic(mcpu/mcpumax)
调度
当创建新的G
或G
变为可运行时,它被塞到当前P
的可运行goroutine
列表。当P
完成执行G
时,它首先尝试从自己的可运行goroutine
列表中弹出G
;如果列表为空,则P
选择一个随机受害者(另一个P
)并试图从中窃取一半可运行的goroutine
。
Syscalls/M 停止和非停止
当M
创建一个新的G
时,它必须确保有另一个M
来执行G
(如果不是所有的M都处于忙碌)。类似的,当M
进入系统调用时,它必须确保有另一个M
来执行Go代码。
有两个选项,我们可以迅速阻止和解锁M
,或采用一些旋转。这是性能跟CPU不必要消耗之间的固有冲突。我们的想法是使用旋转并消耗CPU
循环周期。但是,它不应该影响使用GOMAXPROCS = 1
运行的程序(命令行实用程序,appengine等)。
旋转分两个级别:(1)一个关联P
的空闲M
一直旋转寻找新的G
; (2)一个关联P
的w/o M
旋转等待可用的P
;最多有GOMAXPROCS
数量的旋转M
(包括(1)和(2))。当存在类型(2)的空闲M
时,类型(1)的空闲M
不会阻塞。
当产生新的G,或者M进入系统调用,或者M从空闲转为忙时,它确保至少有1个旋转M(或者所有P都忙)。这确保了没有可以运行的可运行的G;并避免同时过多的M阻塞/解除阻塞。
旋转主要是被动的(屈服于OS,sched_yield()),但可能包括一点点主旋(循环切换CPU)(需要调查和调整)。
终止/死锁检测
终止/死锁检测在分布式系统中更存在问题。一般的想法是仅在所有P
都空闲时才进行检查(空闲P
的全局的原子计数器),这允做一些更昂贵代价的检查比如涉及 prep状态聚合的检查。
系统线程锁
此功能不是性能关键。
- 锁定G变为不可运行(Gwaiting)。 M立即将P返回到空闲列表,唤醒另一个M并阻塞。
- 锁定G变为可运行(并到达runq的头部)。 当前M移出自己的P并将G锁定到与锁定的G相关联的M,并解锁它。 当前的M变得空闲。
实施
目标是将整个事物分成可以独立审查和提交的最小部分。
- 1.介绍P结构; 实现allp / idlep容器(idlep为启动器提供互斥保护); 将P与M运行Go代码相关联。 全局互斥和原子状态仍然存在。
- 2.将G freelist移动到P.
- 3.将mcache移动到P.
- 4.将stackalloc移动到P.
- 5.将ncgocall / gcstats移动到P.
- 6.分散运行队列,实现工作窃取。 消除G的不可接触。 这部分操作仍在全局互斥下。
- 7.删除全局互斥锁,实现分布式终止检测,LockOSThread。
- 8.实现旋转而不是提示阻止/解除阻塞。
该计划可能会失效,有很多未探索的细节。
潜在的进一步改进
- 1.尝试LIFO调度,局部上有所提升。 但是,它仍然必须提供一定程度的公平性,并优雅地处理屈服的goroutines。
- 2.在goroutine首次运行之前,不要分配G和堆栈。 对于新创建的goroutine,我们只需要callerpc,fn,narg,nret和args,即大约6个单词。 这将允许创建大量运行到完成的goroutine,显着降低内存开销。
- 4.更好的G-to-P局部性。 尝试将未阻塞的G排入上一次运行的P。
- P-to-M的更好的局部性。 尝试在上次运行的同一个M上执行P.
- 6.限制M创建。 调度程序可以很容易地强制每秒创建数千M,直到OS拒绝创建更多线程。 必须立即创建M,直到k * GOMAXPROCS,之后可以通过计时器添加新的M.
其他
- 由于这项工作,GOMAXPROCS不会消失。