Read OSS

Go 运行时内部机制:进程启动与 G-M-P 调度器

高级

前置知识

  • 第 1-3 篇:代码仓库概览与编译器流水线
  • 操作系统基础知识(线程、线程本地存储、虚拟内存)
  • 对 Go 所用 Plan 9 风格汇编语法有基本了解

Go 运行时内部机制:进程启动与 G-M-P 调度器

每个 Go 二进制文件都内嵌了一套运行时——这是一段精密的系统级软件,负责 goroutine 调度、内存分配、垃圾回收以及与操作系统的交互。当 OS 加载器启动 Go 程序时,执行流并不是从你的 main() 函数开始的,而是从特定平台的汇编代码开始,由它来完成整个运行时的引导。本文将梳理这一引导流程,并深入剖析 G-M-P 调度器——goroutine 得以运转的核心引擎。

汇编入口点与 rt0_go

在 Linux/amd64 平台上,第一条被执行的指令位于 rt0_linux_amd64.s

src/runtime/rt0_linux_amd64.s#L7-L8

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

整个文件就只有这一条跳转指令——跳转到架构通用的 _rt0_amd64,后者再跳转到 runtime·rt0_go。这种分层派发的设计将 OS 相关的入口逻辑与架构相关的初始化逻辑清晰地分离开来。

真正的初始化工作发生在 rt0_go 中:

src/runtime/asm_amd64.s#L142-L269

sequenceDiagram
    participant OS as OS Loader
    participant rt0 as rt0_linux_amd64
    participant rt0go as rt0_go
    participant sched as schedinit
    participant main as runtime.main

    OS->>rt0: Load binary, jump to entry
    rt0->>rt0go: JMP _rt0_amd64 → rt0_go
    rt0go->>rt0go: Set up g0 stack bounds
    rt0go->>rt0go: CPUID: detect CPU features
    rt0go->>rt0go: Initialize TLS
    rt0go->>rt0go: Link g0 ↔ m0
    rt0go->>sched: CALL schedinit
    rt0go->>rt0go: Create goroutine for runtime.main
    rt0go->>rt0go: CALL runtime.mstart (enters scheduler)

该函数按以下顺序依次完成各项初始化:

  1. g0 栈初始化(第 159-166 行):基于 OS 提供的栈创建初始 goroutine 的栈,将 stack.lo 设置为当前 SP 以下 64KB 处。

  2. CPU 特性检测(第 168-186 行):通过执行 CPUID 指令识别 Intel 或 AMD 处理器及其能力,这些信息将驱动运行时的优化策略,例如更快的内存操作。

  3. TLS 初始化(第 249-258 行):配置线程本地存储,使 getg()——用于获取当前 goroutine——能够正常工作。大多数平台使用 OS 提供的 TLS;在 Linux 上则调用 settls

  4. g0/m0 关联(第 260-269 行):将两个基础运行时对象连接在一起:g0(专用于调度工作的系统 goroutine)和 m0(初始 OS 线程)。

// save m->g0 = g0
MOVQ    CX, m_g0(AX)
// save m0 to g0->m
MOVQ    AX, g_m(CX)

这种双向关联至关重要——每个 M 知道自己的 g0,每个 g0 也知道自己所属的 M。

schedinit 与 runtime.main

rt0_go 完成硬件层面的初始化后,会调用 schedinit

src/runtime/proc.go#L835-L884

该函数按照严格的顺序初始化运行时的各个核心子系统:锁排序、栈分配器、随机数生成器、内存分配器(mallocinit)、CPU 算法选择、GOMAXPROCS 配置等。初始化顺序至关重要——mallocinit 依赖 randinit 已先行完成,而两者都要求栈系统已就绪。

schedinit 执行完毕后,rt0_go 创建第一个真正意义上的 goroutine 来运行 runtime.main

src/runtime/proc.go#L152-L294

runtime.main 依次完成以下工作:

  1. 启动 sysmon 线程——一个负责处理抢占、网络轮询和 GC 步调的后台监控线程
  2. 将主 goroutine 锁定到主 OS 线程(某些 C 库有此要求)
  3. 通过 doInit(runtime_inittasks) 运行所有运行时 init 函数
  4. 调用 gcenable() 启用垃圾回收器
  5. 按依赖顺序运行所有包级 init 函数
  6. 最终调用 main_main()——即你的 main.main
fn := main_main // indirect call; linker resolves the address
fn()

第 139 行的 //go:linkname main_main main.main 指令将运行时中的引用与链接器找到的 main() 函数绑定在一起。

提示:GODEBUG=inittrace=1 设置为环境变量,可以查看每个 init() 函数的耗时信息,非常适合排查程序启动慢的问题。

G-M-P 模型

proc.go 开头的注释文档描述了调度器的三个核心抽象:

src/runtime/proc.go#L24-L34

  • G(goroutine): 工作单元。包含栈、调度状态(gobuf)和 GC 元数据。
  • M(machine): OS 线程。持有用于系统栈操作的 g0,以及指向当前运行 goroutine 的 curg
  • P(processor): 逻辑处理器。拥有本地运行队列、内存缓存(mcache)和定时器堆。P 的数量恰好等于 GOMAXPROCS。
