Files
cs-note/hzh/GolangStar/Go语言原理/gmp调度原理/gmp-lifecycle.md
T

9.0 KiB
Raw Blame History

tags, create time
tags create time
go
golang
go-principle
gmp-scheduler
lifecycle
2026-06-07 16:00

GMP 调度原理 — Goroutine 生命周期

概述

本节从第一人称视角追踪 goroutine 的完整生命周期:从 go func() 触发的创建流程,到被调度器翻牌子执行,再到主动让出执行权的三种方式。涵盖 §3(正向追踪)和 §4(逆向追踪)。

正文

3.1 main 函数的特殊性

main 函数是 Go 程序的唯一起点,由全局唯一的 m0(主线程)直接执行,不涉及普通的调度流程。

func main() {
    fn := main_main    // 获取用户定义的 main 函数
    fn()               // 直接执行
}

3.2 普通 G 的创建流程

自己编写的 go func(){...} 最终被编译器转换为对 runtime.newproc 的调用:

func newproc(fn *funcval) {
    gp := getg()
    pc := getcallerpc()

    systemstack(func() {
        newg := newproc1(fn, gp, pc)   // ① 切换到 g0 栈 → 构造 G 实例
        _p_ := getg().m.p.ptr()
        runqput(_p_, newg, true)        // ② 放入就绪队列
        if mainStarted { wakep() }      // ③ 唤醒空闲 P
    })
    // 切回原用户 G 继续执行
}

核心步骤:

  1. 切换栈:通过 systemstack 从当前用户 G 栈切到 M 的 g0 栈——创建 G 是调度层面的工作,必须交由 g0 处理。
  2. 构造 G:newproc1 分配并初始化新的 g 结构体(设置入口地址、程序计数器等)。
  3. 入队:runqput 按优先级放置:runnext → LRQ → GRQ(满时通过 runqputslow 批量迁移)。
  4. 唤醒:若有休眠中的 P,wakep 将其拉起来干活。

3.3 调度循环:从 g0 到 g

每个 M 的生命周期在两种角色间循环:执行 g0(找任务) ↔ 执行用户 G(做任务)。

flowchart LR
    A["M 执行 g0"] -->|"schedule()"| B["findRunnable()"]
    B -->|"找到 G"| C["execute(gp)"]
    C -->|"gogo()"| D["M 执行用户 G"]
    D -->|"G 让渡 / 阻塞 / 结束"| A
    style A fill:#fff9c4
    style D fill:#e8f5e9

关键桩函数:

函数 方向 说明
mcall / systemstack G → g0 用户代码中切换至调度栈
gogo g0 → G 恢复用户 G 的上下文,交还 CPU

3.4 findRunnable:寻找任务的优先级策略

这是调度环最核心的函数,按固定优先级依次查找:

flowchart TD
    A["每 61 次检查 GRQ<br/>防饥饿"] -->|"命中"| Z["返回 G ✓"]
    A -->|"未命中"| B["LRQ: runqget<br/>无锁 CAS"]
    B -->|"有"| Z
    B -->|"空"| C["GRQ: globrunqget<br/>加锁"]
    C -->|"有"| Z
    C -->|"空"| D["netpoll<br/>非阻塞 epoll_wait"]
    D -->|"有"| Z
    D -->|"空"| E["work-stealing<br/>随机偷其他 P 的一半"]
    E -->|"成功"| Z
    E -->|"失败"| F["再次检查 GRQ"]
    F -->|"有"| Z
    F -->|"空"| G["释放 P → pidleput<br/>阻塞 netpoll 最后一次机会<br/>否则 stopm → 休眠"]
    style A fill:#e8f5e9
    style B fill:#c8e6c9
    style C fill:#fff3e0
    style D fill:#fff9c4
    style E fill:#ffebee
    style G fill:#eceff1

源码路径:runtime/proc.go

3.5 本地与全局队列获取细节

本地队列(无锁)

func runqget(_p_ *p) (gp *g, inheritTime bool) {
    // 先尝试 runnext VIP 位
    next := _p_.runnext
    if next != 0 && _p_.runnext.cas(next, 0) {
        return next.ptr(), true
    }
    // CAS 自旋取头部
    for {
        h := atomic.LoadAcq(&_p_.runqhead)
        t := _p_.runqtail
        if t == h { return nil, false }
        gp := _p_.runq[h%256].ptr()
        if atomic.CasRel(&_p_.runqhead, h, h+1) {
            return gp, false
        }
    }
}

runnext 存放刚创建的或高优先级的 G,跳过队列开销直接执行。

全局队列(加锁)

func globrunqget(_p_ *p, max int32) *g {
    assertLockHeld(&sched.lock)
    if sched.runqsize == 0 { return nil }
    gp := sched.runq.pop()
    return gp
}

[!question] ❓ 为什么需要锁? LRQ 是 per-P 私有数据,并发操作可通过 CAS 保证原子性;GRQ 被所有 P 共享,必须用互斥锁。

