首页 > golang > Go语言编程GMP调度模型
2021
01-05

Go语言编程GMP调度模型

并发和并行

并发和并行的目的都是为了充分利用 CPU 的多核(多处理器)架构,但两者却有着本质的区别。
    并发:在同一时间段内,多条指令在 CPU 上执行。
    并行:在同一时刻内,多条指令在 CPU 上执行。
并发程序 并不要求 CPU 具备多核计算能力,只要求多个线程在同一个 Core 上进行 “分时轮询” 处理。可以在宏观上实现多线程同时执行的效果。并发程序的执行通常是不确定的,这种不确定性来源于资源之间的相关依赖和竞态条件,可能导致执行的线程间相互等待,使并发程序即使在多核环境上也无法做到真正并行执行而降级为串行执行。简而言之,并发程序通常是有状态的(非幂等性)。
并行程序 则要求 CPU 具备多核计算能力,在同一时刻内,多个线程分别在不同的 Core 上同时执行。并行程序的每个执行模块在逻辑上都是独立的,即线程执行时可以独立地完成任务,从而做到同一时刻多个指令能够同时执行。并行程序通常是无状态的(幂等性)。
综上可知,我们在 Golang 中讨论的并发,并非是单存的多线程并行问题(并行不需要交互),而是 多线程间如何调度以及如何交互的问题。

如何交互?CSP 通信模型

Golang 支持两种并发模式:
    一种是常规的 线程与锁并发模型:依赖于共享内存,类似于对底层硬件运行过程的形式化,程序的正确运行很大程度依赖于开发人员的能力和技巧,程序在出错时不易排查。
    另一种是 CSP(Communicating Sequential Processes,通信顺序进程)并发模型:是 Golang 倡导使用的并发模型。

CSP 最初在 1977 年 Tony Hoare 发表的论文中提出,它倡导使用通信的手段来共享内存。CSP 的两个核心概念:
并发实体:通常理解为执行线程,它们相互独立,且并发执行;
通道(Channel):并发实体之间使用通道发送信息。
可见,CSP 最大的特征就是并发实体之间没有共享的内存空间,并发实体之间的数据交换使用通道来完成。并发实体在通道中发送数据或接受数据都会导致并发实体的阻塞,直到通道中的数据被发送或接受完成。并发实体通过这种方式实现交互及同步。
CSP 类似于同步队列(会阻塞),关注的是消息传输的方式,发送和接收信息的并发实体可能不知道对方是谁,它们之间是互相解耦的。通道与并发实体也不是紧耦合的,通道可以独立地进行创建和释放,并在不同的并发实体中传递使用。
通道(Channel)的特性给并发编程带来了极大的灵活性,通道作为独立的对象,可以被任意的创建、释放、读取、放入数据,并在不同的并发实体中被使用。但是它也很容易导致死锁,如果一个并发实体在读取一个永远没有数据放入的通道或者把数据放入一个永远不会被读取的通道中,那么它会将被永远阻塞。

如何调度?GMP 调度模型

并发或并行编程必然会涉及到操作系统对线程的分配与调度。根据访问权限的不同,操作系统会把内存分为内核空间和用户空间:
    内核空间 的指令代码具备直接调度计算机底层资源的能力,比如 I/O 资源等;
    用户空间 的代码没有访问计算底层资源的能力,需要通过系统调用(System Call)等方式切换为内核态之后再完成对计算机底层资源的申请和调度。
线程作为操作系统能够调度的最小单位,也分为用户线程和内核线程:
    用户线程:由存放在用户空间的代码(也称用户态应用程序)创建、管理和销毁。用户线程的调度维持在一个进程的命名空间中,由应用程序的线程库完成,对 CPU 的竞争是以所属进程的维度参与的,同一进程下的所有用户线程只能分时复用分配给进程的 CPU 时间片,所以无法很好利用 CPU 多核运算的优势。好处是无需切换到内核态,资源消耗少且高效。
    内核线程:由操作系统(Linux kernel)管理和调度,能够直接操作计算机底层的资源,开发人员可以通过系统调用使用内核线程,内核现场能够利用 CPU 多核架构进行并行计算的优势。
用户线程是无法被操作系统感知的,用户线程所属的进程或者内核线程才能被操作系统直接调度,分配 CPU 的使用时间。对此衍生出了不同的线程模型,它们之间对 CPU 资源的使用程度也各有千秋。

用户级线程模型(多对一)


图片.png


