在今天的编程世界中,协程已经成为了构建并发程序的重要工具。协程提供了一种在单个线程中处理多个任务的强大机制。
而在 Go 语言中,协程的概念被引入为 “goroutine”,并作为语言核心特性之一。在本文中,我们将深入探讨 Go 语言中的协程及其调度机制。
协程并不是 Go 发明的概念,维基百科上记录,协程术语 coroutine 最早出现在 1963 年发表的论文中,论文的作者为美国计算机科学家 Melvin E. Conway (他提出过著名的康威定律)。
支持协程的编程语言有很多,比如大名鼎鼎的 Python、Perl 等等,但没有那个语言像 Go 一样把协程支持得如此优雅,Go 在语言层面直接提供对协程的支持成为 goroutine 。
在深入了解 goroutine 之前,我们先来看看进程、线程、协程区别。
进程:是应用程序的启动实例,每个进程都有独立的内存空间,不同进程通过进程间的通信方式来通信。
线程:线程从属于进程,每个进程至少包含一个线程,线程是 CPU 调度的基本单位,多个线程之间可以共享进程的资源并通过共享内存等线程间的通信方式来通信。
协程:
协程可理解为一种轻量级线程,主要理解如下:
协程是用户级别的线程:协程由用户程序自行控制,而线程通常由操作系统调度。因此,协程在切换上下文时通常比线程更快,并且协程通常比线程更轻量级。
协程可以在一个线程内并发执行:多个协程可以在一个线程中并发执行,而线程则是并发执行的基本单位。
协程的栈大小是可变的:协程的栈大小可以根据需要动态增长和缩小,而线程的栈大小通常是固定的。
协程不受操作系统调度:与线程相比,协程调度器由用户应用程序提供,协程调度器按照调度策略把协程调度到线程中运行。
Go 应用程序的协程调度器由 runtime 包提供,用户使用 go 关键字即可创建协程,这就是在语言层面直接支持协程的含义。
在高并发应用中频繁创建线程会造成不必要的开销,所以有了线程池技术:该技术是在线程池中预先保存一定数量的线程,新任务将不再创建新线程的方式去执行,而是直接将任务发布到任务队列中,线程池中的线程不停地从任务队列中取出任务并执行,比如 Java 语言的线程池,这样有效地减少了创建和销毁线程带来的系统开销。
上图展示了一个典型的线程池,任务队列中有多个任务(称作 G),任务 G 在代码中往往是一个函数。线程池的 Worker 线程不断地从任务队列中取出任务执行,而 Worker 线程则交给操作系统来进行调度。
如果 Worker 线程执行的 G 任务存在系统调用,则操作系统将会把该线程置为阻塞状态,这样会导致消费任务队列的 Worker 线程数量变少了,即线程池的消费任务的能力变弱了。
解决这个问题的一个方法是:重新审视线程池中的线程数量,增加线程池中的数量可以在一定程度内提供消费能力。但是如果线程数量增多,过多的线程会争夺 CPU 资源,消费任务的能力有上限,甚至会出现消费能力下降的现象。
因为过多的线程会导致上下文切换开销变大(用户态和内核态),而工作在用户态的协程则能大大减少上下文切换的开销。
协程的调度器把可运行的协程逐个调度到线程中执行,同时及时把阻塞的协程调度出线程,从而有效地避免了线程的频繁切换,达到使用少量线程实现高并发的效果。
在 Go 语言中,goroutine 是协程的具体实现。Go 语言的设计者在创建 goroutine 时,借鉴了协程的思想,但又进行了一些独特的改进。
Go 中的每个程序至少有一个 goroutine:主 goroutine。当程序启动时,主 goroutine 就开始运行。你可以把 goroutine 看作一个轻量级的线程。与线程不同,创建一个 goroutine 的成本很小,只需要几千字节的内存。因此,一个 Go 程序可以轻易地启动上百万个 goroutine。
在 Go 语言中,启动一个 goroutine 的语法非常简单,只需要在函数调用前加上 go 关键字:
go doSomething()
这段代码将在一个新的 goroutine 中运行 doSomething 函数,而主 goroutine 将继续执行后续的代码。
讲解 goroutine 的调度之前,先简单介绍一下线程的调度模型:
线程可分为用户线程和内核线程,用户线程由用户创建、同步和销毁,内核线程则由内核来管理。根据用户线程管理方式的不同,可以分为三种线程模型:
第一种是 N : 1 模型,即 N 个用户线程运行在 1 个内核线程中,优点是用户线程上下文切换快,缺点是无法充分利用多核 CPU 资源。
第二种是 1 : 1 模型,即每个用户线程对应一个内核线程,优点是充分利用 CPU 的资源,缺点是用户线程上下文切换较慢。
Go 实现的是 M : N 模型,即前两种模型的组合。M 个用户线程(协程)运行在 N 个线程中,优点是充分利用 CPU 资源且上下文切换快,缺点是该模型的调度算法比较复杂。
Go 语言拥有自己的调度器,这个调度器在用户态和内核态之间进行切换。Go 调度器的工作方式类似于操作系统的线程调度,但它只关注 goroutine。
Go 调度器使用了 M:N 调度模型,即 M 个 goroutine 映射到 N 个 OS 线程。这种模型充分利用了多核处理器的优势,并且能够在任何 goroutine 阻塞时切换到其他非阻塞的 goroutine,从而保持 CPU 的利用率。
Go 协程调度模型中包含了三个关键实体,machine(简称 M),processor (简称 P)和 goroutine(简称 G):
M(machine):工作线程,它由操作系统调度。
P(processor):处理器(这是 Go 定义的概念,不是指 CPU),包含运行 Go 代码的必要资源,也有调度 goroutine 的能力。
G(goroutine):即 Go 协程,代码中每个 go 关键字就是创建一个协程。
M 必须持有 P 才能执行代码,和系统的其他线程一样,M 也会被系统调用阻塞。P 的个数在程序启动时决定。默认情况下等同于 CPU 核数,可以使用环境变量 GOMAXPROCS 或在程序中使用 runtime.GOMAXPROCS()
指定 P 的个数。
// 使用环境变量设置 GOMAXPROCS 为 80
export GOMAXPROCS=80
// 使用 runtime.GOMAXPROCS() 方法设置 GOMAXPROCS 为 80
runtime.GOMAXPROCS(80)
注意:M 的个数一般稍大于 P 的个数,因为除了运行 go 代码,runtime 包还有其他内置任务需要处理。
简单的调度器模型如下:
上图包括两个工作线程 M,每个 M 持有一个处理器 P,并且每个 M 中有一个协程 G 在运行(即绿色的 G),旁边的橘黄色的 G 正在等待被调度,它们位于 runqueues 队列中。
每一个处理器 P 拥有一个 runqueues 队列,此外还有一个全局的 runqueues 队列,它由多个处理器 P 共享。
为什么会设计局部和全局的 runqueues 队列呢?
原因是:在早期的调度器实现中(Go 1.1 之前)只有全局 runqueues ,多个处理器 P 通过互斥锁来调度队列中的协程,在多核 CPU 环境中,多个处理器需要争抢锁来调度全局的队列协程,严重影响了并发执行的效率。后来引入了局部的 runqueues ,每个处理器 P 访问自己的 runqueues 队列时不需要加锁,大大提高了效率。
一般来说,处理器 P 中的协程 G 额外再创建的协程会加入本地的 runqueues 队列中,但是如果本地的队列已满或阻塞的协程被唤醒,则协程会被放入全局的 runqueues 队列中,处理器 P 除了调度本地的 runqueues 队列中的协程以外,还会周期性的从全局 runqueues 队列中摘取协程来调度,这就是接下来要介绍的调度策略。
Go 的调度策略也是不断进行演进的,让 Go 支持越来越多的调度策略,以满足不同场景的并发需求。
每个处理器 P 维护着一个协程 G 的队列,处理器 P 依次将协程 G 调度到 M 中执行。
同时,每个 P 会周期性的查询全局队列中是否有 G 待运行,如果有则将其调度到 M 中执行,这样做为了避免全局队列中的 G 长时间得不到调度机会而被 “饿死”。
前面提到,当线程在执行系统调用时,可能会阻塞,对应到调度器模型,如果一个协程发起系统调用,那么对应的工作线程会被阻塞,这样会导致处理器 P 的 runqueues 队列中的协程得不到调度,相当于队列中的所有协程都会被阻塞。
上面说到 P 的个数默认等于 CPU 的核数,每个 M 必须持有一个 P 才能执行 G。所以一般情况下我们可以设置 M 的个数会稍大于 P 的个数,多出来的 M 将会在 G 产生系统调用时发挥作用。这一点和线程池类似,Go 也会提供一个 M 的池子,需要 M 时从池子中获取,用完再放回池子,不够用时再创建一个新的 M。
如上图所示,当 G0 即将进入系统调用时,M0 将释放 P,进而某个冗余的 M1 获取了 P,继续执行 P 队列中剩下的 G,这样保证了 P 不空闲,充分利用了 CPU 资源。
当 G0 结束系统调用后,根据 M0 是否能获取到 P,对 G0 进行不同的处理:
如果有空闲的 P,则获取一个 P,继续执行 G0。
如果没有空闲的 P,则将 G0 放入全局队列,等待被其他的 P 进行调度。然后 M0 将进入休眠。
通过 go 关键字创建的协程通常会优先放入当前协程对应的处理器中,这样做可能会出现有些协程自身不断的派生新的协程,而有些协程不派生协程,这样会造成多个处理器 P 中维护的 G 队列时不均衡的。如果不加以控制的话,则可能会出现部分处理器 P 非常忙碌,而部分处理器 P 空闲的情况。
为了解决这个问题,Go 调度器提供了工作量窃取策略,即当某个处理器 P 没有需要调度的协程时,将从其他的处理器中窃取协程,示例如下:
发起窃取之前,处理器 P 会查询全局队列,如果全局队列中也没有协程需要调度的,则会从另一个正在运行的处理器 P 中窃取协程,每次偷取一半,偷取的效果如上图所示。
抢占式调度指的是:避免某个协程长时间执行,而阻碍其他协程被调度的机制。
调度器会监控每个协程的执行时间,一旦发现协程执行时间过长其有其他协程在等待时,会把协程暂停,转而调度等待的协程,以达到类似于时间片轮转的效果。
在 Go 1.14 之前,Go 的协程调度器抢占式调度有一定的缺陷,在该设计中,在函数调用间隙进行检查该协程是否可被抢占,如果协程没有函数调用,则会无限期地占用执行权,以下代码在 Go 1.14 之前会陷入协程无限循环中,协程永远无法被抢占,导致主协程无法继续执行。Go 1.14 调度器引入了基于信号的抢占机制,该问题才得以解决。
package main
import (
"fmt"
"runtime"
"time"
)
func main() {
// 打印 go version
fmt.Println(runtime.Version())
// 设置 指定 P 的个数
runtime.GOMAXPROCS(1)
go func() {
for {
// 无函数调用的无限循环
}
}()
time.Sleep(1 * time.Second) // 协同调用,出让执行权给上面的协程
println("main done.")
}
Go 1.13.5 版本执行结果
Go 1.19.4 版本执行结果
一般来说,程序运行时就将 GOMAXPROCS 的大小设置为 CPU 核数,可以让 Go 程序充分利用 CPU,这个适合计算型应用。但是在某些 I/O 密集型的应用中,这个值可能并不意味着性能最好。
理论上当某个 goroutine 进入系统调用时,会有一个新的 M 被启用或创建,继续占满 CPU,但是这个只是理论上,因为 Go 调度器检测到 M 被阻塞是有一定延迟的,即旧的 M 被阻塞和新的 M 得到运行之间是有一定时间间隔的。
所以在 I/O 密集型的应用中不妨把 GOMAXPROCS 的值设置的大一些,这样或许会有更好的效果。
Go 语言的 goroutine 是一种非常强大的并发工具。通过 goroutine,我们可以在同一个程序中同时运行多个任务。Go 语言的内置调度器确保了 goroutine 之间的高效调度。
goroutine 是轻量级的,创建和销毁的成本低,因此可以在一个 Go 程序中创建大量的 goroutine。通过 Channel,goroutine 之间可以安全、有效地进行通信。
总的来说,Go 语言为我们提供了一种简单、高效的并发模型。无论你是正在构建一个高并发的网络服务,还是需要进行大量的并行计算,Go 语言都是一个非常好的选择。
希望本文能帮助你深入理解 Go 语言的 goroutine,也希望你能在你的 Go 语言编程旅程中充分利用这个强大的工具。