欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页  >  IT编程

Go routine调度详解

程序员文章站 2022-03-20 14:40:57
goroutine简介 goroutine是go语言中最为nb的设计,也是其魅力所在,goroutine的本质是协程,是实现并行计算的核心。goroutine使用方式非常...

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的状态有什么,以及各个状态之间的关系。

Go routine调度详解

  • 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)。

以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持。