首页 > 编程笔记 > Go语言笔记 阅读:2

Go语言协程的创建(非常详细)

在 Go 语言中,创建协程使用的是 go 关键字,如果你全局搜索 Go 源码,你会发现并没有 go 函数的定义,其实是因为 go 关键字在编译阶段会替换为函数 runtime.newproc 函数。

我们写一个简单的程序验证一下,代码如下所示:
package main

import (
    "fmt"
    "time"
)

func main() {
    go goroutine()
    time.Sleep(time.Second)
}

func goroutine() {
    fmt.Println("helloworld")
}
基于工具 compile,我们可以将上面的 Go 程序编译为汇编代码,结果如下:
"".main STEXT
0x0014 00020 (test.go:9)  LEAQ   "".goroutine·f(SB), AX
0x0020 00032 (test.go:9)  CALL   runtime.newproc(SB)
0x0025 00037 (test.go:10) MOVL   $1000000000, AX
0x002a 00042 (test.go:10) CALL   time.Sleep(SB)

第二条指令 CALL 就是函数调用指令,程序调用了函数 runtime.newproc 创建协程,该函数有一个输入参数,类型为函数指针。第一条指令 LEAQ 是取函数 goroutine 的地址,将其作为参数传递给函数 runtime.newproc,注意这里是通过寄存器 AX 传递的输入参数。

main 函数默认在主协程执行,也就是说,我们是在主协程调用的函数 runtime.newproc,而创建协程是需要分配协程栈内存的,执行过程稍微复杂,不适合在协程栈执行(协程栈比较小,Go 语言默认 2KB)。所以,创建协程的逻辑都会切换到线程栈执行,协程创建完成后,再切换到原来的协程栈继续执行。

我们可以看一下函数 runtime.newproc 的核心逻辑,代码如下所示:
func newproc(fn *funcval) {
    gp := getg()
    pc := getcallerpc()
    systemstack(func() {
        newg := newproc1(fn, gp, pc)
        _p_ := getg().m.p.ptr()
        runqput(_p_, newg, true)
    })
}
参考上面的代码,函数 systemstack 实现了我们所说的栈切换逻辑,其有一个输入参数,类型为函数类型,功能是切换到系统栈(调度协程 g0 的栈)执行这个函数,函数执行完成后,再切换到原来的栈。函数 runtime.newproc1 真正实现了协程的创建逻辑,函数 runtime.runqput 会将新创建的协程添加到逻辑处理器P的可运行协程队列(当然也有可能添加到全局可运行协程队列)。

函数 runtime.newproc1 的逻辑就比较复杂了,不仅需要申请协程栈内存,还需要构造初始的栈帧结构,以及初始化协程上下文,其核心代码如下所示:
func newproc1(fn *funcval, callergp *g, callerpc uintptr) *g {
    // 申请协程栈内存
    newg = malg(_StackMin) // _StackMin = 2048

    // sp 指向栈顶,stack.hi 为栈最高地址,减去 totalSize 预留空间
    sp := newg.stack.hi - totalSize
    newg.sched.sp = sp

    // goexit 为协程退出函数
    newg.sched.pc = abi.FuncPCABI0(goexit) + sys.PCQuantum

    // 构造栈帧结构,fn 为协程入口函数
    gostartcallfn(&newg.sched, fn)

    // 设置协程状态:可运行
    casgstatus(newg, xxx, _Grunnable)

    return newg
}
参考上面的代码,函数 malg 用于申请协程栈内存,并初始化结构体 g。默认的协程栈内存只需要 2KB,要知道 Linux 系统默认线程栈内存需要 8MB。newg.sched 变量就是与调度相关的协程上下文,主要包含栈顶指针 sp,栈底指针 bp,程序计数器 pc。

可以看到,初始化协程上下文时,newg.sched.pc 指向的是函数 runtime.goexit,顾名思义该函数是协程退出时执行的函数。

函数 runtime.gostartcallfn 用于构造协程栈结构,其需要两个输入参数:
函数 runtime.gostartcallfn 最终也是通过调用函数 runtime.gostartcall 构造的协程栈结构,该函数代码如下所示:
func gostartcall(buf *gobuf, fn) {
    sp := buf.sp
    sp -= goarch.PtrSize
    // buf.pc指向了函数goexit首地址
    *(*uintptr)(unsafe.Pointer(sp)) = buf.pc
    // 设置上下文 sp pc
    buf.sp = sp
    buf.pc = uintptr(fn)
}
函数 runtime.gostartcall 第一个输入参数 buf 是协程上下文,第二个输入参数 fn 是协程的入口函数。

另外需要说明的是,buf.pc 此时已经指向了函数 goexit 首地址。参考上面的代码,函数 runtime.gostartcall 首先需要将栈顶指针 buf.sp 向低地址移动 8 字节,再赋值 sp=buf.pc,这相当于将函数 goexit 首地址入栈。最后还需要初始化程序计数器 pc,使其指向函数 fn 首地址,这样调度到该协程时,就可以跳转到协程的入口函数 fn 了。

初始化后的协程栈结构如下图所示:


图 1 协程栈结构示意图

需要注意的是,创建协程的整个流程只是创建并初始化了协程,并没有立即执行该协程,而是将协程状态赋值为 Runnable “可运行”,并且将协程添加到逻辑处理器 P 的可运行协程队列或者全局可运行协程队列,等待线程M的调度执行。

最后,协程与线程类似,可以有多个状态,并且可以在不同状态之间转移。Go 语言定义的协程状态如下所示:
_Gidle       = iota // 0  空闲状态,刚申请的 g 未完成初始化
_Grunnable          // 1  可运行状态,已加入可运行队列
_Grunning           // 2  运行中状态
_Gsyscall           // 3  系统调用中
_Gwaiting           // 4  阻塞状态(等待锁等)
_Gdead              // 6  结束状态
_Gcopystack         // 8  栈扩容状态(栈不足时自动扩容)
// 省略两种预留状态
参考协程各状态的定义,可以画出协程的状态转移图,如下图所示:


图 2 协程状态转移图

相关文章