写点什么

深入解析 Go 语言 GMP 模型:并发编程的核心机制

  • 2024-07-30
    北京
  • 本文字数:6479 字

    阅读完需:约 21 分钟

前言

本章是 Go 并发编程的起始篇章,在未来几篇文章中我们会围绕 Go 并发编程进行理论和实战的学习,欢迎关注我哦!

本章主要以介绍 GMP 模型为主,偏向于面试和八股,目的是让小伙伴们注重于知识本身,面向面试,面向八股,面向加薪。

Go 语言自诞生以来,就以其简洁、高效的并发模型著称。而这其中的核心正是 GMP 模型。理解 GMP 模型的演进历程,能帮助我们更好地掌握 Go 的并发编程。而 Goroutine 作为 Go 中的核心概念,极大地简化了并发编程的复杂度。本文将详细阐述 Go 语言 GMP 模型的演变过程,并深入解析其设计理念和优点,并详细介绍 Goroutine 的基本概念、优势及其使用方法,并结合具体代码示例进行说明。

面试题目

在阅读本文前,先带着以下几个关于 GMP 模型的面试题目进行思考,以加深理解和掌握:

  1. 什么是 GMP 模型?请解释其基本概念。

  • 回答要点:解释 G、M、P 的概念及其在调度模型中的角色。

  1. 如何理解 GMP 模型中线程的内核态和用户态?

  • 回答要点:区分内核态线程和用户态线程,并说明它们在 GMP 模型中的作用。

  1. Go 语言中的 Goroutine 与线程的映射关系是怎样的?为什么选择这种映射方式?

  • 回答要点:解释 Goroutine 与线程的多对多映射关系及其优点。

  1. GMP 模型如何解决线程调度中的锁竞争问题?

  • 回答要点:介绍全局队列和本地队列的使用,以及 G 的分配机制。

  1. GMP 模型中的 Stealing 机制是什么?它如何工作?

  • 回答要点:描述 Stealing 机制的原理及其在 Goroutine 调度中的应用。

  1. 什么是 Hand off 机制?在什么情况下会使用该机制?

  • 回答要点:解释 Hand off 机制及其在阻塞和系统调用中的应用。

  1. 如何理解 GMP 模型中的抢占式调度?它解决了哪些问题?

  • 回答要点:说明抢占式调度的原理及其在防止协程饿死中的作用。

  1. 什么是 G0 和 M0?它们在 GMP 模型中扮演什么角色?

  • 回答要点:描述 G0 和 M0 的定义及其在 Goroutine 调度中的功能。

  1. 请详细说明 GMP 模型中的调度策略。

  • 回答要点:逐步解释 Goroutine 的创建、唤醒、偷取、切换、自旋、系统调用和阻塞处理策略。

  1. 如何在实际项目中调优 GMP 调度模型?

  • 回答要点:讨论如何通过调整 GOMAXPROCS 等参数来优化调度性能。

带着这些问题阅读本文,可以帮助你更系统地掌握 GMP 模型的核心概念和调度机制,提高面试中的应答能力。

单进程时代

基本概念

在单进程时代,一个进程就是一个运行中的程序。计算机系统在执行程序时,会从头到尾依次执行完一个程序,然后再执行下一个程序。在这种模型中,不需要复杂的调度机制,因为只有一个执行流程。

面临的两个问题

  1. 单一执行流程:由于只能一个个执行程序,无法同时处理多个任务,这大大限制了 CPU 的利用率。

  2. 进程阻塞:当一个进程遇到 I/O 操作等阻塞情况时,CPU 资源会被浪费,等待进程完成阻塞操作后再继续执行,导致效率低下。

多进程/线程并发时代

基本概念

为了解决单进程时代的效率问题,引入了多进程和多线程并发模型。在这种模型中,当一个进程阻塞时,CPU 可以切换到另一个准备好的进程继续执行。这样可以充分利用 CPU 资源,提高系统的并发处理能力。

