Go · #go#goroutine#scheduler#gmp

Go协程调度器GMP模型深入解析

2023.06.15 Go 10 min 3.9k
// 目录 · contents

引言

Go语言最引以为傲的特性之一就是其轻量级的并发模型——goroutine。一个Go程序可以轻松创建数十万甚至上百万个goroutine,而背后支撑这一切的就是Go运行时的调度器。理解GMP模型不仅有助于编写高效的并发程序,更能帮助我们在遇到性能瓶颈时做出正确的判断。

本文将从调度器的演进历史出发,深入分析GMP模型的每一个组件,揭示调度流程中的关键细节,包括工作窃取(work stealing)、抢占式调度、以及如何通过工具进行调度分析。

GMP模型概览

GMP模型是Go调度器的核心,由三个关键组件构成:

  • G(Goroutine):代表一个goroutine,包含栈信息、状态、以及待执行的函数
  • M(Machine):代表一个操作系统线程,是真正执行计算的实体
  • P(Processor):代表逻辑处理器,是G和M之间的桥梁,管理本地运行队列
graph TB
    subgraph "GMP 模型架构"
        GRQ["全局运行队列 (Global Run Queue)"]

        subgraph "P0"
            LRQ0["本地运行队列"]
            G1["G1"] --> LRQ0
            G2["G2"] --> LRQ0
            G3["G3"] --> LRQ0
        end

        subgraph "P1"
            LRQ1["本地运行队列"]
            G4["G4"] --> LRQ1
            G5["G5"] --> LRQ1
        end

        M0["M0 (OS Thread)"] --> P0
        M1["M1 (OS Thread)"] --> P1
        M2["M2 (OS Thread, spinning)"]

        P0 --> GRQ
        P1 --> GRQ
    end

    style GRQ fill:#f9f,stroke:#333
    style M0 fill:#bbf,stroke:#333
    style M1 fill:#bbf,stroke:#333
    style M2 fill:#fbb,stroke:#333

调度器的演进

Go调度器经历了几个重要版本的演进:

版本 模型 特点
Go 0.x GM模型 全局锁,性能差
Go 1.1 GMP模型 引入P,本地队列,大幅提升性能
Go 1.14 基于信号的抢占 解决了长时间运行goroutine的调度问题

G(Goroutine)详解

每个goroutine在运行时内部由一个runtime.g结构体表示。其核心字段包括:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// runtime/runtime2.go (simplified)
type g struct {
stack stack // goroutine的栈空间
stackguard0 uintptr // 用于栈增长检查
m *m // 当前绑定的M
sched gobuf // 保存调度上下文(SP、PC等)
atomicstatus uint32 // goroutine的状态
goid int64 // goroutine ID
preempt bool // 抢占标记
}

type gobuf struct {
sp uintptr // 栈指针
pc uintptr // 程序计数器
g guintptr
ret uintptr
ctxt unsafe.Pointer
bp uintptr // 帧指针(用于profiling)
}

Goroutine的状态机

stateDiagram-v2
    [*] --> _Gidle: 新分配
    _Gidle --> _Gdead: 初始化
    _Gdead --> _Grunnable: go func()
    _Grunnable --> _Grunning: 被调度执行
    _Grunning --> _Grunnable: 被抢占
    _Grunning --> _Gwaiting: 阻塞(channel/mutex/syscall)
    _Gwaiting --> _Grunnable: 唤醒
    _Grunning --> _Gdead: 执行完毕
    _Gdead --> _Grunnable: 复用
    _Grunning --> _Gsyscall: 系统调用
    _Gsyscall --> _Grunnable: 系统调用返回

goroutine初始栈大小仅为2KB(Go 1.4+),远小于OS线程的默认栈大小(通常为1MB-8MB)。Go使用连续栈(contiguous stack),当栈空间不足时会分配一个更大的栈并拷贝原栈内容。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
package main

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

func main() {
var wg sync.WaitGroup
// 创建大量goroutine来观察调度行为
for i := 0; i < 100000; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
// 每个goroutine做一些简单的工作
sum := 0
for j := 0; j < 1000; j++ {
sum += j
}
if id == 0 {
fmt.Printf("Goroutine count: %d\n", runtime.NumGoroutine())
}
}(i)
}
wg.Wait()
}

