Go routine调度详解
goroutine简介
goroutine是go语言中最为nb的设计,也是其魅力所在,goroutine的本质是协程,是实现并行计算的核心。goroutine使用方式非常的简单,只需使用go关键字即可启动一个协程,并且它是处于异步方式运行,你不需要等它运行完成以后在执行以后的代码。
go func()//通过go关键字启动一个协程来运行函数
go routine的调度原理和操作系统的线层调度是比较相似的。这里我们将介绍go routine的相关知识。
goroutine(有人也称之为协程)本质上go的用户级线程的实现,这种用户级线程是运行在内核级线程之上。当我们在go程序中创建goroutine的时候,我们的这些routine将会被分配到不同的内核级线程中运行。一个内核级线程可能会负责多个routine的运行。而保证这些routine在内内核级线程安全、公平、高效运行的工作,就由调度器来实现。
goroutine内部原理
概念介绍
在进行实现原理之前,了解下一些关键性术语的概念。
并发
一个cpu上能同时执行多项任务,在很短时间内,cpu来回切换任务执行(在某段很短时间内执行程序a,然后又迅速得切换到程序b去执行),有时间上的重叠(宏观上是同时的,微观仍是顺序执行),这样看起来多个任务像是同时执行,这就是并发。
并行
当系统有多个cpu时,每个cpu同一时刻都运行任务,互不抢占自己所在的cpu资源,同时进行,称为并行。
进程
cpu在切换程序的时候,如果不保存上一个程序的状态(也就是我们常说的context--上下文),直接切换下一个程序,就会丢失上一个程序的一系列状态,于是引入了进程这个概念,用以划分好程序运行时所需要的资源。因此进程就是一个程序运行时候的所需要的基本资源单位(也可以说是程序运行的一个实体)。
线程
cpu切换多个进程的时候,会花费不少的时间,因为切换进程需要切换到内核态,而每次调度需要内核态都需要读取用户态的数据,进程一旦多起来,cpu调度会消耗一大堆资源,因此引入了线程的概念,线程本身几乎不占有资源,他们共享进程里的资源,内核调度起来不会那么像进程切换那么耗费资源。
协程
协程拥有自己的寄存器上下文和栈。协程调度切换时,将寄存器上下文和栈保存到其他地方,在切回来的时候,恢复先前保存的寄存器上下文和栈。因此,协程能保留上一次调用时的状态(即所有局部状态的一个特定组合),每次过程重入时,就相当于进入上一次调用的状态,换种说法:进入上一次离开时所处逻辑流的位置。线程和进程的操作是由程序触发系统接口,最后的执行者是系统;协程的操作执行者则是用户自身程序,goroutine也是协程。
go调度的组成
go的调度主要有四个结构组成,分别是:
- g:goroutine的核心结构,包括routine的栈、程序计数器pc、以及一些状态信息等;
- m:内核级线程。goroutine在m上运行。m中信息包括:正在运行的goroutine、等待运行的routine列表等。当然也包括操作系统线程相关信息,这些此处不讨论。
- p:processor,处理器,只要用于执行goroutine,维护了一个goroutine列表。其实p是可以从属于m的。当p从属于(分配给)m的时候,表示p中的某个goroutine得以运行。当p不从属于m的时候,表示p中的所有goroutine都需要等待被安排到内核级线程运行。
- sched:调度器,存储、维护m,以及一个全局的goroutine等待队列,以及其他状态信息。
go程序的启动过程
- 初始化sched:一个存储p的列表pidle。p的数量可以通过gomaxprocs设置;
- 创建第一个goroutine。这个goroutine会创建一个m,这个内核级线程(sysmon)的工作是对goroutine进行监控。之后,这个goroutine开始我们在main函数里面的代码,此时,该goroutine就是我们说的主routine。
创建goroutine:
- goroutine创建时指定了代码段
- 然后,goroutine被加入到p中去等待运行。
- 这个新建的goroutine的信息包含:栈地址、程序计数器
创建内核级线程m
内核级线程由go的运行时根据实际情况创建,我们无法再go中创建内核级线程。那什么时候回创建内核级线程呢?当前程序等待运行的goroutine数量达到一定数量及存在空闲(为被分配给m)的p的时候,go运行时就会创建一些m,然后将空闲的p分配给新建的内核级线程m,接着才是获取、运行goroutine。创建m的接口函数如下:
// 创建m的接口函数 void newm(void (*fn)(void), p *p) // 分配p给m if(m != &runtime·m0) {â acquirep(m->nextp); m->nextp = nil; } // 获取goroutine并开始运行 schedule();
m的运行
static void schedule(void) { g *gp; gp = runqget(m->p); if(gp == nil) gp = findrunnable(); // 如果p的类别不止一个goroutine,且调度器中有空闲的的p,就唤醒其他内核级线程m if (m->p->runqhead != m->p->runqtail && runtime·atomicload(&runtime·sched.nmspinning) == 0 && runtime·atomicload(&runtime·sched.npidle) > 0) // todo: fast atomic wakep(); // 执行goroutine execute(gp); }
- runqget: 从p中获取goroutine即gp。gp可能为nil(如m刚创建时p为空;或者p的goroutine已经运行完了)。
- findrunnable:寻找空闲的goroutine(从全局的goroutine等待队列获取goroutine;如果所有goroutine都已经被分配了,那么从其他m的p的goroutine的goroutine列表获取一些)。如果获取到goroutine,就将他放入p中,并执行它;否则没能获取到任何的goroutine,该内核级线程进行系统调用sleep了。
- wakep:当当前内核级线程m的p中不止一个goroutine且调度器中有空闲的的p,就唤醒其他内核级线程m。(为了找些空闲的m帮自己分担)。
routine状态迁移
前面说的是g,m是怎样创建的以及什么时候创建、运行。那么goroutine在m是是怎样进行调度的呢?这个才是goroutine的调度核心问题,即上面代码中的schedule。在说调度之前,我们必须知道goroutine的状态有什么,以及各个状态之间的关系。
- gidle:创建中的goroutine,实际上这个状态没有什么用;
- grunnable:新创建完成的goroutine在完成了资源的分配及初始化后,会进入这个状态。这个新创建的goroutine会被分配到创建它的m的p中;
- grunning:当grunnable中的goroutine等到了空闲的cpu或者到了自己的时间片的时候,就会进入grunning状态。这个装下的goroutine可以被前文提到的findrunnable函数获取;
- gwaiting:当正在运行的goroutine进行一些阻塞调用的时候,就会从grunning状态进入gwaiting状态。常见的调用有:写入一个满的channel、读取空的channel、io操作、定时器ticker等。当阻塞调用完成后,goroutine的状态就会从gwaiting转变为grunnable;
- gsyscall:当正在运行的goroutine进行系统调用的时候,其状态就会转变为gsyscall。当系统调用完成后goroutine的状态就会变为grunnable。(前文提到的sysmon进程会监控所有的p,如果发现有的p的系统调用是阻塞式的或者执行的时间过长,就会将p从原来的m分离出来,并新建一个m,将p分配给这个新建的m)。
以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持。