李乐

问题引入

  提起协程,你可能会说,不就go func吗,我分分钟就能创立上万个协程。可是协程到底是什么呢?都说协程是用户态线程,这里的用户态是什么意思?都说协程比线程更轻量,协程轻量在哪里呢?

  本文次要为读者介绍这些内容:

  • Golang v1.0协程并发模型——MG模型,协程创立,协程切换,协程退出,以及g0协程,重在了解协程栈切换逻辑;
  • 为了了解协程栈,还须要简略理解下虚拟内存,函数栈帧以及简略的汇编语言;
  • Golang v1.0协程调度逻辑;
  • defer,panic以及recover底层实现原理。

  通过本篇文章,你将从根本上理解Golang协程。

  注:为什么抉择v1.0版本呢?因为他足够的简略,不过,麻雀虽小五脏俱全;而且你会发现,即便到了当初,Golang协程实现原理,也就那么回事。v1.0版本代码能够从github上下载,分支为release-branch.go1。

根底补充

  在解说Golang协程实现之前,还须要补充一些基础知识。了解协程,就须要了解函数栈帧,以及虚拟内存。而函数栈帧的治理,须要从汇编档次去解读。

  PS:不要怕,汇编其实很简略,不过几条指令,几个寄存器而已。

虚拟内存

  linux将内存组织为一些区域(段)的汇合,如代码段,数据段,运行时堆,共享库段,以及用户栈都是不同的区域。如下图所示:

  用户栈,自上而下增长,寄存器%rsp指向用户栈的栈顶地位;通过malloc调配的内存通常是在运行时堆。

  想想函数调用过程,比方func1调用func2,待func2执行结束后,还会回归道func1继续执行。该过程十分相似于栈构造,先入后出。大多数语言的函数调用都采纳栈构造实现(基于用户栈),函数的调用与返回即对应一系列的入栈与出栈操作,而咱们平时遇到的栈溢出就是因为函数调用层级过深,一直入栈导致的。函数栈帧如下图所示:

  寄存器%rbp指向函数栈帧底部地位,寄存器%rsp指向函数栈帧顶部地位。能够看到,在函数栈帧入栈时候,还会将调用方函数栈帧的%rbp寄存器入栈,以及实现多个函数栈帧的链接关系。否则,以后函数执行结束后,如何复原其调用方的函数栈帧?

  谁为我保护着函数栈帧构造呢?当然是我的代码了,可是我都没关注过这些啊。能够看看编译器生成的汇编代码,咱们简略写一个c程序:

int add(int x, int y){    return x+y;}int main(){    int sum = add(111,222);}

  查看编译后果:

main:    pushq   %rbp    movq    %rsp, %rbp    subq    $16, %rsp    movl    $222, %esi    movl    $111, %edi    call    add    movl    %eax, -4(%rbp)    leave    retadd:    pushq   %rbp    movq    %rsp, %rbp    movl    %edi, -4(%rbp)    movl    %esi, -8(%rbp)    movl    -8(%rbp), %eax    movl    -4(%rbp), %edx    addl    %edx, %eax    popq    %rbp    ret

  能够看到main以及add函数入口,都对应有批改%rbp以及%rsp指令。

  另外,读者请留神:这个示例,函数调用过程中,参数的传递以及返回值是通过寄存器传递的,比方第一个参数是%edi,第二个参数是%esi,返回值是%eax。参数以及返回值如何传递,其实并不是那么重要,约定好即可,比方Golang语言,参数以及返回值都是基于栈帧传递的。

汇编简介

  任何架构的计算机都会提供一组指令汇合,汇编是二进制指令的文本模式。指令由操作码和操作数组成;操作码即操作类型,操作数能够是一个立刻数或者一个存储地址(内存,寄存器)。寄存器是集成在CPU外部,拜访十分快,然而数量无限的存储单元。Golang应用plan9汇编语法,汇编指令的写法以及寄存器的命名略有不同

  上面简略介绍一些罕用的指令以及寄存器:

  • MOVQ $10, AX:数据挪动指令,该指令示意将立刻数10存储在寄存器AX;AX即通用寄存器,罕用的通用寄存器还有BX,CX,DX等等;留神指令后缀『Q』示意数据长度为8字节;
  • ADDQ AX, BX:加法指令,等价于 BX += AX;
  • SUBQ AX, BX:减法指令,等价于 BX -= AX;
  • JMP addr:跳转道addr地址处继续执行;
  • JMP 2(PC):CPU如何加载指令并执行呢?其实有个专用寄存器PC(等价于%rip),他指向下一条待执行的指令。该语句含意是,以以后指令为根底,向后跳转2行;
  • FP:伪寄存器,通过symbol+offset(FP)模式,援用函数的输出参数,例如 arg0+0(FP),arg1+8(FP);
  • 硬件寄存器SP:等价于下面呈现过的%rsp,执行函数栈帧顶部地位);
  • CALL func:函数调用,蕴含两个步骤,1)将下一条指令的所在地址入栈(还须要复原到这执行);2)将func地址,存储在指令寄存器PC;
  • RET:函数返回,性能为,从栈上弹出指令到指令寄存器PC,复原调用方函数的执行(CALL指令入栈);

  更多plan9常识参考:https://xargin.com/plan9-asse...

  上面写一个go程序,看看编译后的汇编代码:

package mainfunc addSub(a, b int) (int, int){    return a + b , a - b}func main() {    addSub(333, 222)}

  汇编代码查看:go tool compile -S -N -l test.go

"".addSub STEXT nosplit size=49 args=0x20 locals=0x0    0x0000 00000 (test.go:3)    MOVQ    $0, "".~r2+24(SP)    0x0009 00009 (test.go:3)    MOVQ    $0, "".~r3+32(SP)    0x0012 00018 (test.go:4)    MOVQ    "".a+8(SP), AX    0x0017 00023 (test.go:4)    ADDQ    "".b+16(SP), AX    0x001c 00028 (test.go:4)    MOVQ    AX, "".~r2+24(SP)    0x0021 00033 (test.go:4)    MOVQ    "".a+8(SP), AX    0x0026 00038 (test.go:4)    SUBQ    "".b+16(SP), AX    0x002b 00043 (test.go:4)    MOVQ    AX, "".~r3+32(SP)    0x0030 00048 (test.go:4)    RET    "".main STEXT size=68 args=0x0 locals=0x28    0x000f 00015 (test.go:7)      SUBQ    $40, SP    0x0013 00019 (test.go:7)      MOVQ    BP, 32(SP)    0x0018 00024 (test.go:7)      LEAQ    32(SP), BP    0x001d 00029 (test.go:8)      MOVQ    $333, (SP)    0x0025 00037 (test.go:8)      MOVQ    $222, 8(SP)    0x002e 00046 (test.go:8)      CALL    "".addSub(SB)    0x0033 00051 (test.go:9)      MOVQ    32(SP), BP    0x0038 00056 (test.go:9)      ADDQ    $40, SP    0x003c 00060 (test.go:9)      RET

  剖析main函数汇编代码:SUBQ $40, SP为本人调配栈帧区域,LEAQ 32(SP), BP,挪动BP寄存器到本人栈帧构造的底部。MOVQ $333, (SP)以及MOVQ $222, 8(SP)在筹备输出参数。

  剖析addSub函数汇编代码:"".a+8(SP)即输出参数a,"".b+16(SP)即输出参数b。两个返回值别离在24(SP)以及32(SP)。

  留神:addSub函数,并没有通过SUBQ $xx, SP以,来为本人调配栈帧区域。因为addSub函数没有再调用其余函数,也就没有必要在为本人调配函数栈帧区域了。

  另外,留神main函数,addSub函数,是如何传递与援用输出参数以及返回值的。

线程本地存储

  线程本地存储(Thread Local Storage,简称TLS),其实就是线程公有全局变量。一般的全局变量,一个线程对其进行了批改,所有线程都能够看到这个批改;线程公有全局变量不同,每个线程都有本人的一份正本,某个线程对其所做的批改不会影响到其它线程的正本。

  Golang是多线程程序,以后线程正在执行的协程,显然每个线程都是不同的,这就保护在线程本地存储。所以在Golang协程切换逻辑中,随处可见『get_tls(CX)』,用于获取以后线程本地存储首地址。

  不同的架构以及操作系统,能够通过FS或者GS寄存器拜访线程本地存储,如Golang程序,383架构Linux操作系统时,通过如下形式拜访:

//"386", "linux""#define    get_tls(r)    MOVL 8(GS), r\n"//获取线程本地存储首地址get_tls(CX)//构造体G封装协程相干数据,DX存储着以后正在执行协程G的首地址//协程调度时,保留以后协程G到线程本地存储MOVQ    DX, g(CX)

  线程本地存储简略理解下就行,更多常识可参考文章:https://www.cnblogs.com/abozh...

