Golang 的协程调度小结
Golang 调度器的由来
一切的软件都跑在操作系统上,早期的操作系统每个程序就是一个进程,一个程序运行完才能进行下一个进程,所有的程序只能串行发生,这就是单进程时代。
早期的单进程操作系统,存在两个问题:
- 单一的执行流程。计算机只能串行处理任务,所有的程序几乎都是阻塞的。
- 进程阻塞带来的 CPU 时间浪费。
多进程/多线程的操作系统解决了阻塞的问题,一个进程阻塞 CPU 时可以立刻切换到其他进程中去执行,而且调度 CPU 的算法可以保证在运行的进程都可以被分配到 CPU 的运行时间片。从宏观来看,似乎就是多个进程在同时被运行。
但是,进程拥有太多的资源,进程的创建、切换、销毁,都会占用很长的时间,虽然 CPU 利用起来了,但如果进程数太多,CPU 很大程度上都被用来进行进程切换调度。
同时,多线程设计还要考虑很多的同步竞争问题,比如:锁、资源竞争、同步冲突等等。
那么如何才能提高 CPU 的利用率呢?
多进程/多线程已经提高了系统的并发能力,但是在当今的互联网高并发场景下,为每一个任务都创建一个进程是不现实的,因为这样会出现大量的线程同时运行,不仅切换成本高,也会消耗大量的内存。
后来工程师发现一个线程可以分为"内核态"和"用户态"两种形态,所谓的用户态线程就是把内核态的线程在用户态实现了一遍而已,目的是更轻量化(更少的内存占用、更少的隔离、更快的调度)和更好的可控性(可以自己控制调度器)。用户态的所有东西内核态都看得见,只是对于内核而言,用户态线程只是一堆内存数据而已。
一个用户态线程必须要绑定一个内核态线程,但是 CPU 并不知道有用户态线程的存在,它只知道它运行的是一个内核态线程。
如果将线程再进行细化分类,内核线程依然叫线程(Thread),用户线程则叫协程(Coroutine),操作系统层面的线程就是内核态线程,用户态线程则多种多样,只要能满足在同一个内核线程上执行多个任务就算。
既然一个协程可以绑定一个线程,那么多个协程也可以绑定多个线程,即协程和线程的映射关系。
N:1
N 个协程绑定一个线程,优点是协程在用户态线程即完成切换,不会陷入到内核态,这种切换非常轻量快速;缺点是一个进程的所有协程都绑定在一个线程上,某一个协程阻塞会导致线程阻塞,本进程的其他协程都无法执行,进而导致没有任何并发能力。
1:1
一个协程绑定一个线程,协程的调度都由 CPU 完成,虽然不存 N:1 的缺点,但是协程的创建、删除和切换的代价都由 CPU 完成,成本和代价略显昂贵。
M:N
M 个协程绑定一个线程,是 N:1 和 1:1 的结合,解决了上述两种方式的缺点,但实现更为复杂。同一个调度器上挂着 M 个协程,调度器下游则是多个 CPU 核心资源。
协程和线程是有区别的,线程由 CPU 调度,是抢占式的;协程由用户态调度,是协作式的。一个协程让出 CPU 后,才执行下一个协程,所以对于 M:N 的模型,提高线程和协程的绑定关系和执行效率就是优先考虑的目标。
Golang 的协程 Goroutine
Goroutine 的特点:占用更小的内存;更灵活的调度(runtime 调度)。
Go 为了提供更容易使用的并发方法,使用了 Goroutine 和 Channel。Goroutine 来自协程的概念,让一组可复用的函数运行在一组线程上,即使有协程阻塞,该线程的其他协程也可以被 runtime 调度,转移到其他可运行的线程上。
一个 Goroutine 只占用几 KB,并且这几 KB 就足够支撑 Goroutine 运行完,这就能在有限的内存空间中支持大量的 Goroutine,即支持更多的并发。
虽然一个 Gouroutine 的栈只占几 KB,但实际是可伸缩的,如果需要更多内容,runtime 会自动为 Goroutine 分配。
被废弃的 Goroutine 调度器
Go 目前使用的调度器是 2012 年重新设计的,因为之前的调度器性能存在问题。在 Go 的调度器中通常用 G 表示 Goroutine,用 M 表示线程。
早期的调度器基于 M:N 模型,所有的协程(G)都会被放在一个全局的 Go 协程队列中,由于在全局队列的外面是多个线程(M)的共享资源,所以需要加上一个用于同步互斥的锁。M 想要执行、放回 G 都必须访问全局队列,并且 M 有多个,即多线程访问同一资源时需要加锁。
所以早期的调度器存在以下缺点:
- 创建、销毁、调度 G 都需要每个 M 获取锁,这就形成了激烈的锁竞争。
- M 转移 G 会造成延迟和额外的系统负载。比如当 G 中包含创建新的协程时,M 创建了 G’,为了继续执行 G,需要把 G’ 交给 M2(假如被分配到)执行,这就造成了很差的局部性,因为 G’ 和 G 是相关的,最好是放在 M 上执行。
- 系统调用(CPU 在 M 之间的切换)导致频繁的线程阻塞和取消阻塞操作增加了系统开销。
Goroutine 调度器 GPM 模型
针对上述问题,Go 重新设计了新的调度器,在原有的基础上,增加了处理器(P)。处理器包含了运行 Goroutine 的资源,如果线程想运行 Goroutine,必须先获取 P,P 中还包含了可运行的 G 队列。
在 Go 中,线程是运行 Goroutine 的实体,调度器的功能是把可运行的 Goroutine 分配到工作线程上。Goroutine 调度器和 OS 调度器是通过 M 结合起来的,每个 M 都代表了一个内核线程,OS 调度器负责把内核线程分配到 CPU 的核上执行。
在 GPM 模型中存在以下重要概念:
- 全局队列(Global Queue):存放等待运行的 G。全局队列可能被任意的 P 去获取里面的 G,所以全局队列相当于整个模型中的全局资源,自然对于队列中的读写操作需要加入互斥动作。
- P 的本地队列:和全局队列类似,存放的也是等待运行的 G,存放的数量有限,不超过 256 个。新建 G’ 时,G’ 会优先加入到 P 的本地队列里,如果本地队列满了,则会把本地队列中一半的 G 移动到全局队列。
- P 列表:所有的 P 都在程序启动时创建,并且保存在数组中,最多有 GOMAXPROCS 个。
- M:线程想运行任务就必须获取 P,从 P 的本地队列中获取 G,P 队列为空时,M 会尝试从全局队列拿一批 G 放到 P 的本地队列中,或者从其他 P 的本地队列中偷取一半的 G 放到自己的 P 的本地队列。M 运行 G,G 执行之后,M 会从 P 获取下一个 G,不断循环。
P 和 M 的个数
- P 的数量由启动时环境变量 ¥GOMAXPROCS 或者由 runtime 的方法 GOMAXPROCS() 来决定,这意味着在程序执行的任意时刻都只有 $GOMAXPROCS 个 Goroutine 在同时运行。
- M 的数量由 Go 语言本身决定,Go 程序在启动时会设置 M 的最大数量,默认为 10000,但是内核很难支持这么多的线程数,所以这个限制可以忽略。runtime/debug 中的 SetMaxThreads() 函数可以设置 M 的最大数量,当一个 M 阻塞时会创建新的 M。
- M 和 P 的数量没有绝对关系,一个 M 阻塞,P 就会去创建或者切换另一个 M,所以即使 P 的默认数量是 1,也可能会创建很多个 M。
P 和 M 何时被创建
- P 创建的时机在确定了 P 的最大数量之后,运行时系统会根据这个数量去创建对应个 P。
- M 创建的时机在当没有足够的 M 来关联 P 并运行其中的可运行的 G 时,比如此时所有的 M 都阻塞了,而 P 中还有很多就绪任务,就会去寻找空闲中的 M,如果没有空闲的,就会创建新的 M。
调度器的设计策略
策略一:复用线程
避免频繁地创建、销毁线程,采用对线程的复用。
-
偷取机制:当本线程无可运行的 G 时,尝试从其他线程绑定的 P 偷取 G,而不是销毁线程。这里偷取的动作一定是由 P 发起的而非 M,因为 P 的数量是固定的,如果一个 M 得不到一个 P,那么这个 M 是没有执行的本地队列的,就不可能向其他的 P 队列偷取。
-
移交机制:当本线程因为 G 进行系统调用陷入阻塞时,线程释放绑定的 P,并把 P 转移给其他空闲的线程执行。如果在 M1 的 GPM 模型中,G1 正在被调度并且已经发生了阻塞,那么此时就会触发移交机制。GPM 模型为了更大程度的利用 M 和 P 的性能,不会让一个 P 永远被一个阻塞的 G1 耽误后续的工作,所以移交机制的设计概念就是立刻将此时的 P 释放出来。为了释放 P,所以将 P 和 M1、G1 分离,M1 由于正在执行当前的 G1,全部的程序栈空间都在 M1 中保存,所以此时 M1 应该与 G1 一同进入阻塞状态。已经被释放的 P 需要和另一个 M 进行绑定,那么就会选择出一个 M3(如果此时没有 M3 则会创建一个新的或者唤醒一个正在睡眠的)进行绑定,此时新的 P 就会继续工作,接受新的 G 或对其他队列中实施偷取机制。
策略二:利用并行
GOMAXPROCS 设置 P 的数量,最多有 GOMAXPROCS 个线程分布在多个 CPU 上同时运行。
策略三:抢占
在 Coroutine 中要等待一个协程主动让出 CPU 后,才能执行下一个协程。在 Go 中,一个 Goroutine 最多占用 CPU 10ms,防止其他 Goroutine 无资源可用。
策略四:全局 G 队列
在新的调度器中,依然有全局 G 队列,但是功能已经被弱化。当 M 执行偷取机制,但无法从其他 P 获取 G 时,可以从全局 G 队列中获取。
go func() 的调度流程
-
通过 go func() 创建一个 Goroutine。
-
有两个存储 G 的队列,一个是局部调度器 P 的本地队列、一个是全局 G 队列。新创建的 G 会先保存在 P 的本地队列中,如果 P 的本地队列已经满了,就会保存在全局队列中。
-
G 只能运行在 M 中,一个 M 必须持有一个 P,M 和 P 的关系是 1:1 的。M 会从 P 的本地队列中弹出一个可执行状态的 G 来执行,如果 P 的本地队列为空,则会从全局队列进行获取,如果全局队列也获取不到,则会从其他的 MP 组合偷取一个可执行的 G 来执行。
-
一个 M 调度 G 执行的过程是一个循环机制。
-
当 M 执行某一个 G 时,如果发生了 syscall 或其余阻塞操作,此时 M 会被阻塞,如果当前有一些 G 在执行,runtime 会把这个 M 从 P 中摘除,然后再创建一个新的操作系统线程(如果有空闲的线程可复用就用可复用的)来服务这个 P。
-
当 M 系统调用结束时,这个 G 会尝试获取一个空闲的 P 执行,并且放入到这个 P 的本地队列中。如果获取不到 P,则这个 M 变成睡眠状态,加入到空闲线程中,这个 G 会被放入全局队列中。
调度器的生命周期
在 Golang 的调度器的 GPM 模型中还有两个特殊的角色,分别是 M0 和 G0。
M0:
- 启动程序后编号为 0 的主线程。
- 在全局变量 runtime.m0 中,定义在 Go runtime 的全局作用域中;不需要在堆上分配,不像其他动态创建的 M,M0 是静态分配的,效率更高,不涉及垃圾回收。
- 负责执行初始化操作和启动第一个 G。
- 启动第一个 G 后,M0 就和其他的 M 一样。
G0:
- 每次启动一个 M,创建的第一个 Goroutine,就是 G0。
- G0 是仅用于负责调度的 G。
- G0 不指向任何可执行的函数。
- 每个 M 都有一个自己的 G0。
- 在调度或系统调度时,会使用 M 切换到 G0,再通过 G0 进行调度。
- M0 的 G0 会放在全局空间。
参考文档
深入理解Golang协程调度GPM[Go三关典藏版]