graph TD
    subgraph "P0 (Processor)"
        LRQ0["Local Run Queue<br/>[G3, G4, G5]"]
        MC0["mcache"]
        TH0["Timer Heap"]
    end
    subgraph "P1 (Processor)"
        LRQ1["Local Run Queue<br/>[G6, G7]"]
        MC1["mcache"]
        TH1["Timer Heap"]
    end

    M0["M0 (OS Thread)<br/>running G1"] --> P0
    M1["M1 (OS Thread)<br/>running G2"] --> P1
    M2["M2 (OS Thread)<br/>in syscall, no P"]

    GRQ["Global Run Queue<br/>[G8, G9, G10]"]

    style M2 fill:#f99

P 这一抽象是在 Go 1.1 调度器重新设计时引入的。在此之前,所有调度状态要么归属于某个 M,要么是全局的。问题在于:当 M 进入系统调用时,其调度资源就被锁住了。P 的引入解决了这个问题——调度资源变得可以剥离。当 M 进入系统调用时,它的 P 可以移交给另一个准备好执行 Go 代码的 M。

全局变量 m0g0 是它们各自类型的原始实例:

src/runtime/proc.go#L118-L124

Goroutine 状态机

每个 goroutine 都有一个 atomicstatus 字段来跟踪其当前状态。这些状态定义在 runtime2.go 中:

src/runtime/runtime2.go#L17-L99

stateDiagram-v2
    [*] --> _Gidle: newproc allocates G
    _Gidle --> _Grunnable: initialized
    _Grunnable --> _Grunning: execute()
    _Grunning --> _Grunnable: preempted / yield
    _Grunning --> _Gsyscall: entering syscall
    _Gsyscall --> _Grunnable: syscall returns
    _Grunning --> _Gwaiting: gopark()
    _Gwaiting --> _Grunnable: goready()
    _Grunning --> _Gdead: goexit()
    _Gdead --> _Gidle: reused from free list
    _Grunning --> _Gpreempted: async preemption
    _Gpreempted --> _Gwaiting: suspendG

状态字段同时也作为 goroutine 栈的锁。_Gscan 位(0x1000)可以与任意状态按位或,表示 GC 正在扫描该栈。由于 GC 可能并发地设置扫描位,状态转换必须使用原子 CAS 操作。

g 结构体本身包含大量字段:

src/runtime/runtime2.go#L471-L596

其中关键字段包括:stack(栈的边界)、stackguard0(用于抢占——将其设为 stackPreempt 会触发抢占检查)、sched(保存上下文切换所需寄存器的 gobuf)以及 gcAssistBytes(该 goroutine 对 GC 的辅助配额)。

工作窃取与线程管理

调度的核心循环是 schedule() 函数:

src/runtime/proc.go#L4141

它调用 findRunnable() 来实现工作窃取算法:

src/runtime/proc.go#L3395

findRunnable 的查找顺序经过精心设计:

  1. 检查本地运行队列
  2. 检查全局运行队列(每 61 次调度检查一次,防止饥饿)
  3. 轮询网络轮询器,获取就绪的 goroutine
  4. 尝试从其他 P 的运行队列中窃取工作
  5. 如果仍无任务可运行,则挂起当前 M

"自旋线程"优化机制避免了线程频繁挂起与唤醒带来的开销:

src/runtime/proc.go#L58-L83

其核心思路是:只要还有至少一个线程处于自旋状态(正在寻找工作),当有新任务到来时就不额外唤醒其他线程。只有当最后一个自旋线程找到工作并停止自旋时,才唤醒一个新的自旋线程来接替它。这样既能平滑线程创建的突发压力,又能保证最终充分利用所有 CPU 资源。

flowchart TD
    A["schedule()"] --> B["findRunnable()"]
    B --> C{"Local run queue?"}
    C -->|Yes| H["execute(gp)"]
    C -->|No| D{"Global run queue?<br/>(every 61st check)"}
    D -->|Yes| H
    D -->|No| E{"Network poller?"}
    E -->|Yes| H
    E -->|No| F{"Steal from other P?"}
    F -->|Yes| H
    F -->|No| G["Park M<br/>(stopm)"]
    H --> I["Run goroutine"]
    I --> A

抢占:协作式与异步式

Go 支持两种抢占机制,相关说明见 preempt.go

src/runtime/preempt.go#L1-L40

协作式抢占的工作原理是将 goroutine 的 stackguard0 设为 stackPreempt 这个特殊毒化值。每个函数的序言代码都包含栈边界检查;当毒化值触发检查时,函数会进入栈增长路径,而该路径会识别出这实际上是一个抢占请求并主动让出执行权。

异步抢占(Go 1.14 引入)则用于处理不含函数调用的紧凑循环——这类代码永远不会触达协作式抢占点。运行时向线程发送信号(Unix 上为 SIGURG),信号处理器检查 goroutine 的当前状态,若处于安全点则暂停该 goroutine。

提示: 如果你发现 CPU 密集型 goroutine 似乎阻塞了调度器,可能是因为它们运行了不含函数调用的紧凑循环。虽然异步抢占能处理绝大多数这类情况,但 CGo 调用是一个例外——抢占机制在那里仍然无法介入。

深入内存系统

调度器与内存分配器紧密耦合——每个 P 都拥有自己的 mcache 以支持无锁分配,而 GC 也借助调度器来协调 stop-the-world 暂停和标记辅助。在下一篇文章中,我们将深入探讨 Go 的内存管理机制:受 tcmalloc 启发的多层分配器体系,以及并发的三色垃圾回收器。