跳转到正文
zeno's blog
返回

Go 运行时(一):GMP 调度模型

专题: Go 运行时

Table of contents

Open Table of contents

TL;DR

Go 运行时通过 G(goroutine)、M(OS thread)、P(logical processor)三级抽象实现 M:N 线程调度。P 的引入(Go 1.1)解决了全局锁竞争,work-stealing 保证负载均衡,异步抢占(Go 1.14+,SIGURG 信号)消除了 CPU 密集型 goroutine 的饥饿问题。Go 1.25 起 GOMAXPROCS 自动感知容器 cgroup CPU 限制。


1. GMP 解决什么问题

为什么 Go 需要自己的调度器

OS 线程的代价:

M:N 线程模型

M 个 goroutine 复用到 N 个 OS 线程上执行(M >> N)。这是介于 1:1(每个用户线程对应一个内核线程,如 Java 传统线程)和 N:1(所有用户线程映射到一个内核线程,无法利用多核)之间的方案。

历史:GM 模型的问题(Go 1.0)

Go 1.0 只有 G 和 M,没有 P。所有可运行的 goroutine 放在一个全局队列中,受单一全局互斥锁保护。

Dmitry Vyukov 在 2012 年的 Scalable Go Scheduler Design Doc 中指出了四个核心问题:

问题说明
全局锁竞争所有 goroutine 操作(创建、完成、重新调度)都需要获取全局锁,严重限制可扩展性
M 之间无法交接当 M 阻塞在系统调用时,无法将其关联的可运行 goroutine 转移给其他 M
mcache 绑定在 M 上内存分配缓存(mcache,约 2MB)绑定在 M 上,当 M 阻塞在系统调用时,mcache 闲置浪费
过度线程阻塞/唤醒频繁的 M 阻塞和唤醒增加了系统开销

解决方案:引入 P(processor)作为中间层。P 持有本地运行队列和 mcache,数量 = GOMAXPROCS。M 必须获取 P 才能执行 Go 代码。M 阻塞时,P 可以交给其他 M。

来源:Scalable Go Scheduler Design Doc (Dmitry Vyukov, 2012),实现于 Go 1.1。


2. 核心概念:G、M、P

G(Goroutine)

代表一个并发执行单元。数据结构定义在 runtime/runtime2.go

关键字段

type g struct {
    stack       stack        // 栈内存范围 [stack.lo, stack.hi)
    stackguard0 uintptr      // 栈增长检查;设为 stackPreempt 时触发抢占
    sched       gobuf        // 保存的执行上下文(SP, PC, BP 等)
    atomicstatus atomic.Uint32 // 当前状态
    goid        uint64       // goroutine ID
    m           *m           // 当前绑定的 M(nil 如果不在运行)
    preempt     bool         // 抢占信号
    waiting     *sudog       // 等待的 sudog 链表(channel 操作等)
    waitsince   int64        // 阻塞开始时间
    waitreason  waitReason   // 阻塞原因
}

type gobuf struct {
    sp   uintptr  // 栈指针
    pc   uintptr  // 程序计数器
    g    guintptr // 所属 goroutine
    bp   uintptr  // 帧指针
}

状态机

          newproc()          schedule()           gopark()
 _Gidle ---------> _Grunnable ---------> _Grunning ---------> _Gwaiting
                       ^                     |                     |
                       |                     | entersyscall()      | goready()
                       |                     v                     |
                       |                _Gsyscall                  |
                       |                     |                     |
                       +---------------------+---------------------+
                              exitsyscall() / goready()

 _Grunning ---(完成)---> _Gdead(放入 gFree 列表复用)
 _Grunning ---(栈复制)---> _Gcopystack ---> _Grunning
 _Grunning ---(信号抢占)---> _Gpreempted ---> _Grunnable
状态含义
_Gidle0刚分配,尚未初始化
_Grunnable1在运行队列中,未执行用户代码
_Grunning2正在 M 上执行用户代码,拥有栈
_Gsyscall3正在执行系统调用,不执行用户代码
_Gwaiting4阻塞在运行时(channel/mutex/sleep 等)
_Gdead6已完成,可能在 gFree 列表中等待复用
_Gcopystack8栈正在被复制(栈增长/收缩)
_Gpreempted9被 suspendG 抢占暂停

GC 扫描时会叠加 _Gscan(0x1000)标记位,例如 _Gscanrunning = 0x1002

M(Machine / OS Thread)

代表一个操作系统线程。

关键字段

type m struct {
    g0      *g         // 调度栈(16-48KB),执行 schedule() 等运行时代码
    curg    *g         // 当前执行的用户 goroutine
    p       puintptr   // 当前关联的 P(执行 Go 代码时非 nil)
    nextp   puintptr   // 唤醒时将要关联的 P
    oldp    puintptr   // 进入系统调用前关联的 P
    spinning bool      // 正在自旋寻找工作
    blocked  bool      // 阻塞在 note 上
    id      int64
    gsignal *g         // 信号处理 goroutine
    preemptGen atomic.Uint32 // 已完成的抢占信号计数
}

