14 协程

[TOC]

协程

Go以简单高效地编写高并发程序而闻名,这离不开Go中的协程(Goroutine)的设计,也正对应了多核处理器的时代需求。与传统多线程开发不同,Go通过更轻量级的协程让开发更便捷,同时避免了许多传统多线程开发需要面对的困难。

  • 协程是轻量级的线程,它依赖线程。由于协程具有更快的上下文切换速度、更灵活的调度策略、可伸缩的栈空间管理,因此Go在开发大规模、高并发的项目上很有优势。

并发和并行

从2016年开始,单颗CPU的性能因为工艺和功耗的原因已经很难再有指数级的提升,未来多核是主要的发展方向。硬件对软件领域的影响显而易见,软件的并发和并行处理也是未来的方向。

并发和并行是两个不同的概念

  • 并行意味着程序在任意时刻都是同时运行的,多核在同时处理多个线程。

  • 并发意味着程序在一定时间内是同时运行的,是同时处理多个任务的能力。

  • 并行是在任一粒度的时间内都具备同时执行的能力。并发是在规定的时间内多个请求都得到执行和处理,强调的是给外界的感觉,实际上内部可能是CPU不断分时操作的,并发重在避免阻塞,使程序不会因为一个阻塞而停止处理。

  • 并行是硬件和操作系统开发者重点考虑的问题,应用层开发者应充分利用操作系统提供的API和编程语言的特性结合实际需要设计出具有良好并发结构的程序。

  • 现代操作系统最基础的并发模型是多线程和多进程,Go在此基础上进一步封装了协程提高了程序的并发处理能力。

  • 并行具有瞬时性,并发具有过程性。并发在于结构,并行在于执行。应用程序有良好的并发结构,操作系统才能更好地利用硬件并行执行,同时避免阻塞等待,合理地进行调度,提升CPU利用率。

Go的协程是依托于线程的

  • 多个处理器运行同一个线程,在线程内Go的调度器也会切换多个协程执行,这时协程是并发的。若多个协程分配到不同的线程,这些线程同时被不同的处理器核心执行那这些协程就是并行的。

  • 协程的并发是一种很常见的现象,因为处理器核心有限,而Go程序中的协程数量可以有成千上万个,这需要依赖Go调度器合理公平地调度。

进程与线程

协程与操作系统中的线程、进程具有紧密的联系。必须对进程、线程及上下文切换等概念有所了解才能深入理解协程。在计算机中,线程是由调度程序独立管理的最小程序指令集,而进程是程序运行的实例。

  • 进程是操作系统资源分配的基本单位,线程是操作系统调度的基本单位。线程是进程的组成部分,它不能脱离进程存在。进程中的多个线程并发执行并共享进程的内存资源等。进程之间相对独立,不同进程具有不同的内存地址空间、代表程序运行的机器码、进程状态、操作系统资源描述符等。

  • 程序通常采用多线程的设计,因为进程的资源消耗远比线程大,进程具有独立的内存空间,多进程之间的共享信息困难。

  • 操作系统调度到CPU的最小执行单位是线程。多核CPU的计算机线程可以分布在多个CPU核心上可以实现真正的并行处理。

线程上下文切换

理论上多核CPU可以保证并行计算,但是实际中程序的数量以及实际运行的线程数量会比CPU核心数多得多。因此为了平衡每个线程能够被CPU处理的时间并最大化利用CPU资源,操作系统需要在适当时间通过定时器中断、I/O设备中断、系统调用时执行上下文切换。

  • 当发生上下文切换时,需要从操作系统用户态转移到内核态,记录上一个线程的重要寄存器值、进程状态等信息,这些信息存储在操作系统线程控制块中。当切换到下一个要执行的线程时,需要加载重要的CPU寄存器值,并从内核状态转移到操作系统用户态。如果线程在上下文切换时属于不同的进程,那么需要更新额外的状态信息及内存地址空间,同时将更新的页表(Page Tables)导入内存。

  • 进程之间的上下文切换最大的问题在于内存地址空间的切换导致的缓存失效(CPU中的用于缓存虚拟地址和物理地址之间的映射的TLB表等数据),所以不同进程的切换要显著比同一进程中线程的切换。现代的CPU使用了快速上下文切换(Rapid Context Switch)技术来解决不同进程切换带来的缓存失效问题。