用户级线程模型中基本是一个进程对应一个内核线程。进程内的多线程管理由用户代码完成,这使得线程的创建、切换和同步等工作显得异常轻量级和高效,但是这些复杂的逻辑需要在用户代码中实现。同时进程内的多线程无法很好利用 CPU 多核的优势,只能通过分时复用的方式轮换执行。当进程内的任意线程阻塞,比如线程 A 请求 I/O 操作被阻塞,很可能导致整个进程范围内的阻塞,因为此时进程对应的内核线程因为线程 A 的 I/O 阻塞而被剥夺 CPU 执行时间。

内核级线程模型(一对一)


图片.png


内核级线程模型中,进程中的每个线程都会对应一个内核线程。进程内每创建一个新的线程都会进行系统调用在内核创建一个新的内核线程与对应,线程的管理和调度由操作系统负责,这将导致每次线程切换(线程在 Core 上切换)上下文时都会从用户态切换到内核态,会有不小的资源消耗,同时创建线程的数量也会受制于操作系统内核创建可创建的内核线程数量。好处是多线程能够充分利用 CPU 的多核并行计算能力,因为每个线程可以独立被操作系统调度分配到 CPU 上执行指令,同时某个线程的阻塞并不会影响到进程内其他线程工作的执行。

两级线程模型(多对多)


图片.png


两级线程模型相当于用户级线程模式和内核级线程模型的结合,一个进程将会对应多个内核线程,由进程内的调度器决定进程内的线程如何与申请的内核线程对应。进程会预先申请一定数量的内核线程,然后将自身创建的线程与内核进程进行对应。线程的调用和管理由进程内的调度器进行,而内核线程的调度由操作系统负责。这种线程模型即能够有效降低线程创建和管理的资源消耗,也能够很好提供线程并行计算的能力,但是给开发人员带来较大的实现难度。

GMP 线程模型


图片.png


Golang 的 GMP 线程模型在两级线程模型的基础上进行一定程度的改进,使它能够更加灵活地进行线程之间的调度。它由三个主要模块构成:

    M(machine):执行者,一个 machine 对应一个内核线程,表示执行任务(go func)的所必需的上下文环境。M的数量默认限制是10000 , 超出会报错

    P(processor):队列,P 的数量通常与硬件的 Processer 数相同,可以通过 GOMAXPROCS 进行修改。

    G(goroutine):任务,一个 goroutine 表示一段 Golang 代码片段的封装,本质是一种轻量级的用户线程。我们每次调用 go func() 就是生成了一个 G。G 的数量一般没有限制 ,理论上受内存影响

M、P、G 三者组成了 Golang 的 M: N 调度模型:每个 M 都会与一个内核线程绑定,每个 P 又会与 M 进行一对一的绑定,而 P 和 G 的关系则是一对多。在运行过程中,P 的数量通常是固定的,M 的数量则会增长。M 和内核线程之间对应关系的不会变化,在 M 的生命周期内,它只会与一个内核线程绑定,而 M 和 P 以及 P 和 G 之间的关系则是动态可变的。


M必须拥有P才可以执行G中的代码,P含有一个包含多个G的队列,P可以调度G交由M执行。

队列轮转:P 会周期性的将G调度到M中执行,执行一段时间后,保存上下文,将G放到队列尾部,然后从队列中再取出一个G进行调度。除此之外,P还会周期性的查看全局队列是否有G等待调度到M中执行。   

系统调用:当G0即将进入系统调用时,M0将释放P,进而某个空闲的M1获取P,继续执行P队列中剩下的G。M1的来源有可能是M的缓存池,也可能是新建的。

当G0系统调用结束后,如果有空闲的P,则获取一个P,继续执行G0。如果没有,则将G0放入全局队列,等待被其他的P调度。然后M0将进入缓存池睡眠。

图片.png

Go Runtime Scheduler

Go Runtime Scheduler 是 Golang GMP 线程模型的调度器。在 Golang 实际的运行过程中,M 和 P 的组合作为 G 的有效运行环境,而多个可执行 G 将会顺序排成一个队列挂在某个 P 上面,等待调度和执行,如下图所示:


图片.png


M 和 P 共同构成了 G 基本的运行环境,此时 G0 中的代码片段处于正在运行的状态,而右边的 G 队列处于待执行状态。


图片.png


