理解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

<think>嗯,用户问的是Golang的Goroutine调度器为什么被称为分层调度器,还有分层调度机制的原理。首先,我得回忆一下Golang调度器的发展历程,因为分层调度这个概念可能和它的演进有关。记得早期Go使用的是GM模型,也就是一个全局队列,所有Goroutine都由这个队列分配,这可能会有锁竞争的问题,导致扩展性不好。后来改进成GMP模型,引入了P(Processor),这样每个P都有自己的本地队列,减少了全局锁的争用。这里的P可能就是分层的一部分,因为调度分成了全局层和本地层。分层调度应该是指调度器分为不同层次的结构,比如全局和本地。用户提到的分层调度机制,可能是指调度器在多个层次上分配和管理Goroutine,比如全局队列、P的本地队列,还有偷取机制。这样的话,调度任务先在本地处理,不够的时候再从全局或其他P偷取,这样层次分明,效率更高。另外,用户提供的引用里提到GMP模型和调度机制,比如引用1说GMP模型不断演进,引用5提到STW后恢复Goroutines。这可能涉及到调度器的层次结构如何在不同阶段处理任务,比如在垃圾回收时暂停和恢复,这也属于调度的一部分。需要解释清楚分层指的是哪几层,每层的作用,比如全局层负责平衡,本地层负责快速执行,偷取层处理负载均衡。还要说明为什么这种分层设计能提高效率,比如减少锁竞争、利用多核优势等。可能还要对比传统的线程调度,说明Golang的分层如何更高效。 用户可能还想知道分层带来的好处,比如扩展性、低延迟,这些都需要在回答中覆盖。另外,引用中提到的Kubernetes控制器使用Goroutine处理事件,这也展示了分层调度在实际中的应用,比如高效处理大量并发任务。 要确保回答结构清晰,分点解释分层结构,然后说明原理,最后总结优势。可能还需要相关的问题,比如和传统线程调度的区别,GMP模型细节,或者调度中的工作窃取机制,帮助用户深入理解。</think>### Golang Goroutine 调度器的分层调度机制原理 Golang 的 Goroutine 调度器被称为**分层调度器**,核心在于其通过多级调度结构实现高效的任务分配与执行。以下从分层机制和原理两方面展开: --- #### 一、分层调度机制的结构 Golang调度器模型(GMP)分为三层: 1. **全局层(Global Layer)** - 负责管理全局队列(Global Run Queue)和空闲的处理器(P)。 - 当本地队列(Local Run Queue)不足时,从全局队列中获取 Goroutine(G)进行调度。 - 维护全局的负载均衡,例如在多个处理器(P)之间分配任务。 2. **本地层(Local Layer)** - 每个处理器(P)绑定一个本地队列,用于存储待执行的 Goroutine。 - 每个 P 直接调度其本地队列中的 G,避免全局锁竞争,提高并发效率[^1]。 - 若本地队列为空,则触发**工作窃取(Work Stealing)**,从其他 P 的本地队列或全局队列中偷取 G。 3. **系统层(System Layer)** - 由操作系统线程(M)直接执行 Goroutine。 - M 与 P 绑定,当 G 发生系统调用阻塞时,M 会释放 P 以让其他 M 继续执行其他 G,避免资源浪费[^5]。 --- #### 二、分层调度的核心原理 1. **减少锁竞争** - 本地队列的设计避免了全局队列的锁竞争,每个 P 独立处理任务,适合多核 CPU 的并行场景[^1]。 - 例如,当多个 Goroutine 被创建时,优先分配到各自的本地队列,而非全局队列。 2. **动态负载均衡** - **工作窃取算法**:当某个 P 的本地队列为空时,会从其他 P 的队列中窃取 G,确保 CPU 资源充分利用[^1]。 - **全局队列兜底**:当本地队列和窃取均无法获取任务时,从全局队列拉取 G。 3. **非对称协作** - 调度器与操作系统线程(M)解耦,通过 P 的抽象层管理资源。例如,M 的数量可动态调整,而 P 的数量通常等于 CPU 核数[^5]。 - 当 G 阻塞时(如 I/O 操作),M 会与 P 解绑,P 可被其他 M 复用,避免线程资源浪费[^2]。 --- #### 三、分层调度的优势 1. **高并发性能**:通过本地队列和窃取机制,减少锁竞争,提升吞吐量。 2. **低延迟**:优先调度本地队列任务,缩短任务等待时间。 3. **资源高效利用**:解耦 M 和 P,避免线程频繁创建/销毁的开销[^5]。 --- ### 示例代码:Goroutine 调度示意 ```go func main() { // 启动多个 Goroutine,由调度器自动分配到不同 P 的本地队列 for i := 0; i < 1000; i++ { go func(id int) { fmt.Println("Goroutine", id) }(i) } time.Sleep(time.Second) } ``` ---
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值