跳至主要內容

从 Hello World 来看看 go 的运行流程

pedrogaogogoruntime大约 38 分钟

一直以来我们都在享受 Go 语言带来的便利,却不知道它一直在为我们负重前行。本文我们将从底层出发,从 Hello World 这个最基本例子来看看 Go 是如何运行的。重新审视 Hello World,保持对 Hello World 的敬畏。

本文涉及知识点有:

为了帮助大家能够更好的理解 Go 运行流程,这里会先介绍一些前置知识,如果你已经熟稔于心可以直接选择跳过,如果你还一知半解,那么请随我一起来回顾回顾。

操作系统

Go 程序的运行依托于操作系统(本文基于 linux),想要读懂其运行流程,必须先准备一些前置知识。

进程与线程

在 linux 中,线程与进程均有同一个结构体 task_struct 来表示,二者均由内核调度器来调度,该结构体的定义如下:

struct task_struct {
    volatile long   state;       // 进程状态 
    void            *stack;      // 进程内核栈地址 
    struct list_head        tasks;// 所有进程的链表
    struct mm_struct        *mm;  // 指向进程内存结构
    pid_t               pid;            // 进程 id
    struct task_struct __rcu    *parent;// 指向其父进程
    struct fs_struct        *fs;    // 进程相关的文件系统信息
    struct files_struct     *files;// 进程打开的所有文件
    struct vm_struct        *stack_vm_area;// 内核栈的内存区
};

task_struct 是一个非常庞大的结构体,这里只摘出了有用的部分,下面分别说明一下:

  • state:进程状态,表示进程正在运行、睡眠等等。
  • stack:内核栈,在内核运行时使用的调用栈。
  • mm:进程用户地址空间,包括栈、堆等等。
  • files:资源文件

mm 实则就是我们平时所理解的地址空间,如下:

图片

我们暂时不关注代码段、数据段,将注意力放在栈和堆中,这里的栈实则就是进程在用户态的调用栈,当进程在用户态运行时,会使用这个栈,而当进程陷入内核时,则使用内核栈,即 stack 字段。

线程与进程是同一种数据结构 task_struct,二者的区别在于进程拥有独立的地址空间和资源,即独立的 mm 和 files,而线程必须得与进程共享地址空间和资源。因此在 linux 下,进程本身也是可执行单元,可以将其理解为主线程,主线程拥有地址空间和资源,而非主线程则共享主线程的地址空间和资源。

在主线程中新建非主线程后,非主线程也会被分配调用栈(linux 下为 8 M),那么这个栈在什么地方了,它肯定在地址空间里面,而且在堆里面,没错,其它线程调用栈是在堆里面分配的,因为堆是一段很大的地址空间,而栈相对较小,因此选择在堆里面开辟。如下图:

图片

总结一下:

  • 无论进程和线程都有一个内核栈,陷入内核态后再使用。
  • 进程和线程是同一种数据结构,二者并无本质区别,由内核调度器统一调度,进程拥有独立地址空间与资源,而线程则共享其地址空间与资源。
  • 非主线程也有用户态调用栈,且在进程中的堆上开辟。

系统调用

概念

系统调用(system call)指运行在用户空间的程序向操作系统内核请求需要更高权限运行的服务。

现代操作系统都有明确的权限隔离,一般分为内核态和用户态两个运行态。内核态拥有最高权限,而用户态权限受限,无法直接使用高权操作和访问内核数据。

基本使用

举个例子,常见的 print 函数会在终端输出一段文本,可在用户态是没有 IO 权限的,用户态程序需要通过系统调用这个桥梁向内核发起申请,陷入内核态后再进行 IO 操作。

下面我们以 C 语言为例,来看看 IO 系统调用是如何产生的:

#include <stdio.h>

int main() {
  char *str = "Hello, World\n";
  printf("%s", str);
  return 0;
}

上面是一段最基本的 Hello World 代码,编译并运行:

$ gcc -o helloworld helloworld.c 
$ ./helloworld 
Hello, World

在终端成功输出了 Hello World 文本,通过 strace 工具查看一下该程序的系统调用:

$ strace ./helloworld
execve("./helloworld", ["./helloworld"], 0x7ffe88f6ce40 /* 46 vars */) = 0
....
....
....
write(1, "Hello, World\n", 13Hello, World
)          = 13
exit_group(0)                           = ?
+++ exited with 0 +++