M(Machine)详解

M代表操作系统线程,由runtime.m结构体表示:

1
2
3
4
5
6
7
8
type m struct {
g0 *g // 调度栈上的goroutine
curg *g // 当前运行的goroutine
p puintptr // 绑定的P
nextp puintptr // 唤醒时优先绑定的P
spinning bool // 是否处于自旋状态
park note // 用于休眠/唤醒
}

关键概念: - g0:每个M都有一个特殊的g0,用于执行调度代码。g0拥有较大的栈空间(通常8KB),调度循环运行在g0的栈上 - 自旋线程(spinning thread):没有找到可运行G的M会进入自旋状态,而不是立即休眠。这是一个用CPU换延迟的策略 - M的数量默认上限为10000(可通过runtime/debug.SetMaxThreads调整)

P(Processor)详解

P是调度器中最关键的创新,它的引入解决了GM模型中全局锁的性能问题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
type p struct {
id int32
status uint32
m muintptr // 绑定的M
runqhead uint32 // 本地队列头
runqtail uint32 // 本地队列尾
runq [256]guintptr // 本地运行队列(固定大小256)
runnext guintptr // 下一个优先运行的G
gFree struct { // 空闲G的缓存
gList
n int32
}
// mcache, timer等资源
}

GOMAXPROCS

GOMAXPROCS决定了P的数量,也就是同时执行Go代码的最大线程数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package main

import (
"fmt"
"runtime"
)

func main() {
// 获取当前GOMAXPROCS
fmt.Println("GOMAXPROCS:", runtime.GOMAXPROCS(0))
// 默认等于CPU核心数
fmt.Println("NumCPU:", runtime.NumCPU())

// 动态调整(通常不需要手动设置)
prev := runtime.GOMAXPROCS(4)
fmt.Println("Previous GOMAXPROCS:", prev)
}

调度流程

核心调度循环

调度器的核心是schedule()函数,运行在M的g0栈上:

flowchart TD
    A[schedule] --> B{检查 runnext}
    B -->|有| H[执行 G]
    B -->|无| C{检查本地队列}
    C -->|有| H
    C -->|无| D{检查全局队列}
    D -->|有| H
    D -->|无| E{从 netpoller 获取}
    E -->|有| H
    E -->|无| F{从其他 P 窃取}
    F -->|有| H
    F -->|无| G[M 休眠]
    H --> I[execute]
    I --> J[gogo - 切换到 G 的栈]
    J --> K[G 运行用户代码]
    K --> L{G 让出/阻塞}
    L --> M[mcall - 切换回 g0 栈]
    M --> A

查找可运行G的顺序

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// runtime/proc.go (simplified)
func findRunnable() (gp *g, inheritTime bool) {
// 1. 检查本地队列
if gp, inheritTime := runqget(_p_); gp != nil {
return gp, inheritTime
}

// 2. 检查全局队列(每61次调度检查一次,避免饥饿)
if _g_.m.p.ptr().schedtick%61 == 0 {
if gp := globrunqget(_p_, 1); gp != nil {
return gp, false
}
}

// 3. 检查 netpoller
if netpollinited() && atomic.Load(&netpollWaiters) > 0 {
if list := netpoll(0); !list.empty() {
gp := list.pop()
// 将剩余的放入全局队列
injectglist(&list)
return gp, false
}
}

// 4. 从其他P窃取(work stealing)
for i := 0; i < 4; i++ {
// 随机选择一个P,尝试偷取其一半的G
if gp := runqsteal(_p_, allp[random], stealRunNextG); gp != nil {
return gp, false
}
}

return nil, false
}

工作窃取(Work Stealing)

工作窃取是GMP模型中保持负载均衡的关键机制。当一个P的本地队列为空时,它会尝试从其他P的队列中”偷”走一半的goroutine。

sequenceDiagram
    participant P0 as P0 (空闲)
    participant P1 as P1 (繁忙)
    participant GRQ as 全局队列

    P0->>P0: 本地队列为空
    P0->>GRQ: 1. 检查全局队列
    GRQ-->>P0: 无可用G
    P0->>P1: 2. 随机选择P1,窃取一半G
    P1-->>P0: 转移 G4, G5, G6
    Note over P0: 继续执行 G4
    Note over P1: 队列减少,负载更均衡