关键特性

P(Processor)

逻辑处理器,是执行 Go 代码所需的资源。

关键字段

type p struct {
    id          int32
    status      uint32      // _Pidle, _Prunning, _Pgcstop, _Pdead
    m           muintptr    // 反向指向关联的 M

    // 本地运行队列
    runqhead    uint32
    runqtail    uint32
    runq        [256]guintptr  // 256 槽位的环形队列(lock-free)
    runnext     guintptr       // 下一个优先运行的 G(producer-consumer 优化)

    // 资源
    mcache      *mcache     // 内存分配缓存
    gFree       gList       // 已完成的 G 复用池

    // 调度计数器
    schedtick   uint32      // 每次调度递增
    syscalltick uint32      // 每次系统调用递增
    sysmontick  sysmontick  // sysmon 上次观察到的 tick

    // GC 相关
    gcMarkWorkerMode gcMarkWorkerMode
    gcAssistTime     int64

    // 定时器
    timers      timers      // 最小堆定时器
}

P 的状态

状态含义
_Pidle空闲,不在运行用户代码或调度器
_Prunning被 M 持有,正在运行用户代码或调度器
_Pgcstop因 STW 停止
_Pdead不再使用(GOMAXPROCS 缩小后)

P 的数量 = GOMAXPROCS(默认 = CPU 核心数;Go 1.25 起 = min(CPU 核心数, cgroup CPU 限制))。

三者关系图

                    ┌──────────────────────────────────┐
                    │        Global Run Queue           │
                    │  (mutex 保护,所有 P 共享)          │
                    └──────────┬───────────────────────┘

           ┌───────────────────┼───────────────────┐
           │                   │                   │
     ┌─────┴─────┐      ┌─────┴─────┐      ┌─────┴─────┐
     │    P0     │      │    P1     │      │    P2     │
     │ runq[256] │      │ runq[256] │      │ runq[256] │
     │ runnext   │      │ runnext   │      │ runnext   │
     │ mcache    │      │ mcache    │      │ mcache    │
     └─────┬─────┘      └─────┬─────┘      └─────┬─────┘
           │                   │                   │
     ┌─────┴─────┐      ┌─────┴─────┐      ┌─────┴─────┐
     │    M0     │      │    M1     │      │    M2     │
     │  g0 + curg│      │  g0 + curg│      │  g0 + curg│
     └─────┬─────┘      └─────┬─────┘      └─────┬─────┘
           │                   │                   │
     ┌─────┴─────┐      ┌─────┴─────┐      ┌─────┴─────┐
     │  G(用户)  │      │  G(用户)  │      │  G(用户)  │
     └───────────┘      └───────────┘      └───────────┘

     另有 sysmon M(无 P,独立运行)
     以及可能处于系统调用中的 M(已释放 P)

来源:runtime/runtime2.goruntime/proc.go 头部注释(第 24-116 行)。


3. 调度算法:完整流程

schedule() 函数

位于 runtime/proc.go,在每个 M 的 g0 栈上执行,是调度的主循环:

schedule() → findRunnable() → execute(gp) → [用户代码运行] → goexit0() / gopark() / Gosched() → schedule()

findRunnable() 查找下一个 G 的顺序

这是调度器的核心,按以下优先级搜索可运行的 goroutine:

优先级来源说明
1Trace reader内部运行时追踪
2GC mark worker如果 GC 活跃且需要 mark worker
3全局队列(概率性)每 61 次调度检查一次schedtick % 61 == 0)— 61 是质数,避免同步模式
4本地运行队列先检查 runnext,再检查 runq 环形缓冲区
5全局队列(批量)min(len(globalq)/nprocs + 1, len(globalq), 128)
6网络轮询器(netpoll)检查已完成 I/O 的 goroutine
7Work Stealing随机选择其他 P,偷取其本地队列的一半
8再次检查 GC、全局队列如果是 spinning M
9放弃(park)释放 P,M 进入休眠

Work Stealing 算法

当本地队列为空时:

  1. 随机选择另一个 P
  2. 尝试最多 4 次:
    • 前 3 次:只从目标 P 的 runq 偷取(偷走一半)
    • 第 4 次:先尝试偷 runnext,再尝试 runq
  3. 使用 runqsteal() 函数,通过 CAS 操作 lock-free 地转移 goroutine

为什么偷一半:如果只偷一个,在工作分布不均时需要频繁偷取;偷一半使得负载快速均衡。

全局队列公平性

schedtick % 61 == 0 的设计确保全局队列中的 goroutine 不会被饿死。61 是质数,使得不同 P 的检查时机错开,避免所有 P 同时竞争全局锁。

全局队列批量获取公式:

n := min(len(sched.runq)/gomaxprocs + 1, len(sched.runq), 128)

这样的分摊策略减少全局锁的获取频率。

Handoff 机制

当 M 阻塞在系统调用时:

  1. entersyscall():G 状态变为 _Gsyscall,M 与 P 的绑定标记为 syscall 状态
  2. sysmon 检测到 P 在 syscall 状态超过 10ms
  3. retake()handoffp():将 P 从阻塞的 M 上分离
  4. 分离的 P 尝试:先唤醒一个空闲 M,如果没有则创建新 M
  5. 系统调用返回后,原 M 执行 exitsyscall():尝试重新获取一个 P;如果没有空闲 P,G 放入全局队列,M 进入休眠

runnext 优化(生产者-消费者 handoff)

当 G1 通过 channel 唤醒 G2 时,G2 被放入当前 P 的 runnext 槽位而非队尾。这使得 G2 几乎立即获得执行,实现了类似直接 handoff 的效果,对 producer-consumer 模式的延迟极为友好。

关键调度函数

函数作用
runtime.Gosched()主动让出处理器,当前 G 变为 Runnable 放回队列
runtime.LockOSThread()将当前 G 绑定到当前 OS 线程
runtime.UnlockOSThread()解除绑定
gopark()将 G 从 Running 变为 Waiting(channel/mutex/sleep 等场景)
goready() / ready()将 G 从 Waiting 变为 Runnable,放入运行队列
mcall(fn)切换到 g0 栈执行 fn(调度函数的入口)
gogo()从 g0 切换回用户 G 执行(汇编实现)

来源:runtime/proc.go


4. 系统调用处理与网络轮询器

阻塞系统调用

Go 标准库中的系统调用通过 syscall.Syscall 包装,自动调用 entersyscall() / exitsyscall()

流程

G 调用系统调用
  → entersyscall(): G 状态 → _Gsyscall, 保存 SP/PC/BP
  → M 仍持有 P,但 P 标记为 syscall 状态
  → [系统调用执行中...]

快路径(syscall 很快返回,P 还在):
  → exitsyscall(): 重新获取原来的 P 或空闲 P
  → G 状态 → _Grunning,继续执行

慢路径(syscall 太久,P 被 sysmon 拿走):
  → exitsyscall(): 找不到 P
  → G 放入全局队列(_Grunnable)
  → M 休眠(stopm)

RawSyscall(不经过调度器):用于极快的系统调用(如 getpid()),不执行 entersyscall/exitsyscall,M-P 绑定不变。

网络轮询器(Netpoller)

Go 的 net 包将所有网络 I/O 设为非阻塞,通过平台特定的 I/O 多路复用机制管理:

平台实现
Linuxepoll (runtime/netpoll_epoll.go)
macOS / BSDkqueue (runtime/netpoll_kqueue.go)
WindowsIOCP (runtime/netpoll_windows.go)

流程

goroutine 执行 net.Read()
  → 底层 fd 设为非阻塞
  → read() 返回 EAGAIN(无数据)
  → gopark(): G 状态 → _Gwaiting, 记录在 pollDesc 中
  → G 从 M 上摘下,M 继续调度其他 G

[数据到达...]

findRunnable() 中调用 netpoll()
  → epoll_wait() / kevent() 返回就绪的 fd
  → 找到关联的 G,调用 goready()
  → G 状态 → _Grunnable,放入运行队列

关键点:文件 I/O 不走 netpoller。Linux 上 os.File.Read() 仍然是阻塞系统调用,会触发 M-P 分离的 handoff 机制。(Go 运行时在某些平台对 regular file 使用 netpoller,但 Linux 上 epoll 不支持 regular file。)

sysmon:系统监控线程

sysmon 是一个特殊的 M,不需要 P,以守护线程方式独立运行。

职责

功能说明
retake(P 回收)遍历所有 P,如果 P 在 _Prunning_Psyscall 状态超过 10ms,触发抢占或 handoff
抢占长运行 G检测 G 运行超过 10ms(forcePreemptNS = 10_000_000),设置 stackguard0 = stackPreempt 或发送 SIGURG
Syscall P handoffP 在 syscall 状态超过 10ms 且有等待运行的工作 → handoffp() 将 P 交给其他 M
netpoll如果超过 10ms 没有轮询网络,触发一次 netpoll
强制 GC如果超过 2 分钟没有 GC,强制触发
释放定时器检查定时器是否需要触发

sysmon 休眠策略:初始 20 微秒,如果持续空闲则指数增长到最大 10ms。有活跃工作时重置为 20 微秒。

retake 逻辑细节

func retake(now int64) uint32 {
    for i := 0; i < len(allp); i++ {
        pp := allp[i]
        s := pp.status
        if s == _Prunning || s == _Psyscall {
            t := int64(pp.schedtick)
            if int64(pd.schedtick) != t {
                pd.schedtick = uint32(t)
                pd.schedwhen = now  // 重置计时
                continue            // 最近有调度活动,跳过
            }
            if pd.schedwhen+forcePreemptNS <= now {
                preemptone(pp)  // 对 _Prunning: 抢占
                                // 对 _Psyscall: handoffp()
            }
        }
    }
}

