首页 > 编程笔记 > Go语言笔记 阅读:14

Go语言中的协程(非常详细)

根据访问内核空间的方式,线程可以分为内核态和用户态两种:
只有内核态线程才能访问内核空间,因此在使用用户态线程时,需要将用户态线程绑定到内核态线程上,以便执行 I/O 等系统级任务。这种绑定通常是由线程库自动完成的,但有些情况下可能需要手动操作。CPU 无法区分用户态和内核态,对于 CPU 来说,二者是同一种“内核态”。

我们把内核态线程称为线程,把用户态线程称为协程,以示区分,如下图所示:


图 1 用户态线程与内核态线程

线程的类型(内核态线程和用户态线程)与提高 CPU 的使用率密切相关,因为不同的线程类型对 CPU 利用率的影响不同。

内核态线程和用户态线程在处理任务时操作有所不同,因为内核态线程可以访问内核空间并执行一些系统级任务,而用户态线程无法访问,因此,若要访问内核空间或要执行系统级任务,使用内核态线程更适合。

由于内核态线程的创建和管理是由操作系统负责的,因此在创建大量内核态线程时,会增加操作系统的负担,导致上下文切换的成本变得更高。此外,因为内核态线程的创建和销毁需要较长的时间,所以频繁创建和销毁内核态线程可能会拖慢整个系统。

相比之下,用户态线程的创建和销毁更快,我们可以更好地控制其数量和生命周期。当需要访问内核空间或执行系统级任务时,需要将用户态线程委托给内核态线程,这有可能会导致上下文切换的成本变高。

IT 界有一句话叫“软件开发中遇到的所有问题,都可以通过添加一层抽象来解决”。若拆开用户态线程和内核态线程,在两者之间添加调度器,那么内核态线程就可以通过调度器来调度用户态线程,这为并发创造了条件。在这种情况下,内核态线程和用户态线程的关系可以用 m:n 来表示。

内核态线程和用户态线程的比例及特点如下表所示:

表:内核态线程与用户态线程的比较
内核态线程 用户态线程 特点
1 1 一个用户态线程绑定一个内核态线程,用户态线程的调度由 CPU 完成。这与直接用内核态线程没区别,反而多了调度器的开销,因此意义不大
1 n n 个用户态线程绑定一个内核态线程。任意一个用户态线程阻塞都会导致整个内核态线程阻塞,这会使其失去并发的能力
m n 支持并发。用户态线程调度器的能力决定了并发的能力

由上表可知,整个系统的并发能力取决于调度器的算法和优化能力。

用户态线程不是操作系统层面的多任务处理,而是编译器、解释器、虚拟机层面的多任务处理,因此可以认为它是轻量级的内核态线程。

Go语言轻量级的协程

原生的协程叫作 coroutine,Go 语言中实现的协程则称为 goroutine。

Go 语言允许可复用的逻辑处理器运行在线程上,即使有协程被阻塞,该线程的其他协程也可以被调度器调度到可运行的线程上。Go 语言屏蔽了底层的实现细节,使开发者能够轻松地编写并发代码。

控制协程的关键是控制执行的主体、保留状态和恢复现场,协程通过主动让出控制权实现任务切换,若再次切换回来则从上次暂停处继续执行,而非重新开始。每个协程都有自己的协程栈,用于保存执行时的状态。当协程发生上下文切换时,首先保存当前状态,然后让出执行权,最后切换到其他协程上。

协程和线程很相似,都是一种执行流。它们的区别如下:

改造后的Go语言协程

Go 语言在源码级别的 runtime 包中就实现了协程、逻辑处理器和内核态线程三者之间的调度管理,所以我们常说 Go 在语言层面就支持高并发。

与原生的协程相比,Go 语言的协程有以下特色:
在 Go1.13 版本之前,调度器是基于协作的抢占式调度。在 Go1.14 版本后,调度器是基于信号的抢占式调度。

简说Go语言协程的调度

在 Go语言中,协程的调度是由调度器完成的。我们常说的 GPM 调度,其中 G(Goroutine)指的是协程、P(Processor)指的是逻辑处理器,M(Machine)指的是内核态线程。调度器使用 GPM 调度模型来管理协程和线程之间的关系。

1) 相关概念

在 Go语言的 GPM 调度模型中,有一些重要的概念需要了解,具体如下表所示。

表:与GPM调度相关的概念
概念 说明
Sched 调度器,用于维护、存储和调度全局队列以及调度器的部分状态信息,负责协调全局队列、处理器和内存之间的调度关系。Sched 会根据负载情况动态调整系统资源,为每个处理器分配适量的全局队列。此外,Sched 还会在必要时进行全局调度,如将全局队列中的全局队列分配给处理器
GR Global Run Queue,表示全局运行队列。所有的处理器共享一个全局运行队列,新的全局队列创建后,首先会被放入全局运行队列中。如果某个处理器的本地运行队列为空,那么它会尝试从全局运行队列中取出一些全局队列放入自己的本地运行队列中,以备后续执行
LRQ Local Run Queue,表示本地运行队列。每个处理器都有自己的本地运行队列,队列中存储了这个处理器需要运行的全局队列。当处理器有机会执行全局队列时,它会首先从自己的本地运行队列中选取一个全局队列。正在执行的全局队列状态为 running,本地运行队列中的全局队列状态为 runnable

2) 调度器和调度策略

下面基于下图简要说明调度器和调度策略:


图 2 调度器和调度策略

Go 语言的调度模型将多个用户态线程映射到少量内核态线程上,使得程序能够高效地利用系统资源,实现高并发和高吞吐量。

GPM 调度器是 Go 运行时的核心组件,它负责将 G 调度到 M 上并执行。

以下是有关调度器的简要说明:
GPM 调度器在 Go 语言中采用多种策略来实现高效的协程调度和管理,主要如下:

协作式与抢占式调度器

内核态模式意味着处理器可以执行任何代码,若执行操作系统的代码或者进行系统调用,处理器需要切换到内核态下。有些驱动程序之所以会让操作系统崩溃,是因为它们是在内核态下运行的。

而 Go 编写的代码则是运行在用户态下的,这时处理器处于一种受保护的模式中。在这种模式下,处理器上执行的任务代码是受限制的,如果执行了不该执行的命令,应用程序可能会出错。

Go 语言的调度器是内置在 runtime 包中的,编译代码时 runtime 环境会被内置到可执行代码中,这就意味着调度器和应用程序一样,是在用户态下运行的。可见,它不具备在任意时刻强制切换任务的能力,因为这需要操作系统内核级别的权限。也是基于此原因,我们说 Go 语言的调度器是协作式的。

以前,使用协作式调度器意味着应用程序的开发者有许多并发问题需要考虑。也就是说,如果一段代码想获得在 CPU 上执行的机会,那么应用程序的开发者必须通知其他人让出 CPU。但是实际上,很少会有开发者自愿让出 CPU,他们大多会把 CPU 的执行机会留给自己编写的代码。

Go 语言的调度器与以前的协作式调度器不同。Go 语言的调度器会自行协调所有事务,开发者不需要自己去协调。虽然 Go 语言的调度器是运行在用户空间中的协作式调度器,但是它的调度方式与传统的抢占式调度器类似。

具体来说,Go 语言的调度器采用的是抢占式的、基于时间片的调度方式。它会为每个协程分配一个时间片,在其时间片用完之后,调度器就会强制将该协程暂停,并将处理器分配给其他协程。这种调度方式可以保证各个协程之间的公平性和对协程状态变化的响应,避免出现因某个协程占用过多的CPU时间而导致其他协程被阻塞的情况。

与操作系统的调度器一样,我们无法预测 Go 语言的调度器将优先调度哪个协程。因此,从技术角度来看,Go 语言的调度器是协作式的,但它的语义或者效果却是抢占式的。

