一、为什么需要"调度器"

1.单进程时代

早期的操作系统只能同时处理一个任务,即所有任务都是串行执行,一个执行完毕才会执行下一个任务。 alt 这种模式有两个显而易见的缺陷:

  • 计算机无法同时处理多个任务
  • 一个进程卡住其他进程也必须要等待其执行完成,造成CPU时间的浪费

2.多线程/多进程时代

因为单进程模式存在很大的问题,所以演进出了更优的多进程/线程模式 alt 这种模式下解决了单进程模式的问题,当一个进程阻塞式时将CPU运算资源释放给下一个进程,这样既不会浪费CPU资源,而且由于这种切换足够快,从宏观上看起来cpu也同时处理了多个任务;

但这种模式又引入了新的问题,CPU运算资源的切换本身也会占用CPU的时间,如果频繁的进行切换,那么CPU很大一部分资源会被进程的调度所占用;

3.协程

多进程、多线程已经提高了系统的并发能力,但是在当今互联网高并发场景下,为每个任务都创建一个线程是不现实的,因为会消耗大量的内存 (进程虚拟内存会占用 4GB [32 位操作系统], 而线程也要大约 4MB)。

因此大量的进程 / 线程出现了新的问题

  • 高内存占用
  • 调度的高消耗 CPU

随后,然后工程师们发现,其实一个线程分可为 “内核态 “线程和” 用户态 “线程。一个 “用户态线程” 必须要绑定一个 “内核态线程”,但是 CPU 并不知道有 “用户态线程” 的存在,它只知道它运行的是一个 “内核态线程”(Linux 的 PCB 进程控制块)。 alt

这样,我们再去细化去分类一下,内核线程依然叫 “线程 (thread)”,用户线程叫 “协程 (co-routine)”. alt

协程跟线程是有区别的,线程由 CPU 调度是抢占式的,协程由用户态调度是协作式的,一个协程让出 CPU 后,才执行下一个协程。

基于此,就有人想到,既然一个协程 (co-routine) 可以绑定一个线程 (thread),那么能不能多个协程 (co-routine) 绑定一个或者多个线程 (thread) 上呢。因此也就自然而然的衍生了几种绑定关系:

1:1关系

这种模式本质上与多线程/多进程时代的模式没有太大差别,所以优缺点也都基本一致,不过多描述

N:1关系

N 个协程绑定 1 个线程,优点就是协程在用户态线程即完成切换,不会陷入到内核态,这种切换非常的轻量快速。但也有很大的缺点,1 个进程的所有协程都绑定在 1 个线程上。一旦某协程阻塞,造成线程阻塞,本进程的其他协程都无法执行了,根本就没有并发的能力了。 alt

M:N关系

alt 此模式实现最为复杂,但可以解决上述模型的缺点

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

Go 为了提供更容易使用的并发方法,使用了 goroutine 和 channel。goroutine 来自协程的概念,让一组可复用的函数运行在一组线程之上,即使有协程阻塞,该线程的其他协程也可以被 runtime 调度,转移到其他可运行的线程上。最关键的是,程序员看不到这些底层的细节,这就降低了编程的难度,提供了更容易的并发。

Go 中,协程被称为 goroutine,它非常轻量,一个 goroutine 只占几 KB,并且这几 KB 就足够 goroutine 运行完,这就能在有限的内存空间内支持大量 goroutine,支持了更多的并发。虽然一个 goroutine 的栈只占几 KB,但实际是可伸缩的,如果需要更多内容,runtime 会自动为 goroutine 分配。

Goroutine 特点:

占用内存更小(几 kb) 调度更灵活 (runtime 调度)

alt

1.GMP模型

alt

  • 全局队列(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,不断重复下去。

P和M的数量关系

$GOMAXPROCSGOMAXPROCS()SetMaxThreads

P和M的创建时机

  1. 在确定了 P 的最大数量 n 后,程序运行时系统会根据这个数量创建 n 个 P。

  2. 当没有足够的M来绑定对应的P的时候会创建,包括以下两个场景:

    1. 当某个G系统调用阻塞,P将与当前M解绑,寻找一个休眠的M重新进行绑定,当休眠的M不够时,则会创建一个新的,之前的M处理完任务后因为没有P与之绑定则会进入休眠状态

    2. 当在创建新的G时,会尝试去绑定空闲的P与M,如果这时休眠的M不足,也会创建新的M);

综上,也可以得出结论,P与M的数量并没有直接关系

2.一个协程的调度流程

alt

3.特殊的M0和G0

M0 是启动程序后的编号为 0 的主线程,这个 M 对应的实例会在全局变量 runtime.m0 中,不需要在 heap 上分配,M0 负责执行初始化操作和启动第一个 G, 在之后 M0 就和其他的 M 一样了。

G0 是每次启动一个 M 都会第一个创建的 gourtine,G0 仅用于负责调度的 G,G0 不指向任何可执行的函数,每个 M 都会有一个自己的 G0。在调度或系统调用时会使用 G0 的栈空间,全局变量的 G0 是 M0 的 G0。