Go简单实现协程方法

目录
  • 为什么需要协程
  • 协程的本质
  • 协程如何在线程中执行
  • GMP调度模型
  • 协程并发

为什么需要协程

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

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

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

协程的本质

协程在go语言中使用runtime\runtime2.go下的g结构体来表示,这个结构体中包含了协程的很多信息,我们只挑选其中的重要字段来进行分析:

type g struct {
	// 协程的栈帧,里面包含了两个字段:lo和hi,分别是协程栈的高位指针和低位指针
	stack       stack
	// gobuf结构体中储存了很多与协程栈相关的指针,比如pc、sp
	sched     gobuf
	// 用来标记协程当前的状态
	atomicstatus uint32
	// 每个协程的唯一标识,不向应用层暴露。但是goid的地址会存在寄存器里面,可以通过ebpf工具无侵入地去获取
	goid         int64
}

对线程的描述

我们知道,go语言中的协程是跑在线程上面的,那么go中肯定会有对线程的抽象描述,这个结构体也在runtime\runtime2.go中,我们只展示重要的部分:

type m struct {
	// 每次启动一个M都会第一个创建的gourtine,用于操作调度器,所以它不指向任何函数,只负责调度
	g0      *g     // goroutine with scheduling stack
	// 当前正在线程上运行的协程
	curg          *g       // current running goroutine
	// 线程id
	id            int64
	// 记录每种操作系统对于线程额外的描述信息
	mOS
}

协程如何在线程中执行

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

线程循环

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

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

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

schedule

schedule函数的作用是为当前的P获取一个可以执行的g,并执行它。

  • 首先会有1/61的概率检查全局队列,确保全局队列中的G也会被调度。
  • 然后有60/61的概率从本地队列中获取g。
  • 如果从本地队列中没有获取到可执行的g,就会调用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。

如果获取到了可执行的g,就调用execute函数去执行。

// One round of scheduler: find a runnable goroutine and execute it.
// Never returns.
func schedule() {
	......
	// 新建一个gp变量,gp就是即将要运行的协程指针
	var gp *g
	var inheritTime bool
	// 垃圾回收相关的工作
	......
	// 调度过程中有1/61的概率检查全局队列,确保全局队列中的G也会被调度。
	// M绑定的P首先有1/61概率从全局队列获取G,60/61概率从本地队列获取G
	if gp == nil {
		// Check the global runnable queue once in a while to ensure fairness.
		// Otherwise two goroutines can completely occupy the local runqueue
		// by constantly respawning each other.
		if _g_.m.p.ptr().schedtick%61 == 0 && sched.runqsize > 0 {
			lock(&sched.lock)
			gp = globrunqget(_g_.m.p.ptr(), 1)
			unlock(&sched.lock)
		}
	}
	// 从本地队列中获取g
	if gp == nil {
		gp, inheritTime = runqget(_g_.m.p.ptr())
		// We can see gp != nil here even if the M is spinning,
		// if checkTimers added a local goroutine via goready.
	}
	// 如果从本地队列获取失败,就会调用findrunnable函数去获取g
	if gp == nil {
		gp, inheritTime = findrunnable() // blocks until work is available
	}
	......
	execute(gp, inheritTime)
}

execute

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

