本文主体分为2大部分,先介绍了GMP的由来,再介绍了GMP的调度模型,剖析了GMP的工作过程,适合初学GMP的读者。
GMP调度器的由来
单进程操作系统不需要调度器
单进程操作系统中每个进程是串行运行的,没有并行的概念,也就是当进程A阻塞时,CPU也不会切换到其他进程,会一直等待A解阻塞。
多进程/线程操作系统有了调度器需求
在多进程/线程的操作系统中,如果一个进程/线程发生阻塞那么CPU就会切换到另一个进程/线程运行,这样从宏观上来看多个进程/线程就绪并行的。那此时就引入额外的问题,如果有过多的进程/线程的话,CPU会花很多时间在创建、销毁、切换上,这样就会导致CPU利用率很低。
协程的出现
多进程/线程虽然提高了系统的并发能力,但是一个进程/线程也占用了较多的内存(32位操作系统进程虚拟地址空间4GB,线程大约8MB),所以大量的进程/线程会带来以下2个问题:
- 高内存的占用
- 调度消耗大量CPU时间片
但是上面说的线程是内核线程,而程序直接管理的是处于用户态的线程 — 用户线程,也成为co-routine(协程),但是CPU调度的是内核线程,它感知不到用户线程的存在,所以用户线程需要调度器去调度。
通过看图可以看到每个用户线程都会绑定到某个内核线程,那它们的绑定关系是怎么样呢?
- 1:1
协程和线程为一一对应的关系,如下图所示。但这种情况下协程切换还是会涉及到内核线程的切换,还是会出现上面的问题。
- N:1
这种情况下多个协程绑定到同一个内核线程,如下图所示。这种情况下协程的切换仅仅在用户态就可以完成,不需要内核的参与。当然,这种情况也有缺点,例如一个协程进行系统调用导致线程阻塞,那么其他协程也就一直得不到执行,也就没有并发可言了。
- M:N
这种情况下和以上2种情况不一样,每个协程没有强制关联上某个线程,它们的关联是可变的,如下图所示。该模式下,协程的切换也是在用户态中实现的,不需要CPU的参与,每个协程阻塞后会执行下一个协程。
Go的协程
Go语言为了更易用、高效的并发能力,提出了goroutine的概念。通常情况下,一个goroutine只占用几kb的内存,远低于内核线程(几M),而且goroutine的整个生命周期都是由runtime包进行管理,不需要内核的参与。
剖析GMP模型
通过上文我们可以看到协程的调度都是在用户态实现的,不需要内核的参与。那么在Go中协程的调度器是怎么实现的呢?
早期的goroutine调度器
上图为早期的goroutine调度器,它的主要思想就是用一个队列维护所有的goroutine,创建、销毁、调度goroutine都需要先获取一把全局锁保证多线程下资源互斥。
这种调度器有明显的几个缺点:
- 每次创建、销毁、调度goroutine都需要争抢全局锁,带来了激烈的锁竞争,降低了并发度。
- 工作线程经常传递goroutine,这可能会带来延迟的増加和额外开销。例如:M0目前执行G,G由创建了G‘,那这时M0会把G’交给其他线程处理,会造成很差的局部性,因为G和G‘关联,最好都放在M0上执行。
- 系统调用导致线程阻塞/解阻塞,这会带来大量的系统开销。
现在的goroutine调度器
目前的goroutine调度模型也成为GMP(goroutine、thread、processor)模型,相比于旧版调度器加入了processor的角色,每个M想要运行G必须要绑定某个P,其中P和M的关系是灵活的,每个P可以绑定任何一个M。
- 每个P有本地G队列,大小为256,如果满了就会放到全局队列中
- 当P被某个M执行时,首先会调度本地队列中的G,如果本地队列为空则从全局队列转移一部分G过来,如果全局队列也为空则会从其他P窃取
- P的个数为GOMAXPROCS(可以通过runtime.GOMAXPROCS()设置),在程序启动时就会创建好P
- 如果调度G时发生系统调用阻塞,则会解绑该G和P的关系,从而这个P又可以被其他M运行
特殊的m0、g0
go语言中存在特殊的M(m0)、特殊的G(g0),m0可以理解为go程序启动创建的第一个M,也就是main M(主线程),它负责创建第一个G,之后就和其他M没有区别。而每个M都会绑定一个g0,这个g0的栈是分配在内核空间,它主要的功能就是调度其他G。
自旋线程
如果m绑定的p本地队列为空,那这个m会不断寻找可运行的g,这种状态下的m称为自旋线程,自旋线程最大个数为GOMAXPROCS,也就是p的个数。
我们先通过下面这个例子对gmp有个宏观的认识。
go func(){xxxxxx}
上面这个代码就是起了一个goroutine来运行代码,下面这个流程图展示了gmp的工作流程。