前言
Goroutine 是 Go语言中的一种轻量级线程,它可以在单个线程上运行,通过使用协程来实现并发。Goroutine 可以在不阻塞主线程的情况下执行任务,从而提高程序的效率和并发性能。事实上每一个 Go 程序至少有一个 Goroutine:main Goroutine
。Go 程序从 main 包的main()
函数开始,在程序启动时,Go 程序就会为main()
函数创建一个默认的goroutine
。 如果需要了解更多可以查看我另外一篇文章:here。
通过关键字go
启用多个协程:
go f(x)
那么 Go 是如何保证 Goroutine 执行的高效率和并发性?
答案是:Go runtime 的调度器。goroutine 建立在操作系统线程基础之上,它与操作系统线程之间实现了一个多对多(M:N)的两级线程模型。
这里的 M:N 是指 M 个 goroutine 运行在 N 个内核线程之上,内核负责对这 N 个操作系统线程进行调度,而这N个系统线程又通过 goroutine 调度器负责对这 M 个 goroutine 进行调度和运行。
GM 模型
Go1.0 的协程是 GM 模型
- G 指 Goroutine,本质上是轻量级线程,包括了调用栈,重要的调度信息,例如channel
- M 指 Machine,一个 M 关联一个内核 OS 线程,由操作系统管理。
M(内核线程)想要执行、放回 G 都必须访问全局 G 队列,并且 M 有多个,即多线程访问同一资源需要加锁进行保证互斥/同步,所以全局 G 队列是有互斥锁进行保护的。
这个调度器有几个缺点:
- 存在单一全局互斥锁和集中状态。全局锁保护所有 goroutine 相关操作(如:创建、完成、重新调度等),导致锁竞争问题严重;
- goroutine 传递问题:经常在 M 之间传递“可运行”的 goroutine,这导致调度延迟增大;
- G 在多个 M 间切换,每个线程 M 都需要做内存缓存(M.mcache),导致内存占用过高,且数据局部性/亲缘性差;
- 新的 G 由某个 M 创建后,放入全局队列,然后再被调度系统调用。如此频繁地阻塞和解除阻塞正在运行的线程,导致额外的性能损耗。
GPM 模型
从 Go1.2 开始,新的协程调度器引入了P(Processor),成为了完善的GPM模型。Processor,它是处理器(Process)的抽象,但不是真正的CPU,能够分配CPU 资源的还是M。它包含了运行 goroutine 的资源,如果线程想运行goroutine,必须先获取P,P中还包含了可运行的 G 队列。
上图中各个模块的作用如下:
- 全局队列:存放等待运行G
- P 的本地队列:和全局队列类似,存放的也是等待运行的G,存放数量上限256个。新建 G 时,G 优先加入到 P 的本地队列,如果队列满了,则会把本地队列中的一半 G 移动到全局队列
- P 列表:所有的 P 都在程序启动时创建,保存在数组中,最多有
GOMAXPROCS
个,可通过runtime.GOMAXPROCS(N)
修改,N 表示设置的个数。 - M:每个M代表一个内核线程,操作系统调度器负责把内核线程分配到 CPU 的核心上执行。
P 是怎样处理上面的问题的呢?
- P 与 M 结合构成一个并行处理的单元,它限制了并行的任务数量,
runtime.GOMAXPROCS
能够配置它的数量; - G 需要挂到 PM 上面才能执行;
- 每个 P 都有一个队列(lock free),存储当前绑定的 G,M 会优先从绑定的 P 的队列中获取 G,避免加锁到全局队列中竞争获取,新创建的 G 也是直接放入这个本地队列,减少放全局队列的各种开销;
- 由 P 管理着内存缓存,M 与 G 会尽量保持与同个 P 配对,保证了 G-P-M 的内存局部性/亲缘性;
策略
队列轮转
每个P维护着一个包含G的队列,不考虑G进入系统调用或IO操作的情况下,P周期性的将G调度到M中执行,执行一小段时间,将上下文保存下来,然后将G放到队列尾部,然后从队列中重新取出一个G进行调度。
除了每个P维护的G队列以外,还有一个全局的队列,每个P会周期性的查看全局队列中是否有G待运行并将期调度到M中执行,全局队列中G的来源,主要有从系统调用中恢复的G。之所以P会周期性的查看全局队列,也是为了防止全局队列中的G被饿死。
线程自旋
调度器会保证至少有一个M在自旋检查P和G有没有可绑定的,避免任务等待,但自旋也会浪费一些CPU算力。
系统调用
P的个数默认等于CPU核数,每个M必须持有一个P才可以执行G,一般情况下M的个数会略大于P的个数,这多出来的M将会在G产生系统调用时发挥作用。类似线程池,Go也提供一个M的池子,需要时从池子中获取,用完放回池子,不够用时就再创建一个。
当M运行的某个G产生系统调用时,如下图所示:
如图所示,当G0即将进入系统调用时,M0将释放P,进而某个空闲的M1获取P,继续执行P队列中剩下的G。而M0由于陷入系统调用而进被阻塞,M1接替M0的工作,只要P不空闲,就可以保证充分利用CPU。
M1的来源有可能是M的缓存池,也可能是新建的。当G0系统调用结束后,跟据M0是否能获取到P,将会将G0做不同的处理:
- 如果有空闲的P,则获取一个P,继续执行G0。
- 如果没有空闲的P,则将G0放入全局队列,等待被其他的P调度。然后M0将进入缓存池睡眠。
工作量窃取
多个P中维护的G队列有可能是不均衡的,比如下图:
竖线左侧中右边的P已经将G全部执行完,然后去查询全局队列,全局队列中也没有G,而另一个M中除了正在运行的G外,队列中还有3个G待运行。此时,空闲的P会将其他P中的G偷取一部分过来,一般每次偷取一半。偷取完如右图所示。
抢占式调度
sysmon 会检查长时间运行的 G,将其中断并重新放入调度。中断的原理是 sysmon 通过信号量通知 G 的 M,往 G 的 PC 中插入特定指令,G 执行该指令后将自己推入全局队列重新调度。
性能
一般来讲,程序运行时就将 GOMAXPROCS 大小设置为 CPU 核数,可让 Go 程序充分利用 CPU。 在某些 IO 密集型的应用里,这个值可能并不意味着性能最好。 理论上当某个 Goroutine 进入系统调用时,会有一个新的 M 被启用或创建,继续占满 CPU。 但由于 Go 调度器检测到M被阻塞是有一定延迟的,也即旧的 M 被阻塞和新的 M 得到运行之间是有一定间隔的,所以在 IO 密集型应用中不妨把 GOMAXPROCS 设置的大一些,或许会有好的效果。
总结
- GM 模型:起初的 Go 并发性能并不十分亮眼,协程和系统线程的调度比较粗暴,导致很多性能问题,如全局资源锁、M的内存过高等造成许多性能损耗。
- GPM 模型:加入P的设计之后实现了一个通过 work stealing 的调度算法:由P来维护 Goroutine 队列并选择一个适当的M绑定。P 提供了相关的执行环境(Context),如内存分配状态(mcache),任务队列(G)等,P 的数量决定了系统内最大可并行的G的数量。
参考
- https://morsmachine.dk/go-scheduler
- https://books.studygolang.com/GoExpertProgramming/chapter03/3.1-go_schedule.html
- https://www.zhihu.com/question/20862617
本文由 Chakhsu Lau 创作,采用 知识共享署名4.0 国际许可协议进行许可。
本站文章除注明转载/出处外,均为本站原创或翻译,转载前请务必署名。