Golang的协程调度器设计思想

Golang的协程调度器原理及GMP设计思想。


一、协程的出现

  1. 单进程时代不需要调度器
    1. 进程串行执行;
    2. 面临两个问题:
      1. 单一的执行流程;
      2. 阻塞带来CPU时间浪费。
  2. 多进程/线程时代有了调度器
    1. 多进程解决了阻塞的问题;
    2. 但是进程拥有太多的资源,创建、切换、销毁都会占用很多时间,CPU很大部分都用来进行进程调度了。
    3. 对于Linux来说,CPU对待进程和线程是一样的,尽管线程更小,但是多线程的开发设计会变得更复杂,要考虑很多同步竞争问题。
    4. 多进程/线程虽然已经有很高的并发能力,但是为每一个任务都创建一个线程是不现实的,耗费大量的内存。(进程虚拟内存占用4GB,线程占用4MB)。
  3. 协程提高CPU利用率
    1. 线程分为内核态和用户态,一个用户态线程要绑定一个内核态线程。但是CPU并不知道用户态线程的存在,只运行内核态线程(PCB进程控制块)。为了区分,内核态线程依旧叫做线程(Thread),用户态线程叫协程(Co-routine)。
    2. 线程和协程之间需要进行绑定:
      1. N:1关系,N个协程绑定一个线程。
        1. 优点是协程在用户态完成切换,轻量快速。
        2. 缺点是某个程序无法利用多核加速能力(只绑定一个线程,一次一个CPU运行)。
        3. 一旦其中一个协程阻塞,这个协程调度器上的所有协程都无法得到执行。
      2. 1:1关系,最容易实现。缺点是协程的创建、删除和切换都由CPU完成,代价高。
      3. M:N关系,M个协程绑定一个调度器,这个调度器绑定N个线程。

二、GO语言的协程Goroutine

goroutine来自协程的概念,让一组可复用的函数运行在一组线程之上,即使有协程阻塞,该线程的其他协程也可以被runtime调度,转移到其他线程上。

一个goroutine只有几KB,能支持大量的goroutine,虽然一个goroutine栈只占小内存,但是也是可伸缩的,如果需要更多内容,runtime会自动为goroutine分配。

被废弃的goroutine调度器

go目前的调度器是2012年重新设计的,因为之前的调度器性能存在问题,所以废弃,但是先分析被废弃的调度器如何运作。

G:Goroutine协程。M:Thread线程。

有几个缺点:

  • 创建、销毁、调度G都需要M获取锁,造成激烈的锁竞争。
  • M转移G造成延迟和额外的负载。
  • 一个G创建的另一个G可能是相关的,最好在同一个M上执行,但是全局队列的形式难以实现,造成了很差的局部性。

三、Goroutine调度器的GMP模型的设计思想

M(thread)。G(goroutine)。P(Processor)。

1 GMP模型

线程是运行goroutine的实体,调度器的功能是把可运行的goroutine分配到工作线程上。

模型包含:

  • 全局队列(Global Queue):存放等待运行的G。
  • P的本地队列:同全局队列类似,存放的也是等待运行的G,存的数量有限,不超过256个。新建G’时,G’优先加入到P的本地队列,如果队列满了,则会把本地队列中一半的G移动到全局队列。
  • P列表:所有的P都在程序启动时创建,并保存在数组中,最多有GOMAXPROCS(可配置)个。
  • M:线程想运行任务就得获取P,从P的本地队列获取G,P队列为空时,M也会尝试从全局队列拿一批G放到P的本地队列,或从其他P的本地队列偷一半放到自己P的本地队列。M运行G,G执行之后,M会从P获取下一个G,不断重复下去。

Goroutine调度器和OS调度器是通过M结合起来的,每个M都代表了1个内核线程,OS调度器负责把内核线程分配到CPU的核上执行。

有关P和M的个数问题:

  • P的数量:由启动时环境变量$GOMAXPROCS或者是由runtime的方法GOMAXPROCS()决定。这意味着在程序执行的任意时刻都只有$GOMAXPROCS个goroutine在同时运行。
  • M的数量:go程序启动时,会设置M的最大数量,默认10000。一个M阻塞了,会创建新的M。

M与P的数量没有绝对关系,一个M阻塞,P就会去创建或者切换另一个M,所以,即使P的默认数量是1,也有可能会创建很多个M出来。

P和M何时会被创建:

  • 在确定了P的最大数量n后,运行时系统会根据这个数量创建n个P。
  • 没有足够的M来关联P并运行其中的可运行的G。比如所有的M此时都阻塞住了,而P中还有很多就绪任务,就会去寻找空闲的M,而没有空闲的,就会去创建新的M。

2 调度器的策略

  1. 复用线程:避免频繁创建、销毁,而是复用。
    1. work stealing机制:当本线程没有可运行G时,尝试从其他P偷取G,而不是销毁线程。
    2. hand off机制:当本线程有阻塞时,释放绑定的P给其他空闲线程,只保留阻塞的那个G。
  2. 利用并行:GOMAXPROCS设置P的数量,最多有GOMAXPROCS个线程分布在多个CPU上同时运行。GOMAXPROCS也限制了并发的程度,比如GOMAXPROCS = 核数/2,则最多利用了一半的CPU核进行并行。
  3. 抢占:设置时间片,每个goroutine最多占用CPU每次10ms,防止饥饿。
  4. 全局G队列:新的调度器依旧有全局G队列,但是功能弱化了,当M执行work stealing从其他P偷不到G时,才可以从全局G队列获取G。

3 go func()调度流程

  1. 通过go func()来创建一个协程。
  2. 新建的G会优先放到本地队列,如果满了就会保存在全局队列。

特殊的M0和G0:

  • M0是启动程序后的编号为0的主线程,这个M对应的实例会在全局变量runtime.m0中,不需要在heap上分配,M0负责执行初始化操作和启动第一个G, 在之后M0就和其他的M一样了。
  • G0是每次启动一个M都会第一个创建的gourtine,G0仅用于负责调度的G,G0不指向任何可执行的函数, 每个M都会有一个自己的G0。在调度或系统调用时会使用G0的栈空间, 全局变量的G0是M0的G0。