Golang MPG

工作 · 2024-04-08

go语言一个大的语言特色就是goroutine协程,而和很多同事沟通的时候,他们都认为goroutine很快,今天我们就来看一看goroutine是如何运行的。

MPG模型

go使用的是MPG模型,意思是通过一个全局的调度器来实现goroutine协程的调度,来达到通过分配平均使用CPU资源。

go的调度器有3个重要的结构,M(OS线程)、P(协程调度器),G(goroutine协程)

  • M(OS线程):是操作系统的线程,一个程序可以模拟出多个线程。
  • P(逻辑处理器or协程调度器):这个一个专门调度goroutine协程的逻辑处理器,或者称为协程调度器都可以。
  • G(goroutine协程):goroutine协程。

用户空间线程和内核空间线程之间的映射关系有:N:1、1:1和M:N

  • N:1,多个(N)用户线程始终在一个内核线程上跑,context上下文切换确实很快,但是无法真正的利用多核。
  • 1:1,一个用户线程就只在一个内核线程上跑,这时可以利用多核,但是上下文switch很慢。
  • M:N,多个goroutine在多个内核线程上跑,这个看似可以集齐上面两者的优势,但是无疑增加了调度的难度。

而go使用的就是M:N这种映射关系。

实现原理

当go启动一个进程的时候,会默认创建一个线程,这个线程会有一个逻辑处理器,通过具体的逻辑处理器处理goroutine协程。如下图所示:

img

我们假设当前线程为M2,逻辑处理器为P0,有4个协程G1、G2、G3、G4等待执行,当前正在执行G1

假如当G1有执行文件阻塞的操作,这个时候逻辑处理器P0会将G1分离处理,同时与线程M2分离,如下图:

img

这个时候原有的逻辑处理器P0下面还有很多协程需要执行,于是会生成一个新的线程M3来继续进行当前逻辑处理器P0的处理,此时顺序执行G2。而原有的线程M2和G1则等待文件阻塞操作的完成。

img

此时M3正常执行goroutine,过了一段时间,原有的G1阻塞操作完成,等待被继续执行,而此时G2也执行完成,G1会被重新分配回到逻辑处理器P0进行执行。

img

而这个时候,线程M2并不会立即销毁,而是等待被下次利用。这样一个简单的goroutine协程调度流程就完成了。下面来一张完整图:

完整图

但是这个只是针对系统文件的I/O操作的情况。如果是针对网络I/O的情况,稍微有一点不一样。

涉及网络I/O操作的时候,会使用网络轮询器来进行操作,对这个不太了解,有想深入了解的同学可以查阅相关资料。

但是原理是一样的,都是通过将阻塞的G放到其他线程处理,也放一张完整图:

网络i/O完整图

至此,一个单一的MPG模型就完成了,但是如果实现上面的M:N模型乃?

那就是多线程操作了。上面的列子只是说明一个单一的线程在执行goroutie调度。go启动的时候默认是启动4个线程M,4个线程M都都是一个MPG。图示如下:

img

当我们要分配很多goroutine协程的时候,会被平均分配到各个线程M上,这样就实现了并行处理操作。

并发与并行

从上面的简单原理我们可以看到,go采用的是线程+协程的模式,并不是单一的协程。

  • 并发:同时管理很多事情,这些事情可能只做了一半就被暂停去做别的事情了。
  • 并行:同时做很多事情。

协程实现的是并发,线程实现的是并行。将并发和并行的结合,会做到全程的高性能。

小结

无论go语言如何变种,都逃不过操作系统的进程、线程。而我们所谓的go协程很快,是因为实现了一个复杂的调度器,来同时使用线程和goroutine协程,这样即可以充分利用多核CPU资源。

当然,协程的一个优点是开销非常小,要相对于大规模的线程,协程开销的确非常小。

每个 goroutine (协程) 默认占用内存远比 Java 、C 的线程少(goroutine:2KB ,线程:8MB)

所以这也是其中一个重要原因。

在上一篇我们了解了一下go的goroutine的大致实现原理,知道go为了实现高性能,采用了线程和协程的MPG模型,下面我们就通过具体的代码示例简单验证一下。

首先,我们给下面的程序只分配一个逻辑处理器,即相当于只开有一个线程

import (
    "fmt"
    "runtime"
    "sync"
)