v1.0协程模型

  很多人对Golang并发模型MPG应该是比拟理解的,如下图所示。其中,G代表一个协程;M代表一个工作线程;P代表逻辑处理器,其保护着可运行协程的队列runq;须要留神的是,M只有和P绑定后,能力调度并执行协程。另外,g0是一个非凡的协程,用于执行调度逻辑,以及协程创立销毁等逻辑。

  Golang v1.0版本并发模型还是比较简单的,这时候还没有逻辑处理器P,只有MG,如下图所示。留神这时候可运行协程队列保护在全局,因而每次调度都须要加锁,性能是比拟低的。

数据结构

  有几个重要的构造体咱们须要简略理解下,比方M,G,以及协程调度相干Gobuf。

  构造体M封装线程相干数据,字段较多,然而目前根本都能够不关注。构造体G封装协程相干数据,咱们先理解这几个字段:

struct    G{    //协程ID    int32    goid;    //协程入口函数    byte*    entry;    // initial function    //协程栈    byte*    stack0;        //协程调度相干    Gobuf    sched;        //协程状态    int16    status;}

  留神Gobuf构造,其定义了协程调度相干的上下文数据:

struct    Gobuf{    //寄存器SP    byte*    sp;    //寄存器PC    byte*    pc;        //执行协程对象    G*    g;};

  Golang定义协程有上面几种状态:

enum{    //协程创立初始状态    Gidle,    //协程在可运行队列期待调度    Grunnable,    //协程正在被调度运行    Grunning,    //协程正在执行零碎调用    Gsyscall,    //协程处于阻塞状态,没有在可运行队列    Gwaiting,    //协程执行完结,期待调度器回收    Gmoribund,    //协程已被回收    Gdead,};

  协程状态转移如下图所示:

协程创立

  通过go关键字能够很不便的创立协程,Golang编译器会将go关键字替换为runtime.newproc函数调用,函数newproc实现了协程的创立逻辑,定义如下:

//siz:参数数目;fn:入口函数func newproc(siz int32, fn *funcval);

  在解说协程创立之前,咱们先思考下,须要创立什么?仅仅是一个构造体G吗?

  咱们回顾一下函数调用过程,func1调用func2,func2函数栈帧入栈,func2执行结束,func2函数栈帧出栈,从新回到func1的函数栈帧。那如果func1以及func2代表着两个协程呢?这两个函数会并行执行,还能像函数调用过程一样吗?显然是不行的,因为func1以及func2函数栈帧须要随便切换。

  咱们能够类比下线程,多线程程序,每一个线程都有一个用户栈(参考虚拟内存构造,存在多个用户栈),该用户栈由操作系统保护(创立,切换,回收)。线程执行为什么须要用户栈呢?函数的局部变量,函数调用过程的入参传递,返回值传递,都是基于用户栈实现的。

  协程也须要多个用户栈,只不过这些用户栈须要Golang来保护。咱们能通过零碎调用创立用户栈吗?显然是不能的。然而,咱们下面提到过,寄存器%rsp以及寄存器%rbp指向了用户栈,CPU晓得什么是栈什么是堆吗?不晓得,他只须要基于寄存器%rsp入栈以及出栈就行了。正式基于此,咱们能够移花接木,在堆上申请一块内存,将寄存器%rsp以及寄存器%rbp指过来,从而将这块内存伪装成用户栈。

  协程创立次要逻辑由函数runtime·newproc1实现,次要步骤有:1)从闲暇链表中获取构造体G;2)如果没有获取到闲暇的G,则重新分配,包含调配构造体G以及协程栈;3)将创立好的协程退出到可运行队列。

//fn:协程入口函数;argp:参数首地址;narg:输出参数所占字节数;nret:返回值所占字节数;callerpc:调用方PC指针G* runtime·newproc1(byte *fn, byte *argp, int32 narg, int32 nret, void *callerpc) {    //依据参数数目,以及返回值数目;计算栈所需空间    siz = narg + nret;    //加锁;会操作全局数据    schedlock();    //从全局链表获取Gruntime·sched.gfree    if((newg = gfget()) != nil){        }{        //申请G,以及协程栈        newg = runtime·malg(StackMin);    }        //协程栈顶指针(栈自顶向下)    sp = newg->stackbase;    sp -= siz;        //初始化:协程状态,协程栈顶指针sp,协程退出处理函数pc,协程入口函数entry    newg->status = Gwaiting;    newg->sched.sp = sp;    newg->sched.pc = (byte*)runtime·goexit;    newg->sched.g = newg;    newg->entry = fn;        //协程数目统计    runtime·sched.gcount++;    //自增协程ID    runtime·sched.goidgen++;    newg->goid = runtime·sched.goidgen;        //将协程退出到可运行队列    newprocreadylocked(newg);        //开释锁    schedunlock();    return newg;}

  这里读者需重点关注两处逻辑:1)runtime·malg申请协程栈空间,留神栈空间申请逻辑只能在g0栈执行,g0栈就是协程g0的栈,所以这里可能还存在栈的切换,下一个大节将具体介绍;2)初始化协程时候,留神协程栈顶指针sp,协程退出处理函数pc,协程入口函数entry,前面协程切换时候十分重要。

