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

图 1 用户态线程与内核态线程
线程的类型(内核态线程和用户态线程)与提高 CPU 的使用率密切相关,因为不同的线程类型对 CPU 利用率的影响不同。
内核态线程和用户态线程在处理任务时操作有所不同,因为内核态线程可以访问内核空间并执行一些系统级任务,而用户态线程无法访问,因此,若要访问内核空间或要执行系统级任务,使用内核态线程更适合。
由于内核态线程的创建和管理是由操作系统负责的,因此在创建大量内核态线程时,会增加操作系统的负担,导致上下文切换的成本变得更高。此外,因为内核态线程的创建和销毁需要较长的时间,所以频繁创建和销毁内核态线程可能会拖慢整个系统。
相比之下,用户态线程的创建和销毁更快,我们可以更好地控制其数量和生命周期。当需要访问内核空间或执行系统级任务时,需要将用户态线程委托给内核态线程,这有可能会导致上下文切换的成本变高。
IT 界有一句话叫“软件开发中遇到的所有问题,都可以通过添加一层抽象来解决”。若拆开用户态线程和内核态线程,在两者之间添加调度器,那么内核态线程就可以通过调度器来调度用户态线程,这为并发创造了条件。在这种情况下,内核态线程和用户态线程的关系可以用 m:n 来表示。
内核态线程和用户态线程的比例及特点如下表所示:
由上表可知,整个系统的并发能力取决于调度器的算法和优化能力。
用户态线程不是操作系统层面的多任务处理,而是编译器、解释器、虚拟机层面的多任务处理,因此可以认为它是轻量级的内核态线程。
Go 语言允许可复用的逻辑处理器运行在线程上,即使有协程被阻塞,该线程的其他协程也可以被调度器调度到可运行的线程上。Go 语言屏蔽了底层的实现细节,使开发者能够轻松地编写并发代码。
控制协程的关键是控制执行的主体、保留状态和恢复现场,协程通过主动让出控制权实现任务切换,若再次切换回来则从上次暂停处继续执行,而非重新开始。每个协程都有自己的协程栈,用于保存执行时的状态。当协程发生上下文切换时,首先保存当前状态,然后让出执行权,最后切换到其他协程上。
协程和线程很相似,都是一种执行流。它们的区别如下:
与原生的协程相比,Go 语言的协程有以下特色:
在 Go1.13 版本之前,调度器是基于协作的抢占式调度。在 Go1.14 版本后,调度器是基于信号的抢占式调度。

