协程调度

  • 线程与协程
  • go scheduler
  • g、m、p
  • 运行队列
  • 调度机制
  • 调度算法


线程与协程

我们以Java线程为例。

熟悉Java的朋友肯定知道线程,一个Java JVM thread对应一个os thread,是1:1的关系。但是在goland中情况就不是这样的了,多个goroutine可以运行在一个os thread上,是1:n的关系。可以简单理解为goroutine是go对类Java中的thread进行了一层封装。

线程与协程的运行基础都是os thread,最底层的任务调度都是基于os 本身的任务切换。

Java线程调度:

java21 虚拟线程 对比 go 协程_java


goroutine调度:

java21 虚拟线程 对比 go 协程_java_02

那么Java线程与go协程的异同点在哪里呢?

主要提现在内存、创建、调度三个方面

  • 创建一个go goroutine,内存消耗大概在2KB左右,由于go的线程栈本身是支持动态扩容的,后续如果goroutine栈容量不足,可以自动扩容。而创建一个thread是os 开销,一个thread需要消耗内存2-3MB。如果是在高并发场景下,不停创建新的thread的成本是非常高昂的,所以Java中一版会使用线程池来规避创建thread的性能以及内存消耗问题
  • 如上图所示,Java线程是与os 线程一一对应,属于内核级交互,其线程调度归属于os。os在切换线程时需要保存线程当前快照,当该线程再度被唤起时需要恢复快照,需要保存各种寄存器数据。而goroutine则是由go runtime进行管理和调度,属于用户层交互,其goroutine切换效率远高于thread

go scheduler

g、m、p

既然goroutine相较于thread效率明显,那么其scheduler又是如何进行调度的呢?

有三个基础的结构体来实现 goroutines 的调度:g,m,p。

这三个结构体均在runtime2中进行定义:

java21 虚拟线程 对比 go 协程_开发语言_03

我们先来看看g:

java21 虚拟线程 对比 go 协程_go_04


根据结构体g的注释以及参数定义,可以看出g跟栈内容相关。下面是m的定义:

java21 虚拟线程 对比 go 协程_后端_05


在m中我们可以看到关联了很多g,还有thread-local等相关信息最后看看p的定义:

java21 虚拟线程 对比 go 协程_go_06


在p的定义中我们可以看到定义了一个run的运行队列。

根据源码中相关类型定义,我们可以对p、m、g做出如下总结:

  • g 代表一个 goroutine,它包含:表示 goroutine 栈的一些字段,指示当前 goroutine 的状态,指示当前运行到的指令地址,也就是 PC 值等。
  • m 表示内核线程,g运行在m上,也即一个goroutine需要在一个m上才能运行起来
  • p 代表一个虚拟的 Processor,它维护一个处于 Runnable 状态的 g 队列

其三者之间的关系大体是:m需要拿到p里面维护的g队列后,从g队列里面拿出一个g,在m上运行

对于 M 来说,P 提供了相关的执行环境(Context),如内存分配状态,任务队列等。P 的数量就是程序可最大可并行的 G 的数量(前提:物理CPU核数 >= P的数量),由用户设置的 GOMAXPROCS 决定。M 数量不定,但同时只有 P 个 M 在执行,为了防止创建过多系统线程导致系统调度出现问题,目前默认最大限制10000个。

运行队列

  • 本地可运行队列(LRQ):P本身维护的G队列,即为LRQ,维护的是这个P本身可运行的所有 goroutine。
  • 全局可运行队列(GRQ):GRQ 存储全局的可运行 goroutine,这些 goroutine 还没有分配到具体的 P。

调度机制

那么go scheduler是如何对goroutine进行调度的呢?
当go应用运行起来之后,go会给每个cpu核心分配一个P(processer),同时每个cpu核心会创建出一个M(也即内核thread),同时将这个M分配给这个P。当然M本身的调度还是有os自己进行的。
当go应用初始化并运行起来之后,就会产生一个G,这个G会放入P的队列,最终由M执行这个G。

由此我们可以看下,goroutine的调度,实际是由M进行管理的。

假设我们CPU为双核心,现在启动一个go应用,并且go应用内部启动了n个协程进行任务处理:

java21 虚拟线程 对比 go 协程_go_07

调度算法

  • 每个 P 维护一个本地队列;
  • 当一个 G 被创建后,放入当前 P 的本地队列中,如果队列已满,放入全局队列;
  • 当 M 执行完一个 G 后,会在 当前 P 的队列中取出新的 G,队列为空则在全局队列中加锁获取;
  • 如果全局队列也为空,则去其他的 P 的队列中偷出一半的 G,放入自己的本地队列。

调度器部分的代码主要集中在 runtime2.go 与 proc.go 这两个文件中。

源码解析:

java21 虚拟线程 对比 go 协程_Java_08


java21 虚拟线程 对比 go 协程_Java_09