func main() {
    // 分配一个逻辑处理器给调度器使用
    runtime.GOMAXPROCS(1)

    // wg用来等待程序完成
    // 计数加2,表示要等待两个goroutine
    var wg sync.WaitGroup
    wg.Add(2)

    fmt.Println("开始执行goruntine\n")

    // goroutine运行打印大写字母3次
    go printUpperChar(&wg)

    // goroutine运行打印小写字母3次
    go printLowerChar(&wg)

    // 等待goroutine结束
    fmt.Println("等待goroutine执行结束\n")
    wg.Wait()

    fmt.Println("程序终止")
}

//printLowerChar 打印小写字母表3次
func printLowerChar(wg *sync.WaitGroup) {
    // 在函数退出时调用Done来通知main函数工作已经完成
    defer wg.Done()

    // 显示字母表3次
    for count := 0; count < 3; count++ {
        for char := 'a'; char < 'a'+26; char++ {
            fmt.Printf("%c ", char)
        }
    }

    fmt.Println("\n")
}

//printUpperChar 打印大写字母表3次
func printUpperChar(wg *sync.WaitGroup) {
    // 在函数退出时调用Done来通知main函数工作已经完成
    defer wg.Done()

    // 显示字母表3次
    for count := 0; count < 3; count++ {
        for char := 'A'; char < 'A'+26; char++ {
            fmt.Printf("%c ", char)
        }
    }

    fmt.Println("\n")
}

执行结果:

开始执行goruntine

等待goroutine执行结束

a b c d e f g h i j k l m n o p q r s t u v w x y z a b c d e f g h i j k l m n o p q r s t u v w x y z a b c d e f g h i j k l m n o p q r s t u v w x y z 

A B C D E F G H I J K L M N O P Q R S T U V W X Y Z A B C D E F G H I J K L M N O P Q R S T U V W X Y Z A B C D E F G H I J K L M N O P Q R S T U V W X Y Z 

程序终止

我们可以看到,执行结果是协程执行的两个方法,都是一个方法执行完了,才会有另外一个方法执行输出。无论我们执行多少次都是这样。

这是因为我们就只有一个调度器P,只会由一个线程用到一个CPU资源。

下面我们将调度器修改为2个,这个时候go会自动分配2个CPU资源。

// 其他没有变化,只是将runtime.GOMAXPROCS(2)中的1修改为了2
func main() {
    // 分配一个逻辑处理器给调度器使用
    runtime.GOMAXPROCS(2)

    // wg用来等待程序完成
    // 计数加2,表示要等待两个goroutine
    var wg sync.WaitGroup
    wg.Add(2)

    fmt.Println("开始执行goruntine\n")

    // goroutine运行打印大写字母3次
    go printUpperChar(&wg)

    // goroutine运行打印小写字母3次
    go printLowerChar(&wg)

    // 等待goroutine结束
    fmt.Println("等待goroutine执行结束\n")
    wg.Wait()

    fmt.Println("程序终止")
}

执行结果:

css
复制代码开始执行goruntine

等待goroutine执行结束

a b c d e f g A B C D E F G H I J K L M N O h i j k l m n o p q r s t u P Q R S v w x y z a b c d e f g h i j T U V W k l m n o p q r s t u v w x y z a b X Y Z A B C D E F G H I J K L M N O P Q R S T U V c d e f W X Y Z A B C D E F G H I J K L M N O P Q R S T U V W X Y Z 

g h i j k l m n o p q r s t u v w x y z 

程序终止

这次我们发现,输出不再是之前的一个方法执行完了接着执行另外一个方法,而是乱序的了,大小写夹杂着输出。着就是多个线程在同时执行,就是所谓了并行。

不用说,并行的效率肯定会更高效。那我们有必要在每次都指定runtime.GOMAXPROCS(1)的个数吗?

答案是不用,因为go默认会启动4个逻辑处理器,但是不知道后续版本中是会根据CPU核数启动,还是指定的4个,这个需要查阅相关文档。

如果我们编写了一个应用程序,需要将性能最大化,可以进行一定的修改调整,然后进行性能测试。

作者:y80x86ol
链接:https://juejin.cn/post/6987361342688591903

作者:y80x86ol
链接:https://juejin.cn/post/6987360989150707720

Theme Jasmine by Kent Liao