上一篇我们已经学习了 goroutine、channel、select、WaitGroup、Context、Mutex、RWMutex、Cond 和 atomic。现在有一个更底层的问题:

go func() { ... }()

这行代码到底是怎样被执行起来的?一个进程里明明可以有成千上万个 goroutine,操作系统线程却没有那么多,Go 运行时是如何把它们安排到 CPU 上的?当 goroutine 等待 channel、网络 I/O 或锁时,为什么不会把整个程序卡住?

这些问题属于 Go runtime scheduler,也就是 Go 运行时调度器。学习调度器不是为了在业务代码里手动操作它,而是为了知道并发程序的成本来自哪里:goroutine 创建并不等于 CPU 并行,阻塞不一定是坏事,GOMAXPROCS 也不是“启动多少 goroutine”的参数。

按照 Go 官方 runtime 内部文档 HACKING.md 的定义,调度器管理三类核心资源:

  • G(Goroutine):要执行的 Go 代码;
  • M(Machine):可以执行 Go 代码的操作系统线程;
  • P(Processor):执行用户 Go 代码所需要的资源和权限。

调度器的任务可以概括成一句话:把一个可以运行的 G,放到拥有 P 的 M 上执行。

本篇按照“G/M/P → goroutine 创建 → 运行队列 → 工作窃取 → 阻塞与唤醒 → 系统调用和 netpoll → 抢占 → 实际观测”的顺序展开。每一个概念都尽量用可以复制运行的代码验证。

本文代码在 Go 1.27.0 darwin/arm64 环境中实际运行。调度器输出会随 CPU 数量、操作系统和 Go 版本变化,示例日志只展示结构,不应当把具体数字当成固定协议。

G、M、P 分别是什么

G:goroutine 的运行载体

G 就是一个 goroutine。它保存函数入口、栈、程序计数器、状态和调度现场等信息。

启动一个 goroutine 时,Go 编译器会把 go 语句转换成运行时调用。运行时创建或复用一个 G,把它放到可运行队列,然后由调度器在合适的时刻执行。

从业务代码看,G 的生命周期可以简化成:

创建 → 可运行 → 运行中 → 阻塞/等待 → 再次可运行 → 运行结束

G 阻塞时不会占用一个正在执行用户代码的 P。比如下面的 goroutine 在等待 channel:

package main

import (
	"fmt"
	"time"
)

func main() {
	ready := make(chan struct{})

	go func() {
		fmt.Println("worker waits")
		<-ready
		fmt.Println("worker wakes")
	}()

	time.Sleep(20 * time.Millisecond)
	fmt.Println("main sends wake signal")
	close(ready)
	time.Sleep(20 * time.Millisecond)
}

worker 等待期间,调度器可以让其他 goroutine 使用 CPU。这里的“等待”不是忙循环,而是把当前 G 挂起,等 channel 关闭后再标记为可运行。

M:操作系统线程

M 是 operating system thread。它可以执行用户 Go 代码,也可能进入系统调用、执行 runtime 代码,或者处于空闲状态。

M 的数量不一定等于 P 的数量。一个 M 进入阻塞系统调用时,运行时会尽量把它持有的 P 交给其他 M,让其他 G 继续执行;系统调用返回后,原来的 M 可能重新获得 P,也可能因为没有空闲 P 而等待。

可以用下面的程序制造一些系统调用和 Go 代码混合执行的场景:

package main

import (
	"fmt"
	"os"
	"runtime"
	"sync"
	"time"
)

func main() {
	runtime.GOMAXPROCS(2)
	var wg sync.WaitGroup

	wg.Add(2)
	go func() {
		defer wg.Done()
		file, err := os.CreateTemp("", "scheduler-demo-*")
		if err != nil {
			return
		}
		defer os.Remove(file.Name())
		defer file.Close()
		_, _ = file.WriteString("hello")
		fmt.Println("syscall-like file work finished")
	}()

	go func() {
		defer wg.Done()
		for i := 0; i < 3; i++ {
			fmt.Println("go work", i)
			time.Sleep(5 * time.Millisecond)
		}
	}()

	wg.Wait()
}

这里不能从输出顺序反推出具体 M 的调度顺序。runtime 会根据系统调用、P 是否空闲、队列中是否有 G 等条件做动态决策。

P:执行 Go 代码的资源

P 不是 CPU 核心,也不是操作系统线程。它代表执行用户 Go 代码所需要的资源,例如本地可运行队列、内存分配器状态和调度相关状态。

P 的数量等于 GOMAXPROCS。如果 GOMAXPROCS=4,最多同时有 4 个 P 执行用户 Go 代码;每个 P 通常需要绑定一个 M 才能运行。

previous := runtime.GOMAXPROCS(2)
defer runtime.GOMAXPROCS(previous)

不要把 GOMAXPROCS(2) 理解成“程序最多只能启动两个 goroutine”。它只限制同一时刻能够并行执行用户 Go 代码的 P 数量,goroutine 数量可以远大于 2。

官方 runtime 文档对三者关系的描述可以画成下面这样:

          可运行的 G
        ┌─────────────┐
        │ G1 G2 G3 G4 │
        └──────┬──────┘
               │ 调度
      ┌────────▼────────┐
      │       P         │  执行 Go 代码所需的资源
      └────────┬────────┘
               │ 绑定
      ┌────────▼────────┐
      │       M         │  OS thread
      └────────┬────────┘
               │
              CPU

goroutine 创建以后去了哪里

从 go 语句到可运行队列

下面的代码:

go work(10)

在语义上做了三件事:

  1. 在当前 goroutine 中计算函数值和参数;
  2. 创建一个新的 goroutine 描述对象 G;
  3. 把 G 标记为 runnable,放入调度器可以找到的运行队列。

函数参数在调用方求值这一点很重要:

value := expensiveInput()
go work(value)

expensiveInput() 并不会自动在新 goroutine 中执行。只有 work(value) 的函数体属于新 goroutine。

本地队列和全局队列

每一个 P 都有一个本地 runnable queue,用来保存准备在这个 P 上运行的 G。运行时还维护一个全局队列,用于在本地队列之间转移工作。

简化后的结构如下:

P0: [G1 G2 G3]       P1: [G4 G5]
       │                    │
       └───────┬────────────┘
               │ 本地队列不足时寻找工作
        Global run queue: [G6 G7 ...]

当前 P 创建出的新 G,通常会优先放到自己的本地队列。这样做可以减少所有 goroutine 都竞争一个全局锁的成本。

本地队列容量和具体数据结构属于 runtime 实现细节。Go 1.27 的 runtime/proc.go 中可以看到 runqput、runqget、runqgrab 和 runqsteal 等函数。业务代码不应该依赖这些未导出的名字,但理解它们有助于解释为什么调度器能在大量 goroutine 下工作。

runnext:让刚唤醒的工作尽快运行

P 还存在一个 runnext 概念,用于保存一个倾向于快速运行的 G。它可以让通信双方在生产者刚发送数据后,接收者更快得到调度,减少一轮完整队列扫描的延迟。

这不是“严格的优先级队列”。runtime 仍然需要防止某个 goroutine 对长期运行的 goroutine 造成饥饿,并且在 race 模式下会引入调度随机化来暴露依赖固定顺序的测试。

工作窃取:空闲 P 如何找到工作

如果 P0 的本地队列为空,但 P1 还有很多可运行 G,P0 不会一直空转。调度器会尝试从其他 P 的本地队列中窃取一部分工作,这就是 work stealing。

可以把过程简化为:

P0 本地队列为空
       │
       ▼
检查全局队列
       │ 没有足够工作
       ▼
随机选择其他 P
       │
       ▼
窃取对方本地队列的一部分 G
       │
       ▼
放入 P0 本地队列并继续执行

工作窃取的意义是平衡负载,同时尽量保持本地队列的低竞争。窃取不是复制 G,而是把 G 的所有权从一个 runnable queue 转移到另一个 queue。

下面的 CPU 密集型例子可以在 scheduler trace 中看到本地队列和全局队列的变化:

package main

import (
	"runtime"
	"sync"
)

func busy(iterations int) {
	value := 0
	for i := 0; i < iterations; i++ {
		value += i % 7
	}
	if value == -1 {
		panic("unreachable")
	}
}

func main() {
	runtime.GOMAXPROCS(2)
	var wg sync.WaitGroup

	for i := 0; i < 100; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			busy(5_000_000)
		}()
	}

	wg.Wait()
}

不要用 runtime.Gosched() 代替设计良好的同步。Gosched 只是主动让出当前时间片,它不会等待数据、不建立 happens-before,也不会解决数据竞争。

goroutine 阻塞以后发生什么

channel 阻塞

当 goroutine 执行下面的代码,而 channel 当前无法完成接收:

value := <-input

runtime 不会让它继续占用 CPU 反复检查。当前 G 会进入等待状态,调度器把它从 runnable 集合中移除,之后由发送方把它重新变成 runnable。

生产者消费者的最小示例:

package main

import "fmt"

func main() {
	jobs := make(chan int)

	go func() {
		jobs <- 42
	}()

	value := <-jobs
	fmt.Println(value)
}

接收操作和发送操作在 runtime 内部都可能调用 gopark,而匹配成功后通过类似 goready 的路径把等待中的 G 放回 runnable 状态。函数名属于 runtime 内部实现细节,业务代码只需要遵守 channel 的所有权和关闭规则。

锁阻塞

Mutex 竞争时,不能立即获得锁的 goroutine 也会等待,而不是一直占用 CPU 自旋。锁实现会维护等待者,并在解锁时唤醒合适的 goroutine。

var mu sync.Mutex

mu.Lock()
// 临界区
mu.Unlock()

不要在锁的临界区里执行不必要的网络 I/O、磁盘 I/O 或长时间计算。锁持有时间越长,等待队列越长,调度器越难保持吞吐和延迟。

time.Sleep 和定时器

time.Sleep 不会让 M 线程原地忙等。goroutine 会被挂起,计时器到期后再被标记为可运行。

package main

import (
	"fmt"
	"time"
)

func main() {
	started := time.Now()
	time.Sleep(20 * time.Millisecond)
	fmt.Println(time.Since(started) >= 20*time.Millisecond)
}

不过 Sleep 只适合表达“至少等待一段时间”,不适合表达“等另一个任务完成”。后者应该用 channel、WaitGroup 或 Context。

系统调用和 netpoll

系统调用不会简单等于 goroutine 阻塞

网络服务器经常需要同时等待很多连接。如果每个连接都占用一个永久阻塞的操作系统线程,线程数量会迅速膨胀。Go runtime 会把网络 I/O 接入 netpoller:

goroutine 调用网络 API
        │
        ▼
fd 暂时不可读/写
        │
        ▼
G 被挂起,M/P 可以运行其他 G
        │
        ▼
OS 通过 epoll/kqueue/iocp 等机制通知就绪
        │
        ▼
netpoller 把 G 标记为 runnable

具体底层机制由操作系统决定。业务代码不应该直接假设一定使用 epoll 或 kqueue,而应该理解:使用 Go net 包的网络 I/O 时,runtime 会尽量把等待从“占用线程”转化成“等待事件”。

为什么文件 I/O 的行为可能不同

网络 FD 通常可以交给运行时网络轮询器,但普通文件在不同系统上的异步能力和语义不同。文件读写可能通过系统线程池或直接系统调用完成,不能把网络 I/O 的调度行为机械套到所有文件操作上。

如果一个外部库通过 cgo 进入长时间阻塞的 C 调用,runtime 也会把它视为可能阻塞的 M。此时应关注线程数量、P 是否能够继续运行,以及是否应该把阻塞操作放到受控 worker 中。

抢占与长时间运行的 goroutine

早期的 Go 调度更依赖函数调用、channel 操作和显式安全点。如果某个 goroutine 在纯 Go 计算循环中很久不调用可能触发调度的操作,其他 goroutine 可能得不到及时运行。

现代 Go runtime 具备异步抢占能力。调度器和系统监控线程会识别运行时间过长的 G,通过安全的抢占机制让它暂时让出执行权。

下面的循环没有主动调用 Gosched,仍然应该让其他 goroutine 获得机会:

package main

import (
	"fmt"
	"runtime"
	"time"
)

func main() {
	runtime.GOMAXPROCS(1)

	go func() {
		fmt.Println("second goroutine ran")
	}()

	deadline := time.Now().Add(30 * time.Millisecond)
	for time.Now().Before(deadline) {
		// 模拟长时间计算
	}

	fmt.Println("main finished")
}

这不意味着可以放心写无限循环。抢占有延迟,cgo、不可抢占的 runtime 区域和错误的自旋循环仍然可能造成问题。循环本身如果有明确的协作点,可以主动检查 Context 或使用 runtime.Gosched,但不要把它当成锁或通信机制。

用 schedtrace 观察调度器

Go 官方 Diagnostics 文档支持通过 GODEBUG=schedtrace=X 打印调度摘要,X 的单位是毫秒:

GODEBUG=schedtrace=1000 ./scheduler-demo

可能看到类似输出:

SCHED 1004ms: gomaxprocs=4 idleprocs=0 threads=11 spinningthreads=1 idlethreads=4 runqueue=8 [0 1 0 3]

可以这样理解:

字段含义
gomaxprocs当前 P 的数量
idleprocs没有执行用户 Go 代码的 P 数量
threadsruntime 创建过的 M/线程数量统计
idlethreads当前空闲线程数量
runqueue全局 runnable queue 长度
[0 1 0 3]每个 P 的本地 runnable queue 长度

数组长度通常与 GOMAXPROCS 对应。trace 只是一个时间点的快照,不能单独证明某个 goroutine 一直被饿死;要研究延迟,还需要执行追踪或 profile。

运行实验

保存上面的 CPU 任务为 scheduler-demo.go,然后执行:

go run scheduler-demo.go
GODEBUG=schedtrace=200 GOMAXPROCS=2 go run scheduler-demo.go

go run 本身也会启动编译相关进程,所以如果想只观察目标程序,可以先构建:

go build -o scheduler-demo scheduler-demo.go
GODEBUG=schedtrace=200 GOMAXPROCS=2 ./scheduler-demo

使用 execution trace

调度摘要适合快速观察,execution trace 适合看完整事件时间线:

package main

import (
	"os"
	"runtime/trace"
	"sync"
)

func busy(iterations int) {
	value := 0
	for i := 0; i < iterations; i++ {
		value += i % 7
	}
	if value == -1 {
		panic("unreachable")
	}
}

func main() {
	file, err := os.Create("trace.out")
	if err != nil {
		panic(err)
	}
	defer file.Close()

	if err := trace.Start(file); err != nil {
		panic(err)
	}
	defer trace.Stop()

	var wg sync.WaitGroup
	for i := 0; i < 4; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			busy(10_000_000)
		}()
	}
	wg.Wait()
}

运行后使用:

go tool trace trace.out

execution trace 可以帮助回答:

  • goroutine 是运行、阻塞还是被抢占;
  • P 是否长期空闲;
  • 网络轮询是否造成延迟;
  • GC、系统调用和调度等待各占用多少时间。

官方文档建议把 trace 用于理解延迟和利用率,用 CPU profile、heap profile 等工具分析热点和内存成本。不同诊断工具会互相影响,最好分开采集。

调度器与业务代码的边界

调度器会自动完成很多工作,但业务代码仍然必须做好这些事情:

  1. 不要依赖 goroutine 的执行顺序;
  2. 不要用 time.Sleep 代替同步;
  3. 通过 channel 或锁建立明确的 happens-before;
  4. 控制 goroutine 的生命周期,避免无人负责退出;
  5. CPU 密集型任务设置合理 worker 数量,不要无上限启动 goroutine;
  6. 网络和 I/O 任务关注超时、取消和结果消费;
  7. 用 trace、pprof 和 race detector 验证猜测,不要凭日志顺序推断调度器行为。

常见误区

误区一:GOMAXPROCS 就是 goroutine 数量上限

不是。它限制 P 的数量,也就是并行执行用户 Go 代码的上限。goroutine 可以远大于 P,等待 I/O 的 goroutine 也不会持续占用 P。

误区二:goroutine 越多,程序越快

goroutine 创建很轻量,但每个 goroutine 仍然需要栈、调度和通信成本。大量 goroutine 竞争同一个锁、同一个 channel 或下游资源时,吞吐反而可能下降。

误区三:runtime.Gosched 可以解决同步问题

不能。它只让出当前执行机会,不传递数据、不等待条件,也不能修复数据竞争。

误区四:看到线程数多就说明 goroutine 泄漏

M 可能因为系统调用、cgo、网络轮询或 runtime 工作而增加。判断泄漏应该看 goroutine 数量、阻塞栈、生命周期和资源是否持续增长,不能只看线程总数。

误区五:schedtrace 的一次快照就是结论

一次输出只能说明某个时刻队列状态。要分析饥饿、尾延迟和调度等待,需要连续 trace、profile 和可重复压测。

总结

本篇从 runtime 角度理解了 Go 并发:

  1. G 是 goroutine,保存要执行的代码和调度现场;
  2. M 是操作系统线程,可以执行 Go 代码、系统调用或 runtime 代码;
  3. P 是执行用户 Go 代码所需的资源,数量由 GOMAXPROCS 决定;
  4. 调度器的核心任务是把 runnable G 匹配到拥有 P 的 M;
  5. G 通常先进入 P 的本地队列,必要时进入全局队列;
  6. 空闲 P 可以通过 work stealing 从其他 P 获取 runnable G;
  7. channel、锁、定时器和网络 I/O 会让 G 阻塞,阻塞的 G 不应持续占用 CPU;
  8. netpoller 把网络事件转换成 goroutine 的唤醒;
  9. sysmon 和抢占机制帮助长时间运行的 G 让出执行机会;
  10. schedtrace 适合观察队列摘要,execution trace 适合分析事件时间线;
  11. 调度器会尽量提高吞吐,但不会替业务代码决定任务所有权、取消和关闭顺序。

真正理解 G/M/P 之后,可以把 goroutine 看成“可调度的任务”,把 P 看成“执行 Go 代码的资源许可”,把 M 看成“承载执行的线程”。channel 阻塞时,等待的是 G;P 可以转去运行其他 G;网络事件到来时,netpoller 再把等待中的 G 放回调度器。

官方资料

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