Skip to content

什么是 GMP:goroutine 怎样得到执行机会 ​

一个 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
放大图示

原始尺寸下可横向或纵向滚动;按 Escape 关闭。

正在绘制图示…

查看图源代码
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 数,并没有限制任务的数量。

go
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")

原稿保留了下面这段输出;本次审校未取得对应的原始执行日志,暂作待核实记录:

text
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 --> [*]: 函数返回
放大图示

原始尺寸下可横向或纵向滚动;按 Escape 关闭。

正在绘制图示…

查看图源代码
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 阻塞时,究竟占住了什么 ​

再回到还没就绪的任务。同样叫“阻塞”,有时只是 G 暂停,有时却会占住整个操作系统线程。两种情况对资源的影响不同。

场景等待期间通常保留什么运行时如何继续干活
等 channel、Go 锁、time.SleepG 的状态、栈和相关引用将 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 未必能让程序退出,不能把它当作实验的保护措施。

go
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")

原稿将下面的结果记为外部超时保护下的实验记录,目前尚未由原始日志核实:

text
默认配置:main resumed despite the busy goroutine
GODEBUG=asyncpreemptoff=1:无正文输出,timeout 退出码 124

这个循环特意没有在循环体中调用函数。按原稿记录,开启异步抢占时 main 恢复了执行,关闭后的一秒观察窗口内则没有恢复。这组记录尚待核实;即使复现了同样结果,也不能扩展为所有业务 G 都会在固定时限内被抢占。源码里的 forcePreemptNS 是监控逻辑使用的阈值,不能解释成“最长等待 10ms”的服务承诺。安全点、OS 调度和实际负载都会影响等待时间。[5:6][9:2]

抢占让别的任务有机会执行,却不会替业务判断当前工作是否还应继续。真实任务仍需提供取消和退出路径;上面的无限循环只适合放在独立进程里,用来观察机制。

容器里的 GOMAXPROCS:先看谁决定了默认值 ​

知道 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,完成前面的四个任务后再恢复默认计算。原稿保留的普通运行输出如下,本次未独立核实:

text
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 ​

go.mod
module example.com/runtime/gmp

go 1.27

main.go ​

代码块 · 41 行 · 点击收起 / 展开
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 ​

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)
	}
}

执行命令 ​

bash
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 "$?"

交互实验:Go GMP 调度 ​

GO GMP · 执行、队列与等待

G 在等,M 和 P 就一定停下来吗?

沿着同一段教学脚本,比较 channel 等待与阻塞系统调用。观察谁停下、谁接着执行,以及 G 被唤醒后去了哪里。

G 是 goroutine;M 是操作系统线程;P 持有执行 Go 代码所需的调度资源。

01

输入 · 只改变等待方式

固定教学条件2 个 P · 5 个 G本例不可调。每一步的执行、窃取和唤醒顺序由脚本预先指定。
02

过程 · 跟着一个 G 看资源关系

步骤 0 / 9 · 初始队列等待下一步

初始:P0 有 4 个可运行 G;P1 的队列为空;全局队列有 G5。

P0 / M0 · 执行 空闲
本地队列:G1、G2、G3、G4

P1 / M1 · 执行 空闲
本地队列:空

全局:G5
等 channel:无
阻塞的 M/G:无
空闲 M:无
已完成:无

阶段条可横向滚动;手动滚动会暂停播放。键盘聚焦下方区域后,用左右方向键查看完整顺序。

  1. 当前0 · 初始队列
  2. 待进行1 · 执行 G1
  3. 待进行2 · 窃取任务
  4. 待进行3 · 等待发生
  5. 待进行4 · 继续执行
  6. 待进行5 · 唤醒 G1
  7. 待进行6 · G3 完成
  8. 待进行7 · G4 完成
  9. 待进行8 · G2 完成
  10. 待进行9 · 全部完成

初始:P0 有 4 个可运行 G;P1 的队列为空;全局队列有 G5。

当前执行与队列
这是本步快照,不是时间轴上的耗时比例
P0 ↔ M0
当前空闲
正在执行空闲M 持有这个 P,才能执行用户 Go 代码
本地运行队列
  • G1
  • G2
  • G3
  • G4
P1 ↔ M1
当前空闲
正在执行空闲M 持有这个 P,才能执行用户 Go 代码
本地运行队列

空

全局运行队列
已就绪,等待调度
  • G5
挂起等待的 G
等待 channel 条件

无

阻塞的 M/G
此处展示阻塞系统调用

无

已经完成
0 / 5 个 G · 本轮完成数量

无

03

观察 · 等待的到底是谁?

对照两种场景的“等待发生”这一步。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 版本更新)。


  1. Go 语言规范:Go statements。 ↩︎

  2. Go 1.27.2 runtime/HACKING.md,Gs、Ms、Ps 与 Stacks。 ↩︎ ↩︎ ↩︎ ↩︎

  3. Go 1.27.2 runtime/debug.go,GOMAXPROCS、SetDefaultGOMAXPROCS、NumCPU 文档与实现。 ↩︎ ↩︎ ↩︎ ↩︎ ↩︎

  4. Go 1.27.2 time.Sleep,等待至少给定时长;sync.WaitGroup。 ↩︎

  5. Go 1.27.2 runtime/proc.go,findRunnable、stealWork、runqsteal、entersyscall、entersyscallblock、exitsyscall、retake、forcePreemptNS;runtime2.go,G/P 状态。 ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎

  6. Go 1.27.2 runtime/netpoll.go,等待与唤醒;netpoll_epoll.go、netpoll_kqueue.go,平台实现。 ↩︎ ↩︎ ↩︎

  7. Go 1.27.2 debug.SetMaxThreads,线程增长与阻塞系统调用、cgo、线程绑定的关系。 ↩︎

  8. Go 1.27.2 internal/poll/fd_unix.go,非阻塞读与 waitRead;io.Reader,短读约定。 ↩︎

  9. Go 1.27.2 runtime/preempt.go,安全点、同步和异步抢占。 ↩︎ ↩︎ ↩︎

  10. Go 1.14 发布说明:Runtime,异步抢占加入时的说明与平台例外。 ↩︎

  11. Go 1.27.2 runtime/signal_unix.go,sigPreempt、doSigPreempt。 ↩︎

  12. Go 的 GODEBUG 兼容默认值;Go 1.27.2 internal/godebugs/table.go。 ↩︎

  13. Go 1.27.2 runtime 诊断设置;Go diagnostics,profile 与 execution tracing 的用途。 ↩︎

保留各书来源与许可 · 示例运行条件以正文为准