来源:runtime/proc.go sysmon 函数


5. 抢占机制的演进

Go 1.2:协作式抢占

机制:编译器在每个函数入口插入栈检查代码(morestack 检查),检查 stackguard0 是否被设为 stackPreempt。如果是,跳转到 morestack_noctxt 执行抢占。

触发条件sysmon 检测到 G 运行超过 10ms → 设置 g.stackguard0 = stackPreempt → 下次函数调用时检测到并让出。

致命缺陷:没有函数调用的紧密循环无法被抢占

// 这个程序在 Go 1.13- 且 GOMAXPROCS=1 时会死锁
package main

import "fmt"

func main() {
    go fmt.Println("hi")
    for {} // 永远不会调用函数,永远不会检查 stackguard0
}

其他无法抢占的场景

Go 1.14:异步抢占(信号抢占)

机制:使用 POSIX 信号(Linux/macOS: SIGURG;Windows: SuspendThread + GetThreadContext)中断正在执行的 goroutine。

完整流程

1. sysmon 检测到 G 运行超过 10ms
2. sysmon 调用 preemptM(mp)
3. preemptM 对目标 M 发送 SIGURG(通过 tgkill 系统调用)
4. 目标 M 的信号处理器接收 SIGURG
5. 信号处理器在 gsignal goroutine 上运行
6. 检查中断点是否是 safe point
   - 如果是 safe point:
     a. 保存完整寄存器状态
     b. 将 asyncPreempt 注入为返回地址
     c. asyncPreempt → asyncPreempt2 → gopreempt_m
     d. G 状态 → _Grunnable,放回运行队列
     e. M 执行 schedule() 选择下一个 G
   - 如果不是 safe point:
     a. 直接恢复执行,稍后重试

为什么选择 SIGURG

原因说明
调试器兼容默认情况下 Linux 调试器会传递 SIGURG(不会像 SIGSEGV 那样中断调试)
libc 不使用不会与 C 库冲突
可以丢弃即使出现虚假 SIGURG 也无害
几乎无人使用SIGURG 的原始用途(TCP 带外数据)在实践中基本不用

Safe Points(安全点)

安全点是 GC 能够准确找到所有存活指针的位置。不安全的位置包括:

  1. unsafe.Pointeruintptr 转换期间(指针暂时不可见于 GC)
  2. 写屏障序列中间(enabled 检查和实际写入之间)
  3. range 循环中可能产生 past-the-end 指针的位置

在不安全点被中断时,运行时直接恢复执行并稍后重试。

零运行时开销:与编译器在循环头插入检查的方案(有 7.8% 性能损失)不同,信号方案只在实际需要抢占时才有开销。

可以关闭GODEBUG=asyncpreemptoff=1 关闭异步抢占(用于调试)。

来源:Non-cooperative goroutine preemption proposal,Go 1.14 实现。


6. 关键调度场景

场景G 状态流转P 行为M 行为关键机制
CPU 密集型 GRunning → (10ms后) Runnable → RunningP 不变M 收到 SIGURG → 抢占 G → 选择下一个 G异步抢占(Go 1.14+)
网络 I/O(net.Read)Running → Waiting → RunnableP 不释放M 继续调度其他 Gnetpoller(epoll/kqueue),G park 在 pollDesc
文件 I/O(os.File.Read)Running → Syscall → Running/Runnable可能被 sysmon 回收M 阻塞在内核阻塞 syscall → handoff
Channel/Mutex 阻塞Running → Waiting → RunnableP 不释放M 继续调度其他 Ggopark/goready,sudog 队列
CGO 调用Running → SyscallP 可能被回收M 阻塞在 C 代码entersyscall → sysmon handoff
大量 G(100K+)大部分在 Runnable/Waiting各 P 本地队列 + 全局队列M 数 ≈ GOMAXPROCS + syscall 中的work stealing 均衡,全局队列兜底
runtime.Gosched()Running → RunnableP 不释放M 继续调度下一个 G主动让出,G 放入全局队列
runtime.LockOSThread()Running(锁定到 M)P 绑定到该 MM 只运行该 G,其他 G 需要找其他 MG 完成或 Unlock 前 M 不执行其他 G

7. 代码示例(Go 1.22+,可直接运行)

示例 1:GOMAXPROCS 对并行执行的影响

// file: gomaxprocs_demo.go
package main

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

func cpuWork(id int, wg *sync.WaitGroup) {
	defer wg.Done()
	start := time.Now()
	// 纯 CPU 计算
	sum := 0
	for i := 0; i < 1_000_000_000; i++ {
		sum += i
	}
	fmt.Printf("Worker %d: %v (sum=%d)\n", id, time.Since(start), sum%100)
}

