阅读契约:不需要先背 G、M、P。先把 goroutine 想成“待办任务”,线程想成“真正干活的人”。读完前半篇,你应能解释为什么启动 1000 个 goroutine 不等于创建 1000 个线程,以及“正在等”和“已经能跑但还没轮到”为什么是两种问题。

前半篇先讲可观察的调度过程;后半篇再把它对应到 Go 1.26.0 tagruntime/proc.goruntime/runtime2.go。 G/M/P 的概念边界参考官方 runtime HACKING;生产观测参考 runtime/metricsDiagnostics。 本文讨论 gc runtime 的当前实现;别的 Go implementation 可以选择不同映射。

一、八个 goroutine 不等于八条线程

fetchd 最多为八个 URL 启动八个 worker goroutine。可以把它们想成八张待办卡:有的正在计算,有的正在等网络,有的已经拿到数据、正等 CPU。 Go 的调度器负责把“现在可以执行”的卡交给有限数量的线程。于是,八张卡不等于八个工人,更不保证八件事同时发生。

“抓取 go.dev”这张任务卡的一生:
新建 → 可以运行 → 正在运行 → 等网络
    → 网络就绪后再次可以运行 → 再次运行 → 完成

网络数据到达时,任务卡不会瞬间跳回函数里;它只是重新获得“可以运行”的资格,还要等某个执行者选中。 后文的 runnable、running 与 waiting,就是这条时间线上的状态名。

源码用 G、M、P 描述这套分工。先用一句话记:G 是任务,M 是操作系统线程,P 是运行 Go 代码所需的执行资格与本地资源。 调度发生时,runtime 要把一个可运行的 G、一个 M 和一个 P 配到一起。下面的表只是把这个画面说得更精确:

符号runtime 对象持有什么数量关系
Gruntime.ggoroutine 栈、调度保存点、状态、创建关系可远大于线程和 P
Mruntime.m一条 OS thread、g0、当前 G、关联 P系统调用/cgo 可让 M 多于 P
Pruntime.p执行 Go 代码所需资源、local runq、allocator cache、timer恰有 GOMAXPROCS

最重要的关系不是缩写本身,而是它们不会永久绑定。M 可以换 G,G 也可以在不同时间跑到不同 M 上。 M 阻塞在 syscall 时可交还 P,让另一个 M 继续执行 Go;这正是 goroutine 不等于线程的工程价值。

GOMAXPROCS 限制的是并行执行 Go 代码

Go 1.26 中 P 的数量对应当前 GOMAXPROCS。若有 1000 个 runnable G 而 GOMAXPROCS=8,通常最多八条线程同时执行用户 Go 代码, 其余 G 等在调度器。线程总数仍可能因 syscall、cgo、runtime 工作与锁线程而更高。Go 1.25 起默认值还会感知容器 CPU limit;显式设置环境变量或调用 runtime.GOMAXPROCS 会改变自动更新行为,所以诊断时应读取实际 metric,而不是只看宿主机核心数。

二、状态比“goroutine 数量”更接近问题

runtime2.go 把 G 的关键状态区分为 _Grunnable_Grunning_Gwaiting_Gsyscall 等。数量相同,生产含义可能完全不同。

状态含义常见来源延迟解释
_Grunnable已在 run queue,能执行但尚未执行新建、channel 唤醒、I/O 就绪、timer调度/CPU 供给与竞争
_Grunning已与 M/P 匹配,正在执行 GoexecuteCPU 或当前代码路径
_Gwaiting停在 runtime 等待点channel、mutex、timer、netpoll等待资源,不是等待 CPU
_Gsyscall进入系统调用阻塞 syscall/cgo 边界内核或外部代码

因此“goroutine 很多”本身不是故障。十万条长期 waiting 的连接可能是预期模型;几百条持续 runnable 却可能意味着 CPU quota、过度 fan-out、锁唤醒风暴或 GC 工作挤占。 先看状态分布和等待原因,再看总数。

三、go h.fetch 怎样变成一个 runnable G

编译器把 go 语句降为对 runtime.newproc 的调用。源码注释直接写着:创建一个运行 fn 的新 G,并把它放到等待运行的队列。

在读字段前,先把源码压成五个动作:准备一份 goroutine 记录;记住将来从哪个函数开始;把状态改成“可以运行”; 放入队列;必要时叫醒一个执行者。下面的 gfget、栈指针、goid 与 runqput, 分别实现这些动作,不是四套额外概念。

// 编译器把 go 语句变成对此函数的调用。
func newproc(fn *funcval) {
    gp := getg()
    pc := sys.GetCallerPC()
    systemstack(func() {
        newg := newproc1(fn, gp, pc, false, waitReasonZero)
        pp := getg().m.p.ptr()
        runqput(pp, newg, true)
        if mainStarted {
            wakep()
        }
    })
}
对应源码:runtime.newproc
go 语句到 runnable G 的源码路径:go h.fetch 经 newproc 和 newproc1 获取 G、准备 stack/goid、切换到 _Grunnable,再由 runqput 放入 runnext、local runq 或溢出到 global runq

