Golang GMP调度模型:高并发的核心机制

399 阅读10分钟

前言

Golang一直以高效、支持高并发著名,它简单实现高并发的重要机制,有两个,一是GMP调度模型,二是GC垃圾回收机制。而GMP通过合理调度,提升了系统内核线程(M)对协程(G)的处理效率。本篇文章,我们就详细看看,GMP模型是怎么实现高并发调度的。

操作系统与应用程序的交互:内外部视角分析

外部视角:操作系统与Golang应用的交互

操作系统的处理单元是内核态线程,在运行时会把用户态线程,也就是Golang应用的线程绑定到内核态线程,由内核处理。就衍生出了三种绑定关系:

  1. N:1 关系:N个用户态线程绑定到1个内核态线程
  2. 1:1 关系:1个用户态线程绑定到1个内核态线程
  3. N:M 关系 :N个用户态线程绑定到M个内核态线程

M:1模型

M个用户态线程绑定到1个内核态线程

优点

1.切换开销小

由于多个用户态线程共享一个内核态线程,线程之间的切换速度非常快,能显著提高线程切换的效率。

2.天然跨平台能力,可移植性高

用户态线程的创建、销毁、切换操作,都是用户态应用自己完成的,不需要对不同的操作系统线程结构,去实现不同创建、销毁、切换操作。

缺点:

1. 无法充分利用多核处理器

因为1个内核态线程只能跑在CPU的某个核上,所以没办法用多核。

2. 一个线程阻塞导致整个进程阻塞

无法实现并行。I/O 操作是由内核态线程执行的(如磁盘读写、网络通信等),用户态线程的IO阻塞,会导致所有用户态线程都停止运行。

当一个用户态线程发生阻塞(如等待 I/O 操作)时,由于多个用户态线程共享一个内核态线程,整个进程都会被阻塞,其他用户态线程也无法继续执行。

1:1模型

1个协程绑定1个线程。协程的调度都由CPU完成了,但线程创建、删除、切换代价成本高。

M:N模型

在 M:N 线程模型中,操作系统内核感知到 N 个内核态线程,而在用户空间有 M 个用户态线程。应用程序负责管理和调度用户态线程,决定哪个用户态线程可以使用内核态线程来执行。

优点:

1.充分利用多核处理器

多个内核态线程可以同时在不同的处理器上执行,提高整体性能

2.线程切换开销小

用户态线程的创建、调度和销毁操作在应用程序完成,不需要进行用户态和内核态的切换,因此线程切换的开销相对较小。

缺点:

1.实现复杂

用户程序自己实现用户态线程的管理和调度算法,同时还要处理用户态线程和内核态线程之间的映射关系。

而Golang中的 GMP 模型是 Go 语言实现并发调度的一种具体方式,本质上属于 M:N 模型的具体实现。

Golang的GMP模型:M:N 模型的具体实现

GMP模型的简单理解:

为了使得内核态线程这个小牛马保持高速消费,就得在它每个活干完的时候,快速分配下一个活。因此,就天然需要一个PMO的角色,去监督小牛马,让他手头上始终有内核态线程去处理。

Go早期的M:N模型

早期并没有Processor的概念,Gorutine是放在一个全局队列维护的。为了保证数据一致性,每次获取Gorutine,需要先获取到队列的全局锁。

问题:

并发量大时,会导致多个M获取同一个全局队列的锁,产生锁竞争

GMP模型的核心组件:

GMP很好的解决了上述问题:将全局队列打散成多个队列,避免被全局锁影响性能。每个Processor维护自己的gorutine队列。

MP模型中的Gorutine队列怎么维护的

Go程序的执行,主要有两部分组成:Go程序 + Runtime。 Runtime维护所有Gorutine,通过Schedule来调度给操作系统。

GMP分成三个基础结构体:

  1. G:gorutine

  2. M:内核线程,包含gorutine栈

  3. P:虚拟的processor,维护runnable状态的gorutine队列,M需要获得P,才能运行G

Runtime会创建几种gorutine:

  1. 垃圾回收gorutine
  2. 调度Gorutine
  3. 用户程序Gorutine

GMP的G-Gorutine协程

1. Gorutine的状态、存放位置

Gorutine本身分成三种状态:Waiting等待、Runnable就绪、Executing执行。

  • 存放位置
    • 全局可运行队列(GRQ):存储全局就绪态的 Goroutine。
    • 本地可运行队列(LRQ):由 Processor 维护,存储其自身的就绪态 Goroutine。

image.png

2. Goroutine 的状态流转
  • 创建与初始化:使用go关键字创建时为 Gidle 状态。
  • 进入就绪队列:准备好执行后变为 Runnable 状态,放入 LRQ 或 GRQ。
  • 执行状态:被 M 取出执行时进入 Executing 状态。
  • 阻塞等待:遇到 I/O、锁竞争或系统调用时,进入 Waiting 或 Gsyscall 状态。
  • 恢复执行:阻塞条件满足后,回到 Runnable 状态。
  • 结束销毁:执行完代码逻辑后,Goroutine 生命周期结束。
3. Goroutine 的调度时机
  • Go 一个协程:新创建的 Goroutine 需要被调度执行。
  • GC:GC 的 Goroutine 也在 M 上,会不断调度 GC - Goroutine 以保证 GC 操作。
  • 系统调用:系统调用阻塞 M 时,当前 Goroutine 会被调度走。
  • 内存同步访问:锁、channel、atomic 等操作阻塞 Goroutine 时,会触发调度。

GMP 的 M - Machine(系统线程)

1. M 的状态概述

