Golang
我的Go学习笔记-不断改进和完善中

GMP模型的基本概念

  1. G(Goroutine):表示 goroutine,即 Go 语言中的协程。

  2. P(Processor):P代表逻辑处理器,它是一个抽象的概念,并不是真正的物理CPU。P负责维护一个Goroutine队列,调度Goroutine到M上执行。P的数量通常等于CPU的核心数,可以通过GOMAXPROCS参数来设置。每个 P 负责调度和执行多个 goroutine。包含运行Goroutine的资源和本地队列。

  3. M(Machine):表示系统线程(即操作系统的内核线程),是执行Goroutine的实体。负责执行 P 中分配的 goroutineM 通过 Pgoroutine 绑定到内核线程上执行。当 P 空闲时,可以调度其他 goroutine 执行。

  4. P 的本地队列 :每个逻辑处理器(P)都有一个本地队列,用于存放等待运行的 Goroutine(G)。这个本地队列的大小是有限的,通常不超过 256 个 Goroutine。这种设计旨在减少锁的使用,提高调度效率,因为访问本地队列不需要加锁。

  5. 全局队列: 用于存放那些因为本地队列已满而无法加入的 Goroutine。全局队列是一个共享资源,所有逻辑处理器(P)都可以从中获取 Goroutine 来执行。

GMP模型的工作原理

**Goroutine (G)**:比作一个快递包裹。

**线程 (M)**:比作一个分拣员。

**逻辑处理器 (P)**:比作分拣员的工作台。


  1. G与M的绑定机制

(包裹和分拣员的关系)

想象一下,快递包裹(Goroutine)送到分拣中心后,并不是直接交给某个分拣员(线程),而是先放在分拣员的工作台(逻辑处理器 P)上的包裹堆里。分拣员(M)会从自己工作台上的包裹堆里拿包裹来分拣。

Goroutine并不直接绑定到操作系统线程上,而是通过P来调度。当一个M需要执行工作时,它会从与之关联的P的本地队列中取出一个Goroutine来执行。如果M完成了G的执行或者G被阻塞,M会再次从P的队列中取出另一个G来执行。

  1. P的本地运行队列

(工作台上的包裹堆)

每个分拣员的工作台(P)上都有一个包裹堆,用来存放等待分拣的包裹。如果分拣员的工作台上没有包裹了,他会去中心的包裹池(全局运行队列)里拿,或者从其他分拣员的工作台上“偷”几个包裹来分拣,这就是工作窃取。

每个P都有一个本地运行队列,用于存储准备好执行的Goroutine。当P的本地队列为空时,它会尝试从全局运行队列或者其他P的本地队列中“偷取”Goroutine来执行,这种策略称为工作窃取(Work Stealing)。

  1. M的休眠与唤醒

(分拣员的休息与工作)

如果一个分拣员发现自己工作台上没有包裹了,而且中心的包裹池里也没有包裹,他可能会暂时休息,不消耗体力。如果有新的包裹送来,或者有包裹从其他工作台“偷”过来,休息的分拣员会被叫醒来继续工作。

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

  1. G的状态转换

(包裹的处理过程)

  • 快递包裹在分拣过程中会经历几个状态:

    • 可运行(Runnable):包裹刚送到,等待分拣。

    • 运行中(Running):分拣员正在分拣这个包裹。

    • 休眠(Waiting):包裹需要等待某些条件(比如等待特殊处理或者检查)。

    • 死亡(Dead):包裹已经分拣完毕,可以送出去了。

  • 如果包裹在分拣过程中需要等待(比如等待检查),分拣员会先放下这个包裹,去分拣其他包裹。这就像是 Goroutine 在执行过程中遇到会导致阻塞的操作时,它会从分拣员(M)上解绑并进入休眠状态。

  • 一旦等待的条件满足,分拣员会回来继续分拣这个包裹。这就像是阻塞的操作完成后,Goroutine 会变回可运行状态,并等待被调度器重新分配到分拣员(M)上执行。

Goroutine在其生命周期中会经历几种状态,包括可运行(Runnable)、运行中(Running)、休眠(Waiting)和死亡(Dead)。当G在执行过程中遇到会导致阻塞的操作时,它会从M上解绑并进入休眠状态,等待被唤醒。一旦阻塞的操作完成,G会变回可运行状态,并等待被调度器重新分配到M上执行。

GMP是系统线程运行的代码片段

设计策略