3.1 newproc1 优先复用 G,再准备初始执行现场

newproc1 在 system stack 上执行。它先从当前 P 的 free G 池取一个 gfget;没有才用 malg(stackMin) 创建栈并把 G 加入全局集合。 随后清空 g.sched,设置初始 SP、返回到 goexit 的 PC、调用入口、parent goid 与创建 PC,最后分配 goid 并把状态从 _Gdead 切到 _Grunnable

newg := gfget(pp)
if newg == nil {
    newg = malg(stackMin)
    allgadd(newg)
}

newg.sched.sp = sp
newg.sched.pc = abi.FuncPCABI0(goexit) + sys.PCQuantum
gostartcallfn(&newg.sched, fn)
newg.parentGoid = callergp.goid
newg.gopc = callerpc
newg.goid = pp.goidcache
casgstatus(newg, _Gdead, _Grunnable)
对应源码:runtime.newproc1

这说明两个边界。第一,创建 goroutine 需要 G 元数据和初始栈,但通常会复用对象,不等于每次向 OS 创建线程。第二,函数体此时还没有执行; _Grunnable 只表示它有资格被选中。

3.2 local runq、runnext 与 global runq

当前 P 持有一个 [256]guintptr 的 local runq 和一个单独的 runnext 槽。数组长度是 Go 1.26.0 实现细节, 但“局部队列优先保持 locality、全局队列负责溢出与公平”是理解路径的关键。

runnext 不是无限优先级

newprocnext=true 调用 runqput,新 G 会先尝试占据 runnext。取出时它继承当前时间片剩余部分, 让“创建/唤醒后马上协作”的模式少一次长队等待。如果槽已有 G,旧值会被挤进普通 local runq;没有 sysmon 的平台会避免这个优化,以免 ping-pong 任务饿死别人。

local queue 满了怎样溢出

runqput 在环形数组有空间时写 tail。满时 runqputslow 从本地拿走一半,再连同新 G 批量放进 global queue。 它不是“新来的一个直接进全局”,而是重新分布一批工作,给其他 P 获取或窃取的机会。

if t-h < uint32(len(pp.runq)) {
    pp.runq[t%uint32(len(pp.runq))].set(gp)
    atomic.StoreRel(&pp.runqtail, t+1)
    return
}
runqputslow(pp, gp, h, t) // 一半 local G + 新 G → global runq
对应源码:runqput 与 runqputslow;P 的队列字段见 runtime.p

3.3 schedulefindRunnable 怎样找到下一份工作

一轮调度的外壳很短:schedule 调用 findRunnable,得到 G 后处理 locked-M 等特殊情况,最后进入 execute。 真正复杂的是“从哪里找工作”。

func schedule() {
    gp, inheritTime, tryWakeP := findRunnable()
    // spinning、GC worker、locked M 等处理省略
    execute(gp, inheritTime)
}
对应源码:runtime.schedule
Go findRunnable 主搜索环:先处理 timers、trace 和 GC,以及每 61 tick 的全局公平检查,再查 local runq、普通 global runq、netpoll 与 stealWork;无工作时 idle/park,并在停放前重查来源

这不是一条简单 FIFO

findRunnable 顶部先检查 timer,再处理 trace reader 与 GC worker。为了防止两个不断互相唤醒的 local G 长期占满 P, 每当 schedtick % 61 == 0 且 global queue 非空,会先从全局拿一次。常规业务工作随后依次尝试 local queue 与 global queue。

队列没有工作时,调度器做一次非阻塞 netpoll;再让 spinning M 通过 stealWork 从其他 P 获取工作和 timer。 如果仍为空,它准备释放 P 并 park M,但在状态转换前后会重新检查全局/各 P 队列、timer 与 netpoll,避免“工作刚到、所有 worker 却睡了”的丢唤醒。

work stealing 为什么取一批而不是只拿一个

runqsteal 通过 runqgrab 尝试从另一个 P 的 local queue 取一半,把其中一个直接返回运行,其余落到自己的 local queue。 批量平衡减少频繁跨 P 竞争;随机选择受害 P 和 spinning M 限额则避免所有空闲线程同时扫描造成 CPU 浪费。

3.4 execute 才把 runnable 变成 running

选到 G 后,execute 把当前 M 的 curg 指向它、把 G 的 m 指回当前 M, 用 CAS 将状态从 _Grunnable 改为 _Grunning,清理等待与抢占标记,最后 gogo(&gp.sched) 恢复保存现场。

mp.curg = gp
gp.m = mp
casgstatus(gp, _Grunnable, _Grunning)
gp.waitsince = 0
gp.preempt = false
// trace、profiling 与时间片处理省略
gogo(&gp.sched)
对应源码:runtime.execute