write 对应系统调用 sys_write,write 函数是 glibc 库包装好的系统调用函数,直面上感受不到系统调用的发生。 Linux 在 x86 上的系统调用通过 int 80h 实现,用系统调用号来区分入口函数。操作系统实现系统调用的基本过程是:

  1. 应用程序调用库函数(API);
  2. API 将系统调用号存入 eax,然后通过中断调用使系统进入内核态;
  3. 内核中的中断处理函数根据系统调用号,调用对应的内核函数(系统调用);
  4. 系统调用完成相应功能,将返回值存入 eax,返回到中断处理函数;
  5. 中断处理函数返回到 API 中;
  6. API 将 eax 返回给应用程序。 下面我们以汇编的形式来看看如何在 linux 下发起一次系统调用。
.section .data # 数据段
msg:
  .ascii "Hello, World\n"
.section .text # 代码段
.global _start # 全局开始函数,必须是 _start

_start:
  movl $13, %edx # 系统调用 write 的第 3 个参数为 13
  movl $msg, %ecx # 第 2 个参数是 Hello World\n
  movl $1, %ebx # 第 1 个参数是 1,标准输出的编号
  movl $4, %eax # sys_write 系统调用的编号是 4
  int $0x80

  movl $0, %ebx # exit 系统调用的第一个参数,即 exit(0)
  movl $1, %eax # sys_exit 编号为 1
  int $0x80 # 调用 exit,退出程序

在命令行中输入并运行:

$ as helloworld.s -o helloworld.o
$ ld helloworld.o -o helloworld
$ ./helloworld 
Hello, World

可以看到通过汇编系统调用同样实现了与 C 程序一样的结果。关于上面代码的说明已经写在了注释中了,linux 系统调用严格遵循了刚才介绍的系统调用流程,当然这里并不是鼓励大家去写汇编,而是在汇编层面上去理解系统调用。

Go 中重要的系统调用

在 Go 程序运行时,每秒都在发生系统调用,系统调用可以说是无处不在,下面将介绍 Go 程序启动过程中比较重要的两个系统调用,它们分别是:

  • clone:新建线程;
  • arch_prctl:设置或者获取线程局部变量地址。 arch_prctl 系统调用,用来设置架构特定的线程状态。定义如下:
arch_prctl - set architecture-specific thread state

int arch_prctl(int code, unsigned long addr);
int arch_prctl(int code, unsigned long *addr);

ARCH_SET_FS
     Set the 64-bit base for the FS register to addr.

之所以需要介绍 arch_prctl,是因为 Go 在 runtime 中大量的使用了 arch_prctl 系统调用。 arch_prctl 一般用于设置线程局部变量(TLS)地址到 FS 寄存器open in new window ,FS 寄存器指向当前 CPU 正在运行的线程,其中偏移 02C 指向线程的局部存储变量地址,因此调有 arch_prctl 时会将线程局部变量地址设置到 02C 处。

在 Go 中,会将工作线程(m)上正在运行 g 的地址存储到工作线程的线程局部变量中,这样就能轻松获取 m 上正在运行的 g,如下:

_g_ := getg()

Go runtime 中频繁的使用了这行代码,它由编译器实现,核心原理就是通过 archprctl 获取线程局部变量地址,然后拿到 _g 的地址。 clone 系统调用,用于新建子进程或者线程。在 runtime 中,Go 通过 clone 系统调用来新建线程,然后再将其与 m 绑定,Go 中 clone 调用的样例如下:

cloneFlags = _CLONE_VM | /* share memory */
_CLONE_FS | /* share cwd, etc */
_CLONE_FILES | /* share fd table */
_CLONE_SIGHAND | /* share sig handler table */
_CLONE_SYSVSEM | /* share SysV semaphore undo lists (see issue #20763) */
_CLONE_THREAD /* revisit - okay for now */

// 通过 clone 系统调用新建线程,注意 cloneFlags 中共享内存与字段,故是线程
// 传入 mp 即 m,g0 和 mstart 作为线程的启动函数,因此线程创建后会运行 mstart 函数,然后开启调度循环
// 注意:m 可以有 mstartfn,所以 mstart 可能不是立马执行
ret := clone(cloneFlags, stk, unsafe.Pointer(mp), unsafe.Pointer(mp.g0), unsafe.Pointer(funcPC(mstart)))

正是通过 clone 这个系统调用,Go 程序才能创建多个线程,并同时运行多个 g,充分的发挥多核的能力。

GMP 模型

