Go语言学习笔记:调度器与GMP模型

Golang
160
0
0
2024-08-12

一、引言

原生支持并发编程是Go语言的核心特性之一,Go语言通过goroutinechannel提供了简单而强大的并发模型。

goroutines是由Go运行时管理的轻量级线程,它们使用非常少的内存,启动速度快,调度灵活,这使得在Go中创建成千上万个并发任务成为可能。

要高效地管理这些goroutines,就需要一个强大的调度器。GMP是Go运行时的调度器模型,它由GoroutineMachineProcessor三部分组成,简称GMP

本文将深入探讨GMP模型的内部机制,揭示它如何在众多goroutines和系统线程Threads之间高效地调度任务,以及它是如何成为Go并发编程不可或缺的核心组件的。

二、GMP模型基础

GMP是Go运行时负责调度的核心,它代表了GoroutineMachineProcessor三个关键的组成部分。

1. G:goroutine的基本概念与特性

G代表goroutine,是Go并发模型的执行单元。每个goroutine都代表着一个可以并行执行的任务。

与传统的线程模型相比,goroutines是极其轻量级的,它们的初始栈空间小,且可以根据需要动态伸缩。这种设计使得创建数以万计的goroutines成为可能,而不会对系统资源造成过大压力。

goroutines之间的调度不是由操作系统内核管理,而是由Go运行时runtime进行,这使得调度过程更加高效。goroutines在逻辑上是并发执行的,但实际上可能会被多个线程复用,这取决于GMP调度器的策略。

2. M:操作系统线程(machine)的角色与限制

M代表Machine,实际上就是操作系统的线程。在Go的并发模型中,M是执行goroutine代码的实体。每个M都会被分配一个P(我们很快会讲到),并从P的本地运行队列中获取G来执行。

M的数量通常由可用的硬件线程数(如CPU核心数)决定,Go运行时会尝试最大限度地利用所有的硬件线程。然而,M的数量并不是固定的,当存在阻塞调用(如系统调用)时,Go运行时可能会创建额外的M来保持CPU的利用率。

3. P:处理器(processor)的调度作用

P代表Processor,它是G和M之间的调度中介。每个P都有一个本地的goroutine队列,它负责维护和调度这些goroutines到M上执行。P的数量在程序启动时被设置,并且通常等于机器的逻辑CPU数量。

P的存在使得Go的调度器可以有效地平衡负载,通过本地队列减少全局锁的竞争。当M因为某些操作(如系统调用)被阻塞时,它会释放P,这样其他的M就可以接管P并继续执行goroutines,从而保持系统的高效运行。

三、GMP模型的工作原理

GMP模型的工作原理是Go并发调度的核心,它决定了goroutines是如何在操作系统线程上执行的。

1. G与M的绑定机制

在Go的运行时中,goroutines(G)并不直接绑定到操作系统线程(M)上。相反,它们被调度到处理器(P)的本地运行队列中。当一个M需要执行工作时,它会从与之关联的P的本地队列中取出一个G来执行。如果一个M完成了它的G的执行或者G被阻塞,M会再次从P的队列中取出另一个G来执行。

这种绑定机制是临时的,因为G在执行完毕或者被阻塞后,M可以转而去执行其他的G。这种设计使得goroutines能够在多个线程之间高效地调度,而不需要固定的线程关联,从而减少了线程创建和销毁的开销。

2. P的本地运行队列

每个P都有一个本地运行队列,用于存储准备好执行的goroutines。当一个P的本地队列为空时,它可以尝试从全局运行队列或者其他P的本地队列中“偷取”goroutines来执行。这种工作窃取算法可以有效地平衡负载,确保所有的P都有工作可做,从而提高CPU的利用率。

3. M的休眠与唤醒

当一个M在其关联的P的本地队列中找不到可运行的G时,它可能会进入休眠状态。在休眠状态下,M不会消耗CPU资源。当新的goroutines被创建或者有goroutines变为可运行状态时,休眠中的M可以被唤醒来处理这些任务。

4. G的状态转换(可运行、运行中、休眠、死亡)

goroutines在其生命周期中会经历几种状态:

  • 可运行(Runnable):G已经准备好执行,但还没有被分配到M上。
  • 运行中(Running):G正在M上执行。
  • 休眠(Waiting):G在等待某些事件(如I/O操作、channel通信或定时器)。
  • 死亡(Dead):G的执行已经完成,或者被显式地终止。

当G在执行过程中遇到会导致阻塞的操作时,它会从M上解绑并进入休眠状态,等待被唤醒。一旦阻塞的操作完成,G会变回可运行状态,并等待被调度器重新分配到M上执行。如果G完成了所有的工作,它就会进入死亡状态,等待垃圾回收。

四、GMP模型的调度策略

GMP模型的调度策略是Go语言高效并发的关键所在。这些策略确保了goroutines能够平滑地在多核心处理器上运行,同时最小化了上下文切换和同步的开销。下面我们来详细探讨这些策略。

1. 工作窃取(Work Stealing)

工作窃取是GMP模型中用于负载均衡的主要策略。当一个处理器(P)上的本地运行队列中的goroutines都已经被分配给线程(M)执行时,这个P就会尝试从其他P的队列中“偷取”一半的goroutines来执行。这种策略可以确保所有的处理器都尽可能地保持忙碌状态,从而提高整体的CPU利用率。

2. 手动与自动栈增长

Go语言的goroutines拥有非常小的初始栈空间,通常只有几KB。这个栈会根据需要自动增长和缩减。当goroutine的栈空间不足时,Go运行时会自动检测这一情况并分配更多的栈空间,这个过程对程序员来说是透明的。这种自动栈管理机制减少了程序员在编写代码时需要考虑的内存管理问题,同时也保证了内存的高效使用。

3. 系统调用与网络轮询器的影响

goroutine进行系统调用,如文件操作或网络I/O时,这可能会导致它被阻塞。在传统的线程模型中,这会导致整个线程被阻塞,从而浪费宝贵的CPU资源。

在Go中,当一个goroutine进行系统调用时,它所在的线程(M)会被阻塞,但Go运行时会尝试将该线程(M)上的处理器(P)分配给其他的线程(M),这样其他的goroutines就可以继续执行,从而避免了CPU资源的浪费。

此外,Go语言还提供了一个网络轮询器(netpoller),它是一个高效的多路复用器,用于监控网络I/O事件。当goroutine等待网络I/O时,它会被放入网络轮询器,而不是阻塞线程。一旦I/O操作准备就绪,网络轮询器会唤醒相应的goroutine,这时goroutine会重新进入调度器的运行队列,等待执行。