golang 中的协程 goroutine,深度理解 GMP 模型

golang 中的协程 goroutine,深度理解 GMP 模型,第1张

GMP模型 一、前言 1.1 进程与线程

在早期单核CPU的场景下,程序以单进程单线程运行,计算机只能一个任务一个任务的执行,这就造成了进程阻塞时导致CPU资源的浪费。

所以后来就出现了线程,把每个任务单独的放到一个线程中,CPU 按照时间片调度并发的执行每一个线程,这样任务看起来就好像同时在执行。

但是按照线程来调度有两个不可修改的缺点:

CPU 资源有一部分被用于切换任务时内存的占用

上下文的切换

CPU 在执行任务的时候很有可能一个任务还没有执行完,时间片就用完了需要执行下一个任务,这时 CPU 会保存现场,待到下次分配时间片时读取现场继续执行任务,保存和读取现场一定会消耗 CPU 的资源,大量的 CPU 资源就被消耗在线程间上下文的切换。

有时做相同的任务,甚至于在单线程下速度要优于多线程,主要就是由于线程切换上下文带来的消耗。

内存的占用

创建一个线程需要一般情况下需要 4mb 的内存,而创建一个进程在一些 *** 作系统下需要 gb 级别的虚拟内存,实际内存也达到了 mb 的级别,所以说线程和进程的创建是非常消耗资源的。

1.2 协程

实际上一个线程基本可以看作两个部分,用户部分和内核部分。

粗略的看,用户部分就是我们自己写的程序,内核部分就是 *** 作系统对内存、磁盘等资源的 *** 作。我们能否在用户部分创建多个任务共用一个内核线程,并通过一个调度器来控制这些任务的执行从而减少上下文的切换?于是协程(co-routine)出现了。

在 go 语言中称协程为 goroutine

协程除了减少上下文的切换还减少了内存的占用,创建一个携程只需要 2kb 的内存空间,这就可以允许用户创建大量的协程共同工作而不会导致内存的大量占用。

那么协程和线程如何对应才比较合适呢,一般是多个协程对应多个线程。

M : 1

考虑 M 个协程对应 1 个线程的情况:

假如 A, B, C 三个协程共用一个线程,调度器按照 A, B, C 的顺序把每个协程分配给线程去执行,在这种情况下如果 B 协程发生了长时间的阻塞,此时协程 C 也会被阻塞。

所以 M : 1 存在一定的问题。

1 : 1

考虑 1 个协程对应 1 个线程的情况:

这种情况下与多线程几乎没有区别

M : N

考虑 M 个协程对应 N 个线程的情况:

这种情况下就可以解决 M : 1 情况下的阻塞问题,比如只需要将阻塞的协程单独运行在一个线程上,其他任务再拿到一个新的线程上运行,当然这就需要一个非常优秀的调度器来对协程进行调度,这样可以大大的提高 CPU 的利用率。

二、GMP模型 2.1 模型简介

GMP其实是一个缩写,分别代表:

G:协程M:线程P:调度器

在系统中的布局如下:

全局队列与本地队列

本地队列和全局队列存放的都是待执行的 goroutine。

一个 goroutine 在创建时优先会存放到 P 的本地队列中,一个本地队列最大能存放 256 个 goroutine,当本地队列存不下了后会存放在全局队列中。

调度器 P

P 是在程序开始运行是创建的,调度器的数量默认与内核的数量相等,可以通过 runtime.GOMAXPROCS 来设置,调度器会从本地队列中取 goroutine 并交给内核线程去执行。

内核线程 M

M 指的是 *** 作系统分配到程序的内核线程数,M 的数量是动态的,会随着 M 的阻塞和空闲进行分配和回收或睡眠。

2.2 调度器的调度策略

GMP 模型有如下的调度策略:

复用线程 work stealinghand off 利用并行抢占全局 G 队列

线程的复用

work stealing

当一个 P 的本地队列中没有 G 时,它会到其它 P 的本地队列中偷取一半的 G 放在本地执行