GMP 模型是 Go runtime 的调度运行模型,是 Go 高效运行的根本。GMP 实际上是 G、M、P 三个部分的组合,三个部分说明如下:

  • G:goroutine,Go 中最基本的运行单元,也就是我们通常意义上理解的协程,goroutine 是一个抽象的概念;在 runtime 中用数据结构 g 表示,g 存储了协程栈、状态和任务函数等信息。
  • M:machine,runtime 对系统线程的一个包装,可以将 M 简单的理解为工作线程,是操作系统的基本执行单位;在 runtime 中用数据结构 m 表示。
  • P:processor,处理器,P 是 G 与 M 之间的桥梁;在 runtime 中用数据结构 p 表示, 在 p 中维护了 M 执行的上下文,G 的运行队列,P 通过绑定 G 与 M 来执行 G,G 被绑定到 M 后才能运行,否则只能在队列中等待。

    注意:很多地方将 machine 介绍为内核线程,但在源码中 machine 绑定的线程实则是通过 clone 系统调用来创建的,该线程的调用栈分明是在用户地址空间分配的,不仅仅只在内核态运行,所以这种说法是不严谨的! Go 的 GMP 模型示意图如下:

图片

在 Linux 平台下,一个 Go 程序(即一个进程,也可以理解为主线程)通过 execve 系统调用运行后,可以通过 clone 系统调用来新建多个线程,每个线程都与 runtime 中的一个 m 进行绑定,m 可以看作是系统线程的包装。

Go 程序在运行的时候会创建多个 g,这些 g 分布在 runnext 和各种队列中,p 负责协调多个 m 和多个 g,也就是常说的 M:N 模型,g 在绑定 m 后(即获得了执行资源后)才能开始执行。

这里的  M:N  是指 M 个 goroutine 运行在 N 个操作系统线程之上,内核负责对这 N 个操作系统线程进行调度,而这 N 个系统线程又负责对这 M 个 goroutine 进行调度和运行。

另外提一点,m 从其它 p (非自身当前绑定的 p)的本地运行队列中盗取 goroutine 时的状态称为自旋状态

说明:

  • 在 Linux 平台下,进程和线程都是抽象出来的概念,进行和线程都由同一个 task_struct 数据结构表示,不同的是进程独有自己的资源和地址空间,而线程则与其它线程共享进程的资源和地址空间,因此可将进程当作主线程对待。
  • G、M、P 是概念,在 runtime 的实现中分别对应 g、m、p 三个结构体,因此后面统一小写来表示。

流程剖析

在介绍了一系列的前置知识后,我们正式进入 Hello World 的流程剖析。

虽然只是一个简单的 Hello World 的流程剖析,但是会涉及大量知识点:

  • Go runtime 实现;
  • 操作系统;
  • plan9 汇编;
  • 。。。

我们将使用 dlvopen in new window 调试工具来一步步的解析 Hello World 运行流程,里面会涉及到大量 runtime 设计策略和实现细节,如果感到吃力,这很正常,请不要拘泥于细节,跳脱全局,整体把握。

我们剖析的 Hello World 程序代码很简单,如下:

package main

import "fmt"

func main() {
    fmt.Println("Hello World")
}

编译该代码并使用 dlv 来调试:

go build -o helloworld main.go
dlv exec ./helloworld

进入 dlv 交互界面后,使用 l(list) 命令查看程序当前运行点:

(dlv) l
> _rt0_amd64_linux()
     5: #include "textflag.h"
     6:
     7: TEXT _rt0_amd64_linux(SB),NOSPLIT,$-8
=>   8:         JMP     _rt0_amd64(SB) // 入口
     9:
    10: TEXT _rt0_amd64_linux_lib(SB),NOSPLIT,$0
    11:         JMP     _rt0_amd64_lib(SB)

上面 => 箭头所指向的地方是程序的起始点,Go 程序的入口不是 main 函数,而在 runtime 中的 rt0_linux_amd64.s 汇编文件中,如下:

TEXT _rt0_amd64_linux(SB),NOSPLIT,$-8
    JMP _rt0_amd64(SB) // 入口

_rt0_amd64_linux 函数有且只有一行代码,那就是跳转到 _rt0_amd64 函数。执行 si 命令,跟踪到这次代码跳转:

(dlv) si
> _rt0_amd64()
    14: TEXT _rt0_amd64(SB),NOSPLIT,$-8
=>  15:         MOVQ    0(SP), DI       // argc
    16:         LEAQ    8(SP), SI       // argv
    17:         JMP     runtime·rt0_go(SB)
    18:

_rt0_amd64 函数有如下两个工作:

  1. 将程序运行时候的参数地址 argc,argv 拷贝到寄存器 DI,SI 上;
  2. 跳转到核心启动函数 rt0_go 上。 继续使用多次 si 指令(后面将不会再说明 dlv 这些调试指令,而是直接看代码),进入 rt0_go 函数,rt0_go 函数将会完成 Go 程序启动的绝大部分工作,是 Go 程序启动中最重要的函数,我们需要详细来分析它,首先是该函数的前面部分:
TEXT runtime·rt0_go<ABIInternal>(SB),NOSPLIT,$0
    MOVQ    DI, AX      // argc,将 argc 参数从 DI 拷贝到 AX
    MOVQ    SI, BX      // argv,将 argv 参数从 SI 拷贝到 BX
    SUBQ    $(4*8+7), SP        // 2args 2auto  SP 移动,留出足够的栈空间
    ANDQ    $~15, SP    // SP 栈顶寄存器,16 字节对齐,SSE 指令要求
    MOVQ    AX, 16(SP) // 将 argc 拷贝到 SP + 16 字节处
    MOVQ    BX, 24(SP) // 将 argv 拷贝到 SP + 24 字节处
    // ...
    // ...

_rt0_amd64 函数将 argc 和 argv 两个参数从栈中拷贝到 DI,SI 寄存器后,rt0_go 函数继续来处理这两个参数,将 DI、SI 中的参数拷贝到 AX,BX 中,调整 SP 的位置,并 16 字节对齐,然后再将 argc,argv 从寄存器拷贝回栈中。 在 Go runtime 中,g0 是一个特殊的 g,主要负责 goroutine 调度,每一个 m 都有一个 g0。m0 是 Go 程序的第一个工作线程,也就是进程(主线程)本身,g0 是 m0 的第一个 g,二者都是 runtime 包中的全局变量。

rt0_go 函数处理好参数后,开始设置 g0 栈:

TEXT runtime·rt0_go<ABIInternal>(SB),NOSPLIT,$0
    // ....
    MOVQ    $runtime·g0(SB), DI  // g0 是全局变量,第一个 g;将 g0 的地址存储到 DI 中
    LEAQ    (-64*1024+104)(SP), BX  // BX = SP - 64 * 1024 + 104
    MOVQ    BX, g_stackguard0(DI)  // g0.g_stackguard0 = SP - 64 * 1024 + 104
    MOVQ    BX, g_stackguard1(DI)  // g0.g_stackguard1 = SP - 64 * 1024 + 104
    MOVQ    BX, (g_stack+stack_lo)(DI) // g0.stack_lo = SP - 64 * 1024 + 104
    MOVQ    SP, (g_stack+stack_hi)(DI) // g0.stack_hi = SP
    // ....

在调用 rt0_go 时,当前程序正在主线程上执行,因此 SP 寄存器指向的是主线程的调用栈,上面这段代码则是在主线程栈中分配一段内存当作 g0 栈,因此 g0 的调用栈实际就是线程调用栈,在 Go 中统称为系统栈。 接着是一大堆代码,我们挑重要的代码段说明:

TEXT runtime·rt0_go<ABIInternal>(SB),NOSPLIT,$0
    // ....
    LEAQ    runtime·m0+m_tls(SB), DI // DI = &m0.tls,取 m0 的 tls 字段地址存储到 DI 寄存器
    CALL    runtime·settls(SB)  // 调用 settls 设置线程局部存储,settls 的参数在 DI 寄存器中,简单的说,将 m0.tls 存储到线程局部变量
    // ...
    // set the per-goroutine and per-mach "registers"
    get_tls(BX) // 获取 fs 段基址到 BX 寄存器
    LEAQ    runtime·g0(SB), CX // CX = g0
    MOVQ    CX, g(BX) // 将 g0 地址存储到 tls 中,即 m0.tls[0] = &g0
    LEAQ    runtime·m0(SB), AX // AX = m0

    // save m->g0 = g0
    MOVQ    CX, m_g0(AX) // 将 g0 和 m0 相互绑定
    // save m0 to g0->m
    MOVQ    AX, g_m(CX)
    // ....

tls 全称 Thread Local Storage 线程局部变量,是 Go 调度中十分重要的一个机制,这种变量仅当前线程可见。m 中有一个 tls 字段,如下:

type m struct {
   tls [6]uintptr   // thread-local storage (for x86 extern register)
}

代码说明如下: 代码第 3 行将 m0 的 tls 字段地址拷贝到 DI 寄存器中,然后调用 settls 函数将 tls 字段与线程局部变量绑定。

代码第 7 行,调用 get_tls 获取线程局部变量地址并拷贝到 BX 寄存器中,然后将 g0 的地址拷贝到 CX 中。

代码第 9 行,通过 MOVQ 指令将 g0 的地址拷贝到 m0 的 tls 字段中,这样在 m0 中就可以通过线程局部变量来获取 g0 地址。

代码 13 ~ 15 行,将 m0 与 g0 相互绑定。

继续看 rt0_go 函数的代码:

TEXT runtime·rt0_go<ABIInternal>(SB),NOSPLIT,$0
    // ....
    MOVL    16(SP), AX      // copy argc  AX = argc
    MOVL    AX, 0(SP)  // 将 AX 值拷贝到 SP 处,即栈顶
    MOVQ    24(SP), AX      // copy argv  AX = argv
    MOVQ    AX, 8(SP)  // 将 AX 值拷贝到 SP + 8 处
    CALL    runtime·args(SB) // 初始化程序参数,处理参数和环境变量
    CALL    runtime·osinit(SB)  // 初始化 os,获取硬件 CPU 核数
    CALL    runtime·schedinit(SB) // 重点:初始化调度器

    // create a new goroutine to start program
    MOVQ    $runtime·mainPC(SB), AX     // entry 设置程序入口 AX = runtime.main
    PUSHQ   AX          // 入栈,传递参数
    PUSHQ   $0          // arg size,参数内存大小为 0,第一个参数最后入栈
    CALL    runtime·newproc(SB) // 新建 G,来启动 runtime.main,AX 寄存器上存储的是 runtime.main 函数的地址
    POPQ    AX   // 两次出栈
    POPQ    AX

    // start this M
    CALL    runtime·mstart(SB) // 启动 m0,开始等待空闲 G,进入调度循环,m0 是主线程(进程本身)

这段代码中,rt0_go 主要做了以下三件事:

  1. runtime·schedinit:初始化调度器,如初始化栈,内存分配器,p 等;
  2. runtime·newproc 函数新建一个 g 来启动 runtime.main 方法;
  3. runtime·mstart 函数启动 m0 进入调度循环。 首先看 runtime·schedinit 函数:
func schedinit() {
    // ...
     _g_ := getg() // 由编译器实现,见:cmd/compile/internal/amd64/ssa.go#712
    sched.maxmcount = 10000 // M 的最大数量

    moduledataverify()
    stackinit()            // 初始化栈池
    mallocinit()           // 初始化 malloc,内存分配器
    fastrandinit()         // must run before mcommoninit
    mcommoninit(_g_.m, -1) // machine,初始化当前 m,即 m0,将主线程与 m0 绑定
    cpuinit()              // 初始化 cpu,must run before alginit
    alginit()              // maps must not be used before this call
    modulesinit()          // 模块初始化,provides activeModules
    typelinksinit()        // 类型链接初始化,uses maps, activeModules
    itabsinit()            // itabs 初始化,uses activeModules

    sigsave(&_g_.m.sigmask) // 存储 m0 的信号掩码
    initSigmask = _g_.m.sigmask

    goargs()         // 参数
    goenvs()         // 环境变量
    parsedebugvars() // debug 参数
    gcinit()         // gc 初始化

    lock(&sched.lock)
    sched.lastpoll = uint64(nanotime())
    procs := ncpu // p 的数量默认 cpu 核数
    // 获取 GOMAXPROCS 来设置 p 的个数
    if n, ok := atoi32(gogetenv("GOMAXPROCS")); ok && n > 0 {
        procs = n
    }
    // 调整 p 的个数
    if procresize(procs) != nil { // 创建和初始化 allp
        throw("unknown runnable goroutine during bootstrap")
    }
    unlock(&sched.lock)

    // World is effectively started now, as P's can run. 世界开始
    worldStarted()
    // ...
}

schedinit 函数中给出了详细的注释,非主干的代码都已删除,说明如下: 代码第 3 行,通过 getg 函数获取当前 g,此时 g 其实就是 g0,getg 函数是由编译器来实现的,原理则是利用 tls 找到当前运行 m 中的 tls 字段,从而获取 g0 的地址。

代码第 4 行,设置 p 的最大个数为 10000。

代码第 10 行,初始化 m,此时的 m 其实就是 m0,因此 mcommoninit 函数的作用就是将 m0 与主线程绑定,此后可将 m 看作是工作线程。

代码第 33 行,调用 procresize 函数,该函数功能很复杂,此时会创建和初始化所有的 p,即 allp,也可以调整 p 的个数。

代码 39 行,m 开始工作,世界开始!

然后看 runtime·newproc 函数,该函数会新建一个 g:

func newproc(siz int32, fn *funcval) {
    argp := add(unsafe.Pointer(&fn), sys.PtrSize) // goroutine 函数的入口地址
    gp := getg()                                  // 获取当前 g
    pc := getcallerpc()                           // 获取 pc,即 eip 寄存器的值
    // systemstack 属于编译器行为,调用当前函数不在当前 g 栈中进行,而是转移到
    // 当前运行的线程栈(g0 栈)中运行,这个线程栈在用户空间分配
    systemstack(func() { // 切换到系统栈
        newg := newproc1(fn, argp, siz, gp, pc) // 在系统栈中新建 g
        _p_ := getg().m.p.ptr()
        runqput(_p_, newg, true) // 将 newg 加入到运行队列中
        if mainStarted {
            wakep() // 唤醒 p
        }
    })
}

newproc 函数接受两个参数,分别是 siz 和 fn,并新建 newg:

  • siz:siz 是当前 g 传递给 newg 的参数大小;
  • fn:newg 的入口函数。 代码第 2~4 行,获取入口函数地址、当前 g 和 pc 值。

代码第 7 行,切换到系统栈,即 g0 栈。

代码第 8 行,新建 g,即 newg。

代码第 9 ~ 10 行,获取 newg 绑定的 p,并将 newg 加入到 p 的本地运行队列。

代码第 12 行,唤醒 p,继续运行。

newproc 函数中真正创建 g 的函数实际是 newproc1,该函数如下:

func newproc1(fn *funcval, argp unsafe.Pointer, narg int32, callergp *g, callerpc uintptr) *g {
    _g_ := getg() // g0
    acquirem() // disable preemption because it can be holding p in a local var
    siz := narg
    siz = (siz + 7) &^ 7
    _p_ := _g_.m.p.ptr() // 拿到 g0 对应的 p
    newg := gfget(_p_)   // 从 p 的缓冲里面拿到一个空闲的 g,如果为 nil,则初始化
    if newg == nil {
        newg = malg(_StackMin)           // 新建一个 g,从进程堆内存上给其分配栈
        casgstatus(newg, _Gidle, _Gdead) // 设置状态
        allgadd(newg)                    // 将 g 加入 allg, publishes with a g->status of Gdead so GC scanner doesn't look at uninitialized stack.
    }
    // 调整 g 的栈顶指针
    totalSize := 4*sys.RegSize + uintptr(siz) + sys.MinFrameSize // extra space in case of reads slightly beyond frame
    totalSize += -totalSize & (sys.SpAlign - 1)                  // align to spAlign
    sp := newg.stack.hi - totalSize
    spArg := sp

    if narg > 0 {
        memmove(unsafe.Pointer(spArg), argp, uintptr(narg)) // 将参数从 caller g 栈拷贝到新 g 的栈中
    }
    // 将新 g 的 sched 置 0
    newg.sched.sp = sp
    newg.stktopsp = sp
    // 设置 newg 的最后执行函数是 goexit
    newg.sched.pc = funcPC(goexit) + sys.PCQuantum // +PCQuantum so that previous instruction is in same function
    newg.sched.g = guintptr(unsafe.Pointer(newg))
    gostartcallfn(&newg.sched, fn) // 设置 g 的入口函数,设置 sched
    newg.gopc = callerpc
    newg.ancestors = saveAncestors(callergp)
    newg.startpc = fn.fn
    casgstatus(newg, _Gdead, _Grunnable) // 设置为可运行的状态
    newg.goid = int64(_p_.goidcache) // 设置 gid
    _p_.goidcache++
    releasem(_g_.m) // 解锁 m
    return newg
}

代码第 1 行,获取 g,其实就是 g0。 代码第 2 行,锁住 m 禁止抢占。

代码第 7 ~ 12 行,从 p 的空闲池中取 g,如果没有则新建 g。

代码第 14 ~ 17 行,设置 newg 的栈。

代码第 20 行,拷贝 g 参数,即上面谈到的 siz 参数。

代码第 23 ~ 33 行,设置 newg 数据,如 id,状态等。

代码第 35 行,释放 m。

最后我们看看 runtime·mstart 函数:

func mstart() {
    _g_ := getg() // 当前 g
    osStack := _g_.stack.lo == 0 // 判断是否为系统栈
    // 省略了一堆判断代码
    mstart1() // 开始 m
    mexit(osStack)
}

可以发现 mstart 的核心在于 mstart1 函数,它才是 m 开始工作的真正入口点:

func mstart1() {
    _g_ := getg() // 获取 g0
    save(getcallerpc(), getcallersp()) // 保存 g0 的 pc,sp
    asminit()                          // asm 初始化,其实是个空函数
    minit()                            // 信号相关的初始化
    if _g_.m == &m0 {
        mstartm0() // 如果是 m0,则处理一些信号操作
    }
    if fn := _g_.m.mstartfn; fn != nil {
        fn() // 调用 m 的 startfn
    }
    if _g_.m != &m0 { // 如果不是 m0,则获取下一个 p
        acquirep(_g_.m.nextp.ptr()) // 获取下一个 p,然后调度
        _g_.m.nextp = 0
    }
    schedule() // 开启调度,所以线程用户都是在调度循环
}

