文章详情

短信预约-IT技能 免费直播动态提醒

请输入下面的图形验证码

提交验证

短信预约提醒成功

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

2024-11-29 19:12

关注

关键/核心题目

在深入探究Go语言的GMP模型之前,我们先来思考几个关键的题目,这些问题将引导我们更深入地理解和掌握GMP模型的精髓。

  1. 什么是GMP模型?请解释其基本概念。
  1. 如何理解GMP模型中线程的内核态和用户态?
  1. Go语言中的Goroutine与线程的映射关系是怎样的?为什么选择这种映射方式?

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

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

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

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

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

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

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

通过这些问题的思考,将能够系统地掌握GMP模型的核心概念,理解其调度机制,并在工作中展现出对Go并发模型的深刻理解。

基本概念

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

面临的两个问题如下。

  1. 单一执行流程:由于只能一个个执行程序,无法同时处理多个任务,这大大限制了CPU的利用率。
  2. 进程阻塞:当一个进程遇到I/O操作等阻塞情况时,CPU资源会被浪费,等待进程完成阻塞操作后再继续执行,导致效率低下。

多进程/线程并发时代

基本概念

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

两个问题

  1. 高开销:进程拥有大量资源,进程的创建、切换和销毁都需要消耗大量的时间和资源。这导致CPU很大一部分时间都在处理进程调度,而不是实际的任务执行。
  2. 高内存占用:在32位机器下,进程的虚拟内存占用为4GB,线程占用为4MB。大量的线程和进程会导致高内存消耗,限制了系统的扩展性。

协程的引入

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

协程的基本概念

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

内核态和用户态

图片

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

执行流程如下。

  1. 用户态线程:用户程序创建多个用户线程(如协程),如图中的“User Thread 1”、“User Thread 2”和“User Thread 3”。
  2. 内核态线程:用户线程需绑定到内核态线程上执行,如图中的“Kernel Thread 1”和“Kernel Thread 2”。
  3. CPU处理:

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

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

线程和协程的映射关系

  1. 单线程绑定所有协程

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

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

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

  3. 多对多映射

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

图片

Goroutine

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

轻量级

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

高效调度

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

Goroutine的使用示例

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

package main

import (
    "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 main

import (
    "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)进行同步。

关于waitgroup我会在下一篇中进行详细讲解。

Goroutine调度器

基本概念

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

被废弃的调度器

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

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

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

GMP模型的设计思想

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

基本概念

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

GMP模型的组成

图片

设计策略

复用线程的两个策略

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

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

特殊的G0和M0

调度策略

创建两步:

图片

唤醒获取:创建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. 线程的内核态和用户态

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

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

  3. 映射关系

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

三个概念

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

  3. GMP:

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

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

  6. 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来执行。本文转载自微信公众号「王中阳」,作者「王中阳」,可以通过以下二维码关注。

转载本文请联系「王中阳」公众号。

来源:王中阳内容投诉

免责声明:

① 本站未注明“稿件来源”的信息均来自网络整理。其文字、图片和音视频稿件的所属权归原作者所有。本站收集整理出于非商业性的教育和科研之目的,并不意味着本站赞同其观点或证实其内容的真实性。仅作为临时的测试数据,供内部测试之用。本站并未授权任何人以任何方式主动获取本站任何信息。

② 本站未注明“稿件来源”的临时测试数据将在测试完成后最终做删除处理。有问题或投稿请发送至: 邮箱/279061341@qq.com QQ/279061341

软考中级精品资料免费领

  • 历年真题答案解析
  • 备考技巧名师总结
  • 高频考点精准押题
  • 2024年上半年信息系统项目管理师第二批次真题及答案解析(完整版)

    难度     813人已做
    查看
  • 【考后总结】2024年5月26日信息系统项目管理师第2批次考情分析

    难度     354人已做
    查看
  • 【考后总结】2024年5月25日信息系统项目管理师第1批次考情分析

    难度     318人已做
    查看
  • 2024年上半年软考高项第一、二批次真题考点汇总(完整版)

    难度     435人已做
    查看
  • 2024年上半年系统架构设计师考试综合知识真题

    难度     224人已做
    查看

相关文章

发现更多好内容

猜你喜欢

AI推送时光机
位置:首页-资讯-后端开发
咦!没有更多了?去看看其它编程学习网 内容吧
首页课程
资料下载
问答资讯