图 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 语言调度器的行为,我们也应该适当予以控制,这就要使用到同步和编组技术了。
在 Linux 中,可以使用一个线程来监听多个文件描述符的 I/O 事件,这种技术被称为 I/O 多路复用。
I/O 多路复用通过一种机制让单个进程能够监视多个文件描述符,一旦某个文件描述符准备就绪(即数据准备就绪),就通知进程进行相应的 I/O 操作。在 Linux 中,实现 I/O 多路复用的通信模型主要有三种,分别是 select、poll 和 epoll。它们的系统调用参数中都包含了一个 fd 集合,该集合用于指示需要监听的文件描述符。当文件描述符准备就绪时,系统调用会返回该文件描述符的相关信息,以便进行相应的操作。
虽然这三种通信模型都是基于内核提供的 I/O 多路复用机制实现的,但它们的实现细节和性能都略有不同,如下表所示:
使用 I/O 多路复用方式实现业务逻辑时,伴随着事件的状态切换(从等待状态切换到准备就绪状态),系统需要频繁地保存和恢复现场,这会对性能产生影响。而协程因具有轻量级和并发执行等特点,非常适合处理这种场景。
可以将 Socket 分配给协程,然后将等待的 I/O 事件(也就是尚未就绪的 I/O 事件)注册到监听队列中,每个监听对象关联一个事件数据,这个事件数据用来记录处于等待状态的协程。
当事件准备就绪时,恢复对应的协程到运行队列中。由于协程的执行看起来就像在同步执行流,因此这种方式能够使实际可能阻塞线程的操作看起来不会发生阻塞。
可见,使用协程可以让 I/O 多路复用更加高效地处理业务逻辑。
- 内核态线程:指由操作系统内核创建和管理的线程,它们运行在内核态下且可以访问内核空间,这使得它们在处理 I/O 等系统级任务时非常有用。内核态线程与普通线程的区别在于内核态线程没有单独的地址空间;
- 用户态线程:由用户级别的线程库创建和管理,是轻量级的线程。因为用户态线程运行在用户态下,所以它们无法访问内核空间,这使得它们更适合执行应用程序级别的任务。用户态线程由用户运行时管理,操作系统无法识别,它的切换由用户程序自己控制。用户态线程也是由内核态线程运行的。Lua、Python 和 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 语言屏蔽了底层的实现细节,使开发者能够轻松地编写并发代码。
控制协程的关键是控制执行的主体、保留状态和恢复现场,协程通过主动让出控制权实现任务切换,若再次切换回来则从上次暂停处继续执行,而非重新开始。每个协程都有自己的协程栈,用于保存执行时的状态。当协程发生上下文切换时,首先保存当前状态,然后让出执行权,最后切换到其他协程上。
协程和线程很相似,都是一种执行流。它们的区别如下:
- 通常协程占用的内存更小。一个线程通常占用 2~8 MB的内存,而一个协程可能只占用 2KB 的内存。另外,协程的堆栈可以根据程序的需要增大或缩小;
- 协程的上下文切换更快。线程申请内存时需要访问内核。线程的上下文切换会涉及用户态和内核态的切换,还有 PC、SP 等寄存器的刷新,因此需要保存和恢复更多的寄存器信息,故而上下文切换的代价相对较大。协程申请内存时,不需要访问内核。协程的上下文切换发生在用户态,仅涉及三个寄存器(PC、SP、DX)的值的修改,因此只需要保存和恢复少量的寄存器信息,故而上下文切换的代价相对较小;
- 线程的调度是抢占式的,由操作系统内核进行;协程的调度是协作式的,需要显式地将 CPU 让给其他协程。下一个协程要在上一个协程让出 CPU 后才能执行。
改造后的Go语言协程
Go 语言在源码级别的 runtime 包中就实现了协程、逻辑处理器和内核态线程三者之间的调度管理,所以我们常说 Go 在语言层面就支持高并发。与原生的协程相比,Go 语言的协程有以下特色:
- 缩短了冗余的协程生命周期,仅有三个状态,即协程创建、协程完成和协程重用;
- 降低了因协程间频繁交互而导致的延迟和开销;
- 降低了加锁和解锁的频率,减少了部分额外的开销;
- 原生协程是协作式调度,而 Go 语言的协程是抢占式调度。
在 Go1.13 版本之前,调度器是基于协作的抢占式调度。在 Go1.14 版本后,调度器是基于信号的抢占式调度。
简说Go语言协程的调度
在 Go语言中,协程的调度是由调度器完成的。我们常说的 GPM 调度,其中 G(Goroutine)指的是协程、P(Processor)指的是逻辑处理器,M(Machine)指的是内核态线程。调度器使用 GPM 调度模型来管理协程和线程之间的关系。1) 相关概念
在 Go语言的 GPM 调度模型中,有一些重要的概念需要了解,具体如下表所示。概念 | 说明 |
---|---|
Sched | 调度器,用于维护、存储和调度全局队列以及调度器的部分状态信息,负责协调全局队列、处理器和内存之间的调度关系。Sched 会根据负载情况动态调整系统资源,为每个处理器分配适量的全局队列。此外,Sched 还会在必要时进行全局调度,如将全局队列中的全局队列分配给处理器 |
GR | Global Run Queue,表示全局运行队列。所有的处理器共享一个全局运行队列,新的全局队列创建后,首先会被放入全局运行队列中。如果某个处理器的本地运行队列为空,那么它会尝试从全局运行队列中取出一些全局队列放入自己的本地运行队列中,以备后续执行 |
LRQ | Local Run Queue,表示本地运行队列。每个处理器都有自己的本地运行队列,队列中存储了这个处理器需要运行的全局队列。当处理器有机会执行全局队列时,它会首先从自己的本地运行队列中选取一个全局队列。正在执行的全局队列状态为 running,本地运行队列中的全局队列状态为 runnable |
2) 调度器和调度策略
下面基于下图简要说明调度器和调度策略:
图 2 调度器和调度策略
Go 语言的调度模型将多个用户态线程映射到少量内核态线程上,使得程序能够高效地利用系统资源,实现高并发和高吞吐量。
GPM 调度器是 Go 运行时的核心组件,它负责将 G 调度到 M 上并执行。
以下是有关调度器的简要说明:
- G 并非执行体,M 才是实际的执行体,G 不能直接与 M 绑定,而应由 P 作为中介来绑定。一旦 P 获取到了 M,它就会将其绑定到一个可运行的 G 上。同时,P 还会将该 G 加入自己的本地任务队列以等待执行。每个 G 都需要绑定到 P 上,才能被 GPM 调度器调度并执行;
- 每个 P 都有一个本地运行队列。队列中存储的是分配给这个 P 执行的 G。P 获取到 M 后,会将本地运行队列中的 G 调度到 M 上;
- 主线程实际上也是 G(称作主协程),它同样运行在 M 上。主协程还可以创建子协程;
- M 绑定有效的 P 后,就会进入一种称为 scheduler loop 的循环调度中;
- 循环调度机制会扫描所有的 P,并按照优先级依次从本地运行队列、全局运行队列、网络轮询器(NetPoller)和其他P的本地运行队列中“窃取”G。如果所有的队列都没有可执行的 G,则进入睡眠状态等待新的 G 的到来;如果获取到可执行的 G,则将其绑定到 P 上,然后将该 G 调度到 M 上并执行。如果 G 阻塞或执行结束,则将其从 P 上解绑,并放回到对应的队列中,等待下次调度。如此循环往复。
- M 并不保留 G 的状态,这样安排的目的是让 G 可以跨 M 调度。
- 还有一种获取 G 的方式是从网络轮询器中获取。网络轮询器在 Go 语言的调度模型中主要负责处理与网络 I/O 相关的任务。如果一个 G 执行了一个会造成阻塞的网络 I/O 操作(例如读写 TCP 数据),那么它会被移动到网络轮询器中,并且从执行队列中移除,从而让出处理器资源给其他 G 使用。当网络 I/O 操作完成时,这个 G 会被网络轮询器唤醒,并且被重新放入调度队列中等待调度执行。
- G 的数量可以远大于 M 的数量。这意味着可以利用少量的 M 来支撑大量 G 的并发执行。多个 G 通过用户态线程的上下文切换来共享内核态线程的计算资源,而对于操作系统来说,并没有因用户态线程的上下文切换而损失性能。
GPM 调度器在 Go 语言中采用多种策略来实现高效的协程调度和管理,主要如下:
- m:n调度:GPM 调度器将 m 个 M 映射到 n 个 G 上执行。这种调度策略可以有效地利用 CPU 资源,并提高程序的并发性能。
- 抢占式调度:GPM 调度器在任何时刻都可以暂停执行某个 G,并且可切换到其他 G 上。这种调度策略可以有效地避免 G 长时间的阻塞,从而提高程序的响应性能和并发性能。
- 多级反馈队列调度:GPM 调度器采用多级反馈队列调度策略来管理 G。在这种调度策略下,G 会被分成若干个级别,每个级别对应一个队列(包括本地运行队列、全局运行队列、网络轮询器和其他 P 的本地运行队列等)。一个 G 执行完以后,调度器会根据它的执行情况来调整它的优先级,从而实现更好的调度和资源利用。
- 工作窃取(Work Stealing)机制:GPM 调度器通过“工作窃取机制”来实现负载均衡。在这种机制下,当一个 P 的本地运行队列为空时,调度器会从其他P的本地运行队列中“窃取”一些任务来执行。这样可以保证所有 P 的任务负载均衡,提高程序的并发性能。
协作式与抢占式调度器
内核态模式意味着处理器可以执行任何代码,若执行操作系统的代码或者进行系统调用,处理器需要切换到内核态下。有些驱动程序之所以会让操作系统崩溃,是因为它们是在内核态下运行的。而 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 多路复用机制实现的,但它们的实现细节和性能都略有不同,如下表所示:
模型 | 工作原理 |
---|---|
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 多路复用更加高效地处理业务逻辑。