深入探讨 Go 语言中 goroutine协程调度 的底层实现与并发安全

GMP 这个"三件套"本文研究了一周,终于搞明白 Go 调度是怎么回事了

信息图

前言

"老王,为什么本文创建了 1000 个 goroutine,CPU 还是只用了 2 个核?" 新来的实习生小张一脸困惑。

本文看了看他的代码,发现他没设置 GOMAXPROCS。"你这是把 GMP 调度模型给忘了啊!"

"GMP?什么是 GMP?"

看来得从基础讲起了。今天本文们聊聊 Go 的 GMP 调度模型。

一、底层原理

1.1 GMP 三件套

  • G:goroutine,协程
  • M:machine,操作系统线程
  • P:processor,调度器
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 是调度器,数量固定(GOMAXPROCS)
  • G 要在 M 上跑,M 要有 P
  • G 阻塞时,M 让出 P,P 绑定新 M

1.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 的数量,可以看出"同一时刻并行执行的任务数"。

开启 tracing:

import "runtime/trace"

func main() {
	f, _ := os.Create("trace.out")
	trace.Start(f)
	defer trace.Stop()
	// ... 业务代码
}

然后 go tool trace trace.out 就能看到调度情况。

三、核心 API / 深水区

3.1 GMP 相关参数速查

参数 作用 建议
GOMAXPROCS P 的数量 一般等于 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,写并发代码心里就有底了。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