代码第 1 行,获取 g,即 g0。 代码第 2 行,存储 g0 的 pc 和 sp 值。

代码第 6 行,判断是否为 m0,如果是额外进行一些信号处理工作。

代码第 9 行,判断是否有 mstartfn 函数(可以不予理会,不影响对流程的学习),如果有则调用。

代码第 16 行,m 开启调度循环。

mstart1 的核心点在 schedule 函数,该函数永远不会返回,因此工作线程 m 实则一直在调度循环:

func schedule() {
    _g_ := getg() // 获取 g0
top: // 注意,循环调度的关键
    pp := _g_.m.p.ptr() // 获取 p
    pp.preempt = false  // 标记为不可抢占
    checkTimers(pp, 0) // 每次调度的时候,检查定时器
    var gp *g
    var inheritTime bool

    if gp == nil {
        // 每调度 61 次,就去全局队列中获取 g,避免饥饿
        if _g_.m.p.ptr().schedtick%61 == 0 && sched.runqsize > 0 {
            lock(&sched.lock)
            gp = globrunqget(_g_.m.p.ptr(), 1)
            unlock(&sched.lock)
        }
    }
    if gp == nil {
        gp, inheritTime = runqget(_g_.m.p.ptr()) // 本地队列中获取 g
    }
    if gp == nil { // 从其它本地队列中偷 g
        gp, inheritTime = findrunnable() // blocks until work is available
    }
    if tryWakeP {
        wakep()
    }
    // 执行 g
    execute(gp, inheritTime)
}

schedule 函数实际上比较复杂,这里只贴出了主干部分,实际上为了调度循环,schedule 函数会不断的判断当前是否有 g 可运行,如果没有则 goto top 进行下一轮调度。 代码第 6 行,检查定时器,每次调度都会检查定时器,判断是否存在定时任务触发。

代码第 10 ~ 17 行,每调度 61 次就会从全局队列中获取 g,避免全局队列中的 g 饿死。

代码第 19 行,从 runnext 和本地队列中获取 g,如果有则执行。

代码第 22 行,从其它 p 的本地队列中偷 g,其实在 findrunnable 中不会直接偷 g,而是再次判断本地队列和全局队列,实在是没有才去偷。

代码第 28 行,执行 g。

终于到执行 g 了,execute 会执行调度时找到的 g,代码如下:

func execute(gp *g, inheritTime bool) {
    _g_ := getg() // 获取 g0,调度只在 g0 上执行
    _g_.m.curg = gp                       // 设置 gp 为 curg
    gp.m = _g_.m                          // 设置 m
    casgstatus(gp, _Grunnable, _Grunning) // 设置 gp 状态
    gp.waitsince = 0
    gp.preempt = false // 不可抢占
    gp.stackguard0 = gp.stack.lo + _StackGuard
    // 开始 g,见:TEXT runtime·gogo(SB)
    // g0 将 cpu 使用权交给 gp,实则就是切换寄存器上的值
    // 重点:如果在程序开始阶段,g0 将 cpu 使用权交给了 runtime.main go
    gogo(&gp.sched)
}

代码第 3~8 行,设置 gp 的数据字段,如状态、不可抢占等。 代码第 12 行,切换上下文(寄存器值), 当前运行的 g 将 CPU 使用权转交给 gp。

上下文切换的 gogo 由汇编实现,代码如下:

TEXT runtime·gogo(SB), NOSPLIT, $16-8
    MOVQ    buf+0(FP), BX       // gobuf  BX = gobuf
    MOVQ    gobuf_g(BX), DX   // DX = gbuf.sched.g
    MOVQ    0(DX), CX       // make sure g != nil
    get_tls(CX)  // CX = tls 获取当前 g
    MOVQ    DX, g(CX) // tls = DX
    MOVQ    gobuf_sp(BX), SP    // restore SP
    MOVQ    gobuf_ret(BX), AX
    MOVQ    gobuf_ctxt(BX), DX
    MOVQ    gobuf_bp(BX), BP
    MOVQ    $0, gobuf_sp(BX)    // clear to help garbage collector
    MOVQ    $0, gobuf_ret(BX)
    MOVQ    $0, gobuf_ctxt(BX)
    MOVQ    $0, gobuf_bp(BX)
    MOVQ    gobuf_pc(BX), BX  // BX = pc
    JMP BX // 跳转到 pc

