前言

协程是Go语言中一个非常重要的概念,Go语言能够天然支持高并发应用的开发所依赖的就是协程。本文将深入探讨协程的本质以及早期Go语言的线程循环模型,揭开协程的神秘面纱。

协程的概念

首先我们回顾一下《操作系统》这门课的知识。操作系统的处理机管理中,一个非常重要的模块就是进程和线程的管理。

  • 进程:是资源分配的基本单位,进程占用一定的内存空间。
  • 线程:是CPU调度的基本单元,占用CPU资源,共享进程的空间。

操作系统的正常工作有赖于进程和线程的调度,然而,传统的线程调度存在如下问题:

  • 线程占用资源大。
  • 线程操作开销大。
  • 内核态和用户态切换开销大。

协程这是在这一背景下提出来的。协程可以复用线程,节约CPU多个线程之间调度的开销

协程的本质是将一段程序的运行状态打包,可以在线程之间调度。协程不取代线程,协程在线程上运行,线程是协程的资源。使用协程进行并发具有如下的优势:

  • 资源利用率高
  • 支持快速调度(内核感知不到)
  • 支持超高并发

Go语言协程的底层结构

Go语言对协程的抽象存放在runtime/runtime2.go下面的g结构体。其部分代码如下:

type g struct {  
    stack       stack   // offset known to runtime/cgo  
    stackguard0 uintptr // offset known to liblink  
    stackguard1 uintptr // offset known to liblink  
  
    _panic    *_panic // innermost panic - offset known to liblink  
    _defer    *_defer // innermost defer  
    m         *m      // current m; offset known to arm liblink  
    sched     gobuf  
    syscallsp uintptr 
    atomicstatus atomic.Uint32  
    stackLock    uint32 // sigprof/scang lock; TODO: fold in to atomicstatus  
    goid         uint64  
    schedlink    guintptr  
    waitsince    int64      // approx time when the g become blocked    waitreason   waitReason // if status==Gwaiting  
  
    preempt       bool
    preemptStop   bool
    preemptShrink bool // shrink stack at synchronous safe point  
    lockedm        muintptr  
    sig            uint32  
    writebuf       []byte  
    sigcode0       uintptr  
    sigcode1       uintptr  
    sigpc          uintptr  
    gopc           uintptr        

    ancestors      *[]ancestorInfo 

    startpc        uintptr         // pc of goroutine function  
    racectx        uintptr  
    waiting        *sudog        
    
    labels         unsafe.Pointer // profiler labels  
    timer          *timer         // cached timer for time.Sleep  
    selectDone     atomic.Uint32  // are we participating in a select and did someone win the race?  
  
    gcAssistBytes int64  
}

g结构体具有下面几个最主要的字段:

  • stack:堆栈地址。包含lo、hi两个指针,为协程栈的上下限。
  • sched:目前程序运行现场。gobuf类型的结构体
    • sp:栈原始指针
    • pc:记录当前运行到哪一行
  • atomicstatus:协程状态
  • goid:协程ID

我们可以用一张图来表示g结构体:

image.png

Go语言对线程的抽象

协程运行在线程上,接下来我们来看看Go语言是如何抽象线程的。

runtime包将操作系统的线程抽象为m结构体,其重要字段如下:

  • g0:用于启动其它协程的g0协程。
  • curg:指向正在运行的协程g
  • mOS:记录每种OS对线程的额外描述信息

协程如何执行

搞清楚协程在Go语言的底层表示后,我们来看看Go语言早期的协程执行模型。

单线程循环模型(Go 0.x)

在Go语言0.x版本中采用的是单线程循环模型,可以概括为下图:

image.png

我们逐一解释一下每个模块:

  • g0 stack:记录线程调用的方法。
  • g stack:协程栈,里面是协程的调用栈,另外还存有一些局部变量和函数调用信息,后面的讲内存的文章再细讲。
  • 线程循环调用scheduleexecutegogo、业务方法、goexit
  • 有一个全局队列可以获取能运行的协程。

从源码角度查看每个方法的大致执行流程

schedule位于runtime/proc.go中:它是线程运行的第一个方法,大致流程如下:

  • 定义gp,指向即将要运行的协程。
  • 从协程队列中拿到协程
  • 执行execute方法

execute方法:

  • 给gp字段赋值
  • 调用gogo方法,用汇编实现,在asm_amd64.s中,传入gobuf,即栈地址和PC。

gogo方法:

  • 拿到gobuf中协程栈g stack
  • goexit()插入到Go的协程栈
  • 跳到gobufPC,即转到协程运行到的代码,此后开始运行业务代码。每个协程都记录自己运行到哪里。

goexit方法:

  • 使用mcall调用goexit0mcall调用方法会切换栈,此时会从协程栈切换到g0 stack
  • goexit0设置g的参数后,调用schedule方法,继续循环。

整个单线程模型可以抽象为:线程M不断从队列拿到G执行。

多线程循环(Go 1.0)

多线程循环和单线程循环类似,不过在多线程并发获取全局G队列的时候需要加锁。

image.png

小结

  • 操作系统不知道go协程存在。
  • 操作系统线程执行一个调度循环,顺序执行go协程。
  • 调度循环非常类似于线程池。

然而,目前的线程调度存在下面的问题:

  • 第一,目前协程是顺序执行的,无法大规模并发
  • 第二,多线程并发会抢夺协程队列的锁,造成锁并发问题,影响效率。

下篇文章我们将讲述大名鼎鼎的G-M-P调度模型,这也是Go语言现在所采用的协程调度模型,它将很好地解决上述的问题。