线程与协程

Go中协程是轻量级的线程,和线程不同的是,操作系统内核感知不到协程的存在,协程的管理依赖Go语言运行时自身提供的调度器。

  • Go的协程是从属与某一个线程的。协程和线程在调度方式、上下文切换速度、调度策略、栈的大小等方面是不同的。

调度方式

协程是用户态的。

  • 协程的管理依赖Go运行时的调度器,Go的协程是从属与某一个线程的,协程与线程的关系是M:N 多对多的关系。Go的调度器可以将多个协程调度到一个线程中,一个协程也可能切换到多个线程中执行。

上下文切换速度

协程的速度要快于线程,因为协程切换不用经过操作系统用户态与内核态的切换。

  • Go中的协程切换只需要保留极少的状态和寄存器变量值,而线程切换会保留额外的寄存器变量值(如浮点寄存器)。

调度策略

线程的调度在大部分时间是抢占式的,操作系统调度器为了均衡每个线程的执行周期,会定时发出中断新号强制执行线程上下文切换。

  • Go中的协程在一般情况下是协作式调度,当一个协程完成自己的任务可以主动将执行权让给其它协程。这意味着协程可以更好的地规定时间内完成自己的工作,而不会轻易被抢占。当一个协程运行了过长时间Go调度器才会强制抢占它的执行权。

栈的大小

线程的栈大小一般是在创建时指定的,为了避免出现栈溢出(Stack Overflow)默认的栈会相对较大,这意味着创建越多线程就消耗越大的虚拟内存,大大限制了线程创建的数量(64位的虚拟内存地址空间已经降低了这种限制)。

  • Go中的协程栈默认为2KB,是轻量级的资源,在实践中经常会看到上万协程的存在。

  • 协程在运行时会动态检测栈的大小,并动态扩容。

GMP 模型

Go中经典的GMP的概念模型生动地概括了逻辑处理器、线程与协程的关系

  • 协程是依托于线程的,借助操作系统将线程调度到CPU执行,从而执行协程。在GMP模型中,G代表协程,M代表的是实际的线程,P代表Go逻辑处理器,Go为了方便协程调度与缓存抽象出逻辑处理器。

  • 在任一时刻,一个逻辑处理器P可能在本地包含多个协程G,同时一个逻辑处理器P在任一时刻只能绑定一个线程M。

  • 一个协程G并不是固定绑定一个逻辑处理器P,如果逻辑处理器P在运行时被销毁会导致它绑定的协程G转移到其它逻辑处理器P中。

  • 一个逻辑处理器P只能对应一个线程M,但是具体对应哪个线程M是不固定的,一个线程M在某些时候会转移到其它的逻辑处理器P中执行。

GMP 模型

GMP 模型

协程的生命周期与状态转移

Go的调度器将协程分为多种状态

  • _Gidle 协程刚开始创建时的状态。当新创建的协程初始化后,会变更为_Gdead状态,_Gdead状态也是协程被销毁时的状态。

  • _Grunnable 当前协程在运行队列中,正在等待运行。

  • _Grunning 当前协程正在被运行,已经被分配给了逻辑处理器和线程。

  • _Gwaiting 当前协程在运行时被锁定,不能执行用户代码。在垃圾回收及channel通信时经常会遇到这种情况。

  • _Gsyscall 当前协程正在执行系统调用。

  • _Gpreempted 是Go1.14新增的状态,代表协程G被强制抢占后的状态。

  • _Gcopystack 在进行协程栈扫描时发现需要扩容或缩小协程栈空间(协程栈扩容也是新开辟更大的内存空间再将原来栈的数据拷贝到新的内存空间中),将协程中的栈转移到新栈时的状态。

  • _Gscan,_Gscanrunnable,_Gscanrunning 三个状态是GC垃圾回收时出现的状态。

协程的生命周期与状态转移

协程的生命周期与状态转移

