likes
comments
collection
share

深入解析Go语言中的GMP调度模型

作者站长头像
站长
· 阅读数 4

摘要

Golang(Go)是一种现代化的编程语言,以其并发性和高效性而闻名。其中一个关键的组成部分是调度器,它负责协调并发任务的执行。本文将介绍Golang调度器的GMP模型,该模型是Golang v1.16版本中的重要改进。

Golang是一门开发高性能并发应用程序的编程语言。它通过内置的调度器来管理并发任务的调度和执行。在早期的Golang版本中,调度器使用的是M:N模型,即将M个用户级线程(goroutine)映射到N个内核级线程上。然而,在Golang v1.14版本中,调度器的底层实现发生了重大改变,引入了GMP模型。

进程线程协程

在介绍Golang调度器的GMP模型之前,我们先来了解一下进程、线程和协程的概念,以及它们之间的区别。

  1. 进程: 进程是操作系统中的一个执行实体,它拥有独立的内存空间和资源。每个进程都是独立运行的,它们之间无法直接共享数据。进程是操作系统进行资源分配和调度的基本单位。
  2. 线程: 线程是进程中的一个执行单元,它与同一进程中的其他线程共享内存空间和资源。线程可以看作是轻量级的进程,它们之间可以直接共享数据。线程的创建和销毁比进程更加轻量级,因此多线程编程可以提供更高的并发性。
  3. 协程: 协程是一种更加轻量级的并发编程模型,它比线程更加灵活和高效。协程可以在同一个线程中同时执行多个任务,通过协程调度器进行任务切换,实现并发执行。协程之间的切换不需要操作系统的介入,因此切换的开销很小。协程之间可以通过通道进行通信和同步。

区别:

  • 进程是操作系统进行资源分配和调度的基本单位,拥有独立的内存空间和资源。线程是进程中的执行单元,共享进程的内存空间和资源。协程是更加轻量级的执行单元,可以在同一个线程中同时执行多个任务。
  • 进程之间无法直接共享数据,线程可以直接共享进程的内存空间和资源,协程之间也可以直接共享数据。
  • 进程切换的开销较大,需要操作系统的介入。线程切换的开销较小,但仍需要操作系统的介入。协程切换的开销非常小,不需要操作系统的介入。
  • 进程之间的通信和同步需要使用操作系统提供的机制,如管道、消息队列等。线程之间可以直接共享内存进行通信和同步。协程之间可以通过通道进行通信和同步。
进程线程协程
定义操作系统中的一个执行单位进程中的一个执行流程线程中的一个执行流程
资源拥有独立的内存空间和系统资源共享进程的内存空间和系统资源共享线程的内存空间和系统资源
切换开销需要上下文切换,切换开销较大需要较小的上下文切换开销无需上下文切换,切换开销极小
并发性可以并发执行多个进程可以并发执行多个线程可以并发执行多个协程
通信进程间通信需要特殊机制线程间通信直接共享内存协程间通信通过特定方法(如yield)

例子:

  1. 进程:可以将进程类比为一个公司,每个公司有自己的独立资源和员工,彼此之间独立运行,互不干扰。不同的公司可以并发执行,每个公司都有自己的任务和目标。

  2. 线程:可以将线程类比为一个公司中的不同部门,部门之间共享公司的资源和员工,彼此之间可以并发执行。不同部门可以同时进行不同的任务,但需要协调资源的使用。

  3. 协程:可以将协程类比为一个公司中的一个员工,员工可以独立执行任务,但可以通过协作和切换来提高效率。员工可以在执行自己的任务时,根据需要暂停执行,将控制权交给其他员工执行,待其他员工完成任务后再继续执行自己的任务。这样可以实现多个任务之间的切换,提高整体执行效率。

Golang在并发编程中采用了协程的方式,即Goroutine。Goroutine是Golang中的轻量级线程,它比传统的线程更加高效和灵活。Goroutine之间通过通道进行通信和同步,而Golang调度器的GMP模型负责协调Goroutine的调度和执行。

GMP模型

GMP模型是Golang调度器的核心组成部分。它由三个主要的组件组成:

  1. G(Goroutine):Goroutine是Golang中的轻量级线程,它代表一个并发任务。每个Goroutine都有自己的栈和相关的上下文信息。Goroutine的创建和销毁由GMP模型负责管理。
  2. M(Machine):Machine是Golang调度器中的执行线程,它负责将Goroutine映射到操作系统线程上。每个M都有自己的调用栈和寄存器状态。调度器会根据需要创建或销毁M,以适应并发任务的数量和系统负载。
  3. 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的一些内部数据结构:

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的内部数据结构包括以下几个组件:

  1. Goroutine队列(runqueue):P维护一个Goroutine队列,用于存储等待执行的Goroutine。这个队列可以分为本地队列(local runqueue)和全局队列(global runqueue)。本地队列存储与P绑定的Goroutine,而全局队列存储其他P抢占的Goroutine。
  2. 自旋锁(spin lock):P使用自旋锁来保护Goroutine队列的访问。自旋锁是一种无阻塞的锁,它使用原子操作来实现,避免了线程的切换和上下文切换的开销。
  3. 状态(status):P有不同的状态,如运行状态(running)、空闲状态(idle)、系统调用状态(syscall)等。状态用来表示P当前的工作状态。
  4. M指针(m):P维护一个指向M(Machine)的指针。M是Goroutine在物理处理器上执行的实体,一个P可以与多个M绑定。M负责执行P中的Goroutine。
  5. 上次运行的Goroutine(lastg):P记录上次执行的Goroutine,以便在下次调度时能够快速恢复执行。
  6. 工作窃取(work stealing):P在本地队列为空时,可以从其他P的全局队列中窃取Goroutine,以提高并发执行的效率。