复用线程的两个策略

  1. Work Stealing机制: 当本线程无可运行的G时,尝试从其他线程绑定的P偷取G,而不是销毁线程。

  2. Hand Off机制:当本线程因G进行系统调用等阻塞时,线程会释放绑定的P,把P转移给其他空闲的M执行。

利用并行GOMAXPROCS设置P的数量,最多有GOMAXPROCS个线程分布在多个CPU上同时运行。GOMAXPROCS也限制了并发的程度,比如GOMAXPROCS = 核数/2,则最多利用了一半的CPU核进行并行。

调度器的工作机制:

想象一下,你在一个公共场合,大家都在排队上厕所。这里有两种情况:

  1. 协作式调度:

每个人都知道自己大概需要多久,如果有人预计自己会占用比较长时间,比如要“大号”,他们会主动告诉后面的人:“我可能需要很久,你们可以先去别的厕所看看。”这样,其他人就有机会先去别的厕所,这就是协作式调度。在 Go 语言中,Goroutine 在执行长时间任务时,会主动让出 CPU,让其他 Goroutine 先执行。

Go 语言使用协作式调度机制,意味着 g 在某些特定点(如系统调用、I/O 操作)会主动让出 CPU,从而使调度器有机会调度其他 g 执行。这种机制减少了不必要的上下文切换,提高了调度效率。

  • 抢占式调度:

但是,有时候有些人进去后,可能因为玩手机或者别的原因,占用厕所时间过长。管理员(调度器)看不下去了,就会敲门说:“你已经待很久了,快点出来,外面还有人等着呢。”这就是抢占式调度。在 Go 语言中,如果一个 Goroutine 占用 CPU 太久,调度器会强制中断它,让其他 Goroutine 有机会执行。

为了防止某个 g 长时间占用 CPU 资源,Go 语言在 1.14 版本中引入了抢占式调度。如果一个 g 占用 CPU 时间过长,调度器会强制中断其执行,并将控制权交还给调度器,确保其他 g 能够得到执行机会。

特殊的 G0 和 M0:

  1. G0:每个线程(M)启动时都会创建一个特殊的 Goroutine(G0),它用于调度和系统调用,不指向任何用户代码。G0 在调度器需要执行系统调用或者进行调度操作时使用。

  2. M0:程序启动后的第一个主线程(M0)负责执行初始化操作和启动第一个 Goroutine。此后,M0 的行为与其他线程(M)相同。

1
2
3
4
5
6
7
package main

import "fmt"

func main() {
fmt.Println("Hello world")
}

接下来我们来针对上面的代码对调度器里面的结构做一个分析。

也会经历如上图所示的过程:

  1. 创建初始线程和Goroutine

    • Go 程序启动时,runtime 包会创建一个初始线程 m0 和一个特殊的 Goroutine g0g0 是每个线程的调度器 Goroutine,用于执行系统调用和调度操作。
  2. 调度器初始化

    • 初始化线程 m0、栈、垃圾回收器,以及创建由 GOMAXPROCS(可以设置的,决定了同时运行的线程数)个 P(Processor,逻辑处理器)构成的 P 列表。每个 P 都与一个 M(Machine,物理线程)关联。
  3. main函数的调用

    • 示例代码中的 main 函数是用户定义的 main.main,而在 runtime 包中也有一个 main 函数,即 runtime.main。编译后的代码会将 runtime.main 调用 main.main

    • 程序启动时,会为 runtime.main 创建一个 Goroutine,我们可以称之为 main goroutine。这个 Goroutine 会被加入到 P 的本地队列中。

  4. 启动 m0

    • m0 启动后,会绑定到一个 P 上,并从 P 的本地队列中获取 G(Goroutine),即获取到 main goroutine
  5. 设置运行环境

    • 每个 G 都有自己的栈。M 会根据 G 中的栈信息和调度信息设置运行环境。
  6. 运行 G

    • M 开始运行 G。在这个例子中,main goroutine 会执行 main.main 函数,打印 “Hello world”。
  7. G 退出和循环

    • G 执行完毕后,M 会尝试获取下一个可运行的 G。这个过程会一直重复,直到 main.main 退出。

    • 一旦 main.main 退出,runtime.main 会执行任何延迟函数(Defer)和 Panic 处理,或者调用 runtime.exit 来退出程序。

GMP模型的工作流程/调度策略

**Goroutine (G)**:比作一个快递包裹。

**线程 (M)**:比作一个分拣员。