func execute(gp *g, inheritTime bool) {
	_g_ := getg()
	// 初始化g结构体
	// Assign gp.m before entering _Grunning so running Gs have an
	// M.
	_g_.m.curg = gp
	gp.m = _g_.m
	casgstatus(gp, _Grunnable, _Grunning)
	gp.waitsince = 0
	gp.preempt = false
	gp.stackguard0 = gp.stack.lo + _StackGuard
	if !inheritTime {
		_g_.m.p.ptr().schedtick++
	}
	......
	// 汇编实现的函数,通过gobuf结构体中的信息,跳转到执行业务的方法
	gogo(&gp.sched)

gogo

gogo函数实际上是汇编实现的,每个操作系统实现的gogo方法是不同的,它会通过传进来的gobuf结构体,先向普通协程栈中压入goexit函数,然后跳转到执行业务的方法,协程栈也会被切换成业务协程自己的栈。

业务方法

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

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的底层结构

我们发现GMP调度模型中有一个P,P就是调度器,我们来看一下P的底层数据结构,同样在runtime\runtime2.go文件中:

type p struct {
	id          int32
	status      uint32 // one of pidle/prunning/...
	// 指向调度器服务的那个线程
	m           muintptr   // back-link to associated m (nil if idle)
	// Queue of runnable goroutines. Accessed without lock.
	// 调度器的本地队列,因为只服务于一个线程,所以可以无锁的访问,队列本身实际上是一个大小为256的指针数组
	runqhead uint32
	runqtail uint32
	runq     [256]guintptr
	// 指向下一个可用g的指针
	runnext guintptr
}

协程并发

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

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

抢占式调度

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

实现:

  • 主动挂取:gopark方法,当业务调用这个方法线程就会直接回到schedule函数并切换协程栈,当前运行的协程将会处于等待状态,等待状态的协程是无法立即进入任务队列中的。程序员无法主动调用gopark函数,但是我们可以通过Sleep等具有gopark的函数来进行主动挂取,Sleep五秒之后系统将会把任务的等待状态更改为运行状态放入队列中。
  • 系统调用完成时:go程序在运行状态中进行了系统调用,那么当系统的底层调用完成后就会调用exitsyscall函数,线程就会停止执行当前协程,将当前协程放入队列中去。
  • 标记抢占morestack():当函数跳转时都会调用这个方法,它的本意在于检查当前协程栈空间是否有足够内存,如果不够就要扩大该栈空间。当系统监控到协程运行超过10ms,就将g.stackguard0置为0xfffffade(该值是一个抢占标志),让程序在只执行morestack函数时顺便判断一下是否将g中的stackguard置为抢占,如果的确被标记抢占,就回到schedule方法,并将当前协程放回队列中。

全局队列的饥饿问题

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

其实在执行schedule函数寻找可运行g的时候,首先会去执行下面的代码,即调度过程中有1/61的概率去全局队列中获取可执行的协程,防止全局队列中的协程被饿死。

	// 调度过程中有1/61的概率检查全局队列,确保全局队列中的G也会被调度。
	if gp == nil {
		// Check the global runnable queue once in a while to ensure fairness.
		// Otherwise two goroutines can completely occupy the local runqueue
		// by constantly respawning each other.
		if _g_.m.p.ptr().schedtick%61 == 0 && sched.runqsize > 0 {
			lock(&sched.lock)
			gp = globrunqget(_g_.m.p.ptr(), 1)
			unlock(&sched.lock)
		}
	}

到此这篇关于Go简单实现协程方法的文章就介绍到这了,更多相关Go协程内容请搜索我们以前的文章或继续浏览下面的相关文章希望大家以后多多支持我们!

(0)

相关推荐

  • golang协程与线程区别简要介绍

    目录 一.进程与线程 二.并发与并行 三.go协程与线程 1.调度方式 2.调度策略 3.上下文切换速度 4.栈的大小 四.GMP模型 一.进程与线程 进程是操作系统资源分配的基本单位,是程序运行的实例.例如打开一个浏览器就开启了一个进程. 线程是操作系统调度到CPU中执行的基本单位.例如在浏览器里新建一个窗口就需要一个线程来进行处理. 在一般情况下,线程是进程的组成部分,一个进程可以包含多个线程.例如浏览器可以新建多个窗口. 进程中的多个线程并发执行并共享进程的内存等资源.例如多个窗口之间可以

  • Go简单实现协程池的实现示例

    目录 MPG模型 通道的特性 首先就是进程.线程.协程讲解老三样. 进程: 本质上是一个独立执行的程序,进程是操作系统进行资源分配和调度的基本概念,操作系统进行资源分配和调度的一个独立单位. 线程: 是操作系统能够进行运算调度的最小单位.它被包含在进程之中,是进程中的实际运作单位.一个进程中可以并发多个线程,每条线程执行不同的任务,切换受系统控制. 协程:  又称为微线程,是一种用户态的轻量级线程,协程不像线程和进程需要进行系统内核上的上下文切换,协程的上下文切换是由用户自己决定的,有自己的上下

  • golang协程设计及调度原理

    目录 一.协程设计-GMP模型 1.工作线程M 2.逻辑处理器p 3.协程g 4.全局调度信息schedt 5.GMP详细示图 二.协程调度 1.调度策略 获取本地运行队列 获取全局运行队列 协程窃取 2.调度时机 主动调度 被动调度 抢占调度 一.协程设计-GMP模型 线程是操作系统调度到CPU中执行的基本单位,多线程总是交替式地抢占CPU的时间片,线程在上下文的切换过程中需要经过操作系统用户态与内核态的切换.golang的协程(G)依然运行在工作线程(M)之上,但是借助语言的调度器,协程只需

  • go语言中的协程详解

    协程的特点 1.该任务的业务代码主动要求切换,即主动让出执行权限 2.发生了IO,导致执行阻塞(使用channel让协程阻塞) 与线程本质的不同 C#.java中我们执行多个线程,是通过时间片切换来进行的,要知道进行切换,程序需要保存上下文等信息,是比较消耗性能的 GO语言中的协程,没有上面这种切换,一定是通过协程主动放出权限,不是被动的. 例如: C# 中创建两个线程 可以看到1和2是交替执行的 Go语言中用协程实现一下 runtime.GOMAXPROCS(1) 这个结果就是 执行了1 在执

  • Go 并发编程协程及调度机制详情

    目录 协程的概念 goroutine 的诞生 使用 goroutine 加快速度 goroutine 的机制原理 前言: 协程(coroutine)是 Go 语言最大的特色之一,goroutine 的实现其实是通过协程. 协程的概念 协程一词最早出现在 1963 年发表的论文中,该论文的作者为美国计算机科学家 Melvin E.Conway.著名的康威定律:“设计系统的架构受制于产生这些设计的组织的沟通结构.” 也是这个作者. 协程是一种用户态的轻量级线程,可以想成一个线程里面可以有多个协程,而

  • Go简单实现协程方法

    目录 为什么需要协程 协程的本质 协程如何在线程中执行 GMP调度模型 协程并发 为什么需要协程 协程的本质是将一段数据的运行状态进行打包,可以在线程之间调度,所以协程就是在单线程的环境下实现的应用程序级别的并发,就是把本来由操作系统控制的切换+保存状态在应用程序里面实现了. 所以我们需要协程的目的其实就是它更加节省资源.可以在有限的资源内支持更高的并发,体现在以下三个方面: 资源利用:程可以利用任何的线程去运行,不需要等待CPU的调度. 快速调度:协程可以快速地调度(避开了系统调用和切换),快

  • PHP实现简单的协程任务调度demo示例

    本文实例讲述了PHP实现简单的协程任务调度.分享给大家供大家参考,具体如下: <?php class Task { protected $taskId; protected $coroutine; protected $sendValue = null; protected $beforeFirstYield = true; public function __construct($taskId, Generator $coroutine) { $this->taskId = $taskId;

  • 简单介绍Python的Tornado框架中的协程异步实现原理

    Tornado 4.0 已经发布了很长一段时间了, 新版本广泛的应用了协程(Future)特性. 我们目前已经将 Tornado 升级到最新版本, 而且也大量的使用协程特性. 很长时间没有更新博客, 今天就简单介绍下 Tornado 协程实现原理, Tornado 的协程是基于 Python 的生成器实现的, 所以首先来回顾下生成器. 生成器 Python 的生成器可以保存执行状态 并在下次调用的时候恢复, 通过在函数体内使用 yield 关键字 来创建一个生成器, 通过内置函数 next 或生

  • Go 并发实现协程同步的多种解决方法

    go 简洁的并发 多核处理器越来越普及.有没有一种简单的办法,能够让我们写的软件释放多核的威力?是有的.随着Golang, Erlang, Scala等为并发设计的程序语言的兴起,新的并发模式逐渐清晰.正如过程式编程和面向对象一样,一个好的编程模式有一个极其简洁的内核,还有在此之上丰富的外延.可以解决现实世界中各种各样的问题.本文以GO语言为例,解释其中内核.外延. 前言 Java 中有一系列的线程同步的方法,go 里面有 goroutine(协程),先看下下面的代码执行的结果是什么呢? pac

  • Python协程 yield与协程greenlet简单用法示例

    本文实例讲述了Python协程 yield与协程greenlet简单用法.分享给大家供大家参考,具体如下: 协程 协程,又称微线程,纤程.英文名Coroutine. 协程是啥 协程是python个中另外一种实现多任务的方式,只不过比线程更小占用更小执行单元(理解为需要的资源). 为啥说它是一个执行单元,因为它自带CPU上下文.这样只要在合适的时机, 我们可以把一个协程 切换到另一个协程. 只要这个过程中保存或恢复 CPU上下文那么程序还是可以运行的. 通俗的理解:在一个线程中的某个函数,可以在任

  • Unity中协程IEnumerator的使用方法介绍详解

    在Unity中,一般的方法都是顺序执行的,一般的方法也都是在一帧中执行完毕的,当我们所写的方法需要耗费一定时间时,便会出现帧率下降,画面卡顿的现象.当我们调用一个方法想要让一个物体缓慢消失时,除了在Update中执行相关操作外,Unity还提供了更加便利的方法,这便是协程. 在通常情况下,如果我们想要让一个物体逐渐消失,我们希望方法可以一次调用便可在程序后续执行中实现我们想要的效果. 我们希望代码可以写成如下所示: void Fade() { for (float f = 1f; f >= 0;

  • Python协程的用法和例子详解

    从句法上看,协程与生成器类似,都是定义体中包含 yield 关键字的函数.可是,在协程中, yield 通常出现在表达式的右边(例如, datum = yield),可以产出值,也可以不产出 -- 如果 yield 关键字后面没有表达式,那么生成器产出 None. 协程可能会从调用方接收数据,不过调用方把数据提供给协程使用的是 .send(datum) 方法,而不是next(-) 函数. ==yield 关键字甚至还可以不接收或传出数据.不管数据如何流动, yield 都是一种流程控制工具,使用

  • 为什么你还不懂得怎么使用Python协程

    前言 从语法上来看,协程和生成器类似,都是定义体中包含yield关键字的函数. yield在协程中的用法: 在协程中yield通常出现在表达式的右边,例如:datum = yield,可以产出值,也可以不产出--如果yield关键字后面没有表达式,那么生成器产出None. 协程可能从调用方接受数据,调用方是通过send(datum)的方式把数据提供给协程使用,而不是next(...)函数,通常调用方会把值推送给协程. 协程可以把控制器让给中心调度程序,从而激活其他的协程 所以总体上在协程中把yi

  • golang协程池设计详解

    Why Pool go自从出生就身带"高并发"的标签,其并发编程就是由groutine实现的,因其消耗资源低,性能高效,开发成本低的特性而被广泛应用到各种场景,例如服务端开发中使用的HTTP服务,在golang net/http包中,每一个被监听到的tcp链接都是由一个groutine去完成处理其上下文的,由此使得其拥有极其优秀的并发量吞吐量 for { // 监听tcp rw, e := l.Accept() if e != nil { ....... } tempDelay = 0

随机推荐