Files

8.4 KiB
Raw Permalink Blame History

tags, create time, update time
tags create time update time
go/lang
goroutine
gmp-scheduler
work-stealing
stack-dynamic
2026-08-08 19:00 2026-08-08 19:00

Goroutine 调度模型

概述

Go 的并发能力源自其内置的 goroutine 和 M:N 调度模型。与操作系统线程 1:1 映射不同,Go 使用 GMP 三方协作架构将数千个 goroutine 高效地复用到有限的 OS 线程上。理解 GMP 调度原理是排查性能瓶颈、避免 goroutine leak 的基石。

[!NOTE] 为什么 Go 要设计自己的调度器? 操作系统线程的创建和切换成本较高(通常几 MB 栈空间 + 内核态切换),而 goroutine 只需 ~2KB 初始栈且在内核态之外完成调度。这使得 Go 可以同时运行数百万个 goroutine 而不拖慢系统。

核心原理

生命周期状态

一个 goroutine 在生命周期中会经历以下状态转换:

stateDiagram-v2
    [*] -->idle: 创建后初始化
    
    idle-->gwaiting: 阻塞中(等待IO/锁/channel)
    gwaiting-->gorunnable: 被唤醒放入 runqueue
    gorunnable-->grunning: 被 M 取出执行
    grunning-->gidle: 正常返回退出
    grunning-->gsyscall: 执行系统调用
    gsyscall-->gorunnable: 系统调用返回
    gsyscall-->gwaiting: 继续阻塞
    
    state gwaiting {
        channel_wait: 等待channel读写
        mutex_wait: 等待sync.Mutex
        timer_wait: 等待定时器触发
        netpoll_wait: 等待网络事件
    }
    
    style grunning fill:#c8e6c9
    style gorunnable fill:#e3f2fd
    style gwaiting fill:#fff3e0
    style gsyscall fill:#fce4ec
  • Gidling(空闲):刚创建或已退出的 goroutine,尚未进入可调度状态
  • Grunnable(就绪):在 global 或 local runqueue 中等待被调度
  • Grunning(运行):绑定了某个 P,正在执行代码
  • Gwaiting(等待):阻塞于某些外部事件(channel IO、mutex、timer)
  • Gsyscall(系统调用):正在进行系统调用,此时不占用 CPU 但持有 P

G / M / P 三者的关系

这是面试最高频的问题之一。三者的定义和关系如下:

角色 全称 职责 类比
G Goroutine 用户级协程,包含栈、指令指针、状态等 任务/工作单元
M Machine Thread 绑定到 OS 内核线程,真正执行代码 CPU 核心
P Processor 调度的本地资源,拥有 local runqueue 和执行权限 调度上下文/沙盒
graph TD
    subgraph "Global RunQueue<br/>全局等待队列"
        GR["待调度的 goroutine"]
    end
    
    subgraph "P Pool<br/>最多 1024 个 P"
        P1["P1<br/>local queue"]
        P2["P2<br/>local queue"]
        Pn["Pn..."]
    end
    
    subgraph "M Pool<br/>OS Threads"
        M1["M1 (OSThread)"]
        M2["M2 (OSThread)"]
    end
    
    subgraph "G Stack"
        G1["g1 stack<br/>~2KB start"]
        G2["g2 stack<br/>~2KB start"]
        G3["g3 stack<br/>~2KB start"]
    end
    
    GR -.->|"work stealing"| P1
    P1 -->|"pop"| G1
    P2 -->|"pop"| G2
    Pn -->|"pop"| G3
    
    M1 -->|"绑定"| P1
    M1 -->|"执行"| G1
    M2 -->|"绑定"| P2
    M2 -->|"执行"| G2

关键约束:

  • 只有绑定了 P 的 M 才能执行 goroutine。一个 M 必须拥有一个 P 才能运行 G。
  • P 的数量由 GOMAXPROCS 决定,默认是当前机器的逻辑 CPU 数量,最大值为 1024。
  • M 可以多于 P:当 M 在系统调用中被阻塞时,runtime 会创建新的 M 来获取新的 P 继续工作。

Local RunQueue 与 Global RunQueue

每个 P 维护一个长度为 256 的 local runqueue。调度器的工作循环(schedloop):

  1. 从自身 local runqueue 中取一个 goroutine 执行
  2. 如果 local queue 为空,尝试偷取其他 P 的 queue
  3. 如果偷不到,从 global runqueue 取
  4. 如果 global 也空,主动让出 P 进入休眠

Work Stealing 机制

