上一讲我带各位“浅尝”了 Go 语言中的并发,或许最大的感受就是启动一个 Goroutine 太容易了。但在这“容易”的背后,到底隐藏着哪些奥秘呢?Go 语言中的 GPM 任务调度机制又是如何运作的呢?本讲内容将给出答案。

为了让各位对计算机的任务调度有更深刻的理解,我会先为大家梳理任务调度的发展史。这部分并不只是故事,通过了解这段历史,能够加深对进程与线程、并发与并行的理解,它们的优势与弊端,以及更优的解决方案。

然后向大家全景展示 Go 语言是如何进行任务调度的,揭秘高并发能力的本质。最后再来聊聊如何为其它协程任务让出资源以及终止自身运行,这对于更加灵活的任务执行控制非常有用。

# 任务调度进化史

1946 年 2 月 14 日,第一台通用电子计算机 ENIAC 诞生了。在这短短的不到 80 年期间,计算机经历了飞速发展。能做的事情也越来越多,在一台计算机上(这里指广义的“计算机”,包括但不限于电脑、手机等设备)同时执行多个任务已是很常见的事情了。但回顾这段历史可以发现,刚开始的计算机程序都还只是单进程的,它们由操作系统调度执行,采用的运行机制被称为“串行”工作机制。

# 串行工作机制

如果去翻阅 CPU 的发展史,不难发现真正的多核心处理器是随着奔腾D 处理器的推出才面世的。奔腾D 处理器是英特尔公司在 2005 年推出的双核心处理器,之前的奔腾4 处理器虽然在其漫长的生命周期中推出过含有超线程技术(HT)的产品,但并非是真正意义上的多核。虽然奔腾D 处理器在某些方面广受诟病,但不可否认的是:它开创了多核心处理器的先河,为多核 CPU 的发展奠定了基础。

在此之前,CPU 都是单核心的。正如前文所提到的那样,最早期的计算机程序都是单进程的。因此,操作系统在执行时,必须等待一个程序执行完,再执行下一个程序。如果把这种执行方式放到现在,简直太耽误事了。比如我们正在下载文件,就必须等下载完成,才能做别的。又比如播放一段乐曲,那就只能听着它,不能干别的,除非暂停。

除了易用性上欠妥外,对 CPU 的性能也是一种浪费。当进程阻塞时,CPU的用量其实很低甚至处于闲置状态。

综上,这种最为传统的串行单进程工作机制很快便暴露出问题,引入了“并发”的概念。

# 多进程并发模式

从上一讲中我们已经了解到,“并发”是针对单个 CPU 核心而言的,通过切换时间片实现多任务执行。从细节上看,当一个进程发生阻塞时,处于等待状态的其它进程便会得到运行,以此类推。这样做的目的就是为了最大化利用CPU资源。

值得一提的是,并发机制仍然由操作系统调度。从上一讲中我们已经了解到,由操作系统调度则需要耗费一定的资源。进程的创建、切换和销毁都需要时间来进行。当切换进程过于频繁时,很多时间将会花费在调度上。在这个过程中,CPU的占用率可能看上去很高,但实际利用率却并不理想。

# 多线程并行模式

自从英特尔推出含超线程技术的奔腾4 以及后续的奔腾D 处理器后,不同的线程便有机会真正地运行在不同的 CPU 核心上,实现并行了。

从广义上讲,线程可分为两大类,即内核态和用户态。前者是真正意义上的线程,由 CPU 来调度,由内核态线程共同组成的区域成为“内核空间”。用户态的线程实际上是指协程,由协程调度器来调度。从 CPU 的角度上看,只能发现内核态的线程,协程对其不可见。所以当协程任务发生切换时,更加快速和轻量。下面这张图展示了上述调度结构:

image-20220419093331601.png

图中的橙色线条表示绑定关系,其中隐含了协程调度器和协程队列处理器。

然而,单纯的并行模式也并非万金油。如果一个线程承载了全部协程任务,则仍然无法从分利用多核 CPU。在极端情形下,协程任务的阻塞还会引发整个线程的阻塞,后续的任务得不到执行,整个系统便会卡住。另外,当一个线程中只存在一个协程任务时,也并不会带来性能的提升。

看到这,一种更优的解决方案便浮现了出来,这种方案也是 Go 语言能实现高并发的原理。即将多个协程绑定在多个线程中,同时将多个线程分配给不同的 CPU 核心运行。如此将并发与并行模式相结合,便打造出了较为理想的任务调度机制。

# GPM 任务调度模型

前文中已经讲到,Go 语言中的 GPM 任务调度模型充分利用了多核 CPU 的资源。需要时,将创建与之匹配的线程,并将用户态的协程任务“智能”地分配给多个线程执行。整体上运用的是并行+并发的模式,具体如下图所示:

图片1.png

从图中可以看到,整个 GPM 结构分为上下两大部分,我们一起从下往上看,正好对应的是内核空间和用户空间。

首先来看内核空间,这是一颗 4 核心的 CPU(暂时不考虑超线程的情况)。由并行的概念不难得出,4 核心的 CPU 可以由操作系统调度,执行 4 个线程。

在 Go 程序启动时,会自动根据 CPU 的核心数设置线程的最大数量。当然,我们也可以通过编码手动设置,稍后会讲到。当一个线程发生阻塞时,新的线程便会创建。图中黄色的线程是一个空闲的线程,它没有绑定任何协程。