**逻辑处理器 (P)**:比作分拣员的工作台。

  1. 新建 Goroutine(新快递包裹到达)

    • 当新的快递包裹(Goroutine)送到分拣中心时,调度器会优先将这些包裹放到当前分拣员(P)的工作台上(本地队列)。如果工作台上的包裹已经很多了,超出了一定数量,那么一半的包裹会被移动到中心的包裹池(全局队列)中,以便其他分拣员可以处理。

    • 通过go func()创建一个协程,调度器会将其视为一个新的任务,并优先放入当前逻辑处理器(P)的本地队列(Local RunQueue)中。如果本地队列已满,会将一半的(G)移动到全局队列中。

  1. 优先本地队列调度(分拣员优先处理自己工作台上的包裹)

    • 分拣员(M)在工作时,会优先处理自己工作台上的快递包裹(从绑定的 P 的本地队列中获取 G 进行执行)。这种方式不需要分拣员之间互相沟通,提高了分拣效率,因为每个分拣员都专注于自己的工作台。

    • 线程(M)在需要执行 Goroutine 时,会优先从其绑定的逻辑处理器(P)的本地队列中获取 Goroutine 进行执行。这种方式避免了锁的使用,提高了调度效率。

  2. 全局队列调度(分拣员从中心包裹池获取包裹)

    • 如果分拣员发现自己工作台上的包裹都处理完了,他们会去中心的包裹池(全局队列)看看有没有新的包裹可以处理。这个过程需要分拣员之间协调(加锁),虽然比直接处理自己工作台上的包裹慢一些,但可以确保整个分拣中心的包裹都能得到处理,保持负载均衡。

    • 如果逻辑处理器(P)的本地队列为空,线程(M)会尝试从全局队列(Global RunQueue)中获取 Goroutine。访问全局队列需要加锁,这是一个相对较慢的过程,但它确保了系统的负载均衡。

  3. 工作窃取(分拣员从其他工作台“偷”包裹)

    • 如果分拣员发现自己工作台上和中心包裹池里都没有包裹了,而其他分拣员的工作台上还有包裹,他们可以走过去,从其他分拣员的工作台上“偷”一些包裹来处理。这样可以避免一些分拣员太忙而其他分拣员太闲的情况,确保所有分拣员都能保持忙碌。

    • 如果线程(M)从逻辑处理器(P)的本地队列和全局队列都无法获取到 Goroutine,它会尝试从其他逻辑处理器的本地队列中“偷取”一部分 Goroutine 来执行,这种策略称为工作窃取(Work Stealing)。

  4. Goroutine 的阻塞与唤醒(快递包裹因特殊处理而暂时搁置)

    • 当快递包裹在分拣过程中需要等待某些特殊处理(比如等待检查或者需要特别的分拣规则),分拣员会暂时将这个包裹放到一边,去处理其他包裹。这就像是 Goroutine 在执行过程中遇到会导致阻塞的操作时,它会从分拣员(M)上解绑并进入休眠状态。

    • 一旦需要等待的特殊处理完成,这个包裹会被重新放回某个分拣员的工作台上(P 的本地队列),等待下一个可用的分拣员(M)来继续处理。

    • 当 Goroutine 在执行过程中遇到会导致阻塞的操作(如 I/O 操作或系统调用),它会从线程(M)上解绑并进入休眠状态(Waiting State)。一旦阻塞的操作完成,Goroutine 会被唤醒,并变回可运行状态(Runnable State),等待被调度器重新分配到线程(M)上执行。

GMP模型的优势

  • 高效的资源利用:通过在用户态进行调度,避免了频繁的上下文切换带来的开销,充分利用CPU资源。

  • 轻量级并发:Goroutine比线程更加轻量级,可以启动大量的Goroutine而不会消耗大量内存。

  • 自动调度:Go运行时自动管理Goroutine的调度,无需程序员手动干预,简化了并发编程的复杂度。

参考链接

操作系统篇一:进程与线程、并发并行与串行、同步与异步、阻塞与非阻塞当你被问到这些问题:你觉得的并发、并行、串行有什么区别 - 掘金

第三篇、Golang编程设计与通用之路 - 3、对于操作系统而言进程、线程以及Goroutine协程的区别 - 《Golang 修养之路》 - 书栈网 · BookStack

翻译来自 Goroutines

《Go语言轻松进阶:从入门、实战到内核揭秘》

GMP模型


本站由 GINA 使用 Stellar 1.29.1 主题创建。

本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处。

Copyright © 2024 GINA 保留所有权利。