暂无图片
暂无图片
暂无图片
暂无图片
暂无图片

Go调度器原理

专业造轮子 2021-10-03
677

Go调度器的由来

单进程时代

单进程时代是不需要调度器的,每个进程运行完才会进行下一个程序,也就是每个程序都只能串行进行。这样的运行方式存在两个问题

  1. 计算机只能一个任务一个任务的执行
  2. 进程阻塞时,导致CPU处理停止等待

多进程/多线程时代

多进程和多线程是为了解决单进程带来的阻塞问题,让CPU分时去处理个进程或线程,虽然每次CPU也只能处理一个程序,但是由于在时间片结束后,CPU可以切换到其他进程或线程,从宏观的角度看,似乎是多个进程同时被运行。但是多进程和多线程同时也存在问题

  1. 进程拥有太多的资源,进程的创建,切换,销毁都会占用很高的CPU
  2. 线程虽然拥有的资源较少,但是同样存在切换资源的消耗,同时多线程还有同步竞争等问题
  3. 即多进程和多线程都会占用较高的内存和CPU

协程时代

协程的概念涉及到计算机内核相关知识,这里简单做个介绍。计算机内核分为用户态和内核态,从用户态切换到内核态,需要消耗的资源会更多。多进程和多线程都是属于内核态的系统调用,协程则是为了避免用户从用户态切换到内核态产生的系统调用消耗,而设计的一种用户态的线程。虽然是用户态的线程,但是最终还是需要切换到内核态去运行(如下图所示),所以对于协程和线程,就有多种映射关系,即:一对一,多对一,多对多。

一对一关系

即一个协程绑定一个线程。这种最容易实现,但是也是存在一个问题:

  1. 协程的创建,删除和切换的代价都是CPU完成的,消耗依旧很高

多对一关系

即多个协程绑定一个线程。这种协程在用户态就可以完成切换,不会陷入到内核态,这种切换非常轻量。但是也存在问题

  1. 某个程序用不了硬件的多核加速的能力
  2. 一旦某个协程阻塞,绑定的这个线程同时阻塞,最终导致没有达到并发的效果。

多对多关系

即多个协程绑定多个线程。这种克服了上面两种问题,但是实现起来最为复杂。

最后,协程和线程除了用户态和内核态的区别外,协程由用户态调度是协作式的,线程由CPU调度是抢占式的。一个协程让出CPU后,才执行下一个协程

Go语言的协程

go语言的协程提供了goroutine
channel
,让一组可复用的函数运行在一组线程之上,即使有协程阻塞,该线程的其他协程也可以被runtime
调度,转移到其他线程上。最关键的是程序员看不到这些细节,降低了编程难度,提供了容易的并发。而且占用内存非常小,一般只有几KB,总结而言:

  1. goroutine
    占用内存更小
  2. goroutine
    调度更加灵活

go语言的协程主要经历了两个发展,一个是GM模型,一个是GMP模型,其中G是goroutine
,M是线程。目前使用的是2012年重新设计的GMP模型

被废弃的goroutine
调度器-GM模型

GM模型原理如下图

M想要执行或放回G,都必须访问全局队列G,并且M有多个,即多线程访问同一资源需要加锁进行保证互斥/同步,所以全局队列G是有互斥锁进行保护的。这种方式有几个缺点:

  1. 创建/销毁/调度G都需要每个M获取锁,就会形成激烈的锁竞争
  2. M转移G会造成延迟和额外的系统负载。比如G中包含创建新协程的时候,M创建了G',为了继续执行G,需要吧G'叫个M'执行,也造成了很差的局部性,因为G'和G是相关的,所以最好都放在M上执行,而不是其他的M'
  3. 系统调用(CPU在M之间切换)导致频繁的线程阻塞和取消阻塞操作增加了系统开销

GMP模型

针对之前的问题,Go重新设计了调度器,除了G(goroutine)和M(thread)外,还引入了P(Processor)。其中P包含了运行goroutine的资源,如果M想运行G,必须先获取P,P中还包含了可运行的G队列。其主要原理图如下

主要的几个结构有:

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

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

其实可以把GMP模型看作是多核CPU的模型。其中M相当于CPU的核心数,P相当于CPU,G相当于进程或线程。只是两者的调度方案是不同的,多核CPU的调度是通过CPU硬件去完成的,而GMP是纯软件的调度。

有关P和M的问题

  1. P的数量:由启动时环境变量GOMAXPROCS
    来确定
  2. M的数量:go语言本身的限制。启动时,默认会设置M最大数量(10000),但内核很难支持,所以可以忽略
  3. P何时创建:确定了P的最大数量n后,运行时系统会根据这个数量创建n个P
  4. M何时创建:没有足够的M来关联P并运行其中可运行的G。根据需要创建

设计策略

  • 复用线程:可以避免频繁的创建,销毁线程,而是对线程复用。主要通过work stealing和hand off实现
    1. work stealing机制:当本线程无可运行的G时,尝试从其他线程绑定的P偷取G,而不是销毁线程
    2. hand off机制:当本线程因为G进行系统调用阻塞时,线程释放绑定的P,把P转移给其他空闲的线程执行
  • 利用并行:GOMAXPROCS
    设置P的数量,可以让多个线程在多个CPU上同时运行
  • 抢占:在goroutine中要等待一个协程主动让出CPU才执行下一个协程,一个goroutine最多占用CPU 10ms,防止其他goroutine被饿死
  • 全局G队列:在新的调度器中依然有全局队列,但功能已经被弱化了。当M执行work stealing从其他P拿不到G时,它可以从全局队列获取G

go func()
调度流程

一个go func()
完整的调用过程如图:

我们可以得出结论:

  1. 我们通过go func()
    来创建一个goroutine
  2. 有两个存储G的队列,一个是局部调度器P的本地队列,一个是全局G队列。新创建的G会先保存在本地队列中,如果本地队列已经满了,会保存在全局队列中
  3. G只能运行在M中,一个M必须有一个P,M会从P的本地队列弹出一个可执行状态的G来执行,如果本地队列为空,会从其他队列偷一个来执行
  4. 一个M调度G执行的过程是一个循环机制
  5. M执行某一个G时,如果发生了系统调用或其他阻塞操作,M会阻塞,如果当前有一些G在执行,runtime会把这个线程M从P中摘除,然后再创建一个新的线程来服务这个P
  6. 当M系统调用结束时,这个G会尝试获取一个空闲的P执行,并放到P的本地队列,如果获取不到P,M就变成休眠状态,放到空闲线程中,然后G会被放入全局队列中


文章转载自专业造轮子,如果涉嫌侵权,请发送邮件至:contact@modb.pro进行举报,并提供相关证据,一经查实,墨天轮将立刻删除相关内容。

评论