两个问题

  1. 高开销:进程拥有大量资源,进程的创建、切换和销毁都需要消耗大量的时间和资源。这导致 CPU 很大一部分时间都在处理进程调度,而不是实际的任务执行。

  2. 高内存占用:在 32 位机器下,进程的虚拟内存占用为 4GB,线程占用为 4MB。大量的线程和进程会导致高内存消耗,限制了系统的扩展性。

协程的引入

为了解决多进程和多线程带来的高开销和高内存占用问题,引入了协程(Coroutine)。协程是一种比线程更轻量级的执行单元。协程在用户态进行调度,避免了频繁的上下文切换带来的开销。Go 语言的 GMP 模型正是基于协程的设计。

协程的基本概念

在深入了解 Goroutine 之前,先来了解一下协程(Coroutine)的基本概念。

内核态和用户态

  • 内核态线程:由操作系统管理和调度,CPU 只负责处理内核态线程。

  • 用户态线程:由用户程序管理,需绑定到内核态线程上执行,协程即为用户态线程的一种。

内核态和用户态线程关系图

  • Kernel Space(内核空间):上半部分的灰色区域,表示操作系统管理的内核空间。

  • User Space(用户空间):下半部分的白色区域,表示用户程序运行的空间。

  • Kernel Thread 1 和 Kernel Thread 2(内核线程):由操作系统管理的内核线程,CPU 直接处理这些线程。

  • User Thread 1、User Thread 2 和 User Thread 3(用户线程):由用户程序管理的用户线程(协程),需绑定到内核线程上执行。

执行流程

  1. 用户态线程:

  2. 用户程序创建多个用户线程(如协程),如图中的“User Thread 1”、“User Thread 2”和“User Thread 3”。

  3. 内核态线程:

  4. 用户线程需绑定到内核态线程上执行,如图中的“Kernel Thread 1”和“Kernel Thread 2”。

  5. CPU 处理:

  6. CPU 只处理内核态线程,通过绑定关系,用户态线程的执行也依赖于内核态线程的调度。

  7. 图中的红色箭头表示 CPU 正在处理内核线程,从而间接处理绑定的用户线程。

线程和协程的映射关系

  1. 单线程绑定所有协程

  2. 问题 1:无法利用多核 CPU 的能力。

  3. 问题 2:如果某个协程阻塞,整个线程和进程都将阻塞,导致其他协程无法执行,丧失并发能力。

  4. 一对一映射

  5. 将每个协程绑定到一个线程上,退回到多进程/线程的模式,协程的创建、切换、销毁均需 CPU 完成,效率低下。

  6. 多对多映射

  7. 允许多个协程绑定到多个线程上,形成 M:N 的关系。这样可以充分利用多核 CPU,并通过协程调度器高效管理协程的执行。

Goroutine

Goroutine 是 Go 语言中的协程,实现了轻量级并发。与传统的线程相比,Goroutine 具有以下显著特点:

轻量级

Goroutine 非常轻量,初始化时仅占用几 KB 的栈内存,并且栈内存可以根据需要动态伸缩。这使得我们可以在 Go 程序中创建成千上万个 Goroutine,而不会消耗过多的系统资源。

高效调度

Goroutine 的调度由 Go 语言的运行时(runtime)负责,而不是操作系统。Go 运行时在用户态进行调度,避免了频繁的上下文切换带来的开销,使得调度更加高效。

Goroutine 的使用示例

下面是一个简单的示例,展示了如何在 Go 语言中使用 Goroutine 进行并发编程。

package mainimport (    "fmt"    "time")func say(s string) {    for i := 0; i < 5; i++ {        time.Sleep(100 * time.Millisecond)        fmt.Println(s)    }}func main() {    go say("Hello")    go say("World")    time.Sleep(1 * time.Second)    fmt.Println("Done")}
复制代码

在这个示例中,两个 Goroutine 同时执行,分别打印"Hello"和"World"。通过使用go关键字,我们可以轻松地启动一个新的 Goroutine。