从初始状态 Gidle 开始,初始化后进入 Gdead 状态,队列中等待运行是 Grunnable 状态,被调度准备运行是 Gsyscall 状态,;逻辑处理器分配线程正在运行进入 Grunning 状态,Grunning 状态如果被等待则进入 Gwaiting 状态,Gwaiting 状态会从 Grunnable 重新开始一个流程进入运行状态。运行时被抢占进入 Gpreempted 被强制抢占状态,在这个过程还有个 Gcopystack 协程栈正在转移的状态。Grunning 也会 Gdead 也是被销毁状态。 而 _Gscan,_Gscanrunnable,_Gscanrunning 在垃圾清扫阶段分别会在垃圾回收辅助标记、准备辅助清扫、辅助清扫状态。

协程的状态在事件追踪和pprof 分析中分析协程是否泄露或是否正常执行会有帮助。

特殊协程g0与协程g切换

每个线程都有一个特殊的协程g0。

  • 协程g0运行在操作系统线程上,主要作用是执行协程调度的一系列运行时代码,而非g0协程用于执行代码。

  • 非g0协程退出或被抢占就需要重新执行协程调度,这时要切换到g0协程,每个线程内部协程都会经历g -> g0 -> g的调度循环。

  • 协程切换的过程是协程上下文切换,当协程g执行上下文切换时需要保存当前协程的执行现场,才能够在后续切换回g协程时的正常执行。协程的执行现场存储在 g.gobuf 结果体中,g.gobuf 结构体主要保存CPU中的寄存器值rsp、rip、rbp。

  • rsp 寄存器始终指向函数调用栈栈顶,rip寄存器指向程序要执行的下一条指令的地址,rbp存储了函数栈帧的起始位置。

  • 协程g0与协程g有显著不同,g0作为特殊的调度协程,执行的函数和流程相对固定。为了避免栈溢出,协程g0的栈会重复使用。而每个协程g可能都有不同的执行流程,每次上下文切换回去后,会继续执行之前的流程。

协程g0与协程g的对应关系

协程g0与协程g的对应关系

线程本地存储与线程绑定

线程本地存储是一种计算机编程方法,它使用线程本地的静态或全局内存。和普通的全局变量对程序中的所有线程可见不同,线程本地存储中的变量只对当前线程可见。因此这种类型的变量可以看作是线程“私有”的。一般地,操作系统使用FS/GS段寄存器存储线程本地变量。

  • Go中没有直接暴露线程本地存储的编程方式,但运行时的调度器使用线程本地存储将具体操作系统的线程与运行时代表线程的结构体绑定在一起。

调度循环

调度循环是从调度协程g0开始,找到接下来将要运行的协程g、再从协程g切换到协程g0开始新一轮调度的过程。它和上下文切换类似,但是上下文切换关注的是具体切换的状态,而调度循环关注的是调度的流程。

调度策略

调度的核心策略位于schedule函数中,在schedule函数中首先会检测程序是否处理垃圾回收阶段,如果是,则检测是否需要执行后台标记协程。

  • 程序不会同时执行成千上万个协程,那些等待被调度执行的协程存储在运行队列中。Go调度器将运行队列分为局部运行队列与全局运行队列。局部运行队列是每个P特有的长度为256数组,该数组模拟了一个循环队列,其中runqhead标识了循环队列的开头,runqtail标识了循环队列的末尾。每次将G放入本地队列时,都从循环队列的末尾插入,而获取时从循环队列的头部获取。

局部队列

  • 局部运行队列满时调度器会将本地运行队列的一半放入全局队列,以保证程序中有大量协程时每个协程都有执行的机会。

全局队列

  • 全局队列的数据结构是一个链表,每个线程P都共享了全局运行队列,为了保证公平,先根据P的数量平分全局运行队列的G,要转移的数量不能超过局部队列容量的一半(256/2),再通过循环调用runqput将全局队列中的G放入P的局部运行队列中。

Go调度策略,线程P中每执行61次调度,就优先从全局队列中获取一个G到当前P中,并执行下一个要执行的G。

协程窃取

局部运行队列、全局运行队列都找不到可用协程时,需要从其它逻辑处理器P的本地队列中窃取可用的协程执行。

  • 逻辑处理器P都存储在全局allp[]*p中,Go通过独特的算法保证会遍历到allp中所有元素。