g0协程

  咱们之前说过,g0是一个非凡的协程,用于执行调度逻辑,以及协程创立销毁等逻辑。这句话还是比拟形象的,可能还是不明确g0协程到底是什么?其实只有记住一句话:程序逻辑的执行都须要栈空间。因而须要把调度逻辑,以及协程创立销毁等逻辑在独立的栈空间(g0栈)上执行。

  所以随处可见这样的逻辑:

//协程栈申请,必须在g0栈if(g == m->g0) {    // running on scheduler stack already.    stk = runtime·stackalloc(StackSystem + stacksize);} else {    //runtime·mcall专门用于切换栈帧到g0    runtime·mcall(mstackalloc);}//协程调度,必须在g0栈void runtime·gosched(void) {    runtime·mcall(schedule);}

  runtime·mcall函数申明如下,其中fn就是切换到g0栈去执行的函数,如调度逻辑,栈帧调配逻辑:

void mcall(void (*fn)(G*))

  上面就只能硬着头皮看了,不去一行一行看runtime·mcall的汇编实现,永远无奈真正了解协程栈切换的实质。

TEXT runtime·mcall(SB), 7, $0    //FP伪寄存器,fn+0(FP)形式可取得第一个参数fn,存储到寄存器DI    MOVQ    fn+0(FP), DI        //线程本地存储,能够获取以后执行协程g,以及以后线程m    get_tls(CX)    //寄存器CX指向线程本地存储,g(CX)可获取以后执行协程,存储在寄存器AX    MOVQ    g(CX), AX            //基于指令CALL调用函数runtime·mcall时候,会入栈指令寄存器PC;    //0(SP)即调用方下一条待执行指令    MOVQ    0(SP), BX        //g_sched即sched字段在构造体g偏移量;gobuf_pc即pc字段在构造体gobuf偏移量;    //g_sched以及gobuf_pc都是宏定义,并且由脚本生成    //保留以后协程上下文:下一条待执行指令    MOVQ    BX, (g_sched+gobuf_pc)(AX)        //调用方栈顶地位,在8(SP);参考函数栈帧示意图    LEAQ    8(SP), BX        MOVQ    BX, (g_sched+gobuf_sp)(AX)        //AX存储着以后协程    MOVQ    AX, (g_sched+gobuf_g)(AX)            // AX为以后协程,m->g0为g0协程;判断以后协程是否是g0协程    MOVQ    m(CX), BX    MOVQ    m_g0(BX), SI    CMPQ    SI, AX    // if g == m->g0 call badmcall    JNE    2(PC)    //如果是,非法的runtime·mcall调用    CALL    runtime·badmcall(SB)        //SI为g0协程,g(CX)协程本地存储,赋值以后执行协程为g0    //(程序很多中央都须要判断以后执行的是哪个协程,所以切换前须要更新)    MOVQ    SI, g(CX)    // g = m->g0        //SI为g0协程,复原g0协程上下文:sp寄存器    MOVQ    (g_sched+gobuf_sp)(SI), SP    // sp = m->g0->gobuf.sp        //留神函数申明,void (*fn)(G*),输出参数为G。    //AX为行将换出的协程,这里将输出参数入栈    PUSHQ    AX    //DI即第一个参数fn,调用该函数    CALL    DI    POPQ    AX        //因为fn实践上是死循环,永远不会执行完结;如果到这里阐明出异样了    CALL    runtime·badmcall2(SB)    RET

  每一行汇编的含意都有正文,这里就不再一一介绍。

  通过runtime·mcall汇编实现,读者能够看到,协程切换,切换的就是指令寄存器PC,以及栈寄存器SP。重点关注以后协程下一条指令,以及协程栈顶指针获取形式。

  须要特地留神的是:其输出参数fn永远不会返回,该函数会切换到其余协程执行。一旦fn执行返回了,会调用runtime·badmcall2抛异样(panic)。

  最初能够思考下,m->g0即以后线程的g0协程,变量g即以后正在执行的协程,所以代码里才有这样的逻辑:

extern    register    G*    g;if(g == m->g0) {}

  然而又发现,变量g定义的是全局寄存器变量,扭转量实践上应该在协程切换时更新。可是协程切换时,的确没有更新的逻辑,只能找到更新线程本地存储的逻辑。其实这是因为Golang编译器做了一个改变,将extern register变量与协程本地存储关联起来了。

// on 386 or amd64, "extern register" generates// memory references relative to the// gs or fs segment.

  咱们再回顾下协程创立过程中申请栈帧的逻辑:

G* runtime·malg(int32 stacksize) {    if(g == m->g0) {        // running on scheduler stack already.        stk = runtime·stackalloc(StackSystem + stacksize);    } else {        // have to call stackalloc on scheduler stack.        g->param = (void*)(StackSystem + stacksize);        runtime·mcall(mstackalloc);        stk = g->param;    }        newg->stack0 = stk;}static void mstackalloc(G *gp) {    gp->param = runtime·stackalloc((uintptr)gp->param);    runtime·gogo(&gp->sched, 0);}

  假如在协程A中,通过go关键字创立协程B;g->param变量即须要申请的协程栈大小。察看函数mstackalloc申明,该函数在g0栈上执行,其第一个参数gp指向协程A;栈空间申请结束后,又通过runtime·gogo切换回协程,持续协程B的初始化。整个过程如下图所示:

协程切换

  协程创立,协程完结,协程因为某些起因阻塞,可能都会触发协程的切换。

  如上一节介绍的函数runtime·gogo,就实现了协程切换性能。

//gobuf:待执行协程上下文构造;void    runtime·gogo(Gobuf*, uintptr);TEXT runtime·gogo(SB), 7, $0    MOVQ    16(SP), AX        // return 2nd arg    //第一个参数gobuf,存储在寄存器BX    MOVQ    8(SP), BX        // gobuf        //gobuf_g即字段g绝对于gobuf的偏移量;协程g存储在DX    MOVQ    gobuf_g(BX), DX    MOVQ    0(DX), CX        // make sure g != nil        //获取线程本地存储    get_tls(CX)    //行将执行的协程,保留在线程本地存储    MOVQ    DX, g(CX)        //复原协程上下文:栈顶寄存器SP    MOVQ    gobuf_sp(BX), SP    // restore SP    //复原协程上下文:下一条指令    MOVQ    gobuf_pc(BX), BX    //指令跳转,这就切换到新的协程了    JMP    BX

  首次切换到协程时候,并不是通过runtime·gogo实现的。而是基于runtime·gogocall,为什么要辨别呢?因为首次切换到协程,还有一些特殊任务须要解决,如提前设置好协程完结处理函数。

//gobuf:待执行协程上下文构造;第二个参数:协程入口函数void    runtime·gogocall(Gobuf*, void(*)(void));static void schedule(G *gp) {    //协程上下文PC等于runtime·goexit,阐明协程还没有开始执行过    if(gp->sched.pc == (byte*)runtime·goexit) {            runtime·gogocall(&gp->sched, (void(*)(void))gp->entry);    }}TEXT runtime·gogocall(SB), 7, $0    //第二个参数:协程入口函数    MOVQ    16(SP), AX        // fn    //第一个参数,gobuf    MOVQ    8(SP), BX        // gobuf    //待执行协程g,保留在寄存器DX    MOVQ    gobuf_g(BX), DX        //获取线程本地存储    get_tls(CX)    //行将执行的协程,保留在线程本地存储    MOVQ    DX, g(CX)    MOVQ    0(DX), CX    // make sure g != nil        //复原协程上下文:栈顶寄存器SP    MOVQ    gobuf_sp(BX), SP    // restore SP    //此时,gobuf_pc等于runtime·goexit,存储在寄存器BX    MOVQ    gobuf_pc(BX), BX    //思考下为什么BX要入栈?    PUSHQ    BX    //指令跳转,AX为协程入口函数    JMP    AX    POPQ    BX    // not reached

  runtime·gogocall以及runtime·gogo函数实现了协程的换入工作;另外,协程换出时候,通过runtime·gosave保留协程上下文,该函数在协程行将进入零碎调用时候执行。

//gobuf:协程上下文构造void gosave(Gobuf*)TEXT runtime·gosave(SB), 7, $0    //第一个参数gobuf    MOVQ    8(SP), AX        // gobuf        //调用方的栈顶地位存储在8(SP),参考函数栈帧示意图    LEAQ    8(SP), BX        // caller's SP    //协程上下文:栈顶地位保留在gobuf->sp    MOVQ    BX, gobuf_sp(AX)        //协程上下文:下一条待执行指令保留在在gobuf->pc    MOVQ    0(SP), BX        // caller's PC    MOVQ    BX, gobuf_pc(AX)        //获取线程本地存储    get_tls(CX)    MOVQ    g(CX), BX    //以后协程g,存储在gobuf->g    MOVQ    BX, gobuf_g(AX)    RET

  通过下面三个函数的汇编实现,置信读者对协程切换:上下文保留以及上下文复原,都有了肯定理解。

协程完结

  设想下,如果某协程的处理函数为funcA,funcA执行结束,相当于该协程的完结。这之后该怎么办?必定须要执行特定的回收工作。留神到下面大节有一个函数,runtime·goexit,看名字协程完结时候应该执行这个函数。如何在funcA执行结束后,调用runtime·goexit呢?

  再次回顾函数调用过程,以及函数栈帧示意图。函数funcA执行结束时候,存在一个RET指令,该指令会弹出下一条待指令到指令寄存器PC,从而只限指令的跳转。咱们再察看runtime·gogocall的实现逻辑,有这么一行指令:

TEXT runtime·gogocall(SB), 7, $0        //BX即gobuf->pc,初始为runtime·goexit    PUSHQ    BX    //指令跳转,AX为协程入口函数    JMP    AX    POPQ    BX    // not reached

  逻辑串起来了,PUSHQ BX,将函数runtime·goexit首地址入栈,因而协程执行完结后,RET弹出的指令就是函数runtime·goexit首地址,从而开始了协程回收工作。而函数runtime·goexit,则标记协程状态为Gmoribund,开始新一次的协程调度(会切换到g0调度)

void runtime·goexit(void){    g->status = Gmoribund;    runtime·gosched();}

v1.0协程调度

  调度器负责保护协程状态,获取一个可运行协程并执行。调度逻辑次要在函数schedule中,正如下面所说,调度逻辑必定须要运行在g0栈,因而通常这么执行调度函数:

runtime·mcall(schedule);

  调度函数的申明如下,输出参数gp是什么呢?当然是行将换出的协程,参数的筹备可在runtime·mcall汇编中看到:

static void schedule(G *gp)TEXT runtime·mcall(SB), 7, $0    //留神函数申明,void (*fn)(G*),输出参数为G。    //AX为行将换出的协程,这里将输出参数入栈    PUSHQ    AX    //DI即第一个参数fn,调用该函数    CALL    DI    

  切换到调度器后,会更新协程状态,接着从可运行队列获取一个新的协程去执行:

static void schedule(G *gp) {    if(gp != nil) {        switch(gp->status){        case Grunning:            //放入可运行列表            gp->status = Grunnable;            gput(gp);            break;        case Gmoribund:            //协程完结;回收到闲暇列表,可反复利用            gp->status = Gdead;                        gfput(gp);                    //省略        }    }        // Find (or wait for) g to run.    //获取可运行协程    gp = nextgandunlock();    gp->status = Grunning;        //运行协程    if(gp->sched.pc == (byte*)runtime·goexit) {            runtime·gogocall(&gp->sched, (void(*)(void))gp->entry);    }    runtime·gogo(&gp->sched, 0);}

  进一步,在调度函数schedule之上又封装了runtime·gosched,在触发协程调度时候,通常基于该函数实现。能够简略画下协程调度示意图:

  有很多种状况可能会触发协程调度:比方读写管道阻塞了,比方socket操作等等,上面将别离介绍。

管道channel

  管道通常用于协程间的数据交互,管道的构造体定义如下:

struct    Hchan{    //已写入管道的数据总量    uint32    qcount;            // total data in the q    //管道最大数据量    uint32    dataqsiz;        // size of the circular q        //读管道阻塞的协程,是一个队列    WaitQ    recvq;            // list of recv waiters    //写管道阻塞的协程,是一个队列    WaitQ    sendq;            // list of send waiters};

  写管道操作底层由函数runtime·chansend实现,读取管道操作底层由函数runtime·chanrecv实现。有两种状况会导致协程的阻塞:1)往管道写入数据时,已达到该管道最大数据量;2)从管道读取数据时,管道数据为空。咱们以runtime·chansend为例:

void runtime·chansend(ChanType *t, Hchan *c, byte *ep, bool *pres) {    //管道为nil,阻塞以后协程,触发协程调度    if(c == nil) {        g->status = Gwaiting;        g->waitreason = "chan send (nil chan)";        runtime·gosched();        return;  // not reached    }        if(c->dataqsiz > 0) {        //有缓冲管道,写入数据满了,阻塞该协程        if(c->qcount >= c->dataqsiz) {            g->status = Gwaiting;            g->waitreason = "chan send";            enqueue(&c->sendq, &mysg);            runtime·unlock(c);            runtime·gosched();        }    }        ……}

  更多具体的实现细节,读者能够查看函数runtime·chansend与runtime·chanrecv实现逻辑。

socket事件循环

  socket读写怎么解决呢?相熟高并发服务端编程的应该都理解:基于IO多路复用模型,比方epoll。Golang也是这么做的。

  构造体pollServer封装了事件循环相干,其定义如下:

type pollServer struct {    //读写文件描述符,epoll在阻塞期待时候,可用于长期唤醒(只有执行下写或者读操作即可)    pr, pw     *os.File         //代理poll,底层可基于epoll/Kqueue等    poll       *pollster // low-level OS hooks        //socket-fd在读写时候通常都有超时工夫;deadline为最近的过期工夫,用于设置epoll_wait阻塞工夫    deadline   int64 // next deadline (nsec since 1970)}

  注:不理解epoll的读者,搜寻一下就有很多文章介绍。

  Golang过程启动时,会创立pollServer,并启动事件循环,详情参考函数newPollServer。

func newPollServer() (s *pollServer, err error) {    s = new(pollServer)    if s.pr, s.pw, err = os.Pipe(); err != nil {        return nil, err    }    //设置非阻塞标识    if err = syscall.SetNonblock(int(s.pr.Fd()), true); err != nil {        goto Errno    }    if err = syscall.SetNonblock(int(s.pw.Fd()), true); err != nil {        goto Errno    }        //初始化代理poll:可能是epoll/Kqueue等    if s.poll, err = newpollster(); err != nil {        goto Error    }    //监听s.pr,因而在向s.pw写数据时候,能够接触epoll阻塞(以epoll为例)    if _, err = s.poll.AddFD(int(s.pr.Fd()), 'r', true); err != nil {        s.poll.Close()        goto Error    }        go s.Run()    return s, nil}

  留神到这里通过go s.Run()启动了一个协程,即事件循环是以独立的协程在运行。事件循环无非就是,死循环,一直通过epoll_wait阻塞期待socket事件的产生。

func (s *pollServer) Run() {        for {        var t = s.deadline        //梗塞期待事件产生        fd, mode, err := s.poll.WaitFD(s, t)                //超时了,没有事件产生        if fd < 0 {            s.CheckDeadlines()            continue        }                //因为s.pr接触了阻塞,不是真正的socket-fd事件产生        if fd == int(s.pr.Fd()) {        } else {            netfd := s.LookupFD(fd, mode)            //唤醒阻塞在该fd上的协程            s.WakeFD(netfd, mode, nil)        }    }}

  看到这咱们大略明确了事件循环的逻辑,还有两个问题须要确定:1)socket读写操作实现逻辑;2)如何唤醒阻塞在该fd上的协程。

  socket读写逻辑,由函数
pollServer.WaitRead或者pollServer.WaitWrite;即下层的网络IO最终都会走到这里。以WaitRead函数为例:

func (s *pollServer) WaitRead(fd *netFD) error {    err := s.AddFD(fd, 'r')    if err == nil {        err = <-fd.cr    }    return err}

  s.AddFD最终将socket-fd增加到epoll,并且会更新pollServer.deadline,这是一个非阻塞操作;接下来只需期待事件循环监听该fd读/写事件即可。读管道fd.cr导致了该协程的阻塞。

  基于这些,咱们很容易猜到,s.WakeFD唤醒阻塞在该fd上的协程,其实只须要往管道fd.cr/fd.cw写下数据即可。

defer/panic/recover

  这几个关键字应该是很常见的,特地是panic,十分让人厌恶。对于这几个关键字的应用,这里就不介绍了。咱们重点摸索其底层实现原理。

  defer以及panic定义在构造体G,构造体Defer以及Panic这里就不做过多介绍了:

struct    G{    //该协程是否产生panic    bool    ispanic;    //defer链表    Defer*    defer;    //panic链表    Panic*    panic;}

  咱们先摸索第一个问题,都晓得defer是先入后出的,为什么呢?函数执行完结时执行defer,又是怎么实现的呢?defer关键字底层实现函数为runtime·deferproc:

uintptr runtime·deferproc(int32 siz, byte* fn, ...){    //初始化构造体Defer    d = runtime·malloc(sizeof(*d) + siz - sizeof(d->args));    d->fn = fn;    d->siz = siz;    //留神这里设置了调用方待执行指令地址    d->pc = runtime·getcallerpc(&siz);        //头插法,后插入的节点在头部;执行的确从头部遍历执行,因而就是先入后出    d->link = g->defer;    g->defer = d;}

  那defer什么时候执行呢?在函数完结时,Golang编译器会在函数开端增加runtime.deferreturn,用于执行函数fn,有趣味的读者能够写个小示例,通过go tool compile -S -N -l test.go看看。

  接下来咱们摸索第二个问题:panic是怎么触发程序解体的;defer与recover又是如何复原这种解体的;A协程中触发panic,B协程中是否recover该panic呢?

  关键字panic底层实现函数为runtime·panic:

void runtime·panic(Eface e) {    p = runtime·mal(sizeof *p);    p->link = g->panic;    g->panic = p;        //遍历执行以后协程的defer链表    for(;;) {        d = g->defer;        if(d == nil)            break;        g->defer = d->link;        g->ispanic = true;                    //反射调用d->fn        reflect·call(d->fn, d->args, d->siz);                    //recover底层实现为runtime·recover,该函数会标记p->recovered=1        //如果曾经执行了recover,则会打消这次解体        if(p->recovered) {            //将该defer又退出到协程链表;调度时候有用            d->link = g->defer;            g->defer = d;                        //恢复程序的执行            runtime·mcall(recovery);        }    }        //如果没有recover住,则会打印堆栈信息,并完结过程    runtime·startpanic();    printpanics(g->panic);    runtime·dopanic(0); //runtime·exit(2)}

  能够看到,产生panic后,只会遍历以后协程的defer链表,所以A协程中触发panic,B协程中必定不能recover该panic。

  最初一个问题,defer外面recover之后,Golang程序从哪里复原执行呢?参考runtime·mcall(recovery),这就须要看函数recovery实现了:

static voidrecovery(G *gp){    //获取第一个defer,即方才就是该defer recover了    d = gp->defer;    gp->defer = d->link;        //留神在初始化defer时候,设置了调用方待执行指令地址,这里将其设置到协程调度上下文,从而复原到这里执行    gp->sched.pc = d->pc;        //协程切换    runtime·gogo(&gp->sched, 1);}

  留神在初始化defer时候,是如何设置pc的?基于函数runtime·getcallerpc。这样获取的是调用runtime.deferproc的下一条指令地址。

CALL    runtime.deferproc(SB)TESTL    AX, AXJNE    182

  这里通过TESTL校验AX寄存器内容是负数正数还是0值。AX寄存器存储的是什么呢?还须要持续摸索。

  认真看看,这里协程切换时候,为什么runtime·gogo第二个参数是1呢?之前咱们始终没有说第二个参数的作用。其实第二个参数是用作返回值的。参考runtime·gogo汇编实现,第二个参数拷贝到了寄存器AX,前面没有任何代码应用寄存器AX。

TEXT runtime·gogo(SB), 7, $0    MOVQ    16(SP), AX        // return 2nd arg    //省略

  原来如此,runtime·gogo协程切换时候,设置的AX寄存器;在介绍虚拟内存章节,咱们也提到,寄存器AX能够作为函数返回值。其实函数runtime·deferproc也有明确的解释:

// deferproc returns 0 normally.// a deferred func that stops a panic// makes the deferproc return 1.// the code the compiler generates always// checks the return value and jumps to the// end of the function if deferproc returns != 0.return 0;

  runtime·deferproc通常返回0值,然而在呈现panic,并且捕捉解体之后,runtime·deferproc返回1(基于runtime·gogo第二个参数以及AX寄存器实现)。这时候会通过JNE指令跳转到runtime.deferreturn继续执行,相当于函数执行完结。

  最初咱们简略画一下该过程示意图:

总结

  本文以Golang v1.0版本为例,为读者解说协程实现原理,包含协程创立,协程切换,协程退出,以及g0协程。v1.0协程调度还是比较简单的,很多因素可能引起协程的阻塞触发协程调度,本文简略介绍了管道chan,以及socket事件循环。最初,针对defer/panic/recover,咱们介绍了其底层实现原理。