再来看用户空间,最上方的全局队列存放所有等待运行的协程任务(即 Goroutine)。下方若干个协程队列,当发起一个协程任务时,该任务会首先尝试加入到协程队列中。每个协程队列的最大任务数被限制在256个以内

当协程队列满了之后,协程调度器会将一半数量的任务移动至全局队列中。至于一共能有多少个协程队列,在 Go 1.5 版本之后队列数默认为CPU核心数量,也可以通过编码来指定

从另一个角度讲,设置了队列数就意味着设置了程序能同时跑多少个 Goroutine 的数量。一般地,在该参数确定后,所有的队列便会一口气创建完成。

在 Go 程序运行时,一个内核空间的线程若想获取某个协程任务来执行,就需要通过协程队列处理来获取特定的协程任务。当队列为空时,全局队列中的若干协程任务,或其它队列中的一半任务会被放到空队列中。如此循环往复,周而复始。

另一方面,协程队列处理器的数量和线程在数量上并没有绝对关系。如果一个线程发生阻塞,协程队列处理器便会创建或切换至其它线程。因此,即使只有一个协程队列,也有可能会有多个线程。

# 动态调整系统资源

在 Go 程序运行时,可以根据需要设置程序要使用的 CPU 资源,也可以动态调整协程任务的执行方式,实现更灵活地运行。这些操作都是通过 runtime 包来实现的。

# 获取和设置 CPU 核心数量

在 Go 语言中,可以随时获取操作系统类型、CPU 架构类型和 CPU 核心数量,下面的示例代码输出了它们:

go
复制代码// 获取运行当前程序的操作系统
fmt.Println(runtime.GOOS)
// 获取运行当前程序的CPU架构
fmt.Println(runtime.GOARCH)
// 获取运行当前程序的CPU核心数量
fmt.Println(runtime.NumCPU())

在 macOS 中,操作系统名称为darwin;在Windows中,操作系统名称即windows;在Linux中,操作系统名称为linux。

对于 32 位的 CPU,运行结果为 386;对于 64 位的 CPU,运行结果为 amd64;对于 arm 架构 32 位的 CPU,运行结果为 arm;对于 arm 架构 64 位的 CPU,运行结果为 arm64。

💡 提示:若要获取Go语言支持的所有操作系统和CPU架构,可执行命令行:go tool dist list。

若要设置可用的 CPU 核心数,可以通过 runtime.GOMAXPROCS() 函数实现。需要注意的是:该函数将返回设置之前的核心数。

比如,对于一颗多核心的 CPU,若设置程序只能使用一半数量的核心,代码为:

go
复制代码if runtime.NumCPU() > 2 {
   runtime.GOMAXPROCS(runtime.NumCPU() / 2)
}
// 获取当前程序可用的CPU核心数
fmt.Println(runtime.GOMAXPROCS(0))

请留意代码最后,当向 runtime.GOMAXPROCS() 函数传入 0 时,即可实现获取可用核心数。

# 给其它任务“让行”

在程序运行中,某些特定的情况下需要暂停当前协程,让其它协程任务先执行。首先来看下面这段代码:

go
复制代码func main() {
    go fmt.Println("Hello World")
    fmt.Println("程序运行结束")
}

显然,由于输出文本被放在了另一个协程中执行。程序将很快结束,甚至在大多数情况下都不会看到 “Hello World” 输出。

若要想正常看到控制台的输出,一种方法便是使用 sync.Wait() 方法,这一招在上一讲中已经介绍过了。另一种方法还可以使主线程中的任务让出资源,优先执行输出文本。方法如下:

go
复制代码func main() {
   go fmt.Println("Hello World")
   runtime.Gosched()
   fmt.Println("程序运行结束")
}

如此,便会看到控制台输出:

Hello World

程序运行结束

# 终止自身协程

在某些条件下,我们还希望立即停止协程任务的执行。方法便是使用调用 runtime.Goexit() 函数。下面这段示例代码演示了在满足特定条件时终止协程的方法:

scss
复制代码func main() {
    syncWait.Add(1)
    go testFunc()
    syncWait.Wait()
    fmt.Println("程序运行结束")
}
func testFunc() {
    defer syncWait.Done()
    for i := 1; i < 100; i++ {
        fmt.Println(i)
        if i >= 5 {
            runtime.Goexit()
        }
    }
}

# 小结

🎉 恭喜,您完成了本次课程的学习!

📌 以下是本次课程的重点内容总结:

  1. 计算机任务调度方式进化史;
  2. Go 语言中的 GPM 任务调度模型;
  3. Go 语言中动态调整系统资源的方法。

本讲的内容理论部分较多,首先回顾了计算机任务调度的进化史。通过回顾这段历史,更加深了进程与线程、并发与并行的理解。同时推导出更优的任务调度方式:并发+并行。这也是 Go 语言程序进行任务调度的方式。

接着,讲述了 Go 语言中的 GPM 模型。最后,介绍了 runtime 包的一些用法,包括如何获取和设置程序可用的CPU资源以及动态调整协程任务执行方式。

➡️ 在下次课程中,我们会继续介绍 Go 语言中的并发,涉及并发任务间的通信方式,内容如下:

  • 并发中的 Channel 上部