摘要
Golang(Go)是一种现代化的编程语言,以其并发性和高效性而闻名。其中一个关键的组成部分是调度器,它负责协调并发任务的执行。本文将介绍Golang调度器的GMP模型,该模型是Golang v1.16版本中的重要改进。
Golang是一门开发高性能并发应用程序的编程语言。它通过内置的调度器来管理并发任务的调度和执行。在早期的Golang版本中,调度器使用的是M:N模型,即将M个用户级线程(goroutine)映射到N个内核级线程上。然而,在Golang v1.14版本中,调度器的底层实现发生了重大改变,引入了GMP模型。
进程线程协程
在介绍Golang调度器的GMP模型之前,我们先来了解一下进程、线程和协程的概念,以及它们之间的区别。
- 进程:
进程是操作系统中的一个执行实体,它拥有独立的内存空间和资源。每个进程都是独立运行的,它们之间无法直接共享数据。进程是操作系统进行资源分配和调度的基本单位。 - 线程:
线程是进程中的一个执行单元,它与同一进程中的其他线程共享内存空间和资源。线程可以看作是轻量级的进程,它们之间可以直接共享数据。线程的创建和销毁比进程更加轻量级,因此多线程编程可以提供更高的并发性。 - 协程:
协程是一种更加轻量级的并发编程模型,它比线程更加灵活和高效。协程可以在同一个线程中同时执行多个任务,通过协程调度器进行任务切换,实现并发执行。协程之间的切换不需要操作系统的介入,因此切换的开销很小。协程之间可以通过通道进行通信和同步。
区别:
- 进程是操作系统进行资源分配和调度的基本单位,拥有独立的内存空间和资源。线程是进程中的执行单元,共享进程的内存空间和资源。协程是更加轻量级的执行单元,可以在同一个线程中同时执行多个任务。
- 进程之间无法直接共享数据,线程可以直接共享进程的内存空间和资源,协程之间也可以直接共享数据。
- 进程切换的开销较大,需要操作系统的介入。线程切换的开销较小,但仍需要操作系统的介入。协程切换的开销非常小,不需要操作系统的介入。
- 进程之间的通信和同步需要使用操作系统提供的机制,如管道、消息队列等。线程之间可以直接共享内存进行通信和同步。协程之间可以通过通道进行通信和同步。
进程 | 线程 | 协程 | |
---|---|---|---|
定义 | 操作系统中的一个执行单位 | 进程中的一个执行流程 | 线程中的一个执行流程 |
资源 | 拥有独立的内存空间和系统资源 | 共享进程的内存空间和系统资源 | 共享线程的内存空间和系统资源 |
切换开销 | 需要上下文切换,切换开销较大 | 需要较小的上下文切换开销 | 无需上下文切换,切换开销极小 |
并发性 | 可以并发执行多个进程 | 可以并发执行多个线程 | 可以并发执行多个协程 |
通信 | 进程间通信需要特殊机制 | 线程间通信直接共享内存 | 协程间通信通过特定方法(如yield) |
例子:
-
进程:可以将进程类比为一个公司,每个公司有自己的独立资源和员工,彼此之间独立运行,互不干扰。不同的公司可以并发执行,每个公司都有自己的任务和目标。
-
线程:可以将线程类比为一个公司中的不同部门,部门之间共享公司的资源和员工,彼此之间可以并发执行。不同部门可以同时进行不同的任务,但需要协调资源的使用。
-
协程:可以将协程类比为一个公司中的一个员工,员工可以独立执行任务,但可以通过协作和切换来提高效率。员工可以在执行自己的任务时,根据需要暂停执行,将控制权交给其他员工执行,待其他员工完成任务后再继续执行自己的任务。这样可以实现多个任务之间的切换,提高整体执行效率。
Golang在并发编程中采用了协程的方式,即Goroutine。Goroutine是Golang中的轻量级线程,它比传统的线程更加高效和灵活。Goroutine之间通过通道进行通信和同步,而Golang调度器的GMP模型负责协调Goroutine的调度和执行。
GMP模型
GMP模型是Golang调度器的核心组成部分。它由三个主要的组件组成:
- G(Goroutine):Goroutine是Golang中的轻量级线程,它代表一个并发任务。每个Goroutine都有自己的栈和相关的上下文信息。Goroutine的创建和销毁由GMP模型负责管理。
- M(Machine):Machine是Golang调度器中的执行线程,它负责将Goroutine映射到操作系统线程上。每个M都有自己的调用栈和寄存器状态。调度器会根据需要创建或销毁M,以适应并发任务的数量和系统负载。
- P(Processor):Processor是M的上下文,它维护了一组可运行的Goroutine队列。每个P都与一个M关联,并负责将Goroutine调度到M上执行。当一个Goroutine被调度执行时,它会占用所在的P,并在执行完成后释放。
Goroutine
Goroutine的内部数据结构是由Golang运行时系统维护的,对于开发者来说,我们无法直接访问或操作它们。但是,我们可以根据Golang运行时的源代码了解一些关于Goroutine内部数据结构的信息。
在Golang运行时源代码中,Goroutine的内部数据结构可以在runtime
包中找到。其中,Goroutine的内部数据结构体定义在runtime/runtime2.go
文件中,名为g
。
下面是一些常见的Goroutine内部数据结构字段的解释:
goid
:Goroutine的唯一标识符。status
:Goroutine的状态,如运行、阻塞等。stack
:Goroutine的栈,用于存储函数调用栈帧。m
:与Goroutine相关联的操作系统线程(Machine)。atomicstatus
:Goroutine状态的原子化版本,用于并发访问。sched
:与Goroutine相关联的调度器。lockedm
:当Goroutine被阻塞时,锁定的操作系统线程(Machine)。
此外,Goroutine的内部数据结构还包含其他一些字段,用于管理调度、垃圾回收等。这些字段的具体细节可能会因Golang版本和运行时实现而有所不同。
好的,下面是一个简单的示例代码,演示了Goroutine的一些内部数据结构:
go
package main
import (
"fmt"
"runtime"
)
func main() {
go hello()
// 获取当前Goroutine的ID
goroutineID := runtime.GoID()
fmt.Println("Main Goroutine ID:", goroutineID)
// 获取当前Goroutine的状态
status := getGoroutineStatus(goroutineID)
fmt.Println("Main Goroutine Status:", status)
// 获取当前Goroutine的栈大小
stackSize := getGoroutineStackSize(goroutineID)
fmt.Println("Main Goroutine Stack Size:", stackSize)
}
func hello() {
fmt.Println("Hello Goroutine!")
// 获取当前Goroutine的ID
goroutineID := runtime.GoID()
fmt.Println("Hello Goroutine ID:", goroutineID)
// 获取当前Goroutine的状态
status := getGoroutineStatus(goroutineID)
fmt.Println("Hello Goroutine Status:", status)
// 获取当前Goroutine的栈大小
stackSize := getGoroutineStackSize(goroutineID)
fmt.Println("Hello Goroutine Stack Size:", stackSize)
}
func getGoroutineStatus(goroutineID int64) string {
g := runtime.FindGoroutine(goroutineID)
if g != nil {
return g.Status.String()
}
return "Unknown"
}
func getGoroutineStackSize(goroutineID int64) int {
g := runtime.FindGoroutine(goroutineID)
if g != nil {
return g.StackSize
}
return 0
}
在这个示例中,我们创建了一个简单的Goroutine,在其中打印了一条消息。在main
函数中,我们获取了主Goroutine的ID、状态和栈大小,并打印出来。在hello
函数中,我们也获取了Goroutine的ID、状态和栈大小,并打印出来。
Processor
Processor(简称P)是调度器的一部分,负责管理和执行Goroutine。P的内部数据结构包括以下几个组件:
- Goroutine队列(runqueue):P维护一个Goroutine队列,用于存储等待执行的Goroutine。这个队列可以分为本地队列(local runqueue)和全局队列(global runqueue)。本地队列存储与P绑定的Goroutine,而全局队列存储其他P抢占的Goroutine。
- 自旋锁(spin lock):P使用自旋锁来保护Goroutine队列的访问。自旋锁是一种无阻塞的锁,它使用原子操作来实现,避免了线程的切换和上下文切换的开销。
- 状态(status):P有不同的状态,如运行状态(running)、空闲状态(idle)、系统调用状态(syscall)等。状态用来表示P当前的工作状态。
- M指针(m):P维护一个指向M(Machine)的指针。M是Goroutine在物理处理器上执行的实体,一个P可以与多个M绑定。M负责执行P中的Goroutine。
- 上次运行的Goroutine(lastg):P记录上次执行的Goroutine,以便在下次调度时能够快速恢复执行。
- 工作窃取(work stealing):P在本地队列为空时,可以从其他P的全局队列中窃取Goroutine,以提高并发执行的效率。
这些组件共同构成了P的内部数据结构,通过它们,调度器可以高效地管理和调度Goroutine,实现高效的并发执行。
如何调度
GMP模型的工作流程如下:
- 当一个Goroutine被创建时,调度器会将其放入全局的可运行Goroutine队列中。
- 当一个M空闲时,它会从全局队列中获取一个Goroutine,并将其绑定到自己的P上。
- M会执行绑定的P上的Goroutine,直到Goroutine完成或发生阻塞。
- 如果一个Goroutine发生阻塞,P会将其从M上解绑,并将其放入相应的等待队列中。
- 系统调用:当Goroutine执行系统调用(如I/O操作)时,它会被阻塞,此时P会将其从M上解绑,并将其放入系统调用等待队列中,然后按照 手动交接(hand off) 机制调度。(锁竞争、channel操作相同调度机制)
- 时间片用完:当一个Goroutine的时间片用完时,调度器会将其暂停,并将其放回到本地队列中等待下一次调度。然后,调度器按照 抢占式调度(preemptive scheduling) 选择另一个就绪的Goroutine来执行。
- 当一个M执行完当前的Goroutine后
- 当一个 M(Machine,即操作系统线程)执行完当前的 Goroutine 后,调度器会从全局运行队列中选择一个可运行的 Goroutine,并将其分配给该 M 来执行。
- 当全局运行队列中没有可运行的 Goroutine 时,会从其他 M 的本地运行队列中窃取 Goroutine以保持工作的平衡。
- 当从其他 M没有窃取到Goroutine,会再次尝试从全局运行队列中取Goroutine
- 当一个M长时间没有执行Goroutine时,调度器会将其标记为闲置,并将其销毁以释放系统资源。
手动交接(hand off)机制
手动交接(hand off)机制是调度器在Goroutine因为某种原因(如系统调用)被阻塞时,将其从当前的P上解绑,并将其放入等待队列,然后找到一个空闲的M与之绑定。
具体来说,当一个Goroutine因为系统调用等原因被阻塞时,调度器会将其从当前的P上解绑,并将其放入系统调用等待队列。然后,调度器会检查当前的P是否还有其他可用的Goroutine可以执行。如果有,调度器会选择一个Goroutine绑定到当前的M上继续执行。
如果当前的P没有其他可用的Goroutine,调度器会尝试找到一个空闲的M。如果找到了空闲的M,调度器会将被阻塞的Goroutine与新的M绑定,并继续执行。原来的M会被标记为空闲状态,等待其他的Goroutine抢占。
如果当前没有空闲的M可用,调度器会创建一个新的M,并与被阻塞的Goroutine绑定。这样,被阻塞的Goroutine会继续与原来的M绑定,等待满足条件重新调度的时候再次执行。
手动交接机制确保了在Goroutine被阻塞期间,其他的Goroutine可以继续执行,不会被阻塞。同时,当Goroutine的阻塞条件满足后,它会被重新调度执行。
抢占式调度机制 preemptive scheduling
Go语言的调度器使用一种抢占式调度的机制来管理Goroutine的执行。抢占式调度意味着调度器可以在任何时刻中断正在执行的Goroutine,并将控制权转移到其他可运行的Goroutine上。
抢占式调度的目的是确保公平性和响应性。通过在Goroutine的执行过程中进行抢占,调度器可以防止某个Goroutine长时间占用CPU资源,从而保证其他Goroutine也有机会运行。此外,抢占式调度还可以在某个Goroutine发生阻塞时,立即将控制权转移到其他可运行的Goroutine上,从而提高系统的响应性。
调度器在何时进行抢占取决于两个因素:时间片和协作点。时间片是调度器给予每个Goroutine的执行时间,当时间片用尽时,调度器会暂停该Goroutine,并切换到其他可运行的Goroutine上。协作点是指Goroutine主动让出执行的点,例如系统调用、阻塞操作等。当Goroutine进入协作点时,调度器会立即将控制权转移到其他可运行的Goroutine上。
调度器使用一个全局的运行队列来管理可运行的Goroutine。当一个Goroutine创建或恢复时,它会被添加到运行队列中。调度器在每个逻辑处理器上运行,它会选择一个可运行的Goroutine,并将其分配给对应的逻辑处理器来执行。当一个Goroutine的时间片用尽或进入协作点时,调度器会重新选择一个可运行的Goroutine,并将控制权转移给它。
Goroutine 窃取(Goroutine stealing)
Goroutine 窃取的具体策略是根据一种称为 "work-stealing" 的算法来实现的。
当一个 M 的本地运行队列为空时,它会尝试从其他 M 的本地运行队列中窃取 Goroutine。具体的策略如下:
- 当一个 M 需要从其他 M 的本地运行队列中窃取 Goroutine时,它会选择一个非空的 M。
- 选择的 M 可以是随机选择的,也可以使用一些启发式算法进行选择,例如选择最后一个有可执行 Goroutine的 M。
- 选择的 M 的本地运行队列是一个双端队列,M 会从队列的尾部窃取一个 Goroutine。
- 窃取的 Goroutine 会被放入当前 M 的本地运行队列中,并准备执行。
这种策略的目的是为了实现负载均衡,避免某个 M 的本地运行队列中的 Goroutine 长时间得不到执行,而其他 M 的本地运行队列中的 Goroutine 却处于空闲状态。
当在main.go文件中只写一个简单的fmt.Println("hello world"),
当执行go run main.go
时,Golang会创建一个主Goroutine,并将其放入调度器中进行调度。
Golang的调度器是一个运行时组件,负责管理和调度Goroutine的执行。调度器在程序启动时自动初始化,并创建一个操作系统线程(通常称为M),作为执行Goroutine的物理线程。这个M会与一个逻辑处理器(P)相关联。
主Goroutine是由操作系统线程(M)执行的,它会在main函数中开始执行。在这个简单的示例中,主Goroutine只包含一个fmt.Println("hello world")
语句。当主Goroutine执行到这个语句时,它会调用相应的系统调用,在终端输出"hello world"。
在主Goroutine执行完毕后,调度器会检查是否还有其他可运行的Goroutine。如果有,调度器会从全局队列中选择一个Goroutine,并将其分配给一个空闲的逻辑处理器(P)上的操作系统线程(M)执行。这个新的Goroutine会成为新的活跃Goroutine,继续执行。
当使用go
关键字创建一个Goroutine时
当使用go
关键字创建一个Goroutine时,它会被优先放在它所在P(Processor)的本地队列中。每个P都有一个本地队列,用于存储待执行的Goroutine。
当一个Goroutine被创建时,它会被添加到当前P的本地队列的末尾。如果本地队列已经满了(默认为256),则会触发本地队列的扩容操作。在扩容之前,会检查全局队列是否有空闲的空间,如果有,则会将本地队列中一半的Goroutine移动到全局队列中。
这个策略是为了提高调度的效率和公平性。通过将一部分Goroutine放到全局队列中,可以确保每个P都有机会执行其他Goroutine,避免某个P的本地队列一直被占满而导致其他P上的Goroutine无法得到执行。
通过GMP模型,Golang调度器能够高效地管理并发任务的执行。它能够动态地创建和销毁M,根据系统负载自动调整并发度,并通过工作窃取算法实现负载均衡。
面试题
下面是一些关于 Go 语言 GMP 调度模型的面试题:
请解释一下 Go 语言的 GMP 调度模型是什么?
- 首先,简要介绍 GMP 调度模型的三个主要组件:Goroutine(G)、操作系统线程(M)和处理器(P)。
- 解释 Goroutine 是 Go 语言并发执行的最小单位,类似于轻量级的线程,由 Go 语言运行时负责创建和管理。
- 说明操作系统线程(M)是实际执行 Goroutine 的线程,调度器会将 Goroutine 映射到 M 上执行。每个 M 都有一个本地运行队列,用于存放可运行的 Goroutine。
- 强调处理器(P)是调度器的一部分,负责调度和管理 M。每个 P 都绑定到一个 M 上,它负责将 Goroutine 分配给 M 执行,并监控 M 的状态。P 还负责管理全局运行队列,将新创建的 Goroutine 添加到队列中,并从队列中分发给空闲的 M。
- 描述 GMP 调度模型的工作流程,包括将 Goroutine 添加到全局运行队列、分配给空闲的 M 执行、M 执行 Goroutine 并交还控制权给 P、P 将 Goroutine 放回全局运行队列等。
- 强调 GMP 调度模型的优点,例如它能够充分利用多核处理器,提高系统的并发性能。同时,它还实现了抢占式调度和窃取机制,以确保 Goroutine 的公平调度和高效执行。
- 最后,总结一下 GMP 调度模型的重要性和作用,以及它在 Go 语言中实现并发的机制。
什么是 Goroutine 的抢占式调度?它是如何实现的?
Goroutine 的抢占式调度是指在 Go 语言中,多个 Goroutine 之间会自动进行抢占式调度,而不是依赖于显式的调度点。
- 当一个 Goroutine 主动调用了一个可能会发生阻塞的操作,如通道的读写操作、系统调用等,运行时系统会在这个点进行抢占。
- 当一个 Goroutine 的时间片用尽,即执行时间超过了一定的阈值,运行时系统会中断该 Goroutine 的执行,切换到其他可运行的 Goroutine 上继续执行。
- 当一个 Goroutine 主动调用了 runtime.Gosched() 函数,它会主动让出当前 Goroutine 的执行权,让其他 Goroutine 有机会执行。
Go 语言的调度器是如何实现 Goroutine 的窃取机制的?
Goroutine 的窃取机制是调度器通过工作窃取算法,让空闲的 M 主动从其他繁忙的 M 中窃取 Goroutine 来执行。被窃取的 Goroutine 会被放入当前 M 的本地运行队列中,并被当前 M 执行。
请解释一下 Goroutine 窃取的工作原理和策略。
Goroutine 窃取的工作原理是当前 M 在本地运行队列为空时,尝试从其他 M 的本地运行队列中窃取 Goroutine。窃取的策略通常是选择一个非空的 M,从其本地运行队列的尾部窃取一个 Goroutine
Goroutine 窃取是如何实现负载均衡的?
Goroutine 窃取实现负载均衡的方式是通过工作窃取算法,将 Goroutine 动态地分配给不同的 M。这样可以提高系统的并发性能和负载均衡,避免某些 M 被过度繁忙而导致资源浪费。
GMP 调度模型中的 M 是什么?它的作用是什么?
M 是 GMP 调度模型中的操作系统线程,它负责执行 Goroutine。每个 M 都有自己的本地运行队列,用于存放可运行的 Goroutine。
GMP 调度模型中的 P 是什么?它的作用是什么?
P 是 GMP 调度模型中的处理器,它是调度器的一部分。每个 P 都绑定到一个 M 上,并负责调度该 M 上的 Goroutine。
GMP 调度模型中的 G 是什么?它的作用是什么?
G 是 GMP 调度模型中的 Goroutine,它是并发执行的最小单位。Goroutine 由 Go 语言运行时负责创建和管理。
GMP 调度模型中的 M:N 表示什么意思?
M:N 表示 Goroutine 到操作系统线程的映射关系,即多个 Goroutine 共享一个操作系统线程。这种模型允许高效地并发执行大量 Goroutine。
Goroutine 是如何与操作系统线程(M)关联的?
Goroutine 与操作系统线程(M)关联是通过调度器实现的。调度器会将 Goroutine 映射到可用的 M 上执行,并在需要时进行调度和切换。
总结
Golang调度器的GMP模型是Golang v1.16版本中的重要改进,它通过G、M和P三个组件的协同工作,实现了高效的并发任务调度和执行。深入理解GMP模型对于开发高性能的Golang应用程序至关重要,它能够帮助开发者更好地利用Goroutine并发模型的优势。