协程调度
- 线程与协程
- 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线程调度:
goroutine调度:
那么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中进行定义:
我们先来看看g:
根据结构体g的注释以及参数定义,可以看出g跟栈内容相关。下面是m的定义:
在m中我们可以看到关联了很多g,还有thread-local等相关信息最后看看p的定义:
在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个协程进行任务处理:
调度算法
- 每个 P 维护一个本地队列;
- 当一个 G 被创建后,放入当前 P 的本地队列中,如果队列已满,放入全局队列;
- 当 M 执行完一个 G 后,会在 当前 P 的队列中取出新的 G,队列为空则在全局队列中加锁获取;
- 如果全局队列也为空,则去其他的 P 的队列中偷出一半的 G,放入自己的本地队列。
调度器部分的代码主要集中在 runtime2.go 与 proc.go 这两个文件中。
源码解析: