P0 ↔ M0
当前空闲本地运行队列
- G1
- G2
- G3
- G4
一个 Go 服务启动了很多 goroutine,为什么 CPU 仍然不高?这些 goroutine 可能还在等网络,也可能已经准备好,却没轮到执行。要区分这两种情况,得先看任务怎样得到执行机会。goroutine 承载任务,真正执行代码的是操作系统线程;Go 运行时用 GMP 这套机制把可运行的任务安排到线程上,让等待中的任务让出资源,让空闲的执行资源接手排队的任务,也让一直计算的任务有机会被切换出去。
沿着这条执行过程,也能进一步解释“CPU 已经满了,请求为什么还在排队”和“容器只给两核,为什么有这么多线程”。本篇以 Go 1.27.2 的标准工具链实现为准:G、M、P、队列形状和抢占策略都有版本边界。语言规范只承诺 go 语句启动并发执行,没有规定一个 goroutine 对应一个线程,也没有保证某个 goroutine 最迟多久会被运行。[1][2]
| 名字 | 装的是什么 | 它回答的问题 | 别混成什么 |
|---|---|---|---|
| G,goroutine | 待执行的代码、栈、保存的执行状态等 | 这次运行哪个任务? | 不是操作系统线程 |
| M,machine | 一个操作系统线程及运行时状态 | 代码在哪个线程上跑? | 不是一颗独占的 CPU 核 |
| P,processor | 执行 Go 代码需要的调度、分配等资源 | 这个线程有没有执行 Go 代码的资源? | 不是物理 CPU,也不是任务 |
运行普通 Go 代码时,M 要先持有一个 P,才能执行 G。GOMAXPROCS 决定 P 的数量,也就限定了同一时刻能用于执行 Go 代码的这类资源。任务可以有很多个,线程也可能正在系统调用中阻塞、执行运行时代码或等待工作,因此 G、M、P 的数量不必相等。即使 M 已经持有 P,最终仍由操作系统决定这个线程何时得到 CPU。[2:1]
正在绘制图示…
flowchart TB
Q["可运行的 G<br/>G1、G2、G3、G4"] --> P0["P0:调度和分配资源"]
Q --> P1["P1:调度和分配资源"]
P0 --- M0["M0:持有 P0 的线程"]
P1 --- M1["M1:持有 P1 的线程"]
M0 --> G1["当前执行 G1"]
M1 --> G2["当前执行 G2"]
M2["M2:阻塞在系统调用<br/>暂不执行 Go 代码"]
OS["操作系统调度线程到 CPU"] -.-> M0
OS -.-> M1
OS -.-> M2
这张图画的是某一刻的分配关系,连线不表示永久归属。普通 G 不固定在某个 M 上,M 与 P 也可以重新组合。若应用使用了 runtime.LockOSThread,线程绑定会增加额外约束;分析这类程序时,就不能再假设图中的所有 G 都能自由迁移。[2:2][3]
下面的程序把 P 数设为 1,再启动四个 goroutine。它用来检查一个常见误解:限制 P 数,并没有限制任务的数量。
runtime.GOMAXPROCS(1)
var wg sync.WaitGroup
for range 4 {
wg.Go(func() { time.Sleep(time.Millisecond) })
}
wg.Wait()
fmt.Println("four goroutines completed with one P")原稿保留了下面这段输出;本次审校未取得对应的原始执行日志,暂作待核实记录:
four goroutines completed with one P四个任务都完成了,因为一个 G 进入 Sleep 等待时,P 可以用于执行其他 G。它们的工作得以交错推进,这就是并发;若要让多个 Go 任务在同一时刻执行,还需要多个可用 P 和操作系统提供的 CPU 时间。增加 G 本身不会增加算力。这个示例没有测量耗时,输出只能说明任务完成,不能据此说“四个任务一定只花一毫秒”。[4][3:1]
前面的 G 在 Sleep 结束后,也不会跳过调度直接执行。网络数据到达、channel 消息就绪或定时器到期,都可能让等待中的 G 变成“可以运行”;接下来,它还要等线程和执行资源,才会变成“正在运行”。把这两个状态分开,才能看懂任务究竟慢在哪里。[5][6]
正在绘制图示…
stateDiagram-v2
[*] --> Runnable: go 语句创建任务
Runnable --> Running: M 持有 P,调度到这个 G
Running --> Waiting: 等 channel、锁、网络或 timer
Waiting --> Runnable: 条件满足,被唤醒
Running --> Runnable: 让出执行机会或被抢占
Running --> Syscall: 进入运行时支持的系统调用路径
Syscall --> Running: 返回时成功取得 P
Syscall --> Runnable: 返回时暂时没有 P
Running --> [*]: 函数返回
这张状态图省略了 GC 扫描状态等内部细节,保留的是排查时最需要的区别。G 如果还在 Waiting,就要查它等待的锁、网络或其他依赖;如果已经 Runnable 却迟迟没执行,才需要进一步查 CPU、运行队列与调度延迟。两者都可能表现为“请求还没完成”,需要寻找的证据却不同。[5:1]
已经就绪的任务,需要有地方排队。Go 1.27.2 既为 P 维护本地运行队列,也保留全局运行队列。常见情况下,从本地取任务可以减少对全局锁的争用;但任务分散到不同队列后,又可能出现一个 P 很忙、另一个 P 无事可做的情况。调度器因此还会检查其他工作来源,并从别的 P 窃取一批可运行的 G。这里的“窃取”是运行时内部的任务转移,应用不能把某个 G 所属的 P 当作稳定约定。[5:2]
| 工作来源 | 为什么需要它 | 代价或边界 |
|---|---|---|
P 的本地队列与优先候选 runnext | 让常见的唤醒、接续执行更便宜 | 不构成应用可依赖的先来先服务规则 |
| 全局运行队列 | 接住一部分批量工作,也给全局公平性留下入口 | 访问共享状态需要协调 |
| 其他 P 的队列 | 当前 P 无事可做时,重新平衡负载 | 窃取也要访问其他 P 的状态,并非零成本 |
| netpoller、到期 timer | 把外部事件转换成新的可运行 G | 事件就绪仍不等于 G 当场执行 |
| GC 等运行时任务 | 与业务任务共同推进垃圾回收等工作 | 可用 CPU 还要分给运行时 |
阅读 findRunnable 时,可以用这张表确认调度器在找哪些工作,再看各个分支何时生效。源码中还包含定期查看全局队列、检查 timer、选择 GC worker 等条件,表格并没有把它们排成固定的五个步骤。因此,“永远先本地,再全局,再偷一半”无法准确描述这段实现,更不能作为应用可以依赖的调度协议。[5:3]
再回到还没就绪的任务。同样叫“阻塞”,有时只是 G 暂停,有时却会占住整个操作系统线程。两种情况对资源的影响不同。
| 场景 | 等待期间通常保留什么 | 运行时如何继续干活 |
|---|---|---|
等 channel、Go 锁、time.Sleep | G 的状态、栈和相关引用 | 将 G 停放,M/P 去执行别的任务 |
| Go 网络库等待可轮询连接就绪 | G 与描述符上的等待关系 | netpoller 等内核事件,G 停放 |
| 会阻塞线程的系统调用或 cgo 调用 | M 可能被操作系统或外部代码占住 | P 可以交给其他 M;返回的任务重新取得执行资源 |
前两种等待中,运行时不需要为每个停下来的 G 专门留一个被它占用的 OS 线程。第三种则不同:M 还在系统调用或外部代码里,可能需要其他 M 接手后续工作,线程需求也就可能增加。能容纳很多 goroutine,并不表示可以无成本地并发任意数量的阻塞系统调用;线程、G 的栈、连接和它们引用的数据都要占用资源。[2:3][5:4][6:1][7]
M 进入系统调用后,P 怎样交出去,要看具体路径。运行时会先记录进入系统调用的状态;明确的阻塞调用可以主动移交 P,其他情况则可能由监控线程从正在执行系统调用的线程收回 P。Go 1.27.2 通过 G 的状态识别这类调用,不再使用独立的 P 系统调用状态;若调用很快返回,M 可能始终保有原来的 P。因此,不能把这套机制简化为“每次系统调用开始,就立即新建一个线程”。[5:5]
网络等待通常又是另一条路径。对 Linux 上可轮询的 TCP 连接,读操作暂时无法完成时,Go 的非阻塞 I/O 路径会等待 epoll 的就绪通知;Darwin 和部分 BSD 使用 kqueue。netpoller 负责告诉调度器哪些等待者可以继续,应用协议仍由应用处理,一次 Read 也不保证填满目标缓冲区。分析普通文件、特定设备或第三方库时,还要先确认它们是否走同样的轮询路径。[8][6:2]
等待中的 G 可以让出执行资源,但一直计算的 G 未必会主动停下来。如果循环既不阻塞,也不经过适当的检查点,其他任务就可能长期等不到机会。同步抢占利用函数入口的栈检查等检查点:运行时先提出抢占请求,G 走到检查点后进入调度器。异步抢占则进一步借助操作系统机制,在满足条件的指令位置打断 G。Go 1.14 已加入 goroutine 异步抢占;原稿将实验环境记为 Go 1.27.2、Linux/amd64,但本次未取得原始执行日志;切换当前版本的开关也不等于实际运行了历史版本。[9][10]
异步抢占仍要选择安全的位置。运行时必须能够恢复执行,也必须知道栈和寄存器中可能保存了哪些指针,因此需要检查异步安全点,排除不安全区域。当前 Unix 路径通过信号发起这类抢占;它与同步检查、阻塞检查共同工作,并没有取代前两者。[9:1][11]
下面故意让一个 G 进入无法自行退出的循环,观察 main 能否重新得到执行机会。复现时必须先构建程序,再用操作系统的 timeout 启动。关闭抢占后,同一进程里的另一个 timer 未必能让程序退出,不能把它当作实验的保护措施。
runtime.GOMAXPROCS(1)
started := make(chan struct{})
go func() {
close(started)
for {}
}()
<-started
time.Sleep(20 * time.Millisecond)
fmt.Println("main resumed despite the busy goroutine")原稿将下面的结果记为外部超时保护下的实验记录,目前尚未由原始日志核实:
默认配置:main resumed despite the busy goroutine
GODEBUG=asyncpreemptoff=1:无正文输出,timeout 退出码 124这个循环特意没有在循环体中调用函数。按原稿记录,开启异步抢占时 main 恢复了执行,关闭后的一秒观察窗口内则没有恢复。这组记录尚待核实;即使复现了同样结果,也不能扩展为所有业务 G 都会在固定时限内被抢占。源码里的 forcePreemptNS 是监控逻辑使用的阈值,不能解释成“最长等待 10ms”的服务承诺。安全点、OS 调度和实际负载都会影响等待时间。[5:6][9:2]
抢占让别的任务有机会执行,却不会替业务判断当前工作是否还应继续。真实任务仍需提供取消和退出路径;上面的无限循环只适合放在独立进程里,用来观察机制。
知道 P 的作用以后,再看容器给程序的 CPU 预算,就不容易把线程数与可用算力混在一起。runtime.GOMAXPROCS(0) 用于查询当前值;传入正整数会设置新值,并返回原值。它控制的是同时执行 Go 代码的 P 数量,不是 goroutine 总数,也不是线程总数。[3:2]
Go 1.27.2 计算默认值时,会同时考虑逻辑 CPU 数、CPU affinity 和 Linux cgroup CPU 配额。但当前程序未必正在使用这套默认计算:旧模块的兼容默认值、环境变量或代码中的手工设置,都可能影响结果。因此,不能只凭工具链版本推断实际 P 数,还要检查下面这些条件。[3:3][12]
| 设置或条件 | Go 1.27.2 中的含义 |
|---|---|
正整数环境变量 GOMAXPROCS | 使用指定值,关闭自动更新 |
程序调用 GOMAXPROCS(n),n > 0 | 手工设置,也关闭自动更新;即使碰巧与当前值相同仍算手工设置 |
| 当前默认行为、没有手工覆盖 | 综合 CPU 数、affinity、Linux cgroup 平均 CPU 吞吐限额 |
| 模块语言版本为 1.24 或更早 | containermaxprocs=0 和 updatemaxprocs=0 的兼容默认值仍需留意 |
runtime.SetDefaultGOMAXPROCS() | 忽略 GOMAXPROCS 环境变量,恢复运行时默认计算;仍尊重相应 GODEBUG 设置 |
cgroup 配额通过 quota/period 表达平均 CPU 吞吐量,并没有承诺把几颗核专门留给这个进程。当前实现通常取相关限制中的较小值,非整数配额向上取整;除非逻辑 CPU 数或 affinity 已经小于 2,否则默认 P 数不会因为 cgroup 配额而降到 2 以下。于是,限额为 0.5 CPU 时,P 也未必是 1。自动更新至多每秒进行一次,空闲时可能更少;这些规则应连同版本一起理解。[3:4]
文末程序查询当前值,手工改成 1,完成前面的四个任务后再恢复默认计算。原稿保留的普通运行输出如下,本次未独立核实:
version: go1.27.2 linux amd64
startup: NumCPU= 9 GOMAXPROCS= 9
after explicit setting: 1
four goroutines completed with one P
restored default: 9 previous: 9这段程序用于观察查询、手工设置与恢复默认值,不涉及改变 cgroup 配额或容器限流压测。输出中的 9 是原稿记录的环境值,尚未核实,不能用于推断其他机器或容器。
服务调优时,可以先查清当前 P 数由谁设置,再核对 CPU 配额与节流信息,最后比较吞吐、CPU 使用和尾延迟。调大 P,可能只是让更多工作争用同一份 CPU 预算;调小 P,也可能闲置本来可用的资源。需要靠这些指标判断调整是否合适,不能从 GMP 推出“P 越小越稳定”这样的通用公式。
| 现象 | 先核实什么 | 适合看的信息 |
|---|---|---|
| 很多 G 等着,CPU 并不高 | 在等外部 I/O、锁,还是任务没有产生? | goroutine 栈、block/mutex profile、执行 trace |
| CPU 很高,已就绪的任务迟迟不跑 | CPU 用在哪里,运行队列与 GC 是否占了时间? | CPU profile、执行 trace、调度统计 |
| 线程数增加 | 是否有阻塞系统调用、cgo、线程绑定? | goroutine/线程相关栈与调用路径 |
| 容器尾延迟变化 | P 数由谁设置,是否有 CPU 限额及节流? | GOMAXPROCS(0)、部署配置、平台指标 |
确定要查的方向后,再选择诊断工具。GODEBUG=schedtrace=1000 可以定期打印调度摘要,scheddetail=1 会补充细节;它们的输出格式属于诊断接口,不适合作为业务协议。如果需要沿时间线看任务如何等待、何时被唤醒,可以使用 go test -trace=trace.out 和 go tool trace。本文没有采集生产 trace,也没有仅凭 goroutine 总数判断是否存在泄漏。[13]
回到开头的现象:goroutine 多,只说明任务多,还不知道它们在等什么、是否已经就绪,以及有哪些线程和执行资源可用。把卡住的位置找出来以后,才能决定是减少计算、缩短锁持有时间、限制并发,还是调整执行资源。
本次审校核对了 Go 官方发布记录及带版本标签的调度、抢占和 GOMAXPROCS 源码,没有运行程序或测试。原稿称曾在 Linux/amd64、Go 1.27.2 下执行单 P 功能测试、race、vet 和带进程级超时的抢占实验;由于缺少原始日志,这些执行与通过记录仍待核实。正文输出和完整示例按原稿保留,不代表本次实测结果。即便 race 通过,也只覆盖测试运行到的路径;本章没有调度延迟上限、历史版本、容器配额变化、NUMA 或生产负载基准的验证证据。
要复现上述程序,把下面三个文件放到同一目录,并先用 go version 确认版本为 Go 1.27.2。Linux 上的 timeout 来自 GNU coreutils;两个无限循环实验都必须通过它启动,避免实验进程一直占用资源。
go.mod module example.com/runtime/gmp
go 1.27main.go package main
import (
"fmt"
"os"
"runtime"
"sync"
"time"
)
func main() {
if len(os.Args) > 1 && os.Args[1] == "busy" {
busy()
return
}
fmt.Println("version:", runtime.Version(), runtime.GOOS, runtime.GOARCH)
fmt.Println("startup: NumCPU=", runtime.NumCPU(), "GOMAXPROCS=", runtime.GOMAXPROCS(0))
previous := runtime.GOMAXPROCS(1)
fmt.Println("after explicit setting:", runtime.GOMAXPROCS(0))
var wg sync.WaitGroup
for range 4 {
wg.Go(func() { time.Sleep(time.Millisecond) })
}
wg.Wait()
fmt.Println("four goroutines completed with one P")
runtime.SetDefaultGOMAXPROCS()
fmt.Println("restored default:", runtime.GOMAXPROCS(0), "previous:", previous)
}
func busy() {
runtime.GOMAXPROCS(1)
started := make(chan struct{})
go func() {
close(started)
for {
}
}()
<-started
time.Sleep(20 * time.Millisecond)
fmt.Println("main resumed despite the busy goroutine")
}main_test.go package main
import (
"runtime"
"sync"
"testing"
)
func TestOnePDoesNotLimitGoroutineCount(t *testing.T) {
old := runtime.GOMAXPROCS(1)
defer runtime.GOMAXPROCS(old)
results := make(chan int, 100)
var wg sync.WaitGroup
for i := range 100 {
wg.Go(func() { results <- i })
}
wg.Wait()
close(results)
sum := 0
for n := range results {
sum += n
}
if sum != 4950 {
t.Fatalf("sum=%d", sum)
}
}go version
go build -o gmp .
./gmp
go test -count=1 -v ./...
go test -count=1 -race -v ./...
go vet ./...
timeout 3s ./gmp busy
# 预期被外部 timeout 终止;124 是 timeout 的退出码。
timeout 1s env GODEBUG=asyncpreemptoff=1 ./gmp busy
echo "$?"沿着同一段教学脚本,比较 channel 等待与阻塞系统调用。观察谁停下、谁接着执行,以及 G 被唤醒后去了哪里。
G 是 goroutine;M 是操作系统线程;P 持有执行 Go 代码所需的调度资源。
初始:P0 有 4 个可运行 G;P1 的队列为空;全局队列有 G5。
P0 / M0 · 执行 空闲
本地队列:G1、G2、G3、G4
P1 / M1 · 执行 空闲
本地队列:空
全局:G5
等 channel:无
阻塞的 M/G:无
空闲 M:无
已完成:无
阶段条可横向滚动;手动滚动会暂停播放。键盘聚焦下方区域后,用左右方向键查看完整顺序。
初始:P0 有 4 个可运行 G;P1 的队列为空;全局队列有 G5。
空
无
无
无
对照两种场景的“等待发生”这一步。channel 场景里,G1 挂起,M0 仍与 P0 配对;系统调用场景里,M0 / G1 一起阻塞,本例让 M2 接管 P0。接管并非所有系统调用都会立即发生。
真实机制:执行用户 Go 代码的 M 需要 P;P 的数量等于当前 GOMAXPROCS;就绪 G 可在本地或全局运行队列中;运行时会窃取其他 P 的任务;G 等待 channel 与 M 阻塞于系统调用不是同一件事。
教学选择:固定两个 P;人为指定每一步先执行哪个 G、何时窃取与唤醒。窃取步骤取约一半待运行 G,先运行一个;真实实现还有 runnext、全局队列公平性检查、随机探测、netpoll、计时器、抢占、GC 与线程停放等路径。
系统调用场景显示“P 已被接管”的结果,并不声称任意调用都会立即释放 P。G1 恢复后在本例进入全局队列,实际去向取决于恢复路径。不能据此推断程序输出顺序、吞吐量或线程数。P 也不等于物理 CPU 核心。
不操作也能读懂:可运行的 G 等待调度;M 持有 P 才执行 Go 代码;G 挂起可让 M 去跑其他 G;阻塞系统调用可能需要把 P 交给其他 M。
依据:Go runtime:G、M、P;调度源码 proc.go(核对日期:2026-10-14,源码链接随 Go 版本更新)。
Go 1.27.2 runtime/HACKING.md,Gs、Ms、Ps 与 Stacks。 ↩︎ ↩︎ ↩︎ ↩︎
Go 1.27.2 runtime/debug.go,GOMAXPROCS、SetDefaultGOMAXPROCS、NumCPU 文档与实现。 ↩︎ ↩︎ ↩︎ ↩︎ ↩︎
Go 1.27.2 time.Sleep,等待至少给定时长;sync.WaitGroup。 ↩︎
Go 1.27.2 runtime/proc.go,findRunnable、stealWork、runqsteal、entersyscall、entersyscallblock、exitsyscall、retake、forcePreemptNS;runtime2.go,G/P 状态。 ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎
Go 1.27.2 runtime/netpoll.go,等待与唤醒;netpoll_epoll.go、netpoll_kqueue.go,平台实现。 ↩︎ ↩︎ ↩︎
Go 1.27.2 debug.SetMaxThreads,线程增长与阻塞系统调用、cgo、线程绑定的关系。 ↩︎
Go 1.27.2 internal/poll/fd_unix.go,非阻塞读与 waitRead;io.Reader,短读约定。 ↩︎
Go 1.27.2 runtime/preempt.go,安全点、同步和异步抢占。 ↩︎ ↩︎ ↩︎
Go 1.14 发布说明:Runtime,异步抢占加入时的说明与平台例外。 ↩︎
Go 1.27.2 runtime/signal_unix.go,sigPreempt、doSigPreempt。 ↩︎
Go 1.27.2 runtime 诊断设置;Go diagnostics,profile 与 execution tracing 的用途。 ↩︎