Golang 会不断地在 M 上循环查找可运行的 G 来执行相应的任务。

  1. 当我们执行 go func() 时,实际上就是创建一个全新的 Goroutine。

  2. 新创建的 G 会被放入 P 的本地队列(Local Queue)或全局队列(Global Queue)中,准备下一步的动作。

  3. 唤醒或创建 M 以便执行 G。

  4. 不断地进行事件(Event)循环。

  5. 寻找在可用状态下的 G 执行其任务(func)。

  6. 清除后,重新进入事件循环。

在很多时候 M 的数量可能会比 P 要多,如果没有足够的 M 来和 P 组合以为 G 提供运行环境,Golang 就会创建出新的 M。在单个 Golang 进程中,P 的最大数量决定了并发的规模,且 P 的最大数量是由程序决定的。可以通过修改环境变量 GOMAXPROCS 和调用函数 runtime#GOMAXPROCS 来设定 P 的最大值。

Golang 会维护两种类型队列,全局队列和 P 的本地队列,在功能上来讲两者都是用于存放正在等待运行的 G,区别在于:本地队列有数量限制,不允许超过 256 个。新建 G 时,会优先选择 P 的本地队列,如果本地队列满了,则将 P 的本地队列的一半的 G 移动到全局队列,这其实可以理解为调度资源的共享和再平衡。


图片.png


在上图中,还有 steal 的行为:我们知道当新建的 G 或者 G 变成可运行状态时,它会被推送加入到当前 P 的本地队列中。当 P 执行 G 完毕后,P 会将 G 从本地队列中弹出,同时会检查当前本地队列是否为空,如果为空,则会随机的从其他 P 的本地队列中尝试窃取一半可运行的 G 到自己的名下。

图片.png

在上述例子中,P2 在本地队列中找不到可以运行的 G,它会执行 work-stealing 调度算法,随机选择其它的 P,例如 P1,并从 P1 的本地队列中窃取了三个 G 到它自己的本地队列中去。至此,P1、P2 都拥有了可运行的 G,P1 多余的 G 也不会被浪费,调度资源将会更加平均的在多个处理器中流转。

另外,M 执行任务时必须绑定到一个 P,没有绑定到 P 的 M 就是空闲的,或者游离态的。这样的设计为 P 和 M 分离增加了扩展性。也就是说 M 和 P 会适时的组合和分离,保证 P 中的待执行 G 队列能够得到及时运行。举两个例子:

  1. 如果 M 被阻塞,这是队列里的 G 应该移交到其他的 M 上。在 GMP 模型中,只需要 M 释放 P,然后空闲的 M 接管 P 即可。非常灵活。比如说上图中的 G0 此时因为网络 I/O 而阻塞了 M,那么 P 就会携带剩余的 G 投入到其他 M 的怀抱中。这个新的 M1 可能是新创建的,也可能是从调度器空闲 M 列表中获取的,取决于此时的调度器空闲 M 列表中是否存在 M,从而避免 M 的过多创建,如下图所示:


图片.png


  1. 当 M 对应的内核线程被唤醒时,M 将会尝试为 G0 捕获一个 P 上下文,可能是从调度器的空闲 P 列表中获取,如果获取不成功,M 会被 G0 放入到调度器的可执行 G 队列中,等待其他 P 的查找。为了保证 G 的均衡执行,非空闲的 P 会运行完自身的可执行 G 队列中,会周期性从调度器的可执行 G 队列中获取代执行的 G,甚至从其他的 P 的可执行 G 队列中掠夺 G。如果当前 M 执行完了 P 中所有的 G,那么也不会空闲等待,而是会尝试去 steal 其他的 G。先尝试从全局队列里获取,没有获取到,那么再去随机挑选一个 P 队列,拿走部分的 G,也就是 worke-steal(任务窃取)。

注意,有些特殊的 M,比如 sysmon 是不绑定 P 的。这个用于监控一些阻塞的异常情况,比如一个 M 长时间阻塞超过 10ms,那么强制把 M-P 解绑,把 M 游离出去,P 绑定到一个空闲的 M 上,继续执行队列里的 G 任务。

G-M 锁定

Golang 支持 G-M 锁定功能,通过 lockOSThread 和 unlockOSThread 来实现。主要是用于有些要求固定在一个线程上跑的库。

  1. G_a 锁定 M0 lockOSThread。

  2. G_a 调用 gosched 切走,投入 P1 队列。

  3. M0 调度,发现是 lockedm,于是让出 P0,自己调用 notesleep 睡眠。

  4. M1 取出 G_a,发现是 lockedg,于是让出 P1 给 M0,并且唤醒 M0,自己变 idle,stopm 休眠。

  5. M0 继续执行 G_a。