func main() {
	for _, procs := range []int{1, 2, 4, runtime.NumCPU()} {
		runtime.GOMAXPROCS(procs)
		var wg sync.WaitGroup
		start := time.Now()
		for i := 0; i < 4; i++ {
			wg.Add(1)
			go cpuWork(i, &wg)
		}
		wg.Wait()
		fmt.Printf("GOMAXPROCS=%d, Total: %v\n\n", procs, time.Since(start))
	}
}

运行:go run gomaxprocs_demo.go

预期:GOMAXPROCS=1 时 4 个 worker 串行执行,GOMAXPROCS=4 时并行,总耗时约 1/4。

示例 2:使用 GODEBUG=schedtrace 观察调度器

// file: schedtrace_demo.go
package main

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

func main() {
	runtime.GOMAXPROCS(4)
	fmt.Printf("GOMAXPROCS=%d, NumCPU=%d\n", runtime.GOMAXPROCS(0), runtime.NumCPU())

	var wg sync.WaitGroup
	// 启动大量 goroutine
	for i := 0; i < 1000; i++ {
		wg.Add(1)
		go func(id int) {
			defer wg.Done()
			// 混合 CPU 和 sleep
			sum := 0
			for j := 0; j < 1_000_000; j++ {
				sum += j
			}
			time.Sleep(10 * time.Millisecond)
		}(i)
	}
	wg.Wait()
	fmt.Println("Done")
}

运行:

GODEBUG=schedtrace=100 go run schedtrace_demo.go

输出格式:

SCHED 100ms: gomaxprocs=4 idleprocs=0 threads=6 spinningthreads=0 idlethreads=1 runqueue=45 [123 89 76 102]
字段含义
gomaxprocs=4GOMAXPROCS 值
idleprocs=0空闲 P 数量
threads=6OS 线程总数
spinningthreads=0自旋寻找工作的 M 数
idlethreads=1空闲 M 数
runqueue=45全局队列长度
[123 89 76 102]各 P 的本地队列长度

更详细的输出:GODEBUG=schedtrace=100,scheddetail=1

示例 3:展示无抢占时的 goroutine 饥饿

// file: starvation_demo.go
// 教学目的:展示 Go 1.14 之前紧密循环的问题
// Go 1.14+ 默认有异步抢占,所以这个程序能正常运行
// 使用 GODEBUG=asyncpreemptoff=1 可以重现旧行为
package main

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

func main() {
	runtime.GOMAXPROCS(1)

	done := make(chan struct{})
	go func() {
		fmt.Println("goroutine: started")
		done <- struct{}{}
	}()

	// 紧密循环 —— 在 Go 1.14+ 会被异步抢占
	// 在 GODEBUG=asyncpreemptoff=1 时会死锁
	start := time.Now()
	for time.Since(start) < 2*time.Second {
		// busy loop
	}
	<-done
	fmt.Println("main: done")
}

运行对比:

# Go 1.14+:正常运行
GOMAXPROCS=1 go run starvation_demo.go

# 关闭异步抢占:死锁(Ctrl+C 退出)
GOMAXPROCS=1 GODEBUG=asyncpreemptoff=1 go run starvation_demo.go

示例 4:正确使用 runtime.LockOSThread()

// file: lockosthread_demo.go
package main

import (
	"fmt"
	"runtime"
)

// 模拟需要在固定线程上执行的操作(如 OpenGL、GUI 框架)
var mainThreadWork = make(chan func())

func init() {
	// 在 init 中锁定:确保 main goroutine 绑定到主线程
	runtime.LockOSThread()
}

func main() {
	// 启动工作 goroutine
	done := make(chan struct{})
	go func() {
		// 其他 goroutine 通过 channel 提交工作到主线程
		for i := 0; i < 5; i++ {
			i := i
			result := make(chan int, 1)
			mainThreadWork <- func() {
				// 这段代码保证在主线程执行
				fmt.Printf("  Main thread work #%d (thread %d)\n", i, getThreadID())
				result <- i * 10
			}
			fmt.Printf("Worker got result: %d\n", <-result)
		}
		close(done)
	}()

	// 主线程事件循环
	fmt.Println("Main thread event loop started")
	for {
		select {
		case f := <-mainThreadWork:
			f()
		case <-done:
			fmt.Println("All work done")
			return
		}
	}
}

func getThreadID() int {
	// runtime 不直接暴露线程 ID,这里用 goroutine ID 替代演示
	// 真正需要线程 ID 时用 syscall.Gettid()(Linux)
	var buf [64]byte
	n := runtime.Stack(buf[:], false)
	// 解析 "goroutine N" 中的 N
	id := 0
	for i := len("goroutine "); i < n; i++ {
		if buf[i] < '0' || buf[i] > '9' {
			break
		}
		id = id*10 + int(buf[i]-'0')
	}
	return id
}

