美文网首页
为什么Go1.1从G-M模型转变成G-M-P模型?

为什么Go1.1从G-M模型转变成G-M-P模型?

作者: one_zheng | 来源:发表于2019-09-16 14:13 被阅读0次

    翻译至:[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,特别是刚刚创建GM

      1. Per-M 内存缓存(M.mcache)。内存缓存与其他缓存(堆栈分配)都与所有M相关联,而其实它们只需要与运行Go代码的M相关联(在syscall内部阻塞的M其实并不需要mcache)。 运行Go代码的M与所有M的比率高达1:100。这导致过多的资源消耗(每个MCache最多可以到2M)和槽糕的数据局部性。
      1. 过于积极的线程阻塞/解除阻塞。在系统调度时,工作线程经常被阻塞和解除阻塞。这增加了很多开销。

    设计

    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)

    调度

      当创建新的GG变为可运行时,它被塞到当前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状态聚合的检查。

    系统线程锁

      此功能不是性能关键。

      1. 锁定G变为不可运行(Gwaiting)。 M立即将P返回到空闲列表,唤醒另一个M并阻塞。
      1. 锁定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。
      1. P-to-M的更好的局部性。 尝试在上次运行的同一个M上执行P.
    • 6.限制M创建。 调度程序可以很容易地强制每秒创建数千M,直到OS拒绝创建更多线程。 必须立即创建M,直到k * GOMAXPROCS,之后可以通过计时器添加新的M.
    其他
    • 由于这项工作,GOMAXPROCS不会消失。

    相关文章

      网友评论

          本文标题:为什么Go1.1从G-M模型转变成G-M-P模型?

          本文链接:https://www.haomeiwen.com/subject/uzjxuctx.html