既然在正常情况下,我们无法预测 Go 语言调度器的行为,那么在编写代码时,就不能假设我们知道哪个协程会被先执行,然后基于这种假设编写代码。当然,对于 Go 语言调度器的行为,我们也应该适当予以控制,这就要使用到同步和编组技术了。

协程与I/O多路复用

真正让协程大放异彩的是它在 I/O 多路复用中的应用。

在 Linux 中,可以使用一个线程来监听多个文件描述符的 I/O 事件,这种技术被称为 I/O 多路复用。

I/O 多路复用通过一种机制让单个进程能够监视多个文件描述符,一旦某个文件描述符准备就绪(即数据准备就绪),就通知进程进行相应的 I/O 操作。在 Linux 中,实现 I/O 多路复用的通信模型主要有三种,分别是 select、poll 和 epoll。它们的系统调用参数中都包含了一个 fd 集合,该集合用于指示需要监听的文件描述符。当文件描述符准备就绪时,系统调用会返回该文件描述符的相关信息,以便进行相应的操作。

虽然这三种通信模型都是基于内核提供的 I/O 多路复用机制实现的,但它们的实现细节和性能都略有不同,如下表所示:

表:Linux下I/O多路复用的通信模型
模型 工作原理
select 操作系统维护了一个文件描述符集合和记录每个文件描述符状态的数据结构。在进行 I/O 多路复用系统调用时,内核会遍历文件描述符集合查找准备就绪的文件描述符,并返回其状态信息。为了提高 I/O 多路复用的查询效率,内核使用三个位图分别表示读、写和异常状态的文件描述符。这三个位图可通过位运算快速检查文件描述符的状态,以避免遍历整个文件描述符集合。每次进行 I/O 多路复用系统调用时,都需要将文件描述符集合从用户态复制到内核态下
poll poll 和 select 类似,都可在单线程中监听多个文件描述符的状态变化。当有一个或多个文件描述符准备好时,它们会返回对应的事件类型。相较于 select,poll 最大的优势是不受文件描述符数量的限制,它所支持的文件描述符的个数可以超过 1024。不过,在处理大量文件描述符时,其性能会有所下降
epoll epoll 是 select 和 poll 的改进版,它使用一组事件来描述一个或多个文件描述符的状态。select 和 poll 这两个函数需要遍历整个文件描述符集合来检查哪些描述符是就绪状态,操作的时间复杂度是O(n)。而 epoll 操作的时间复杂度则是 O(1),因为它只会处理那些就绪的文件描述符。这就是为什么在处理大量的文件描述符时,epoll 通常比 select 和 poll 更高效。在使用 epoll 时,首先调用 epoll_create 创建一个 epoll 对象,该对象内部有一个待监听的文件描述符集合。接着调用 epoll_ctl 为 epoll 对象添加、修改或删除文件描述符。调用 epoll_wait 函数时,仅返回发生变化的文件描述符,避免了遍历整个文件描述符集合。此外,epoll 还支持边缘触发和水平触发这两种工作模式,前者满足条件就会持续触发,后者在状态发生变化时触发

使用 I/O 多路复用方式实现业务逻辑时,伴随着事件的状态切换(从等待状态切换到准备就绪状态),系统需要频繁地保存和恢复现场,这会对性能产生影响。而协程因具有轻量级和并发执行等特点,非常适合处理这种场景。

可以将 Socket 分配给协程,然后将等待的 I/O 事件(也就是尚未就绪的 I/O 事件)注册到监听队列中,每个监听对象关联一个事件数据,这个事件数据用来记录处于等待状态的协程。

当事件准备就绪时,恢复对应的协程到运行队列中。由于协程的执行看起来就像在同步执行流,因此这种方式能够使实际可能阻塞线程的操作看起来不会发生阻塞。

可见,使用协程可以让 I/O 多路复用更加高效地处理业务逻辑。

相关文章