示例 5:使用 go tool trace 追踪调度器

// file: trace_demo.go
package main

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

func main() {
	// 创建 trace 文件
	f, err := os.Create("trace.out")
	if err != nil {
		panic(err)
	}
	defer f.Close()

	// 开始追踪
	if err := trace.Start(f); err != nil {
		panic(err)
	}
	defer trace.Stop()

	runtime.GOMAXPROCS(4)
	var wg sync.WaitGroup

	// 创建不同类型的负载
	for i := 0; i < 20; i++ {
		wg.Add(1)
		go func(id int) {
			defer wg.Done()
			if id%3 == 0 {
				// CPU 密集
				sum := 0
				for j := 0; j < 10_000_000; j++ {
					sum += j
				}
				_ = sum
			} else if id%3 == 1 {
				// Channel 通信
				ch := make(chan int, 1)
				go func() { ch <- 42 }()
				<-ch
			} else {
				// 主动让出
				for j := 0; j < 10; j++ {
					runtime.Gosched()
				}
			}
		}(i)
	}
	wg.Wait()
	fmt.Println("Trace written to trace.out")
}

运行并查看:

go run trace_demo.go
go tool trace trace.out
# 浏览器自动打开,可查看:
# - Goroutine analysis: 各 goroutine 的执行/等待时间
# - Scheduler latency profile: 调度延迟分布
# - Processor view: 各 P 上的 goroutine 执行时间线
# - Thread view: 各 M 的活动

8. 常见陷阱(Pitfalls)

陷阱 1:GOMAXPROCS=1 不能防止 race condition

// 错误认知:"单核就不会有 data race"
runtime.GOMAXPROCS(1)
// 即使 GOMAXPROCS=1,goroutine 仍然可以在任何调度点切换
// 只要有共享变量的非原子读写,就有 data race
// 正确做法:使用 sync.Mutex 或 atomic 操作

GOMAXPROCS=1 意味着只有一个 P,任意时刻只有一个 G 在运行。但 G 可以在函数调用、channel 操作、内存分配等时刻被抢占切换。go test -race 仍然能检测到 data race。

陷阱 2:LockOSThread 不调用 UnlockOSThread 导致 M 泄漏

func leakyHandler() {
    runtime.LockOSThread()
    // ... 忘记 UnlockOSThread
    // 当这个 goroutine 结束时,绑定的 M 会被直接销毁
    // 如果在循环中频繁创建这样的 goroutine,会不断创建新 M
}

// 正确做法:
func correctHandler() {
    runtime.LockOSThread()
    defer runtime.UnlockOSThread()
    // ...
}

注意:LockOSThread / UnlockOSThread引用计数语义,必须调用对等次数。

陷阱 3:Go < 1.14 紧密循环饿死其他 goroutine

如第 5 节所述。Go 1.14+ 已通过异步抢占解决,但如果设置 GODEBUG=asyncpreemptoff=1 仍可重现。

陷阱 4:大量 goroutine 阻塞在系统调用 → 线程爆炸

// 每个阻塞的 syscall 都需要一个 OS 线程
// 如果同时有 10000 个 goroutine 都在做文件 I/O:
for i := 0; i < 10000; i++ {
    go func() {
        f, _ := os.Open("/some/file")
        f.Read(buf) // 阻塞 syscall → M 不释放 → 需要新 M
    }()
}
// 可能创建 10000 个 OS 线程(受 SetMaxThreads 限制,默认 10000)
// 正确做法:使用 worker pool 限制并发文件 I/O

陷阱 5:用 channel 模式对抗调度器

// 反模式:忙轮询 channel
for {
    select {
    case msg := <-ch:
        process(msg)
    default:
        // 忙等待 → 浪费 CPU,抢占机制有效但低效
        runtime.Gosched() // 即使加了这个,仍然是忙等
    }
}

// 正确做法:直接阻塞在 channel 上
for msg := range ch {
    process(msg)
}

陷阱 6:Goroutine 泄漏

// 泄漏 1:忘记取消 context
func fetch(url string) {
    ctx := context.Background() // 永远不会被取消
    go func() {
        req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
        http.DefaultClient.Do(req) // 如果服务器不响应,永远阻塞
    }()
}

// 正确做法:
func fetch(ctx context.Context, url string) {
    ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
    defer cancel()
    // ...
}

// 泄漏 2:向无人接收的 channel 发送
func leak() {
    ch := make(chan int)
    go func() {
        ch <- 42 // 永远阻塞,goroutine 泄漏
    }()
    // 忘记读 ch
}

监控:runtime.NumGoroutine() 持续增长是泄漏的信号。

陷阱 7:假设 goroutine 调度顺序是确定的

for i := 0; i < 10; i++ {
    go fmt.Println(i)
}
// 输出顺序不确定!不要依赖 goroutine 的创建顺序 == 执行顺序
// 调度器可能按任意顺序执行