窃取策略的关键细节: - 窃取时会随机选择目标P,避免所有空闲P同时窃取同一个P - 窃取数量为目标P本地队列长度的一半 - 如果目标P设置了runnext,也可能被窃取

抢占式调度

协作式抢占(Go 1.13及之前)

早期Go使用协作式抢占——在函数调用时插入栈增长检查点,如果goroutine运行超过10ms,调度器会设置抢占标记:

1
2
3
4
5
6
7
8
// 编译器在函数入口插入的检查(伪代码)
func someFunction() {
if stackguard0 == stackPreempt {
// 触发调度
runtime.morestack()
}
// 函数正常逻辑
}

问题:如果goroutine执行密集计算循环且没有函数调用,就永远不会被抢占。

1
2
3
4
5
6
7
// 在Go 1.13之前,这个goroutine可能永远不会被调度出去
go func() {
for {
// 纯计算,没有函数调用
// 不会触发抢占检查
}
}()

基于信号的抢占(Go 1.14+)

Go 1.14引入了基于信号的异步抢占,解决了上述问题:

sequenceDiagram
    participant Sysmon as sysmon线程
    participant M as M (OS线程)
    participant G as G (运行中)

    Sysmon->>Sysmon: 检测到G运行超过10ms
    Sysmon->>M: 发送SIGURG信号
    M->>M: 信号处理函数执行
    M->>G: 在安全点暂停G
    Note over G: 保存寄存器状态
    M->>M: 将G放回运行队列
    M->>M: 重新调度
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
package main

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

func main() {
// 设置只用一个P,便于观察抢占行为
runtime.GOMAXPROCS(1)

// 在Go 1.14+中,这个死循环也能被正确抢占
go func() {
for {
// 纯计算密集循环
}
}()

// 这个goroutine依然可以得到执行
time.Sleep(100 * time.Millisecond)
fmt.Println("Main goroutine is still running! Preemption works.")
}

Sysmon:系统监控线程

sysmon是一个特殊的M,它不绑定P,独立运行,负责:

  1. 抢占长时间运行的G:检查是否有G运行超过10ms
  2. 回收syscall阻塞的P:如果M因系统调用阻塞,sysmon会将P分配给其他M
  3. 网络轮询:定期执行netpoll获取就绪的网络事件
  4. 强制GC:如果超过2分钟没有GC,强制触发
flowchart LR
    subgraph "sysmon 职责"
        A[抢占检测] --> B[回收P]
        B --> C[网络轮询]
        C --> D[强制GC]
        D --> A
    end
    E[sysmon线程<br/>独立运行] --> A

系统调用处理

当goroutine执行系统调用时,M会被阻塞。调度器通过”hand off”机制确保P不会被浪费:

sequenceDiagram
    participant G1 as G1
    participant M1 as M1
    participant P as P0
    participant M2 as M2 (新/空闲)

    G1->>M1: 执行系统调用(如read)
    M1->>M1: 进入syscall,解绑P
    M1-->>P: P0被释放
    P->>M2: P0绑定新的M
    M2->>M2: 继续运行其他G
    Note over M1: M1在syscall中阻塞
    M1->>M1: syscall返回
    M1->>M1: 尝试获取空闲P
    alt 有空闲P
        M1->>M1: 绑定P,继续运行G1
    else 无空闲P
        M1->>M1: 将G1放入全局队列
        M1->>M1: M1进入休眠
    end

Trace分析实战

Go提供了强大的trace工具来分析调度行为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
package main

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

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

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

// 模拟并发工作负载
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
sum := 0
for j := 0; j < 1000000; j++ {
sum += j
}
fmt.Printf("Worker %d done, sum=%d\n", id, sum)
}(i)
}
wg.Wait()
}

使用trace工具分析:

1
2
3
4
5
# 运行程序生成trace文件
go run main.go

# 使用go tool trace分析
go tool trace scheduler.trace

trace工具会在浏览器中打开一个可视化界面,可以看到: - 每个P上goroutine的调度时间线 - goroutine的创建、运行、阻塞、唤醒事件 - 系统调用的耗时 - 网络I/O事件

性能优化建议

1. 合理设置GOMAXPROCS

