tags:
- GO
GPM Model of Go
并发是一系列独立执行计算的集合 ,并发通过对 CPU 时间片 (time slice/quantum) 的划分来实现多个进程共享计算资源。无论你的机器时单核心的还是多核心的,对并发的支持会让进程在宏观上有一种自己独占所有计算资源的错觉,结合操作系统对线程的调度策略从而实现对 CPU 的虚拟化。
在许多系统级语言中,并发通常依赖开发者手动引入线程并发库来实现。而在 Go 中,并发作为语言特性提供给用户,既然有现成的线程库可以用,为什么 Go 还要设计引入自己的并发模型呢?
目前,大多数的线程库都采用 1:1 的线程模型,也就是说,用户使用线程库在用户空间创建的线程会直接映射创建一个内核线程。而这种方式实际上在用户空间并不维护用户线程(也称为协程 co-routine)的数据结构,这就导致了并发完全通过内核线程来实现。看上去不错,但内核线程是很“重”的,操作系统对内核线程的调度切换和上下文切换会花费大量系统资源。这也就是协程诞生的背景。
协程运行在用户空间(内核无感知),而且协程的栈通常远小于线程栈,这就使得协程的切换开销非常小:即不需要用户态到内核态到转变;又由于协程栈很小,使用协程实现的内存占用也会小很多;再加上协程又用户态的调度器管理,其调度协程的逻辑也“轻”许多。这样,CPU 就可以在用户内核空间转换和线程调度上浪费更少的时间,即 CPU 的利用率提高了。
然而,由于操作系统对硬件资源的封装,CPU 只能“看见”操作系统中的线程(即内核线程),而无法识别协程的存在——对 CPU 来说,协程只是一段指令序列。想要协程真正运行在 CPU 上,就必须将其绑定在线程(即内核线程)上。在这之中,我们可以选择两种不同的线程模型:
在 Go 中,所采用的线程模型就是 M:N 线程模型。为了提供用户友好的并发接口,Go 抽象封装出 Goroutines 和 Channels。
Goroutines 就是 Go 中的协程,它们由 Go 运行时 (Go runtime) 调度,并映射到底层线程上运行。每个 Goroutine 启动时只占用几 KB 的栈空间,其栈大小可按需动态扩展。这种设计使得我们可以在内存资源有限的情况下同时运行成千上万个 Goroutine,从而轻松应对高并发场景。
而 Channels 是 Go 提供的并发通信原语,用于 Goroutine 之间的传递数据,同时同步 Goroutines 之间的行为。
最初,Go 使用的调度器逻辑非常简单,调度由一个全局 Goroutine 队列和一个简单的调度循环组成。由于这种调度器的性能太差,可能导致 Goroutine 堆积,形成瓶颈,后来 Go 逐步引入 GPM 三元组模型。我们先来看看原先已弃用的调度模型是怎么样的。
我们用 G 来表示 Goroutine,用 M 来表示线程。早期 Go 调度器如下图所示,其顶层设计仍然是 M:N 的线程模型。所有的 M 个 Goroutines 都在一个全局 FIFO 的 Goroutine 队列中,因为所有的线程都盯着同一个 Goroutine 队列,为了保证对 Goroutine 队列的访问不会引起数据竞争,这个队列由一个互斥锁进行保护。
这里调度器的结构看起来很简明,但一旦 Goroutines 数量急剧增多,就可能带来一系列的问题:
这样,一旦 Goroutine 数量极多,这样的全局调度就会变成性能瓶颈。
为解决之前调度器所存在的问题,Go 引入了新的调度器模型——GPM scheduler。在这个调度器中,除了之前的 G(Goroutines) 和 M(Threads) 外,新增了一个 P(Processer)。这个新引入的 P 就主要用来解决之前可怜的处理器亲和性问题(一般而言,操作系统对线程的处理器亲和性都很不错,所以这种方法是行之有效的)。