需要注意的事项

  1. 主 Goroutine 的结束:在 Go 程序中,main 函数本身也是一个 Goroutine,称为主 Goroutine。当主 Goroutine 结束时,所有其他 Goroutine 也会随之终止。因此,需要确保主 Goroutine 等待所有子 Goroutine 执行完毕。

  2. 同步和共享数据:虽然 Goroutine 之间共享内存空间,但需要通过同步机制(如通道和锁)来避免竞争条件。Go 语言推荐使用通道(channel)进行 Goroutine 之间的通信,以保证数据的安全性和同步性。

示例:使用通道进行同步

下面的示例展示了如何使用通道来同步多个 Goroutine 的执行。

package mainimport (    "fmt"    "sync")func worker(id int, wg *sync.WaitGroup) {    defer wg.Done()    fmt.Printf("Worker %d starting\n", id)    // 模拟工作    fmt.Printf("Worker %d done\n", id)}func main() {    var wg sync.WaitGroup    for i := 1; i <= 5; i++ {        wg.Add(1)        go worker(i, &wg)    }    wg.Wait()    fmt.Println("All workers done")}
复制代码

在这段代码中,使用sync.WaitGroup来同步多个 Goroutine。主 Goroutine 启动多个子 Goroutine 并等待它们完成,每个子 Goroutine 在完成任务后调用wg.Done()减少计数,主 Goroutine 调用wg.Wait()阻塞等待所有子 Goroutine 完成。

执行流程

  1. 主 Goroutine 启动多个子 Goroutine(Goroutine 1、2、3)。

  2. 各个 Goroutine 并发执行它们的任务。

  3. 每个 Goroutine 在完成任务后,向通道发送信号表示已完成。

  4. 主 Goroutine 通过通道接收所有子 Goroutine 的完成信号,然后继续执行。

Goroutine 执行与同步流程图

这张图展示了多个 Goroutine 同时执行的流程以及如何通过通道(Channel)进行同步。

  • Goroutine 1、2、3:代表多个并发执行的 Goroutine,分别标记为“Goroutine 1”、“Goroutine 2”和“Goroutine 3”。

  • Main Goroutine:主 Goroutine,它负责启动其他 Goroutine 并等待它们完成。

  • Channel:用于同步 Goroutine 的通道。

关于 waitgroup 我会在下一章节中进行详细讲解,欢迎订阅我的频道!在本实例代码中大家了解使用即可。

Goroutine 调度器

基本概念

在 Go 中,线程是运行 Goroutine 的实体,而调度器的功能是将可运行的 Goroutine 分配到工作线程上。Go 语言采用了一种高效的 Goroutine 调度机制,使得程序能够在多核处理器上高效运行。

被废弃的调度器

早期的调度器采用了简单的设计,存在多个缺陷:

  • 概念:用大写的 G 表示协程,用大写的 M 表示线程。

  • 问题:

  • 锁竞争:每个 M(线程)想要执行、放回 G(协程)都必须访问一个全局 G 队列,因此对 G 的访问需要加锁以保证并发安全。当有很多线程时,锁竞争激烈,影响系统性能。

  • 局部性破坏:M 转移 G 会造成延迟和额外的系统负载。例如,当一个 G 内创建另一个 G'时,为了继续执行 G,需要将 G'交给另一个 M'执行,这会破坏程序的局部性。

  • 系统开销:CPU 在线程之间频繁切换导致频繁的系统调用,增加了系统开销。

GMP 模型的设计思想

为了克服上述问题,Go 引入了 GMP 模型:

  • 基本概念

  • Go 语言使用 GMP 模型来管理并发执行。GMP 模型由三个核心组件组成:G(Goroutine)、M(Machine)、P(Processor)。

  • G(Goroutine)

  • Goroutine 是 Go 语言中的协程,代表一个独立的执行单元。Goroutine 比线程更加轻量级,启动一个 Goroutine 的开销非常小。Goroutine 的调度由 Go 运行时在用户态进行。

  • M(Machine)

  • M 代表操作系统的线程。M 负责实际执行 Go 代码。一个 M 可以执行多个 Goroutine,但同一时间只能执行一个 Goroutine。M 与操作系统的线程直接对应,Go 运行时通过 M 来利用多核 CPU 的并行计算能力。

  • P(Processor)

  • P 代表执行上下文(Processor)。P 管理着可运行的 Goroutine 队列,并负责与 M 进行绑定。P 的数量决定了可以并行执行的 Goroutine 的数量。Go 运行时会根据系统的 CPU 核数设置 P 的数量。

  • GMP 模型的组成

  • 全局 G 队列:存放等待运行的 G。

  • P 的本地 G 队列:存放不超过 256 个 G。当新建协程时优先将 G 存放到本地队列,本地队列满了后将一半的 G 移动到全局队列。

  • M:内核态线程,线程想要运行协程需要先获取一个 P,从 P 的本地 G 队列中获取 G。当本地队列为空时,会尝试从全局队列或其他 P 的本地 G 列表中偷取 G。

  • P 列表:程序启动时创建 GOMAXPROCS 个 P,并保存在数组中。

  • 调度器与 OS 调度器结合:Go 的 Goroutine 调度器与操作系统调度器结合,OS 调度器负责将线程分配给 CPU 执行。

设计策略

  • 复用线程的两个策略

  • Work Stealing 机制:当本线程没有可执行的 G 时,优先从全局 G 队列中获取一批 G。如果全局队列中没有,则尝试从其他 P 的 G 队列中偷取 G。

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

  • 利用并行:有 GOMAXPROCS 个 P,则可以有同样数量的线程并行执行。

  • 抢占式调度:Goroutine 是协作式的,一个协程只有让出 CPU 才能让下一个协程执行,而 Goroutine 执行超过 10ms 就会强制让出 CPU,防止其他协程饿死。

  • 特殊的 G0 和 M0

  • G0:每次启动一个 M 都会创建的第一个 Goroutine,仅用于调度,不指向任何可执行的函数。每个 M 都有一个自己的 G0,在调度或系统调用时使用 G0 的栈空间。

  • M0:启动程序后的第一个主线程,负责执行初始化操作和启动第一个 Goroutine,此后与其他 M 一样。

调度策略

  • 创建两步:

  • 通过go func()创建一个协程。

  • 新创建的协程优先保存在 P 的本地 G 队列,如果本地队列满了,会将 P 本地队列中的一半 G 打乱顺序移入全局队列。

  • 唤醒获取

  • 创建 G 时运行的 G 会尝试唤醒其他的 PM 组合去执行。假设 G2 唤醒了 M2,M2 绑定了 P2,但 P2 本地队列没有 G,此时 M2 为自旋线程。M2 便会尝试从全局队列中获取 G。

  • 偷取

  • 假设 P 的本地队列和全局队列都空了,会从其他 P 偷取一半 G 到自己的本地队列执行。

  • 切换逻辑

  • G1 运行完后,M 上运行的协程切换回 G0,G0 负责调度时协程的切换。先从 P 的本地队列获取 G2,从 G0 切换到 G2,从而实现 M 的复用。

  • 自旋

  • 自旋线程会占用 CPU 时间,但创建销毁线程也会消耗 CPU 时间,系统最多有 GOMAXPROCS 个自旋线程,其余的线程会在休眠 M 队列里。

  • 系统调用

  • 当 G 进行系统调用时会进入内核态被阻塞,GM 会绑定在一起进行系统调用。M 会释放绑定的 P,把 P 转移给其他空闲的 M 执行。当系统调用结束时,GM 会尝试获取一个空闲的 P。

  • 阻塞处理

  • 当 G 因 channel 或 network I/O 阻塞时,不会阻塞 M,当超过 10ms 时 M 会寻找其他可运行的 G。

  • 公平性

  • 调度器每调度 61 次时,会尝试从全局队列里取出待运行的 Goroutine 来运行,如果没有找到,就去其他 P 偷一些 Goroutine 来执行。

GMP 模型的优势

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

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

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

面试题

如果问到了说一说 GMP 调度模型,建议需要说的内容

在面试中,如果被问到 GMP 调度模型,建议全面地回答以下内容。如果能完整且详细地讲述这些内容,将会展示你对 GMP 调度模型的深刻理解和熟练掌握,这将是面试中的亮点。

基本概念

  1. 线程的内核态和用户态

  2. 线程分为“内核态”和“用户态”。用户态线程即协程,必须绑定一个内核态线程。CPU 只负责处理内核态线程。

  3. 调度器

  4. 在 Go 中,线程是运行 Goroutine 的实体。调度器的功能是将可运行的 Goroutine 分配到工作线程上。

  5. 映射关系

  6. 在 Go 语言中,线程与协程的映射关系是多对多的。这样避免了多个协程对应一个线程时出现的无法使用多核和并发的问题。Go 的协程是协作式的,只有让出 CPU 资源才能调度。如果一个协程阻塞,只有一个线程在运行,其他协程也会被阻塞。

四个概念

  1. 全局队列

  2. 存放等待运行的 Goroutine。

  3. 本地队列

  4. 每个 P(处理器)都有一个本地队列,存放不超过 256 个 Goroutine。新建协程时优先放入本地队列,本地队列满了则将一半的 G 移入全局队列。

  5. GMP

  6. G:Goroutine,Go 语言中的协程。

  7. M:Machine,内核态线程,运行 Goroutine 的实体。

  8. P:Processor,处理器,包含运行 Goroutine 的资源和本地队列。

设计策略

  1. 复用线程

  • Stealing 机制:当一个线程没有可执行的 G 时,会从全局队列或其他 P 的本地队列中偷取 G 来执行。

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

  1. P 并行

  • 有 GOMAXPROCS 个 P,代表最多有这么多个线程并行执行。

  1. 抢占式调度

  • Goroutine 执行超过 10ms 就会强制让出 CPU,防止其他协程饿死。

  1. 特殊的 G0 和 M0

  • G0:每个 M 启动时创建的第一个 Goroutine,仅用于调度,不执行用户代码。每个 M 都有一个 G0。

  • M0:程序启动后的第一个主线程,负责初始化操作和启动第一个 Goroutine。

调度策略

  1. 创建

  • 通过go func()创建一个协程。新创建的协程优先保存在 P 的本地 G 队列,如果本地队列满了,会将 P 本地队列中的一半 G 移入全局队列。

  1. 唤醒

  • 创建 G 时,当前运行的 G 会尝试唤醒其他 PM 组合执行。若唤醒的 M 绑定的 P 本地队列为空,M 会尝试从全局队列获取 G。

  1. 偷取

  • 如果 P 的本地队列和全局队列都为空,会从其他 P 偷取一半 G 到自己的本地队列执行。

  1. 切换

  • G1 运行完后,M 上运行的 Goroutine 切换回 G0,G0 负责调度协程的切换。G0 从 P 的本地队列获取 G2,实现 M 的复用。

  1. 自旋

  • 自旋线程会占用 CPU 时间,但创建销毁线程也消耗 CPU 时间。系统最多有 GOMAXPROCS 个自旋线程,其他线程在休眠 M 队列里。

  1. 系统调用

  • 当 G 进行系统调用时进入内核态被阻塞,M 会释放绑定的 P,把 P 转移给其他空闲的 M 执行。当系统调用结束,GM 会尝试获取一个空闲的 P。

  1. 阻塞处理

  • 当 G 因 channel 或 network I/O 阻塞时,不会阻塞 M。超过 10ms 时,M 会寻找其他可运行的 G。

  1. 公平性

  • 调度器每调度 61 次时,会尝试从全局队列中取出待运行的 Goroutine 来运行。如果没有找到,就去其他 P 偷一些 Goroutine 来执行。

欢迎关注 ❤

我们搞了一个免费的面试真题共享群,互通有无,一起刷题进步。

没准能让你能刷到自己意向公司的最新面试题呢

感兴趣的朋友们可以加我微信:wangzhongyang1993,备注:infoq 面试群。