1
2
3
4
// CPU密集型任务:GOMAXPROCS = CPU核心数(默认值)
// I/O密集型任务:可以适当增大
// 容器环境:使用 uber-go/automaxprocs 自动适配
import _ "go.uber.org/automaxprocs"

2. 避免创建过多goroutine

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 不好的做法:为每个请求创建大量goroutine
// 好的做法:使用worker pool模式
func workerPool(tasks <-chan Task, results chan<- Result, numWorkers int) {
var wg sync.WaitGroup
for i := 0; i < numWorkers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for task := range tasks {
results <- process(task)
}
}()
}
wg.Wait()
close(results)
}

3. 减少goroutine切换开销

1
2
3
4
5
6
7
// 使用runtime.LockOSThread()将goroutine绑定到OS线程
// 适用于调用C库、OpenGL等需要线程亲和性的场景
func bindToThread() {
runtime.LockOSThread()
defer runtime.UnlockOSThread()
// 在固定的OS线程上执行
}

4. 监控调度延迟

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
package main

import (
"fmt"
"runtime/metrics"
)

func printSchedulerMetrics() {
// Go 1.16+ 提供标准化的metrics
descs := metrics.All()
samples := make([]metrics.Sample, len(descs))
for i := range descs {
samples[i].Name = descs[i].Name
}
metrics.Read(samples)
for _, s := range samples {
if s.Name == "/sched/latencies:seconds" {
hist := s.Value.Float64Histogram()
fmt.Printf("Scheduler latency p50: %v\n", hist.Counts)
}
}
}

总结

Go的GMP调度模型是一个精心设计的系统,它通过以下机制实现了高效的goroutine调度:

  1. P的引入消除了全局锁竞争,每个P拥有本地队列
  2. 工作窃取保证了负载均衡,避免某些P空闲而其他P过载
  3. 基于信号的抢占确保了公平调度,即使面对计算密集型goroutine
  4. sysmon监控线程处理系统调用阻塞、网络轮询等异步任务
  5. hand off机制确保系统调用不会浪费P资源

理解GMP模型有助于我们: - 编写更高效的并发程序 - 合理设置GOMAXPROCS - 使用trace工具诊断调度问题 - 理解goroutine泄漏和调度延迟的根因

深入掌握调度器原理,是成为Go高级开发者的必经之路。


踩坑记录

团队用 Go 重写核心推送服务,原 Java 服务维持 50 万长连接需要 20G 内存,Go 版本上线初期只用 7G,效果很好。但某天凌晨监控发现 goroutine 数量从正常的 50 万暴涨到 800 万,内存从 8G 飙到 60G,触发 OOM。

排查过程:先用 pprof 抓 goroutine 快照,发现 80% 的 goroutine 卡在一个第三方推送回调的 HTTP 请求上。再看代码——http.Client 没有设置 Timeout。网络分区时下游服务无响应,所有发出去的 HTTP 请求都在无限等待,GMP 调度器不断创建新 M 来处理积压的新连接请求,goroutine 越堆越多。

修复:给所有 http.ClientTimeout: 3s,同时用 semaphore 限制最大并发 goroutine 数量为 100 万。之后再没出现过 goroutine 泄漏。

实测结果

同等 50 万长连接,8 核 16G 服务器

指标 Java(Netty) Go(修复前) Go(修复后)
空闲内存占用 22G 6.8G 7.1G
CPU(空闲时) 15% 8% 9%
goroutine 峰值 不适用 800 万(OOM) 稳定 52 万
运行稳定性 稳定 OOM 宕机 稳定运行 6+ 个月

我的看法

Go 的 goroutine 比线程便宜,但不代表可以无限创建。GMP 调度器解决的是「调度效率」问题,不是「资源无限」问题。每个 goroutine 初始栈 2KB,800 万 goroutine 光栈就 16G,再加上各自持有的连接、缓冲区,OOM 是必然的。

做 Go 服务有两条铁律:一是所有 I/O 操作必须带 timeoutcontext + deadline 是最低限度;二是对 goroutine 生命周期要有明确控制,goroutine 泄漏比内存泄漏更难发现,pprof 的 goroutine 端点应该纳入日常监控。

作者 · authorzt
发布 · date2023-06-15
篇幅 · length3.9k 字 · 10 min
许可 · licenseCC BY-SA 4.0
$ echo "comments" · 评论