当某个 P 的 local queue 为空而其他 P 繁忙时,空 P 会随机选择一个目标 P,从中偷取约一半的 goroutine。这就是 work-stealing 的核心思想——负载均衡通过窃取而非推送实现,减少了全局锁的竞争。

具体算法(semi-global work-stealing):

  • 发送者将自己的 goroutine push 到本地 queue 尾部
  • 接收者在 queue 满时将一半元素 steal 到全局 queue
  • 空闲 P 随机选一个忙碌 P,从其 queue 头部 steal 一半元素

[!TIP] 面试常考点 为什么从头部偷而不是尾部?因为头部是最近加入的 goroutine(时间局部性好),从头部偷可以减少对原 P 的 cache 干扰。原 P 自己取的是尾部的旧元素。

Stack 动态伸缩

Go 的 goroutine 栈采用段式内存管理,初始大小为 2KB,可动态伸缩:

+----------------------------+
| Segment 1 (8KB)           |
| +------------------------+ |
| | GOROUTINE STACK         | |
| | [2KB grow → up to 1GB]  | |
| +------------------------+ |
+----------------------------+
     ^                        ^
   minSize                  maxSize
   2KB (x86: 4KB)            ~1GB

伸缩规则:

场景 操作 新大小
需要更多栈空间 GrowStack old * 2 (不超过 maxStackSize)
大量未使用的栈空间 ShrinkStack old / 2 (不低于 minStackSize)
  • minStackSize = 2KB(32位平台为 4KB)
  • maxStackSize = 1GB
  • 扩容时会分配更大的段并拷贝原有数据,缩容类似

[!WARNING] 常见误区 "goroutine 栈初始 2KB,最大 1GB"这句话容易引起误解。实际上 goroutine 不会一开始就申请 2KB——Go 1.4 之前是这样,之后采用了段式分配。goroutine 第一次需要栈时只申请一个小段,按需增长。

代码示例

观察 GOMAXPROCS 的影响

func main() {
    runtime.GOMAXPROCS(2) // 限制为 2 个逻辑处理器
    
    var wg sync.WaitGroup
    for i := 0; i < 10; i++ {
        wg.Add(1)
        go func(id int) {
            defer wg.Done()
            fmt.Printf("G%d running on M-%d\n", id, runtime.Stack(nil, false))
        }(i)
    }
    wg.Wait()
}

调整 GOMAXPROCS 的值可以直接控制并发度上限。设为 1 时所有 goroutine 串行执行;设为大于 CPU 数量的值时,多余的 P 会通过 spin 模式短暂竞争 CPU。

栈逃逸检查

func capturesVar() func() int {
    x := 42
    return func() int { return x } // x 逃逸到堆上
}

// 编译命令:go build -gcflags="-m -m"
// 输出: ./main.go:4:10: func captured by closure escapes to heap

栈逃逸意味着变量被分配到堆上而非栈上,这会触发 GC 并且降低局部性。编译器自动处理,开发者可以通过 -m 标志查看逃逸分析结果。

实践场景

面试高频问题

Q: GOMAXPROCS 设置为多少最合适? 默认值即可(等于机器逻辑 CPU 数)。大多数场景下不需要手动调优。只有纯 CPU 密集型任务偶尔可以从超配获得一点收益,但收益有限且会增加上下文切换开销。

Q: goroutine 和 OS 线程的区别?

维度 OS 线程 Goroutine
栈大小 静态(通常 1-8MB) 动态(2KB 起,按需增长)
创建成本 高(内核态) 极低(内核外)
调度方式 内核调度 Go runtime 调度
并发规模 数千 百万级

Q: 什么情况会导致 goroutine 泄漏?

  1. Channel 无人接收导致 send 侧永久阻塞
  2. Context 从未取消,等待 cancelled 信号的 goroutine 无法退出
  3. 在 select 中向永不关闭的 channel 发数据
  4. 定时器未 Stop 导致 Timer goroutine 泄漏

[!TIP] 最佳实践 使用 go tool pprof -inuse_goroutines 定期检查活跃 goroutine 的数量变化趋势,异常增长往往意味着 goroutine leak。

实战建议

  • 永远给 goroutine 提供退出路径:用 context 或 channel 传递取消信号,不要让 goroutine 无限循环。
  • 合理设置 GOMAXPROCS:I/O 密集型和 CPU 密集型有不同策略,但 Go 的默认值对大部分场景都足够好。
  • 关注 goroutine 数量:监控工具中 goroutine 数量持续上升是危险的信号。

扩展阅读