3.6 网络 IO 事件处理

sequenceDiagram
    participant Net as netpoll
    participant EP as epoll_wait
    participant Q as gList
    loop 遍历就绪事件
        Net->>EP: epollwait(fd, events, 128, timeout)
        EP-->>Net: n 个就绪事件
        Net->>Q: netpollready(event)
    end
    Note over Q: 返回待运行的 G 列表

位于 runtime/netpoll_epoll.go,每次最多批量处理 128 个事件。

3.7 Work-Stealing 工作窃取

当本地和全局队列为空时,P 会从其他繁忙的 P 那里"偷"一半 G:

func stealWork(now int64) (...) {
    const stealTries = 4       // 最多试探 4 轮
    for i := 0; i < stealTries; i++ {
        for enum := stealOrder.start(fastrand()); !enum.done(); enum.next() {
            p2 := allp[enum.position()]
            if pp == p2 { continue }          // 不偷自己
            if idlepMask.read(enum.position()){ continue }  // 目标也是 idle,跳过
            if gp := runqsteal(pp, p2, ...); gp != nil {
                return gp, ...                // 偷到一半,成功!
            }
        }
    }
    return nil, ...
}

设计要点:

  • 随机起始:fastrand() 打乱探查顺序,避免热点竞争
  • 偷一半:runqsteal 窃取目标 P LRQ 中的一半而非全部,减少反复争夺
  • 4 轮试探:兼顾负载均衡与 CPU 利用率

3.8 P/M 回收机制

无事可做时,将闲置资源归还池中以节省 CPU:

// 释放 P 并放回 pidle 队列
releasep()
_p_ = pidleput(_p_, now)

// 停止 M 并放入 midle 队列
stopm()  // → mPark() → pthread_cond_wait()

[!tip] 💡 优雅缩容 sysmon 线程还会定期清理长时间处于 _Psyscall 状态的 P(见抢占篇),防止因系统调用导致 P 被白白占用。

4.1 让渡总览

G 拿到 CPU 后需要适时归还执行权,让给其他 G 使用。存在三种让渡方式:

flowchart TD
    A["正在执行的 G"] --> B{"如何让渡?"}
    B -->|"正常结束"| C["goexit1 → goexit0<br/>状态→Gdead,回收复用"]
    B -->|"主动让出"| D["Gosched → gosched_m<br/>状态→Grunnable,入 GRQ"]
    B -->|"等待外部条件"| E["gopark → park_m<br/>状态→Gwaiting,上层保管"]
    C & D & E --> F["g0 调用 schedule() 寻找下一个 G"]
    style C fill:#e8f5e9
    style D fill:#fff9c4
    style E fill:#e3f2fd

4.2 功成身退:执行结束

func goexit1() { mcall(goexit0) }              // G → g0

func goexit0(gp *g) {                          // g0 执行
    casgstatus(gp, _Grunning, _Gdead)           // 标记死亡
    dropg()                                     // 解除 M 绑定
    gfput(_p_, gp)                              // 回收到 gfree 链表
    schedule()                                  // 下一轮调度
}

被回收的 G 缓存在 P 的 gfree 链表中,下次 newproc 可直接复用,无需重新 malloc。

4.3 主动让出:Gosched

func Gosched() { mcall(gosched_m) }                         // G → g0

func goschedImpl(gp *g) {                                    // g0 执行
    casgstatus(gp, _Grunning, _Grunnable)
    dropg()
    lock(&sched.lock); globrunqput(gp); unlock(&sched.lock)  // 入 GRQ
    schedule()
}

[!note] 📝 与 goexit 的区别 Gosched 将 G 放回 GRQ(等待再次调度),goexit 将 G 放回 gfree(彻底回收)。

4.4 情非得已:阻塞让渡(gopark / goready)

最常见的让渡方式——等待 channel 数据、Mutex、Timer 等外部条件触发。

阻塞流程(gopark):

func gopark(...) { mcall(park_m) }                        // G → g0

func park_m(gp *g) {                                       // g0 执行
    casgstatus(gp, _Grunning, __Gwaiting)
    dropg()
    schedule()                                              // g0 去找别的 G
}

关键点:_Gwaiting 状态的 G 不入任何就绪队列,由上层调用者(channel / mutex 等)自行保管。

唤醒流程(goready → ready):

func goready(gp *g, traceskip int) {
    systemstack(func() { ready(gp, traceskip, true) })
}

func ready(gp *g, traceskip int, next bool) {              // g0 执行
    casgstatus(gp, _Gwaiting, _Grunnable)
    runqput(_g_.m.p.ptr(), gp, next)                       // 入 LRQ 或 GRQ
    wakep()                                                // 唤醒空闲 P
}

这一 park 一 ready 构成了 Go 并发原语的基础——G 级别阻塞,不拖垮 OS 线程。

关联笔记