理解golang调度器

golang调度器 设计过程
05/02/2012 Dmitry Vyukov dvyukov@google.com

学习go runtime 包的过程中看了一些调度器的资料,翻译一下加强记忆。本文写在 2012年,是谷歌内部的go语言设计文档,之前的go 还在 v1.0时代,使用的是GM模型,GC 的STW时间在百ms级别。本文也是 Processor这个概念引入的时刻。

现有的调度器存在的问题

现有的调度器缺乏在并行下的可伸缩性,特别是高吞吐和高并行计算需求的项目。Vtocc 在使用了一台8核计算机的70%的cpu,但是 profile 工具发现有14%都花费在 runtime.futex()上。通常,调度器会在对性能要求很高的情况下,阻碍用户使用更好的细粒度的并行方法。
现有的实现中出现的问题

  1. 全局锁和中心化的状态,用全局锁同步整个goroutine相关的操作(创建,完毕,重新调度等等)。
  2. goroutine(G) 传递(G.nextg)。 工作线程(M)之间频繁传递可运行的 goroutine 会增加系统的延迟和系统开支。每个 M 必须能够运行任何一个可运行的G,特别是创造了这个G的那个M。
  3. 每一个M的 cache(M.mcache)。内存缓存和其他的缓存(栈内存等)和所有的M 联系。其实他们只需要和正在运行Go代码的M联系就可以,因为处于系统调用中的M根本不需要mcache。而运行Go 代码的goroutine只有全部M的1%,这导致了大量的额外资源消耗(每一个mchache可能达到2M)和很差的数据局部性。
  4. 侵略性的线程阻塞和恢复,在面临系统调用的时候,工作线程会频繁的阻塞和恢复,这会增加许多的额外消耗。

设计思路

主要的思想是引入一个P(Processors)的概念,并引入了工作窃取。
M代表系统线程,P代表执行go代码所需要的资源。当M执行Go代码时,它有一个相关联的P。当M处于空闲态或者陷入系统调用,它就不需要P。
P的数量为GOMAXPROCS,所有的P被放在一个列表里,这是工作窃取所需要的。GOMAXPROCS的改变会引发 STW(stop the world, 暂停所有的工作线程)来改变P的列表长度。一些原本在 sched 结构中的变量被去中心化了,并移动到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

调度

当一个新的G诞生了或者一个存在的G 变为 runnable, 它会进入当前P的可运行队列中。当P 执行完毕一个G时,它尝试从本地G队列中取出一个G,如果本地队列为空,它会随机从另一个P哪里偷取一半的G。

系统调用M parking/unparking

当一个M 创建了一个新的G时,它必须保证有另一个M 来运行该G(如果不是所有的M 都在忙碌状态) 相同的,当M陷入到系统调用的时候,它也必须保证有另一个M来执行Go代码。

有两种方法,我们可以定时地 阻塞和恢复 M,或者引入某种Spinning机制。性能和额外的cpu消耗之间存在与生俱来的矛盾。我们的设计方向是使用Spinning(很消耗cpu周期)来确保性能。然而,这不会影响那些在GOMAXPROCS=1的程序(命令行程序等等)
Spinning 有两种级别

  1. 一个拥有P却闲置的M 寻找可用的G。
  2. 一个有G 的M 寻找可用的P。[todo] an M w/o an associated P spins waiting for available P’s

最多有 GOMAXPROCS个Spinning的M(包括1和2)
当存在第二种闲置的M时,第一种闲置的M不会阻塞
当一个新的G诞生时,或者M进入系统调用,或者M从空闲态转为忙碌状态,它保证了至少有个处于 spining 的M(或者所有的P 都在忙碌状态)这保证了没有可运行的G可以运行,同时也避免了不需要的 M blocking/unblocking

Spining大部分是被动的(yield to OS, shced_yield()),但是也有一些活跃的 Spining(loop循环烧CPU)(需要调查和调整)

死锁检测

在分布式系统中,死锁检测会更加难以实现。大体的思路是只在所有的P 都空闲的情况下才做这样的检测,(空闲p的全局原子计数),这样可以做一些昂贵的,需要汇集各个P信息的检查。

LockOSThread

该功能对性能不是至关紧要的

  1. 锁住的G 变为 unrunable , M 立刻将 P 放回到 空闲列表中,唤醒另一个M 并且进入阻塞
  2. 锁住的G 变为 runable 并且处于 runq的头部,当前的M 将自己的P连同 锁住的G 一起交给和这个G相关的M,unblock它(让它们去执行),自己变为空闲态。

空闲 G

该功能对性能不是至关紧要的
有一个全局的闲置G,当前M 在几次试图工作窃取不成功后会 查看这个列表

执行计划

目标是将所有的工作划分为小的部分,从而可以独立的查看和提交

  1. 引入 P 结构,实现全部/空闲 p容器(空闲 p 为新手做了锁保护);将 P 和一个运行GO 代码的M 联系起来。全局锁和原子态仍然保留

  2. 将 G的 freelist 移到 P

  3. 将 mchache 移到 P

  4. 将 stackalloc 移到 P

  5. 将 mcgocall/gcstates 移到 P

  6. 将 runqueue去中心化,实现工作窃取,消除 M之间的 G传递,仍然在全局锁下进行

  7. 去除全局锁,使用分布式的死锁检测, 锁系统线程

  8. 实现用 spinning 代替 频繁的 blocking/unblocking

未来的改善计划

  1. 尝试 LIFO 调度,这会改善局部性能,但它仍然必须体现一定程度公共性,并且优雅的处理让出 gorouine的情形
    2.直到 goroutine第一次运行前,不要分配 G的栈,对一个新创建的gorotine,我们只需要 callerpc, fn, narg, nret, args,差不多6个参数,这样可以在使用很小的内存的情况下创建出许多的具有完整生命周期的goroutine。
  2. G-P 之间更好的局部性,尝试让 G 回到它上一次运行的P 那里。
  3. P-M 之间更好的局部性,尝试在 P 的上一次执行的M上执行它。
  4. 减慢 M 创建的速度,当前的调度器很容易在运行中创建数以千计的 M,直到操作系统拒绝创建更多的 M。 M应该最多创建到 k * GOMAXPROCS 个, 之后隔一段时间才能产生新的G。

其他的随笔

GOMACPROCS 应该不会消失。
附带一张 go 现在调度模型图
在这里插入图片描述

原文链接 Scalable Go Scheduler Design Doc

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值