为什么需要协程

协程的本质是将一段数据的运行状态进行打包,可以在线程之间调度,所以协程就是在单线程的环境下实现的应用程序级别的并发,就是把本来由操作系统控制的切换+保存状态在应用程序里面实现了。

所以我们需要协程的目的其实就是它更加节省资源、可以在有限的资源内支持更高的并发,体现在以下三个方面:

  • 资源利用:程可以利用任何的线程去运行,不需要等待CPU的调度。
  • 快速调度:协程可以快速地调度(避开了系统调用和切换),快速的切换。
  • 超高并发:有限的线程就可以并发很多的协程。

协程的本质

runtime\runtime2.go

对线程的描述

runtime\runtime2.go

协程如何在线程中执行

我们从最简单的单线程调度模型来看,协程在线程中的执行流程可以参考下图:

线程循环

在go中每个线程都是循环执行一系列工作,又称作单线程循环如下图所示:左侧为栈,右侧为线程执行的函数顺序,其中的业务方法就是协程方法。

普通协程栈只能记录业务方法的业务信息,且当线程没有获得协程之前是没有普通协程栈的。所以在内存中开辟了一个g0栈,专门用于记录函数调用跳转的信息,因此g0栈其实就是调度中心的栈。

线程循环会按顺序循环去执行上图右侧的函数:schedule->execute->gogo->业务方法->goexit。

schedule

schedule
findrunnable
findrunnable
  • 调用runqget函数来从P自己的runnable G队列中得到一个可以执行的G;
  • 如果1失败,调用globrunqget函数从全局runnableG队列中得到一个可以执行的G;
  • 如果2失败,调用netpoll(非阻塞)函数取一个异步回调的G;
  • 如果3失败,尝试从其他P那里偷取一半数量的G过来;
  • 如果4失败,再次调用globrunqget函数从全局runnableG队列中得到一个可以执行的G;
  • 如果5失败,调用netpoll(阻塞)函数取一个异步回调的G;
  • 如果6仍然没有取到G,那么调用stopm函数停止这个M。
execute

execute

execute函数会为schedule获取到的可执行协程初始化相关结构体,然后以sched结构体为参数调用gogo函数:

gogo

gobufgoexit

业务方法

业务方法就是协程中需要执行的相关函数。

goexit

goexit也是汇编实现的,当执行完协程栈中的业务方法之后,就会退到goexit方法中,它会将业务协程的栈切换成调度器的栈(也就是g0栈),然后重新调用schedule函数,形成一个闭环。

GMP调度模型

上述的调度模型是单线程的,但是现代CPU往往是多核的,应用采用的也是多线程,因此单线程调度模型有些浪费资源。所以我们在实际使用中,其实是一种多线程循环。但是多个线程在获取可执行g的时候就会存在并发冲突的问题,所以就有了GMP调度模型。

GMP调度模型简单来说是这样的:

G是指协程goroutine,M是指操作系统线程,P是指调度器。

首先,GMP调度模型中有一个全局队列,用于存放等待运行的G。然后每个P都有自己的本地队列,存放的也是等待运行的G,但是存的数量有限,不会超过256个。我们新建goroutine的时候,是优先放到P的本地队列中的,如果队列满了,会把本地队列中一半的G都移到全局队列中。

线程想运行任务就得获取P,从P的本地队列获取G,G执行之后,M会从P获取下一个G,不断重复下去。P队列为空时,M会尝试从全局队列拿一批G放到P的本地队列,如果获取不到就会从其他P的本地队列偷一半放到自己P的本地队列。

当M执行某一个G时候如果发生了系统调用或者其余阻塞操作,M会阻塞,如果当前有一些G在执行,runtime会把这个线程M从P中摘除(detach),然后再创建一个新的操作系统的线程(如果有空闲的线程可用就复用空闲线程)来服务于这个P。当M系统调用结束时候,这个G会尝试获取一个空闲的P执行,并放入到这个P的本地队列。如果获取不到P,那么这个线程M变成休眠状态, 加入到空闲线程中,然后这个G会被放入全局队列中。

P的底层结构

runtime\runtime2.go

协程并发

我们上面介绍的调度模型实际上是非抢占式的,非抢占式模型的特点就是只有当协程主动让出后,M才会去运行本地队列后面的协程,那么这样就很容易造成队列尾部的协程饿死。

其实Go语言的协程是基于抢占式来实现的,也就是当协程执行一段时间后将当前任务暂定,执行后续协程任务,防止时间敏感携程执行失败。如下图所示:

抢占式调度

当目前线程中执行的协程是一个超长时间的任务,此时先保存该协程的运行状态也就是保护现场,若是后续还需继续执行就将其放入本地队列中去,如果不需要执行就将其处于休眠状态,然后直接跳转到schedule函数中。

实现:

10ms

全局队列的饥饿问题

上述操作让本地队列成了一个小循环,但是如果目前系统中的线程的本地队列中都拥有一个超大的协程任务,那么所有的线程都将在一段时间内处于忙碌状态,全局队列中的任务将会长期无法运行,这个问题又称为全局队列饥饿问题,解决方式就是在本地队列循环时,以一定的概率从全局队列中取出某个任务,让它也参与到本地循环当中去。

schedule1/61