你会发现,G_a 只在 M0 上运行,锁定这段期间,M0 也只执行了 G_a 的任务。
使用 GODEBUG 查看 Go Runtime Scheduler 的状态信息
设置 GODEBUG 可以让 Golang 程序在运行时输出调试信息,包括可以直观的 GMP 调度器或垃圾回收等详细信息。GODEBUG 参数以逗号分隔,格式为:name=val。
对于 Go Runtime Scheduler,具有以下两个关键参数:
    schedtrace=X 参数:可以使运行时在每 X 毫秒发出一行调度器的摘要信息到标准 err 输出中。
    schedtrace=X 和 scheddetail=1 参数:可以使运行时在每 X 毫秒发出一次详细的多行信息,信息内容主要包括调度程序、M、P、G 的状态。
示例:

package test

import "sync"

func main() {
   wg := sync.WaitGroup{}
   wg.Add(10)
   for i := 0; i <= 10; i++ {
      go func(wg *sync.WaitGroup) {
         var counter int
         for i := 0; i <= 10; i++ {
            counter++
         }
         wg.Done()
      }(&wg)
   }
   wg.Wait()
}

运行:

$ GODEBUG=schedtrace=1000 go run main.go
SCHED 0ms: gomaxprocs=4 idleprocs=2 threads=4 spinningthreads=1 idlethreads=0 runqueue=0 [0 0 0 0]
# command-line-arguments
SCHED 0ms: gomaxprocs=4 idleprocs=1 threads=5 spinningthreads=1 idlethreads=0 runqueue=0 [0 0 0 0]
SCHED 1008ms: gomaxprocs=4 idleprocs=4 threads=9 spinningthreads=0 idlethreads=2 runqueue=0 [0 0 0 0]
SCHED 0ms: gomaxprocs=4 idleprocs=1 threads=4 spinningthreads=1 idlethreads=0 runqueue=0 [1 0 0 0]

    SCHED:每一行都代表调度器的调试信息,后面提示的毫秒数表示启动到现在的运行时间,输出的时间间隔受 schedtrace 值影响。

    gomaxprocs:当前的 CPU 核心数(GOMAXPROCS 的当前值)。

    idleprocs:空闲的处理器数量,后面的数字表示当前的空闲数量。

    threads:线程数量,后面的数字表示当前正在运行的线程数量。

    spinningthreads:自旋状态的线程数量。

    idlethreads:空闲的线程数量。

    runqueue:全局队列中中的 Goroutine 数量,而后面的 [0 0 1 1] 则分别代表这 4 个 P 的本地队列正在运行的 Goroutine 数量。

注:自旋线程(Spinning Thread)是 Go Scheduler 设计者在考虑了 “OS 的资源利用率” 以及 “频繁的线程抢占给 OS 带来的负载” 之后,提出的概念。也就是当 “自旋线程” 没有找到可供其调度执行的 Goroutine 时,并不会销毁该线程 ,而是采取 “自旋” 的操作保存了下来。虽然看起来这是浪费了一些资源,但比起 “自旋",线程间频繁的抢占以及频繁的创建和销毁操作可能带来的损耗会更大。

如果我们想要更详细的看到调度器的完整信息时,可以增加 scheddetail 参数:

$ GODEBUG=scheddetail=1,schedtrace=1000 go run main.go
...
SCHED 10ms: gomaxprocs=4 idleprocs=2 threads=5 spinningthreads=0 idlethreads=2 runqueue=0 gcwaiting=1 nmidlelocked=0 stopwait=2147483647 sysmonwait=0
  P0: status=1 schedtick=6 syscalltick=0 m=0 runqsize=0 gfreecnt=6 timerslen=0
  P1: status=0 schedtick=2 syscalltick=0 m=-1 runqsize=0 gfreecnt=0 timerslen=0
  P2: status=1 schedtick=5 syscalltick=0 m=4 runqsize=0 gfreecnt=4 timerslen=0
  P3: status=0 schedtick=0 syscalltick=0 m=-1 runqsize=0 gfreecnt=0 timerslen=0
  M4: p=2 curg=10 mallocing=1 throwing=0 preemptoff= locks=2 dying=1 spinning=false blocked=false lockedg=-1
  M3: p=-1 curg=-1 mallocing=0 throwing=0 preemptoff= locks=0 dying=0 spinning=false blocked=true lockedg=-1
  M2: p=-1 curg=-1 mallocing=0 throwing=0 preemptoff= locks=0 dying=0 spinning=false blocked=true lockedg=-1
  M1: p=-1 curg=-1 mallocing=0 throwing=0 preemptoff= locks=0 dying=0 spinning=false blocked=false lockedg=-1
  M0: p=0 curg=1 mallocing=1 throwing=0 preemptoff= locks=2 dying=1 spinning=false blocked=false lockedg=-1
  G1: status=2(semacquire) m=0 lockedm=-1
  G2: status=4(force gc (idle)) m=-1 lockedm=-1
  G3: status=4(GC sweep wait) m=-1 lockedm=-1
  G4: status=4(GC scavenge wait) m=-1 lockedm=-1
  G5: status=6() m=-1 lockedm=-1
  G6: status=6() m=-1 lockedm=-1
  G7: status=6() m=-1 lockedm=-1
  G8: status=6() m=-1 lockedm=-1
  G9: status=6() m=-1 lockedm=-1
  G10: status=2() m=4 lockedm=-1
  G11: status=6() m=-1 lockedm=-1
  G12: status=6() m=-1 lockedm=-1
  G13: status=6() m=-1 lockedm=-1
  G14: status=6() m=-1 lockedm=-1
  G15: status=6() m=-1 lockedm=-1

M:

    p:隶属哪一个 P。

    curg:当前正在使用哪个 G。

    runqsize:运行队列中的 G 数量。

    gfreecnt:可用的 G(状态为 Gdead)。

    mallocing:是否正在分配内存。

    throwing:是否抛出异常。

    preemptoff:不等于空字符串的话,保持 curg 在这个 m 上运行。

P:

    status:P 的运行状态。

    schedtick:P 的调度次数。

    syscalltick:P 的系统调用次数。

    m:隶属哪一个 M。

    runqsize:运行队列中的 G 数量。

    gfreecnt:可用的 G(状态为 Gdead)。

图片.png

G:

    status:G 的运行状态。

    m:隶属哪一个 M。

    lockedm:是否有锁定 M。

G 共涉及如下 9 种状态:
图片.png

结合上述案例看看,如下:

G1: status=4(semacquire) m=-1 lockedm=-1
G2: status=4(force gc (idle)) m=-1 lockedm=-1
G3: status=4(GC sweep wait) m=-1 lockedm=-1
G17: status=1() m=-1 lockedm=-1
G18: status=2() m=4 lockedm=-1

在这个片段中,G1 的运行状态为 _Gwaiting,并没有分配 M 和锁定,表示 Goroutine 在运行时时被阻止,而阻止它的就是 semacquire 事件,是因为 semacquire 会检查信号量的情况,在合适的时机就调用 goparkunlock 函数,把当前 Goroutine 放进等待队列,并把它设为 _Gwaiting 状态。

实际运行中还有以下原因会导致这种现象:

    waitReasonZero                                    // ""
    waitReasonGCAssistMarking                         // "GC assist marking"
    waitReasonIOWait                                  // "IO wait"
    waitReasonChanReceiveNilChan                      // "chan receive (nil chan)"
    waitReasonChanSendNilChan                         // "chan send (nil chan)"
    waitReasonDumpingHeap                             // "dumping heap"
    waitReasonGarbageCollection                       // "garbage collection"
    waitReasonGarbageCollectionScan                   // "garbage collection scan"
    waitReasonPanicWait                               // "panicwait"
    waitReasonSelect                                  // "select"
    waitReasonSelectNoCases                           // "select (no cases)"
    waitReasonGCAssistWait                            // "GC assist wait"
    waitReasonGCSweepWait                             // "GC sweep wait"
    waitReasonChanReceive                             // "chan receive"
    waitReasonChanSend                                // "chan send"
    waitReasonFinalizerWait                           // "finalizer wait"
    waitReasonForceGGIdle                             // "force gc (idle)"
    waitReasonSemacquire                              // "semacquire"
    waitReasonSleep                                   // "sleep"
    waitReasonSyncCondWait                            // "sync.Cond.Wait"
    waitReasonTimerGoroutineIdle                      // "timer goroutine (idle)"
    waitReasonTraceReaderBlocked                      // "trace reader (blocked)"
    waitReasonWaitForGCCycle                          // "wait for GC cycle"
    waitReasonGCWorkerIdle                            // "GC worker (idle)"

通过以上 waitReason 可以了解到 Goroutine 会被暂停运行的原因,也就是会出现在括号中的事件。

本文》有 0 条评论

留下一个回复