这些组件共同构成了P的内部数据结构,通过它们,调度器可以高效地管理和调度Goroutine,实现高效的并发执行。

如何调度

GMP模型的工作流程如下:

  1. 当一个Goroutine被创建时,调度器会将其放入全局的可运行Goroutine队列中。
  2. 当一个M空闲时,它会从全局队列中获取一个Goroutine,并将其绑定到自己的P上。
  3. M会执行绑定的P上的Goroutine,直到Goroutine完成或发生阻塞。
  4. 如果一个Goroutine发生阻塞,P会将其从M上解绑,并将其放入相应的等待队列中。
    • 系统调用:当Goroutine执行系统调用(如I/O操作)时,它会被阻塞,此时P会将其从M上解绑,并将其放入系统调用等待队列中,然后按照 手动交接(hand off) 机制调度。(锁竞争、channel操作相同调度机制)
    • 时间片用完:当一个Goroutine的时间片用完时,调度器会将其暂停,并将其放回到本地队列中等待下一次调度。然后,调度器按照 抢占式调度(preemptive scheduling) 选择另一个就绪的Goroutine来执行。
  5. 当一个M执行完当前的Goroutine后
    • 当一个 M(Machine,即操作系统线程)执行完当前的 Goroutine 后,调度器会从全局运行队列中选择一个可运行的 Goroutine,并将其分配给该 M 来执行。
    • 当全局运行队列中没有可运行的 Goroutine 时,会从其他 M 的本地运行队列中窃取 Goroutine以保持工作的平衡。
    • 当从其他 M没有窃取到Goroutine,会再次尝试从全局运行队列中取Goroutine
  6. 当一个M长时间没有执行Goroutine时,调度器会将其标记为闲置,并将其销毁以释放系统资源。

深入解析Go语言中的GMP调度模型

手动交接(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。具体的策略如下:

  1. 当一个 M 需要从其他 M 的本地运行队列中窃取 Goroutine时,它会选择一个非空的 M。
  2. 选择的 M 可以是随机选择的,也可以使用一些启发式算法进行选择,例如选择最后一个有可执行 Goroutine的 M。
  3. 选择的 M 的本地运行队列是一个双端队列,M 会从队列的尾部窃取一个 Goroutine。
  4. 窃取的 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 调度模型是什么?

  1. 首先,简要介绍 GMP 调度模型的三个主要组件:Goroutine(G)、操作系统线程(M)和处理器(P)。
  2. 解释 Goroutine 是 Go 语言并发执行的最小单位,类似于轻量级的线程,由 Go 语言运行时负责创建和管理。
  3. 说明操作系统线程(M)是实际执行 Goroutine 的线程,调度器会将 Goroutine 映射到 M 上执行。每个 M 都有一个本地运行队列,用于存放可运行的 Goroutine。
  4. 强调处理器(P)是调度器的一部分,负责调度和管理 M。每个 P 都绑定到一个 M 上,它负责将 Goroutine 分配给 M 执行,并监控 M 的状态。P 还负责管理全局运行队列,将新创建的 Goroutine 添加到队列中,并从队列中分发给空闲的 M。
  5. 描述 GMP 调度模型的工作流程,包括将 Goroutine 添加到全局运行队列、分配给空闲的 M 执行、M 执行 Goroutine 并交还控制权给 P、P 将 Goroutine 放回全局运行队列等。
  6. 强调 GMP 调度模型的优点,例如它能够充分利用多核处理器,提高系统的并发性能。同时,它还实现了抢占式调度和窃取机制,以确保 Goroutine 的公平调度和高效执行。
  7. 最后,总结一下 GMP 调度模型的重要性和作用,以及它在 Go 语言中实现并发的机制。

什么是 Goroutine 的抢占式调度?它是如何实现的?

Goroutine 的抢占式调度是指在 Go 语言中,多个 Goroutine 之间会自动进行抢占式调度,而不是依赖于显式的调度点。

  1. 当一个 Goroutine 主动调用了一个可能会发生阻塞的操作,如通道的读写操作、系统调用等,运行时系统会在这个点进行抢占。
  2. 当一个 Goroutine 的时间片用尽,即执行时间超过了一定的阈值,运行时系统会中断该 Goroutine 的执行,切换到其他可运行的 Goroutine 上继续执行。
  3. 当一个 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并发模型的优势。