Skip to content

Goroutine 调度器 GMP

GMP 是 Go 并发的核心引擎。深入理解它,能帮你写出更高效的并发代码,也能解释很多"奇怪"的行为。

GMP 三元组

G — Goroutine
    用户态协程,初始栈 2-8KB,可动态增长到 1GB
    包含:栈、PC 寄存器、状态、等待的 channel 等

M — Machine(OS Thread)
    真正执行代码的 OS 线程
    每个 M 绑定一个 P 才能运行 G
    数量上限默认 10000(可调)

P — Processor(逻辑处理器)
    调度上下文,持有本地 G 队列(runq,最多 256 个)
    数量 = GOMAXPROCS(默认 = CPU 核数)
    M 必须持有 P 才能执行 G

调度模型图

全局队列 (GRQ)
[G][G][G][G][G]
       ↕ steal/inject
  ┌────┴────┐
  P0        P1        P2        P3
  [G][G][G] [G][G]    [G][G][G] [G]
  ↑         ↑         ↑         ↑
  M0        M1        M2        M3
  ↑         ↑         ↑         ↑
 CPU0      CPU1      CPU2      CPU3

G 的状态机

_Gidle      → 刚分配,未初始化
_Grunnable  → 在运行队列中,等待被调度
_Grunning   → 正在 M 上执行
_Gsyscall   → 正在执行系统调用
_Gwaiting   → 阻塞等待(channel、锁、timer 等)
_Gdead      → 执行完毕,等待复用

调度时机

Go 调度器在以下时机进行调度切换:

go
// 1. goroutine 主动让出
runtime.Gosched()

// 2. 系统调用(自动)
os.ReadFile("large.txt")  // 进入 syscall,M 与 P 解绑

// 3. channel 操作阻塞
ch := make(chan int)
<-ch  // 阻塞,调度其他 G

// 4. 函数调用(抢占点)
// Go 1.14+ 支持基于信号的异步抢占
// 每 10ms 发送 SIGURG 信号,强制抢占长时间运行的 G

// 5. 内存分配(可能触发 GC,进而调度)

工作窃取(Work Stealing)

当某个 P 的本地队列为空时,会从其他地方"偷"任务:

P 本地队列为空时的查找顺序:
1. 从全局队列取(每 61 次调度检查一次全局队列,防止饥饿)
2. 从 netpoller 取(就绪的网络 I/O goroutine)
3. 从其他 P 的本地队列偷一半
4. 都没有 → M 进入休眠
go
// 演示工作窃取效果
package main

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

func main() {
    runtime.GOMAXPROCS(4)  // 4 个 P

    var wg sync.WaitGroup
    // 创建大量 goroutine,调度器会自动负载均衡
    for i := 0; i < 100; i++ {
        wg.Add(1)
        go func(id int) {
            defer wg.Done()
            // 模拟计算
            sum := 0
            for j := 0; j < 1_000_000; j++ {
                sum += j
            }
            _ = sum
        }(i)
    }
    wg.Wait()
    fmt.Println("完成")
}

系统调用处理

系统调用是 GMP 调度的关键场景:

goroutine 发起系统调用(如 read())

M 进入内核态,P 不能等待(会浪费 CPU)

P 与 M 解绑,P 寻找空闲 M 或创建新 M

系统调用返回

原 M 尝试获取 P(优先原来的 P)
  ├── 成功 → 继续执行 G
  └── 失败 → G 放入全局队列,M 进入休眠
go
// 可以用 GODEBUG=schedtrace=1000 观察调度
// GOMAXPROCS=2 GODEBUG=schedtrace=1000 go run main.go
// 输出:
// SCHED 1000ms: gomaxprocs=2 idleprocs=0 threads=4 spinningthreads=0
//               idlethreads=1 runqueue=0 [2 1]
//                                         ↑ ↑
//                                         P0 P1 的本地队列长度

goroutine 泄漏

最常见的 Go 性能问题之一:

go
// 泄漏示例 1:channel 永远没人读
func leak1() {
    ch := make(chan int)
    go func() {
        ch <- 1  // 永远阻塞,goroutine 泄漏
    }()
    // 函数返回,ch 被 GC,但 goroutine 还在等待
}

// 泄漏示例 2:没有退出机制的 goroutine
func leak2() {
    go func() {
        for {
            time.Sleep(time.Second)
            // 没有退出条件,永远运行
        }
    }()
}

// 正确做法:使用 context 控制生命周期
func noLeak(ctx context.Context) {
    go func() {
        for {
            select {
            case <-ctx.Done():
                return  // 收到取消信号,退出
            case <-time.After(time.Second):
                doWork()
            }
        }
    }()
}

goroutine 数量监控

go
package main

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

func monitorGoroutines() {
    ticker := time.NewTicker(time.Second)
    for range ticker.C {
        fmt.Printf("goroutine 数量: %d\n", runtime.NumGoroutine())
    }
}

// 使用 goleak 检测泄漏(测试中)
// import "go.uber.org/goleak"
//
// func TestMyFunc(t *testing.T) {
//     defer goleak.VerifyNone(t)
//     myFunc()
// }

调度器可视化

go
// 使用 runtime/trace 可视化调度
package main

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

func main() {
    f, _ := os.Create("trace.out")
    defer f.Close()

    trace.Start(f)
    defer trace.Stop()

    var wg sync.WaitGroup
    for i := 0; i < 10; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            // 你的代码
        }()
    }
    wg.Wait()
}

// 分析:go tool trace trace.out
// 可以看到每个 goroutine 在哪个 P 上运行、何时被调度

实践建议

go
// 1. 合理设置 GOMAXPROCS
// CPU 密集型:GOMAXPROCS = CPU 核数(默认)
// I/O 密集型:可以适当增大,但收益递减

// 2. 避免在热路径上创建大量短命 goroutine
// 使用 worker pool 复用 goroutine
type Pool struct {
    work chan func()
    wg   sync.WaitGroup
}

func NewPool(workers int) *Pool {
    p := &Pool{work: make(chan func(), 100)}
    for i := 0; i < workers; i++ {
        p.wg.Add(1)
        go func() {
            defer p.wg.Done()
            for f := range p.work {
                f()
            }
        }()
    }
    return p
}

// 3. 使用 pprof 分析 goroutine 状态
// go tool pprof http://localhost:6060/debug/pprof/goroutine

常见误区

  • runtime.GOMAXPROCS(1) 不等于单线程,系统调用仍会创建新 M
  • goroutine 不是线程,不要用线程的思维管理它
  • go func() 是非常廉价的操作(~2μs),但不是零成本

本站内容由 褚成志 整理编写,仅供学习参考