调度时机

主动调度

协程可以主动让渡执行权,在大多数情况下不需要开发者去编写让渡执行权的代码,Go编译器会在主动让渡函数runtime.Gosched()执行前插入检查代码以检查该协程是否需要被抢占执行权。

  • 也有需要手动让渡执行权的特殊场景,在一个密集计算的业务下,无限for循环的场景,这种场景由于没有抢占的时机,在Go1.14版本之前是无法被抢占的。Go1.14以后的版本对长时间执行的协程使用了操作系统的信号机制进行强制抢占,这种抢占方式需要进入操作系统的内核,速度比不上用户直接调度runtime.Gosched()函数。

  • 主动调度是需要从先前协程切换到协程g0,取消协程G与线程M之间的绑定关系,将协程G放入全局运行队列,并开始新一轮的循环。

被动调度

协程在休眠、通道堵塞、网络I/0堵塞、执行垃圾回收而暂停时,被动让渡自己执行权利的过程就是被动调度。被动调度可以最大化利用CPU的资源。

  • 被动调度的原因不同,调度器也会针对执行一些特殊的操作。

  • 被动调度需要从当前协程切换到g0协程,更新协程的状态并解绑与线程M的关系,重新调度。

  • 被动调度和主动调度不同的是,被动调度不会把协程G放入全局运行队列,因为当前协程G不是_Grunnable而是_Gwaiting,所以被动调度需要一个额外的唤醒机制。如因为读取通道数据而阻塞会被动调度让渡了执行权并且协程进入_Gwaitin状态。在协程需要被唤醒时,会先将协程状态从_Gwaiting转换为_Grunnable状态并添加到当前P的局部运行队列中。

抢占调度

  • 为了保证每个协程都有执行的机会,并最大化利用CPU资源,Go在初始化时会启动一个特殊的线程来执行系统监控任务。系统监控在一个独立的线程M上运行,不用绑定逻辑处理器P,系统监控每隔10ms会检测。

协程的使用

使用协程更快完成任务

开启多个协程可以更快完成任务

  • 开启协程标语语法 【go 函数()】,开启一个协程执行函数()

  • prime函数 计算begin到end的素数数量并输出数量和耗时

不使用协程进行计算1到5000000的素数

使用5个协程计算,将1到5000000平均每个协程计算1000000个数

  • 但是它有问题,main主协程无法得到协程的结果,也无法知道协程是否完成任务,只能通过time.Sleep()来让main主协程休眠等待,休眠等待是无法准确的,一个任务也许需要很久或很短的时间,最好是让协程都完成工作后主协程main再继续执行,那就需要用到并发控制手段。

获取协程编号(Goroutine ID)

每个进程都有唯一的进程编号,每个线程也有一个唯一的线程编号,在Go中每个协程也有一个唯一编号,在出现panic时就会打印出协程编号。

  • 尽管协程有编号,但是Go为了避免编程者编写出强依赖协程编号的代码造成代码不好移植、并发模型复杂化以及Go中协程可能存在数量极多,每个协程销毁并不好监控,这会导致依赖协程编号的资源无法很好自动回收,等这些原因,Go没有提供获取协程编号的接口。

  • 通过调用栈的方式获得协程编号是低效的,高效的方法是通过汇编高性能获取协程编号。

低性能方式获取协程编号

使用Go获取协程编号性能虽然低,但代码有很好的移植性。

  • 利用panic()函数获取协程编号,因为panic()函数打印调用栈肯定能够得到协程编号。

  • 如果直接调用panic()会让协程终止,因此要用到runtime.Stack() 函数获得当前栈帧信息的字符串从字符串中获取。

  • runtime.Stack() 将调用它的协程的调用栈踪迹格式化后写入到buf中并返回写入的字节数。若all为true,函数会在写入当前go程的踪迹信息后,将其它所有go程的调用栈踪迹都格式化写入到buf中。

  • panic() 后控制台打印出 【goroutine 18 [running]:】 这就是协程编号

runtime.Stack() 获取协程编号

  • 利用runtime.Stack()封装了GetGoID()函数获取协程编号,这是低效的。

Last updated