陷阱 8:CGO 调用长时间持有 P

CGO 调用通过 entersyscall 机制处理。如果 C 函数执行时间长:

/*
#include <unistd.h>
void slow_c_func() { sleep(10); }
*/
import "C"

func main() {
    for i := 0; i < 100; i++ {
        go C.slow_c_func() // 可能创建 100 个 OS 线程
    }
}

9. 生产环境最佳实践

9.1 容器中的 GOMAXPROCS

Go 1.25+:运行时自动感知 cgroup CPU 限制,不需要额外处理。GOMAXPROCS 默认 = min(CPU_limit, core_count),并且会动态更新。

Go < 1.25:使用 Uber 的 automaxprocs

import _ "go.uber.org/automaxprocs"

为什么重要:如果容器 CPU limit = 2 但宿主机有 64 核,GOMAXPROCS=64 会导致:

来源:Container-aware GOMAXPROCS (Go 官方博客)

9.2 GODEBUG=schedtrace 诊断

# 每秒输出一次调度器状态
GODEBUG=schedtrace=1000 ./myapp

# 带详细的 G/M/P 信息
GODEBUG=schedtrace=1000,scheddetail=1 ./myapp

诊断要点

观察指标异常信号可能原因
runqueue 持续很高CPU 密集负载超过 GOMAXPROCS增加 GOMAXPROCS 或优化计算
threads 远大于 gomaxprocs大量 goroutine 阻塞在 syscall使用 worker pool 限制并发 syscall
idleprocs 持续等于 gomaxprocsI/O 等待为主正常,或检查是否有不必要的阻塞
spinningthreads 持续非零工作不均匀,频繁 steal检查 goroutine 分布
某个 P 的本地队列远大于其他负载不均避免从单一 goroutine 扇出到同一 P

9.3 go tool trace 调度分析

# 方法 1:代码中嵌入(见示例 5)
go run trace_demo.go && go tool trace trace.out

# 方法 2:对运行中的程序(需要 net/http/pprof)
curl -o trace.out 'http://localhost:6060/debug/pprof/trace?seconds=5'
go tool trace trace.out

重点关注

9.4 调度延迟 profiling

Go 1.22+ 提供了更细粒度的 STW 指标:

import "runtime/metrics"

// 读取调度指标
samples := []metrics.Sample{
    {Name: "/sched/pauses/stopping/gc:seconds"},
    {Name: "/sched/goroutines:goroutines"},
    {Name: "/sched/latencies:seconds"},
}
metrics.Read(samples)

9.5 何时使用 runtime.LockOSThread

场景说明
GUI 框架Cocoa、OpenGL、SDL 要求在主线程调用
C 库的线程本地存储C 代码使用 TLS,goroutine 切换线程会破坏状态
Linux namespace 操作setns() 等 syscall 是 per-thread 的
需要固定线程 ID某些审计/安全场景需要稳定的线程 ID

正确模式:在 init() 中调用 LockOSThread(),用 channel 将工作派发到锁定线程执行(见示例 4)。


10. 与其他运行时调度器对比

