Golang 语言的 goroutine 调度器模型 GPM

简介: Golang 语言的 goroutine 调度器模型 GPM

01

介绍


Golang 语言与其他编程语言之间比较,最大的亮点就是 goroutine,使 Golang 语言天生支持并发,可以高效使用 CPU 的多个核心,而并发执行需要一个调度器来协调。

Golang 语言的调度器是基于协程调度模型 GMP,即 goroutine(协程)、processor(处理器)、thread(线程),通过三者的相互协作,实现在用户空间管理和调度并发任务。

640.png

其中 thread(M) 是由操作系统分配的执行 Go 程序的线程,一个 thread(M) 和一个 processor(P) 绑定,M 是实际执行体,以调度循环的方式执行 G 并发任务。M 通过修改寄存器,将执行栈指向 G 自带的栈内存,并在此空间内分配堆栈帧,执行任务函数。M 只负责执行,不再持有状态,这时并发任务跨线程调度,实现多路复用的根本所在。每个 processor(P) 维护一个本地 goroutine(G) 队列,此外还有一个全局 goroutine(G) 队列。

其中,M 的数量由操作系统分配,并且如果有 M 阻塞,操作系统会创建新的 M,如果有 M 空闲,操作系统会回收 M 或将空闲 M 睡眠,此外,Golang 语言还可以限定 M 的最大数量为 10000,runtime/debug 包中的 SetMaxThread 函数也可以设置 M 的数量。

Go 程序启动时,会创建 P 列表,P 的数量由环境变量 GOMAXPROCS 的值设置,也可以在 Go 程序中通过调用 runtime 包的 GOMAXPROCS 函数来设置。自 Go1.5 开始,GOMAXPROCS 的默认值为 CPU 的核心数,但是 GOMAXPROCS 的默认值在某些情况下也不是最优值。

P 的作用类似 CPU 的核心,用来控制可同时并发执行的任务数量,每个内核的线程 M 必须绑定到一个 P 上,M 才可以被允许执行任务,否则被操作系统将其睡眠或回收。M 独享所绑定的 P 的资源(对象分配内存、本地任务队列等),可以在无锁状态下执行高效操作。

虽然一个 P 绑定一个 M,但是 P 和 M 的数量并不一致。原因是当 M 因陷入系统调用而长时间阻塞时,P 就会被监控线程抢占,去唤醒睡眠的 M 或新建 M 去执行 P 的本地任务队列,这样 M 的数量就会增长。

每个 P 维护一个本地 G 队列,此外,还有一个全局 G 队列,那么新创建的 G 会放在哪里?新创建的 G 优先放在有空闲空间的 P 中(每个 P 的最大存储数量是 256个),如果所有 P 的存储空间都满了,则存放在全局 G 队列中。

02

调度器的发展历史


单进程的操作系统

多进程之间按照先后顺序执行,同一时间只能执行其中一个进程。如果在执行过程中,正在执行的进程如果发生阻塞,CPU 需要等待进程执行完成后,再执行下一个进程,会造成对 CPU 执行时间的浪费。

多线程/多进程的操作系统

CPU 调度器轮询调度多个进程,固定时间内轮询执行其中一个进程,不关心进程在固定时间内是否执行完毕。视觉效果是并发执行,实际上是 CPU 调度器的轮询调度。避免了因为正在执行的进程阻塞,浪费 CPU 的执行时间。

但是带来了另外一些问题,CPU 切换执行多个线程/进程也需要一定成本,并且随着任务的增加,线程/进程的数量也会增加,同时就会增加 CPU 切换成本。即 CPU 使用率一部分被切换成本消耗。另外,多线程/进程之间,还会有数据竞争。

关于内存占用方面,在 32 位操作系统中,进程大概占用 4G,线程大概占用 4M。

因为多线程/进程在 CPU 调度上和内存占用上消耗较大,并且优化难度较大,所以出现了协程,也叫做用户线程。

一个线程分为内核空间和用户空间两个部分,其中内核态负责线程创建和销毁,分配物理内存空间和磁盘空间;用户态负责承担应用开销。

协程(用户线程)

协程,也叫做用户线程,位于用户态。用户线程和内核态的线程之间是绑定关系。用户线程负责应用开销,内核线程负责系统调用的开销。CPU 只需负责系统调用,无需关心应用开销,提升了 CPU 使用率。

