深入探讨 Go 语言中 goroutine协程调度 的底层实现与并发安全GMP 这个三件套本文研究了一周终于搞明白 Go 调度是怎么回事了前言老王为什么本文创建了 1000 个 goroutineCPU 还是只用了 2 个核 新来的实习生小张一脸困惑。本文看了看他的代码发现他没设置 GOMAXPROCS。你这是把 GMP 调度模型给忘了啊GMP什么是 GMP看来得从基础讲起了。今天本文们聊聊 Go 的 GMP 调度模型。一、底层原理1.1 GMP 三件套Ggoroutine协程Mmachine操作系统线程Pprocessor调度器graph TD A[G(Goroutine)] -- B[运行] B -- C{P 的数量} C --|有限| D[P(Processor)] D -- E[M(Machine/线程)] E -- F[CPU] G[全局运行队列] -- D H[G 阻塞] -- I[M 让出 P] I -- J[P 绑定新 M]关键点G 是逻辑上的协程M 是真正的操作系统线程P 是调度器数量固定GOMAXPROCSG 要在 M 上跑M 要有 PG 阻塞时M 让出 PP 绑定新 M1.2 调度协作 vs 抢占调度方式优点缺点协作式减少上下文切换代码要主动让出抢占式不用代码配合开额外开销Go 混合兼顾实现复杂Go 1.14 之后实现了基于信号的抢占式调度但仍然保留了协作式的特征。二、快速上手看个例子理解调度本质package main import ( fmt runtime sync ) func main() { runtime.GOMAXPROCS(2) // 用 2 个 P var wg sync.WaitGroup for i : 0; i 10; i { wg.Add(1) go func(id int) { defer wg.Done() fmt.Printf(G %d 在 P 上跑\n, id) }(i) } wg.Wait() fmt.Println(全部完成) }用GOMAXPROCS控制 P 的数量可以看出同一时刻并行执行的任务数。开启 tracingimport runtime/trace func main() { f, _ : os.Create(trace.out) trace.Start(f) defer trace.Stop() // ... 业务代码 }然后go tool trace trace.out就能看到调度情况。三、核心 API / 深水区3.1 GMP 相关参数速查参数作用建议GOMAXPROCSP 的数量一般等于 CPU 核数全局队列待调度的 G调度器自动管理本地队列P 私有的 G优先调度工作窃取P 偷其他 P 的 G自动触发3.2 GOMAXPROCS 怎么设// 看 CPU 核数 fmt.Println(runtime.NumCPU()) // 设置 P 的数量 runtime.GOMAXPROCS(4) // 一般默认就够 // runtime.GOMAXPROCS(0) 返回当前值3.3 工作窃取原理当一个 P 的本地队列空了它会去其他 P 的队列偷一半 G 过来。这能充分利用多核// 演示工作窃取的效果 func workStealingDemo() { runtime.GOMAXPROCS(4) var wg sync.WaitGroup for i : 0; i 1000; i { wg.Add(1) go func() { defer wg.Done() for j : 0; j 1000000; j { _ j * j } }() } wg.Wait() }四、实战演练模拟 I/O 阻塞时的调度package main import ( fmt runtime sync time ) func main() { runtime.GOMAXPROCS(2) var wg sync.WaitGroup // CPU 密集型 for i : 0; i 4; i { wg.Add(1) go func(id int) { defer wg.Done() for j : 0; j 5; j { for k : 0; k 10000000; k { _ k * k } fmt.Printf(G %d CPU 完成第 %d 轮\n, id, j) } }(i) } // I/O 密集型模拟 for i : 10; i 14; i { wg.Add(1) go func(id int) { defer wg.Done() for j : 0; j 5; j { time.Sleep(10 * time.Millisecond) // 模拟 I/O fmt.Printf(G %d I/O 完成第 %d 轮\n, id, j) } }(i) } wg.Wait() }注意I/O 阻塞时G 让出 P其他 G 就能跑。五、避坑指南与最佳实践 **技巧GOMAXPROCS 不是越大越好P 多了调度开销也大通常是 CPU 核数或者少一点。⚠️ **警告不要创建太多 goroutine虽然轻量但 10 万个也比 100 个重。用 goroutine pool。✅ **推荐用 channel 做工作池控制并发数量防止 goroutine 爆炸。六、综合实战演示并发控制工作池package main import ( fmt runtime sync time ) type WorkerPool struct { maxWorkers int tasks chan func() wg sync.WaitGroup } func NewWorkerPool(max int) *WorkerPool { pool : WorkerPool{ maxWorkers: max, tasks: make(chan func(), 1000), } pool.start() return pool } func (p *WorkerPool) start() { for i : 0; i p.maxWorkers; i { p.wg.Add(1) go p.worker(i) } } func (p *WorkerPool) worker(id int) { defer p.wg.Done() for task : range p.tasks { fmt.Printf(工人 %d 开始工作\n, id) task() } } func (p *WorkerPool) Submit(task func()) { p.tasks - task } func (p *WorkerPool) Stop() { close(p.tasks) p.wg.Wait() } func main() { runtime.GOMAXPROCS(4) pool : NewWorkerPool(4) for i : 0; i 100; i { n : i pool.Submit(func() { time.Sleep(10 * time.Millisecond) fmt.Printf(任务 %d 完成\n, n) }) } pool.Stop() fmt.Println(全部完成) }七、总结GMP 调度是 Go 并发的基础G 是 goroutine轻量的用户态线程M 是线程真正执行代码的操作系统线程P 是调度器连接 G 和 M 的桥梁阻塞时 G 让出 P让其他 G 有机会执行工作窃取P 之间互相偷 G充分利用多核理解了 GMP写并发代码心里就有底了。