原文 https://golang.org/s/go11sched 在 go 的 runtime 包下,阅读最重要的关于流程调度的文件 proc.go 时。贴了这个文档地址。

机翻一下:

可扩展的 Go Scheduler 设计文档

德米特里·维尤科夫

dvyukov@google.com

2012年5月2日

本文档假定您对 Go 语言和当前的 goroutine 调度器实现有一定的先验知识。

当前调度程序的问题
当前的 goroutine 调度程序限制了用 Go 编写的并发程序的可伸缩性,特别是高吞吐量服务器和并行计算程序。Vtocc 服务器在 8 核机箱上的最大 CPU 为 70%,而配置文件显示 14% 用于 runtime.futex()。通常,调度程序可能会禁止用户在性能至关重要的情况下使用惯用的细粒度并发。

当前实现有什么问题:

1.单个全局互斥锁(Sched.Lock)和中心化状态。互斥锁保护所有与 goroutine 相关的操作(创建、完成、重新调度等)。

  1. Goroutine (G) 切换 (G.nextg)。工作线程 (M) 经常在彼此之间传递可运行的 goroutine,这可能会导致延迟增加和额外的开销。每个 M 都必须能够执行任何可运行的 G,尤其是刚刚创建 G 的 M。

  2. 每 M 内存缓存 (M.mcache)。内存缓存和其他缓存(堆栈分配)与所有 M 相关联,而它们只需要与 M 正在运行的 Go 代码相关联(在 syscall 中阻塞的 M 不需要 mcache)。

M 运行的 Go 代码和所有 M 之间的比率可以高达 1:100。这导致过度的资源消耗(每个 MCache 最多可以吸收 2M)和糟糕的数据局部性。

  1. 积极的线程阻塞/解阻。在存在系统调用的情况下,工作线程经常被阻止和取消阻止。这增加了很多开销。

设计
处理器
总体思路是将 P(处理器)的概念引入运行时,并在处理器之上实现工作窃取调度程序。

M 表示操作系统线程(就像现在一样)。P 表示执行 Go 代码所需的资源。当 M 执行 Go 代码时,它有一个关联的 P。当 M 处于空闲状态或处于系统调用状态时,它不需要 P。

确切地说,有 GOMAXPROCS P。所有 P 都组织成一个数组,这是工作窃取的要求。GOMAXPROCS 更改涉及停止/启动世界以调整 P 数组的大小。

sched 中的一些变量被去中心化并移动到 P。M 中的一些变量被移动到 P(与 Go 代码的主动执行相关的变量)。

结构体 P

{

锁;

G *gfree;freelist,从 Sched 移出

G *ghead;可运行,从 Sched 移出

G *尾巴;

MCache *mcache;从 M 移出

FixAlloc *stackalloc;从 M 移出

uint64 ncgocall;

GCStats gcstats;

};

P *allp;[GOMAXPROCS]

还有一个空闲 P 的无锁列表:

P *空闲;无锁列表

当 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 进入 syscall 时,它必须确保有另一个 M 来执行 Go 代码。

有两种选择,我们可以立即阻止和解除阻止 M,或者使用一些旋转。这是性能和燃烧不必要的 CPU 周期之间的固有冲突。这个想法是使用旋转并烧毁 CPU 周期。

但是,它不应影响使用 GOMAXPROCS=1 运行的程序(命令行实用程序、appengine 等)。

旋转是两个层次的:(1) 一个空闲的 M 和一个相关的 P 旋转寻找新的 G,(2) 一个没有关联 P 的 M 等待可用的 P。最多有 GOMAXPROCS 旋转 M((1) 和 (2))。类型 (1) 的空闲 M 不会阻塞,而类型 (2) 的空闲 M 不会阻塞。

当生成新的 G 时,或者 M 进入系统调用,或者 M 从空闲过渡到忙碌时,它确保至少有 1 个旋转的 M(或所有 P 都忙)。

这确保了没有可运行的 G 可以以其他方式运行;并同时避免过多的 M 阻断/解阻。

旋转主要是被动的(屈服于操作系统,sched_yield()),但可能包括一点点主动旋转(循环烧毁 CPU)(需要调查和调整)。

终止/死锁检测
在分布式系统中,终止/死锁检测更成问题。一般的想法是仅当所有 P 都处于空闲状态时才进行检查(空闲 P 的全局原子计数器),这允许执行涉及每个 P 状态聚合的更昂贵的检查。

暂无细节。

LockOSThread
此功能对性能不重要。

  1. 锁定的 G 变为不可运行 (Gwaiting)。M 立即将 P 返回到空闲列表,唤醒另一个 M 并阻止。

  2. 锁定的 G 变得可运行(并到达 runq 的头部)。当前 M 将自己的 P 和锁定的 G 交给与锁定的 G 关联的 M,并解除封锁。当前 M 变为空闲状态。

怠速 G
此功能对性能不重要。

有一个(或单个?)空闲 G 的全局队列。查找工作的 M 在多次窃取尝试失败后检查队列。

实施计划
目标是将整个事情拆分为可以独立审查和提交的最小部分。

  1. 引入 P 结构体(暂时为空);实现 allp/idlep 容器(idlep 对初学者有互斥锁保护);将 P 与运行 Go 代码的 M 相关联。全局互斥锁和原子状态仍保留。

  2. 将 G freelist 移动到 P。

  3. 将 mcache 移动到 P。

  4. 将 stackalloc 移动到 P。

  5. 将 ncgocall/gcstats 移至 P。

6.分散运行队列,实现工作窃取。消除 G 交接。仍在全局互斥锁下。

  1. 移除全局互斥锁,实现分布式终止检测,LockOSThread。

  2. 实现旋转而不是提示阻止/解除阻止。

该计划可能会奏效,还有很多未探索的细节。

潜在的进一步改进

  1. 尝试后进先出调度,这将改善本地化。但是,它仍然必须提供一定程度的公平性,并优雅地处理生成 goroutine。

  2. 在 goroutine 首次运行之前,不要分配 G 和堆栈。对于新创建的 goroutine,我们只需要 callerpc、fn、narg、nret 和 args,即大约 6 个单词。这将允许创建大量从运行到完成的 goroutine,同时显着降低内存开销。

  3. 更好的 G-to-P 位置。尝试将未阻止的 G 排队到上次运行它的 P 中。

  4. 更好的P-to-M位置。尝试在上次运行的同一 M 上执行 P。

  5. M 创建的节流。调度程序可以很容易地强制每秒创建数千个 M,直到操作系统拒绝创建更多线程。必须立即创建 m,直到 k*GOMAXPROCS,之后可以通过计时器添加新的 M。

随机笔记

  • GOMAXPROCS 不会因为这项工作而消失。