hand off

当 P 正在执行的 G 发生了阻塞,此时会创建/唤醒一个新的 M 并继续执行其他的 G,原来的 M 睡眠或销毁,如果阻塞的 G 在阻塞结束后需要继续执行会加入到一个本地队列。

利用并行

可以通过 GOMAXPROCS 限定使用 CPU 的个数,从而让剩下的 CPU 执行其他的线程。

抢占

每个 G1 在分配到 CPU 时如果有 G2 在等待运行,当前的 G1 在运行 10ms 后被正在等待的 G2 抢占 CPU 并重新加入到本地队列或全局队列中去。

这样保证了在并发场景下协程的相对公平,让每个 G 都有机会运行不会等待太久。

全局队列

当一个 P 的本地队列为空,他会尝试去其他本地队列获取 G,如果其他本地队列也为空,就回到全局队列获取 G。

2.3 调度器的生命周期

开始前首先要了解一个特殊的线程 M0 和一个特殊的协程 G0

M0 启动程序后编号为 0 的主线程在全局变量 runtime.m0 中,不需要在堆中额外分配负责执行初始化 *** 作和启动第一个 G启动第一个 G 后,M0 就与其他的 M 一样了 G0 每启动一个 M,都会创建一个 G 称为 G0G0 仅用于调度其它的 GG0 不指向任何可执行的函数在调度或系统调用时会使用 M 会切换到 G0 ,来调度M0 的 G0 会放在全局空间

当一个 go 程序启动后:

启动 M0,M0 启动 G0,初始化 P 列表,全局队列G0 加载 main 函数生成一个 GM0 对 G0 解绑,M0 绑定到一个 P 并把 main函数生成的 G 加入本地队列程序正常运行(按照 2.2 的调度策略) 2.4 可视化查看GMP
package main

import (
	"fmt"
	"os"
	"runtime/trace"
)

func main() {
	// 1. 创建 trace.out 文件
	f, err := os.OpenFile("trace.out", os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644)
	if err != nil {
		return
	}

	// 运行 trace
	err = trace.Start(f)
	if err != nil {
		return
	}

	// 要调试的代码
	fmt.Println("hello gmp")

	// 关闭trace
	trace.Stop()
}

执行上列代码生成一个 trance.out 文件

go tool trace ./trace.out

通过 go 工具包查看信息

其中包括了 GMP 的信息

三、场景 创建新的 G 当一个 G 创建另一个 G时,会优先放在同一个本地队列中 G 执行完毕 当 G 执行完毕后,G0 会被分配资源进行一系列初始化 *** 作,然后从本地队列获取 G 连续创建多个 G 如果连续创建多个 G,首先会将本地队列先创建满如果本地队列满了,创建新的 G 时会先将本地队列中前一半的 G 打乱并和新创建的 G 一起加入全局队列重复前两个 *** 作 唤醒一个休眠中的 M 当一个 G 创建一个新的 G 时会尝试唤醒一个 M,唤醒后 M 会尝试与一个 P 去绑定M 会分配给 G0 资源尝试获取 G如果绑定的 P 是空闲的,M 就会开始自旋尝试获取 G,M此时成为自旋线程 偷取G 自旋线程会尝试到全局获取1个 G,没有则到其他本地队列偷取后半部分的 G G 发生阻塞 阻塞的 G 会被分配给当前 MP 会尝试获取一个空闲的 M,如果没有则进入空闲 P 队列等待有空闲的 M G 的阻塞完成 原来的 M 会先尝试获取原来的 P,失败则尝试获取空闲 P 队列中的 P,还没有则把 G 加入全局队列并且自身加入休眠队列

欢迎分享,转载请注明来源:内存溢出

原文地址: http://outofmemory.cn/langs/995633.html

(0)
打赏 微信扫一扫 微信扫一扫 支付宝扫一扫 支付宝扫一扫
上一篇 2022-05-21
下一篇 2022-05-21

发表评论

登录后才能评论

评论列表(0条)

保存