李乐
问题引入
提起协程,你可能会说,不就 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
ret
add:
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 main
func 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 void
recovery(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, AX
JNE 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,咱们介绍了其底层实现原理。