维度Go GMPErlang/BEAMRust tokioJava Virtual Threads (Loom)Node.js Event Loop
模型M:N(G 在 M 上通过 P 调度)M:N(process 在 scheduler 上)M:N(task 在 worker thread 上)M:N(虚拟线程在 carrier thread 上)1:1 事件循环 + 线程池
任务开销~2KB 初始栈,可增长~300 字节/进程几十字节/task(无栈协程)~几百字节(起始帧)N/A(callback/Promise)
抢占协作 + 信号异步抢占(10ms)基于 reduction 计数的抢占(~4000 次函数调用)无抢占(协作式 .await协作式(阻塞操作时让出 carrier)无抢占(单线程事件循环)
公平性work-stealing + 全局队列每 61 tick 检查高度公平(per-reduction 调度)无内建公平性保证JVM 调度(ForkJoinPool)FIFO 事件队列
I/O 模型netpoller(epoll/kqueue)+ 阻塞 syscall handoff内建 per-process mailbox全异步(epoll/io_uring)阻塞时自动 unmount 虚拟线程全异步(libuv)
内存隔离共享内存(需要 mutex/channel)完全隔离(message passing only)共享内存(所有权系统保证安全)共享内存(需要同步)共享内存(单线程无竞争,worker_threads 除外)
GC并发三色标记清除per-process GC(无全局停顿)无 GC(所有权系统)分代 GC(STW 风险)V8 分代 GC
最大并发量数十万~百万 goroutine数百万 process(久经验证)数百万 task数百万虚拟线程受事件循环吞吐限制
CPU 密集处理好(多核并行 + 抢占)好(多调度器 + 抢占)差(需手动 spawn_blocking好(多核并行)差(需 worker_threads)
典型延迟goroutine 切换 ~50-100nsprocess 切换 ~微秒级task 切换 ~10-50ns虚拟线程切换 ~微秒级 [需验证]事件循环 tick ~毫秒级

关键差异点评


11. GMP 与 GC 的交互

STW 需要停止所有 P

GC 的两个 STW 阶段(Sweep Termination 和 Mark Termination)需要停止所有 P:

// runtime/proc.go
func stopTheWorld(reason stwReason) worldStop {
    semacquire(&worldsema)
    // ... 停止所有 P
}

STW 的代价主要在于等待所有 P 到达安全点。如果某个 G 在紧密循环中(Go 1.14+ 通过异步抢占解决),或在系统调用中,等待时间会更长。

Go 1.22 新增了更细粒度的 STW 指标:

GC Mark Workers 作为 goroutine 调度

GC 的并发标记阶段,mark workers 就是普通的 goroutine,由调度器调度:

// runtime/mgc.go - gcBgMarkStartWorkers()
// 为每个 P 创建一个 background mark worker goroutine
for gcBgMarkWorkerCount < gomaxprocs {
    go gcBgMarkWorker(ready)
    <-ready
}

三种 mark worker 模式

模式说明
gcMarkWorkerDedicatedMode专用模式:P 完全用于 GC 标记,不可被抢占
gcMarkWorkerFractionalMode部分模式:P 运行部分时间的 GC 标记,可被抢占
gcMarkWorkerIdleMode空闲模式:P 空闲时运行 GC 标记

GC 控制器目标是使用 25% 的 GOMAXPROCS 资源进行并发标记。例如 GOMAXPROCS=4 时,1 个 P 运行 dedicated worker,剩余 P 在空闲时运行 idle worker。

findRunnable() 中的 GC 优先级:调度器在搜索普通 goroutine 之前会检查是否需要运行 GC mark worker:

// runtime/proc.go - findRunnable()
if gcBlackenEnabled != 0 {
    gp, tnow = gcController.findRunnableGCWorker(pp, now)
    if gp != nil {
        return gp, false, true
    }
}

GC Assist(分配辅助)

当 GC 并发标记阶段进行时,分配内存的 goroutine 必须按比例辅助标记工作(back-pressure):

// 简化的 GC assist 流程
func mallocgc(size uintptr, ...) unsafe.Pointer {
    // 如果在 GC mark 阶段且当前 G 有 assist 债务
    if assistG.gcAssistBytes < 0 {
        gcAssistAlloc(assistG) // 做一些标记工作来"偿还"
    }
    // ... 实际分配
}

来源:runtime/mgc.go


调度器版本演进总结

Go 版本调度器变化
1.0 (2012)GM 模型,全局队列 + 全局锁
1.1 (2013)引入 P,GMP 模型,work-stealing(Dmitry Vyukov)
1.2 (2013)协作式抢占(函数入口 morestack 检查)
1.5 (2015)并发 GC(mark workers 作为 goroutine 调度)
1.14 (2020)异步抢占(SIGURG 信号),解决紧密循环饥饿
1.22 (2024)执行追踪器重写,新 STW 指标
1.24 (2025)运行时内部锁优化(spinbit mutex),2-3% CPU 开销降低
1.25 (2025)GOMAXPROCS 自动感知 cgroup CPU 限制,动态更新 GOMAXPROCS
1.25 (2025)实验性 Green Tea GC(GOEXPERIMENT=greenteagc),预期降低 10-40% GC 开销

参考来源

  1. Scalable Go Scheduler Design Doc — Dmitry Vyukov (2012) — 引入 P 的原始设计文档
  2. runtime/proc.go 源码 — 调度器核心实现
  3. runtime/runtime2.go 源码 — G/M/P/schedt 数据结构定义
  4. runtime/mgc.go 源码 — GC 与调度器交互
  5. Non-cooperative goroutine preemption proposal — Go 1.14 异步抢占设计文档
  6. Container-aware GOMAXPROCS — Go 官方博客 — Go 1.25 容器感知 GOMAXPROCS
  7. Go 1.22 Release Notes — 执行追踪器重写、STW 指标
  8. Go 1.24 Release Notes — 运行时性能优化
  9. Go 1.25 Release Notes — GOMAXPROCS cgroup 感知、Green Tea GC
  10. Understanding the Go Runtime: The Scheduler — 深度技术分析
  11. Go Scheduler (Melatoni, 2025) — 基于 Go 1.24 的详细源码分析
  12. Preemption in Go — hidetatz — 抢占机制演进详解
  13. Go Wiki: LockOSThread — LockOSThread 官方指南
  14. Scheduler Tracing in Go — Ardan Labs — GODEBUG=schedtrace 使用指南
  15. uber-go/automaxprocs — 容器 GOMAXPROCS 自动配置(Go < 1.25)

分享这篇文章:

上一篇
Go 运行时(二):并发三色标记清除收集器
下一篇
Go 并发(五):并发模式与最佳实践