为了进一步提升内核态线程的使用率,所以出现了协程调度器,协程调度器与内核态的线程绑定,负责轮询调度多个协程。CPU 无需关心多个协程之间的调度,降低了 CPU 切换成本。通过协程调度器,线程和任务之间的关系由1 比 1,变为 1 比 N,但是仍然存在协程阻塞和无法利用 CPU 的多个核心的问题。

协程调度器又做了改进,由一个内核的线程对一个协程调度器,改为多个内核的线程对一个协程调度器,即线程和任务之间由 1 比 M,变为 N 比 M。这样的好处是可以有效利用多核 CPU,每个 CPU 的核心处理一个内核的线程。

将 CPU 切换消耗成本转换到协程调度器的切换消耗成本,可以通过进一步优化协程调度器,提升操作系统的性能。相比优化操作系统,优化协程调度器更加容易。

Golang 语言的协程调度

关于内存占用方面,创建一个 Golang 的协程(goroutine)初始栈仅有 2K,并且创建操作只在用户空间简单地分配对象,比在内核态分配线程要简单的多,所以可以创建成千上万的 goroutine,并且可以通过协程调度器循环调度 goroutine。

关于协程调度方面,Golang 语言早期是通过多个线程去轮询调度一个全局的 goroutine 队列,存在锁竞争和 CPU 切换线程的成本。

03

Golang 语言的 goroutine 调度器模型 GMP 的设计思想


复用线程:

我们已经知道,一个 M 和一个 P 绑定,M 和 P 的关系是 1 比 1,如果 M 阻塞,操作系统会唤醒睡眠的 M,如果没有睡眠的 M,操作系统会创建新的 M,并把阻塞的 M 上的 P 绑定到唤醒的睡眠的 M 或新创建的 M,该操作被称为 hand off 机制。阻塞的 M 达到操作系统最大时间就会被操作系统销毁或将其睡眠,未被执行的 G 会加入到其他 P 的队列中。

如果一个 P 的 G 被 M 处理完,会怎么样的?M 会等待 P 去获取新的 G,P 优先去其他 P 上获取(偷)它们的待处理的 G,该操作被称为 work stealing 机制。

使用 CPU 的多个核心:

我们已经知道,P 的数量由环境变量 GOMAXPROCS 的值设置, Go1.5 之前,GOMAXPROCS 的默认值为 1,自 Go1.5 开始,GOMAXPROCS 的默认值为 CPU 的核心数,可以有效使用 CPU 的多个核心,并行执行。

抢占调度:

co-routine 时代,CPU 轮询执行每个协程,每个协程执行完,主动释放 CPU 资源。

goroutine 时代,CPU 规定每个协程的最大执行时间为 10ms,超过最大执行时间,即便该协程未执行完,其它协程也会抢占 CPU 资源。

全局 G 队列:

如果一个 P 的 G 被 M 处理完,并且其他所有的 P 都没有待处理的 G,那么 P 会去全局 G 队列获取 G,此时会触发锁机制。

也许有的读者会说,那如果全局 G 队列也没有待处理的 G 呢?如果这样,该 M 达到最大空闲时间,就会被操作系统回收或将其睡眠。

04

m0 和 g0


什么是 m0?

m0 表示进程启动的第一个线程,也叫主线程。它和其他的 m 没有什么区别,要说区别的话,它是进程启动通过汇编直接复制给 m0 的,m0 是个全局变量,而其他的 m 都是 runtime 内自己创建的。m0 的赋值过程,可以看前面 runtime/asm_amd64.s 的代码。一个 go 进程只有一个 m0。

什么是 g0?

首先要明确的是每个 m 都有一个 g0,因为每个线程有一个系统堆栈,g0 虽然也是 g 的结构,但和普通的 g 还是有差别的,最重要的差别就是栈的差别。g0 上的栈是系统分配的栈,在 linux 上栈大小默认固定 8M,不能扩展,也不能缩小。而普通 g 一开始只有 2K 大小,可扩展。在 g0 上也没有任何任务函数,也没有任何状态,并且它不能被调度程序抢占。因为调度就是在 g0 上跑的。

proc.go 中的全局变量 m0和g0