gogo 函数通过 get_tls 获取到当前运行 g 然后通过 MOVQ 指令来交换上下文数据。 切换上下文后,程序将进入到 gp 的执行流程,在程序启动时,这个 gp 实际就是 runtime.main 函数,在 rt0_go 中有如下代码:

MOVQ    $runtime·mainPC(SB), AX // entry 设置程序入口 AX = runtime.main
PUSHQ   AX          // 入栈,传递参

runtime.main 函数地址被当作参数,传递给了 newproc 函数,因此 gp 的开始函数就是 runtime.main。该函数代码如下:

func main() {
    g := getg()
    if sys.PtrSize == 8 {
        maxstacksize = 1000000000
    } else {
        maxstacksize = 250000000
    }
    mainStarted = true
    if GOARCH != "wasm" { // no threads on wasm yet, so no sysmon
        atomic.Store(&sched.sysmonStarting, 1)
        systemstack(func() { // 在系统栈中新建一个 m,用于 sysmon,系统监控
            newm(sysmon, nil, -1)
        })
    }
    lockOSThread() // 锁住 m
    if g.m != &m0 { // runtime.main 必须由 m0 开始
        throw("runtime.main not on m0")
    }
    m0.doesPark = true
    doInit(&runtime_inittask) // Must be before defer.
    needUnlock := true
    defer func() {
        if needUnlock {
            unlockOSThread() // 释放锁
        }
    }()

    gcenable() // 开启 gc
    main_init_done = make(chan bool)
    doInit(&main_inittask)
    close(main_init_done)

    needUnlock = false
    unlockOSThread()
    // 开始执行 main.main,main 包下的 main 函数
    // main_main 会链接到程序的 main 函数
    fn := main_main // make an indirect call, as the linker doesn't know the address of the main package when laying down the runtime
    fn()            // 此处执行程序的 main 函数
    exit(0) // 执行完毕后退出
    for {   // 如果退出失败,反复访问非法地址,造成程序 crash
        var x *int32
        *x = 0
    }
}

同样的,runtime.main 函数代码比较长,这里摘取了其中的主干部分: 代码第 9 ~ 14 行,开启监控线程,注意在系统栈调用。

代码第 37~38 行,获取 main 包下的 main 函数地址,然后调用该函数。

终于,程序进入到了 main.main 函数,也就是:

package main

import "fmt"

func main() {
    fmt.Println("Hello World")
}

然后,我们可以看到熟悉的 Hello World 了。Hello World 的运行流程到此结束了! 流程图如下:

图片

这里需要说明几个点:

  1. main 包下的 main 函数并没有新建 g,而是被 runtime.main 的 g 来执行。
  2. 如果 m 没有找到可运行 g 那么就会进入休眠,等待被唤醒。
  3. 每个工作线程一直都在执行 schedule 函数,即调度循环。

热点问题

goexit0 是如何嵌入到非 main goroutine 中的?

在 runtime/proc.go:4066 的函数 newproc1 中,新建的 g 的 sched.pc 字段被设置为 goexit:

// 将新 g 的 sched 置 0
memclrNoHeapPointers(unsafe.Pointer(&newg.sched), unsafe.Sizeof(newg.sched))
newg.sched.sp = sp
newg.stktopsp = sp
// 设置 newg 的最后执行函数是 goexit
newg.sched.pc = funcPC(goexit) + sys.PCQuantum // +PCQuantum so that previous instruction is in same function
newg.sched.g = guintptr(unsafe.Pointer(newg))
gostartcallfn(&newg.sched, fn) // 设置 g 的入口函数,设置 sched

这样在 goroutine 完成使命时,会调用 goexit 函数。

m 是如何与系统线程绑定的?

答案在 newosproc 函数中,该函数有多种实现,如果是 linux,那么在 runtime/os_linux.go 下面,如下(省略了部分代码):

func newosproc(mp *m) {
    stk := unsafe.Pointer(mp.g0.stack.hi)
    var oset sigset
    sigprocmask(_SIG_SETMASK, &sigset_all, &oset)
    // 通过 clone 系统调用新建线程,注意 cloneFlags 中共享内存与字段,故是线程
    // 传入 mp 即 m,g0 和 mstart 作为线程的启动函数,因此线程创建后会运行 mstart 函数,然后开启调度循环
    // 注意:m 可以有 mstartfn,所以 mstart 可能不是立马执行
    ret := clone(cloneFlags, stk, unsafe.Pointer(mp), unsafe.Pointer(mp.g0), unsafe.Pointer(funcPC(mstart)))
    sigprocmask(_SIG_SETMASK, &oset, nil)
}

在 linux 下,m 被初始化后,通过系统调用 clone 传入到新建线程中,此后系统线程就与 m 进行了绑定。

参考链接