M 主要有 Midle(空闲)、Mrunning(运行)、Mblocked(阻塞)等状态。

2. M 的状态流转
  • 空闲状态:无绑定 P 或无可执行 Goroutine 时为 Midle 状态。
  • 运行状态:绑定 P 并开始执行 Goroutine 时进入 Mrunning 状态。
  • 阻塞状态:执行的 Goroutine 发起阻塞系统调用或遇到锁竞争时,进入 Mblocked 状态,此时 P 会与 M 解绑。
  • 恢复运行:阻塞原因解除后,从 Mblocked 恢复到 Mrunning 状态。
  • 终止状态:程序退出或资源回收时,M 被销毁。

GMP 的 P - Processor(处理器)

1. P 的状态概述

P 主要有 Pidle(空闲)、Prunning(运行)、Psyscall(系统调用)等状态。

2. P 的状态流转
  • 空闲状态:未与 M 绑定或本地队列无 Goroutine 时为 Pidle 状态。此时 P 会等待调度器分配新的任务或寻找新的 M 进行绑定。
  • 运行状态:与 M 绑定且 M 执行本地队列 Goroutine 时进入 Prunning 状态。若本地队列任务执行完毕,P 会进入空闲检查逻辑。
  • 系统调用状态:本地 Goroutine 发起系统调用时,进入 Psyscall 状态。此时 P 与 M 解绑,P 会等待系统调用完成或寻找新的 M。
  • 恢复运行:系统调用完成后,从 Psyscall 恢复到 Prunning 状态。
  • 终止状态:程序结束或资源回收时,P 被销毁。

Go调度器:GMP的核心组件

Go调度器是程序运行的核心组件之一,负责协调和管理Goroutine的调度与执行,完成了整个GMP的过程

Go调度器主要目的:将协程调度到内核线程上,它做了三件事:

  1. 重用线程
  2. 限制同时运行的线程数为N,N相当于CPU的核数。
  3. 工作窃取。维护了一个本地线程,当M被阻塞后,可以通过P提供其他P`的地址。M`把当前P的gorutine窃取,消费。
工作窃取(M + P)
  1. 首先M从P自己的本地的进程去窃取Gorutine
  2. M从全局队列窃取
  3. M从netpoll里找
  4. M从其他P偷取:随机选择一个P,偷一半的工作承担
  • 工作窃取中,P 的作用

    • 管理任务队列:每个 P 都有自己的本地任务队列,用于存放准备执行的 Goroutine。当 P 的本地队列为空时,P 就有进行工作窃取的需求,以充分利用资源,避免空闲。
    • 提供任务信息:P 需要向 M 提供关于其他 P 的任务队列信息,以便 M 能够知道从哪里去窃取任务。
  • 工作窃取中,M 的作用

    • 执行窃取操作:M 代表操作系统线程,是实际执行任务的实体。当 M 通过其绑定的 P 发现本地队列为空时,会根据 P 提供的信息,去其他 P 的本地队列中窃取 Goroutine 来执行。
    • 维持负载平衡:M 通过不断地检查和执行工作窃取操作,确保整个系统中的各个 P 的负载相对均衡,从而提高系统的整体性能和资源利用率。

image.png


Go调度器的初始化

调度器在程序启动时全局创建,且整个程序中只有一个调度器实例。每个系统线程(M)都有一个对应的g0,它是调度器在底层操作中使用的特殊Goroutine。

在Go的runtime中,有三个关键的结构体:g0p0m0,它们分别代表初始的Goroutine、处理器(Processor)和系统线程(Machine)。

  • m0:程序启动时创建的第一个系统线程,负责初始化调度器的全局数据结构,并创建main goroutine
  • p0:初始的处理器,与m0绑定,负责管理Goroutine的本地运行队列(LRQ)。
  • g0:每个系统线程(M)都有一个g0,它用于执行调度器的底层操作,如栈的增长和收缩、系统调用的处理等。

程序启动时,m0会启动调度器的主循环,开启整个Goroutine的调度和执行流程。g0p0m0共同确保了程序能够顺利运行。

调度器正式开始工作-调度主协程

主协程的生命周期

主协程的生命周期与程序的运行周期一致:

  • main函数执行完毕后,主协程会退出。
  • 如果主协程退出时还有其他Goroutine在运行,程序不会立即结束,而是等待所有Goroutine完成。
  • 如果主协程退出且没有其他Goroutine在运行,程序会正常退出。
  1. 调度main goroutine
    m0在调度器的主循环中,从p0的本地运行队列(LRQ)中取出main goroutine并开始执行。

    m0会从g0(调度器使用的特殊Goroutine)切换到主协程,开始执行runtime.main函数。

  2. 执行runtime.main
    runtime.main是主协程的入口函数,它会执行以下操作:

    • 调用runtime.init,执行所有的init函数(包括用户定义的init函数和包的初始化函数)。
    • 调用用户编写的main函数。
    • main函数执行完毕后,调用runtime.exit结束程序。
  3. 执行用户代码
    runtime.main调用用户定义的main函数时,程序正式进入用户代码的执行阶段。

    • 每个新创建的M都会有自己的g0,用于执行调度器的底层操作。
    • p0m0作为调度系统的基础,会持续参与到调度和管理工作中,确保整个程序的并发执行能够高效进行。
主协程的生命周期

主协程的生命周期与程序的运行周期一致:

  • main函数执行完毕后,主协程会退出。
  • 如果主协程退出时还有其他Goroutine在运行,程序不会立即结束,而是等待所有Goroutine完成。
  • 如果主协程退出且没有其他Goroutine在运行,程序会正常退出。