runtime/proc.go 的文件中声明了两个全局变量,m0 表示主线程,这里的 g0 表示和 m0 绑定的 g0,也可以理解为 m0 线程的堆栈,这两个变量的赋值是汇编实现的。

到这里我们应该知道了 g0 和 m0 是什么了?m0 代表主线程、g0 代表了线程的堆栈。调度都是在系统堆栈上跑的,也就是一定要跑在 g0 上,所以 mstart1 函数才检查是不是在 g0 上, 因为接下来就要执行调度程序了。

05

调度器跟踪调试


Go 允许跟踪运行时调度器。这是通过 GODEBUG 环境变量完成的:

GODEBUG=scheddetail=1,schedtrace=1000 ./program

输出示例:

SCHED 0ms: gomaxprocs=8 idleprocs=7 threads=2 spinningthreads=0 idlethreads=0 runqueue=0 gcwaiting=0 nmidlelocked=0 stopwait=0 sysmonwait=0
 P0: status=1 schedtick=0 syscalltick=0 m=0 runqsize=0 gfreecnt=0
 P1: status=0 schedtick=0 syscalltick=0 m=-1 runqsize=0 gfreecnt=0
 P2: status=0 schedtick=0 syscalltick=0 m=-1 runqsize=0 gfreecnt=0
 P3: status=0 schedtick=0 syscalltick=0 m=-1 runqsize=0 gfreecnt=0
 P4: status=0 schedtick=0 syscalltick=0 m=-1 runqsize=0 gfreecnt=0
 P5: status=0 schedtick=0 syscalltick=0 m=-1 runqsize=0 gfreecnt=0
 P6: status=0 schedtick=0 syscalltick=0 m=-1 runqsize=0 gfreecnt=0
 P7: status=0 schedtick=0 syscalltick=0 m=-1 runqsize=0 gfreecnt=0
 M1: p=-1 curg=-1 mallocing=0 throwing=0 preemptoff= locks=1 dying=0 helpgc=0 spinning=false blocked=false lockedg=-1
 M0: p=0 curg=1 mallocing=0 throwing=0 preemptoff= locks=1 dying=0 helpgc=0 spinning=false blocked=false lockedg=1
 G1: status=8() m=0 lockedm=0

请注意,输出结果使用了 G、M 和 P 的概念及其状态,比如 P 的 queue 大小。通常,您不需要那么多细节,因此只需使用:

GODEBUG=schedtrace=1000 ./program

此外,Golang 还有一个高级工具,名为 go tool trace,它具有 UI,允许您浏览程序以及运行时正在做什么。

06 

总结


本文通过 Golang 语言的 goroutine 调度器模型 GPM、调度器的发展历史、Golang 语言的 goroutine 调度器的设计思想、m0 和 g0 的概念,以及调度器跟踪调试几个方面来介绍,关于介绍 Golang 语言调度器模型 GPM 的文章在网上有很多,建议读者多阅读一些相关文章,加深理解。