因为 G 与 M 在这里才绑定,普通业务不应依赖 goroutine identity 对应固定线程。只有确有 thread-local/GUI/cgo 要求时才考虑 runtime.LockOSThread,并承担它对调度灵活性的影响。

四、等待、唤醒与抢占

4.1 阻塞不是停住线程:goparkready

goroutine 在 channel、mutex、timer 或 netpoll 上不能继续时,runtime 通过 gopark 保存等待原因,并用 mcall(park_m) 切到 M 的 g0 stack 完成状态转换与调度。稍后资源就绪,goready 在 system stack 调用 ready

casgstatus(gp, _Gwaiting, _Grunnable)
runqput(mp.p.ptr(), gp, next)
wakep()
对应源码:gopark 与 goreadyready

第一篇的 socket 路径正好落在这里:netpoll 把 I/O 就绪的 G 从 waiting 转成 runnable 并注入队列;某个 M/P 以后才会执行它。 “I/O 已就绪”不是“handler 已恢复”,中间仍可能存在 runnable latency。

4.2 抢占保证机会,不保证硬实时

长时间不阻塞的 G 不能永远占住 P。Go 1.26 的 sysmon 在 retake 中观察 P 的 schedtick;同一 tick 超过内部 forcePreemptNS = 10ms 会请求 preemptone。它设置 gp.preemptstackguard0 = stackPreempt, 支持的平台还会通过 preemptM 请求异步抢占。

10ms 是当前实现触发检查的阈值,不是 goroutine 的最大运行时,也不是延迟 SLA。信号投递、安全点、runtime 临界区、平台支持、cgo/syscall 与 OS 调度都会影响实际交接。 抢占提供公平机会,不能把 Go 变成硬实时系统。

五、用 runnable latency 验证调度假设

Go runnable latency 证据图:G 从 waiting 经 ready 进入 runnable,再到 running;runnable latency 区间由 /sched/latencies:seconds、go tool trace 与 schedtrace 观察,区别于 I/O waiting

/sched/latencies:seconds 测量什么

runtime 内部 sched.timeToRun 的注释把它定义为 G 在 _Grunnable_Grunning 之间的时间分布; runtime/metrics 以累计直方图 /sched/latencies:seconds 暴露。它不包含 G 在 I/O、channel 或 mutex 上的 waiting 时间。

samples := []metrics.Sample{
    {Name: "/sched/gomaxprocs:threads"},
    {Name: "/sched/goroutines/runnable:goroutines"},
    {Name: "/sched/latencies:seconds"},
}
metrics.Read(samples)
histogram := samples[2].Value.Float64Histogram()
metric 定义:scheduler metrics descriptions;内部直方图:sched.timeToRun

运行 burst、trace 与 handoff benchmark

scheduler_lab_test.go 先启动 64 个 goroutine,让它们全部在 release channel 上 waiting;关闭 channel 会一次唤醒它们,形成清晰的 runnable burst。

cd go-runtime/examples/fetchd
go test -run TestSchedulerBurst -trace scheduler.trace
go tool trace scheduler.trace

go test -run TestSchedulerMetricsAreAvailable
go test -bench BenchmarkGoroutineHandoff -benchmem
GODEBUG=schedtrace=1000,scheddetail=1 go test -run TestSchedulerBurst
证据回答的问题不能单独回答
scheduler metricsrunnable 数与延迟分布是否长期异常哪条具体 G 在哪里等待
execution trace一段窗口内 G/P/M、unblock、syscall、GC 的时间关系长期趋势与低开销常驻监控
schedtrace某时刻 P、thread、runq 的粗快照精细因果链
goroutine profile采样时各 G 的栈与 waiting reason过去发生过的 runnable 排队
CPU profile真正运行时 CPU 花在哪没拿到 CPU 的 runnable 时间

六、回到 fetchd 的工程边界

  1. 并发不是并行。八个 fetch worker 可以并发等待网络;CPU 阶段仍受 P 与容器 quota 限制。
  2. 先限制 fan-out,再调调度器。无界创建 G 会放大内存、队列、连接池和唤醒压力;GOMAXPROCS 不是背压器。
  3. waiting 与 runnable 分开告警。前者通常指向资源依赖,后者指向执行供给或唤醒风暴。
  4. 不要把 runtime.Gosched 当公平性修复。它可用于实验或特殊协作,业务正确性应靠阻塞边界、队列和同步协议。
  5. 从趋势到窗口再到源码。先用 metrics 找异常时间,再抓 trace,还原具体 G 的状态转换,最后回到函数路径。

这一篇的可复用结论是:创建 G ≠ 创建线程,runnable ≠ running,唤醒 ≠ 立刻恢复;G 必须与 M/P 匹配,延迟才能继续向前。 下一篇会放大 fetchd 的 result channel:沿 chansend/chanrecvsudog、semaphore、mutex 与 select,回答什么时候 channel,什么时候锁,以及背压到底发生在哪里。

参考源码与文档