目录
相关文章
|
1月前
|
监控 算法 Go
Golang深入浅出之-Go语言中的服务熔断、降级与限流策略
【5月更文挑战第4天】本文探讨了分布式系统中保障稳定性的重要策略:服务熔断、降级和限流。服务熔断通过快速失败和暂停故障服务调用来保护系统;服务降级在压力大时提供有限功能以保持整体可用性;限流控制访问频率,防止过载。文中列举了常见问题、解决方案,并提供了Go语言实现示例。合理应用这些策略能增强系统韧性和可用性。
122 0
|
14天前
|
Go
GoLang 使用 goroutine 停止的几种办法
GoLang 使用 goroutine 停止的几种办法
16 2
|
1月前
|
前端开发 Go
Golang深入浅出之-Go语言中的异步编程与Future/Promise模式
【5月更文挑战第3天】Go语言通过goroutines和channels实现异步编程,虽无内置Future/Promise,但可借助其特性模拟。本文探讨了如何使用channel实现Future模式,提供了异步获取URL内容长度的示例,并警示了Channel泄漏、错误处理和并发控制等常见问题。为避免这些问题,建议显式关闭channel、使用context.Context、并发控制机制及有效传播错误。理解并应用这些技巧能提升Go语言异步编程的效率和健壮性。
65 5
Golang深入浅出之-Go语言中的异步编程与Future/Promise模式
|
1月前
|
监控 Go 开发者
Golang深入浅出之-Goroutine泄漏检测与避免:pprof与debug包
【5月更文挑战第2天】本文介绍了Go语言并发编程中可能遇到的Goroutine泄漏问题,以及如何使用`pprof`和`debug`包来检测和防止这种泄漏。常见的问题包括忘记关闭channel和无限制创建goroutine。检测方法包括启动pprof服务器以监控Goroutine数量,使用`debug.Stack()`检查堆栈,以及确保每个Goroutine有明确的结束条件。通过这些手段,开发者可以有效管理Goroutine,维持程序性能。
81 7
|
1月前
|
Prometheus 监控 Cloud Native
Golang深入浅出之-Go语言中的分布式追踪与监控系统集成
【5月更文挑战第4天】本文探讨了Go语言中分布式追踪与监控的重要性,包括追踪的三个核心组件和监控系统集成。常见问题有追踪数据丢失、性能开销和监控指标不当。解决策略涉及使用OpenTracing或OpenTelemetry协议、采样策略以及聚焦关键指标。文中提供了OpenTelemetry和Prometheus的Go代码示例,强调全面可观测性对微服务架构的意义,并提示选择合适工具和策略以确保系统稳定高效。
165 5
|
1月前
|
监控 负载均衡 算法
Golang深入浅出之-Go语言中的协程池设计与实现
【5月更文挑战第3天】本文探讨了Go语言中的协程池设计,用于管理goroutine并优化并发性能。协程池通过限制同时运行的goroutine数量防止资源耗尽,包括任务队列和工作协程两部分。基本实现思路涉及使用channel作为任务队列,固定数量的工作协程处理任务。文章还列举了一个简单的协程池实现示例,并讨论了常见问题如任务队列溢出、协程泄露和任务调度不均,提出了解决方案。通过合理设置缓冲区大小、确保资源释放、优化任务调度以及监控与调试,可以避免这些问题,提升系统性能和稳定性。
65 6
|
1月前
|
负载均衡 算法 Go
Golang深入浅出之-Go语言中的服务注册与发现机制
【5月更文挑战第4天】本文探讨了Go语言中服务注册与发现的关键原理和实践,包括服务注册、心跳机制、一致性问题和负载均衡策略。示例代码演示了使用Consul进行服务注册和客户端发现服务的实现。在实际应用中,需要解决心跳失效、注册信息一致性和服务负载均衡等问题,以确保微服务架构的稳定性和效率。
37 3
|
1月前
|
安全 Go
Golang深入浅出之-Go语言中的并发安全队列:实现与应用
【5月更文挑战第3天】本文探讨了Go语言中的并发安全队列,它是构建高性能并发系统的基础。文章介绍了两种实现方法:1) 使用`sync.Mutex`保护的简单队列,通过加锁解锁确保数据一致性;2) 使用通道(Channel)实现无锁队列,天生并发安全。同时,文中列举了并发编程中常见的死锁、数据竞争和通道阻塞问题,并给出了避免这些问题的策略,如明确锁边界、使用带缓冲通道、优雅处理关闭以及利用Go标准库。
42 5
|
1月前
|
分布式计算 Java Go
Golang深入浅出之-Go语言中的分布式计算框架Apache Beam
【5月更文挑战第6天】Apache Beam是一个统一的编程模型,适用于批处理和流处理,主要支持Java和Python,但也提供实验性的Go SDK。Go SDK的基本概念包括`PTransform`、`PCollection`和`Pipeline`。在使用中,需注意类型转换、窗口和触发器配置、资源管理和错误处理。尽管Go SDK文档有限,生态系统尚不成熟,且性能可能不高,但它仍为分布式计算提供了可移植的解决方案。通过理解和掌握Beam模型,开发者能编写高效的数据处理程序。
160 1
|
1月前
|
缓存 测试技术 持续交付
Golang深入浅出之-Go语言中的持续集成与持续部署(CI/CD)
【5月更文挑战第5天】本文介绍了Go语言项目中的CI/CD实践,包括持续集成与持续部署的基础知识,常见问题及解决策略。测试覆盖不足、版本不一致和构建时间过长是主要问题,可通过全面测试、统一依赖管理和利用缓存优化。文中还提供了使用GitHub Actions进行自动化测试和部署的示例,强调了持续优化CI/CD流程以适应项目需求的重要性。
71 1