Go 协程调度--- GMP 模型
为什么 Go 适合高并发
Go 语言在云原生、微服务、网关、IM、任务调度、容器编排等领域使用非常广泛,一个重要原因就是它天然支持高并发。
Go 写并发代码非常简单:
go func() {
// 并发执行任务
}()
但是 go func() 只是表象。
Go 真正强大的地方在于:
- goroutine 非常轻量
- Go runtime 自己负责调度 goroutine
- 通过 GMP 模型实现用户态调度
- 网络 I/O 由 runtime netpoller 高效管理
- 调度器支持工作窃取、系统调用交接、抢占式调度
一句话理解:
Go 的高并发能力,不只是语法简单,而是 runtime 在底层帮我们做了大量调度优化。
传统线程模型的问题
在传统编程模型中,通常使用操作系统线程处理并发。
例如 Java 中常见的是线程池:
ExecutorService executor = Executors.newFixedThreadPool(100);
操作系统线程的问题:
- 创建成本较高
- 默认栈空间较大
- 线程数量过多会占用大量内存
- 上下文切换需要进入内核态,成本较高
- 海量并发下线程调度压力大
线程和 Goroutine 对比
| 对比项 | 操作系统线程 | Goroutine |
|---|---|---|
| 创建成本 | 高 | 低 |
| 初始栈空间 | 通常 MB 级别 | 初始约 2KB |
| 调度者 | 操作系统内核 | Go runtime |
| 上下文切换 | 成本较高 | 成本较低 |
| 并发数量 | 通常有限 | 可以轻松创建成千上万 |
| 编程复杂度 | 相对较高 | go func() 即可 |
Go 没有让一个 goroutine 固定绑定一个线程,而是通过 GMP 模型把大量 goroutine 调度到少量操作系统线程上运行。
这就是所谓的:
M:N 调度模型
即:
M 个 Goroutine 映射到 N 个操作系统线程上执行
GMP 模型整体理解
GMP 是 Go runtime 调度器的核心模型。
三个字母分别代表:
| 名称 | 全称 | 含义 |
|---|---|---|
| G | Goroutine | Go 协程,用户代码的执行单元 |
| M | Machine | 操作系统线程,真正执行代码的线程 |
| P | Processor | 逻辑处理器,负责调度 G 给 M 执行 |
可以简单理解为:
G = 要执行的任务
M = 真正干活的工人
P = 工人的工作台和任务队列
一个 M 必须绑定一个 P,才能执行 G。
M + P -> 执行 G
整体关系:
全局运行队列
↓
┌────────┬────────┬────────┐
│ P1 │ P2 │ P3 │
│ 本地队列 │ 本地队列 │ 本地队列 │
└───┬────┴───┬────┴───┬────┘
│ │ │
M1 M2 M3
│ │ │
G G G
G:Goroutine
G 是什么
G 代表 goroutine,是 Go 中最小的执行单元。
每当我们写:
go doTask()
Go runtime 就会创建一个 G。
G 里面有什么
G 中保存了 goroutine 执行所需的信息,例如:
- 当前执行的函数
- 程序计数器 PC
- 栈指针 SP
- goroutine 栈空间
- goroutine 状态
- 调度相关信息
G 的栈空间
Go goroutine 的初始栈空间很小,约为 2KB。
特点:
- 初始小
- 可以按需增长
- 栈空间不够时会自动扩容
- 最大可增长到较大空间
这就是 goroutine 可以大量创建的重要原因之一。
G 的常见状态
| 状态 | 含义 |
|---|---|
| waiting | 等待中,例如等待 channel、锁、I/O |
| runnable | 可运行,等待被调度 |
| running | 正在运行 |
| syscall | 正在执行系统调用 |
| dead | 已结束 |
M:Machine
M 是什么
M 代表 Machine,可以理解为操作系统线程。
M 是真正执行机器指令的载体。
但是 M 不能单独执行 G,它必须绑定 P。
M 只有拿到 P,才有资格执行 G
M 的作用
M 负责:
- 执行 goroutine 代码
- 执行系统调用
- 和 P 绑定后从队列中取 G 执行
- 在阻塞时与 P 分离
为什么 M 不能直接执行 G
如果所有 M 都直接去抢 G,会导致调度混乱和大量锁竞争。
Go 引入 P 作为中间层,让每个 P 维护自己的本地队列,减少全局竞争。
P:Processor
P 是什么
P 代表 Processor,逻辑处理器。
它不是 CPU 核心,也不是线程,而是 Go runtime 中的调度资源。
P 的核心作用:
管理可运行的 G,并把 G 交给 M 执行
P 里面有什么
P 中维护了很多调度相关资源,最重要的是:
- 本地运行队列 Local Run Queue
- 可用的 G 缓存
- 内存分配缓存
- 调度状态
P 的数量由什么决定
P 的数量由 GOMAXPROCS 决定。
查看当前值:
fmt.Println(runtime.GOMAXPROCS(0))
设置 P 的数量:
runtime.GOMAXPROCS(4)
默认情况下,Go 会根据 CPU 核心数设置 GOMAXPROCS。
GMP 调度流程
当我们启动一个 goroutine:
go func() {
fmt.Println("hello")
}()
大致流程如下:
- Go runtime 创建一个 G
- 新创建的 G 优先放入当前 P 的本地队列
- M 绑定 P 后,从 P 的本地队列取出 G
- M 执行 G 的代码
- G 执行完成后变成 dead 状态
- M 继续从 P 的本地队列取下一个 G
调度流程可以理解为:
go func()
↓
创建 G
↓
放入 P 的本地队列
↓
M 从 P 获取 G
↓
M 执行 G
↓
G 执行结束或被阻塞
本地队列与全局队列
本地队列
每个 P 都有自己的本地运行队列。
本地队列保存等待执行的 G。
特点:
- 每个 P 独立维护
- M 优先从绑定 P 的本地队列取 G
- 减少全局锁竞争
- 本地队列容量有限,常见说法是 256
全局队列
除了每个 P 的本地队列,Go runtime 还有一个全局运行队列。
当本地队列满了,或者某些调度场景需要时,G 可能会进入全局队列。
M 获取 G 的大致优先级:
P 的本地队列 -> 全局队列 -> Netpoller -> 从其他 P 窃取
不同版本细节可能有所调整,但整体思想是一致的:
优先本地,减少竞争;本地没有,再从其他地方找任务。
Work Stealing:工作窃取
什么是工作窃取
当某个 P 的本地队列没有 G 可以执行时,它不会马上让 M 休眠。
它会尝试从其他 P 的本地队列中偷一部分 G 来执行。
这就是 Work Stealing。
某个 P 没活干 -> 随机找其他 P -> 偷一半 G 过来执行
为什么需要工作窃取
假设有 4 个 P:
P1:100 个 G
P2:0 个 G
P3:0 个 G
P4:0 个 G
如果没有工作窃取,只有 P1 很忙,其他 P 对应的 CPU 资源就浪费了。
有了工作窃取后:
P2、P3、P4 可以从 P1 偷任务执行
这样可以提升 CPU 利用率。
工作窃取的好处
- 避免某些 P 忙死,某些 P 空闲
- 提升整体吞吐量
- 减少中心化调度带来的锁竞争
- 更适合高并发场景
Hand Off:系统调用阻塞交接
系统调用阻塞的问题
有些操作会让线程陷入系统调用阻塞。
例如:
- 文件 I/O
- 某些阻塞系统调用
- CGO 调用
如果 M 带着 P 一起阻塞,会导致 P 本地队列中的其他 G 无法执行。
这会浪费 CPU 调度能力。
Hand Off 机制
当某个 G 执行阻塞系统调用时:
- M 带着当前 G 进入系统调用
- M 与 P 解绑
- P 被释放出来
- P 寻找其他空闲 M,或者创建新的 M
- 新的 M 绑定 P,继续执行 P 队列里的其他 G
可以理解为:
M 阻塞了,但 P 不能跟着一起浪费
图示:
阻塞前:
P1 -> M1 -> G1
G1 进入阻塞系统调用:
M1 + G1 阻塞
P1 释放
交接后:
P1 -> M2 -> 执行其他 G
Hand Off 的意义
- 避免一个阻塞系统调用拖住整个 P
- 提高 CPU 利用率
- 保证其他 goroutine 可以继续执行
- 提升高并发服务稳定性
抢占式调度
早期协作式调度的问题
早期 Go 调度主要依赖协作式调度。
也就是说,goroutine 需要在某些安全点主动让出 CPU。
如果某个 goroutine 长时间执行纯计算,没有函数调用、没有阻塞点,就可能长时间占用 M。
例如:
for {
// 长时间 CPU 计算
}
这可能导致其他 goroutine 饥饿。
Go 1.14 后的异步抢占
从 Go 1.14 开始,Go runtime 引入了基于信号的异步抢占机制。
系统监控线程 sysmon 会定期检查运行中的 goroutine。
如果某个 G 执行时间过长,runtime 会尝试抢占它,让其他 G 有机会运行。
常见说法是超过约 10ms 可能会触发抢占检查。
抢占式调度的意义
- 避免单个 goroutine 长时间霸占 CPU
- 减少其他 goroutine 饥饿
- 提升调度公平性
- 改善高并发服务的尾延迟
Netpoller:网络轮询器
Netpoller 是什么
Go runtime 内置了网络轮询器 Netpoller。
它用于高效处理网络 I/O。
底层在不同系统上使用不同机制:
| 系统 | 底层机制 |
|---|---|
| Linux | epoll |
| macOS / BSD | kqueue |
| Windows | IOCP |
网络 I/O 为什么不阻塞线程
当 goroutine 执行网络读写时,例如:
conn.Read(buf)
如果数据还没准备好,Go runtime 通常不会让 M 一直阻塞在这里。
它会:
- 把当前 G 挂起
- 把网络 fd 注册到 netpoller
- M 继续执行其他 G
- 当网络事件就绪时,netpoller 唤醒对应 G
- G 重新进入可运行队列
Netpoller 的意义
- 支撑大量网络连接
- 避免一个连接占用一个线程
- 提升 Web 服务吞吐量
- 非常适合网关、长连接、微服务通信场景
GOMAXPROCS 的作用
GOMAXPROCS 决定同时可以执行 Go 代码的 P 的数量。
查看当前值:
runtime.GOMAXPROCS(0)
设置:
runtime.GOMAXPROCS(8)
它影响什么
GOMAXPROCS 影响的是:
同一时刻最多有多少个 P 在执行 Go 代码
通常可以近似理解为 Go 程序使用 CPU 并行能力的上限。
是否越大越好
不是。
如果设置过大,可能导致:
- 调度开销增加
- CPU 上下文切换增加
- 缓存命中率下降
- 性能不升反降
一般建议:
| 场景 | 建议 |
|---|---|
| CPU 密集型 | 接近 CPU 核心数 |
| I/O 密集型 | 默认值通常足够,压测后再调整 |
| 容器环境 | 注意 CPU quota,建议使用 Go 新版本或自动适配库 |
| 不确定 | 先使用默认值 |
工程中不要凭感觉修改 GOMAXPROCS,应该通过压测和监控验证。
高并发工程优化实践
理解 GMP 的目的,不是为了背概念,而是指导实际优化。
1. 减少频繁内存分配
高并发下,大量对象创建会增加 GC 压力。
不推荐:
func Handle() {
buf := make([]byte, 0, 1024)
_ = buf
}
如果是高频临时对象,可以使用 sync.Pool:
var bufferPool = sync.Pool{
New: func() any {
buf := make([]byte, 0, 1024)
return &buf
},
}
func Handle() {
bufPtr := bufferPool.Get().(*[]byte)
defer bufferPool.Put(bufPtr)
buf := (*bufPtr)[:0]
_ = buf
}
适合使用 sync.Pool 的对象:
bytes.Buffer- 临时切片
- 编解码临时对象
- 高频请求中的临时结构体
注意:sync.Pool 不是缓存,也不是连接池,里面的对象可能被 GC 清理。
2. 预分配切片容量
不推荐:
var list []int
for i := 0; i < 10000; i++ {
list = append(list, i)
}
推荐:
list := make([]int, 0, 10000)
for i := 0; i < 10000; i++ {
list = append(list, i)
}
好处:
- 减少扩容次数
- 减少内存拷贝
- 降低 GC 压力
- 提升高并发下稳定性
3. 大结构体使用指针传递
不推荐:
func Process(order Order) {
// Order 很大时会产生拷贝
}
推荐:
func Process(order *Order) {
// 避免大对象拷贝
}
但也不要所有对象都用指针。
简单小对象按值传递通常更清晰,逃逸到堆上反而可能增加 GC 压力。
4. 控制 Goroutine 数量
不推荐无限制创建 goroutine:
for _, id := range ids {
go process(id)
}
如果 ids 很大,可能瞬间创建大量 goroutine,压垮数据库、Redis 或下游服务。
推荐使用并发限制:
func BatchProcess(ids []int64) {
var wg sync.WaitGroup
sem := make(chan struct{}, 20)
for _, id := range ids {
wg.Add(1)
sem <- struct{}{}
go func(orderID int64) {
defer wg.Done()
defer func() { <-sem }()
process(orderID)
}(id)
}
wg.Wait()
}
5. 避免 Goroutine 泄漏
常见泄漏场景:
- channel 只读不写
- channel 只写不读
for range ch但 channel 永远不关闭- 后台 goroutine 没有退出条件
- 超时后子 goroutine 仍然阻塞发送结果
推荐使用 context 控制生命周期:
func worker(ctx context.Context, jobs <-chan int) {
for {
select {
case job, ok := <-jobs:
if !ok {
return
}
process(job)
case <-ctx.Done():
return
}
}
}
6. I/O 操作设置超时
网络请求、数据库查询、Redis 操作都应该有超时控制。
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
err := db.WithContext(ctx).Where("id = ?", id).First(&order).Error
不要让一个慢请求无限占用资源。
7. Channel 用于通信,Mutex 用于保护共享数据
不要机械地认为 channel 一定比锁好。
| 场景 | 推荐 |
|---|---|
| goroutine 之间传递任务 | channel |
| goroutine 之间传递结果 | channel |
| 保护共享 map | sync.RWMutex 或 sync.Map |
| 简单计数器 | sync/atomic |
| 复杂状态修改 | sync.Mutex |
核心判断:
通信优先 channel,共享状态保护优先锁。
常见问题与避坑指南
1. Goroutine 很轻量,是否可以无限创建
不可以。
goroutine 虽然轻量,但不是没有成本。
每个 goroutine 至少需要:
- 栈空间
- G 结构体
- 调度开销
- 可能占用外部资源
工程中要根据下游承载能力限制并发数量。
2. GOMAXPROCS 是不是越大越好
不是。
GOMAXPROCS 设置过大可能增加调度成本。
通常先使用默认值,再通过压测决定是否调整。
3. 为什么高并发下 GC 压力会变大
因为并发请求越多,单位时间内创建的对象越多。
对象越多,GC 扫描和回收压力越大。
优化方向:
- 减少临时对象
- 复用对象
- 预分配容量
- 避免不必要的字符串拼接
- 减少逃逸到堆上的对象
4. 为什么 channel 会导致 goroutine 泄漏
channel 的发送和接收可能阻塞。
例如:
ch := make(chan int)
go func() {
ch <- 1
}()
如果永远没人接收,子 goroutine 会永远阻塞。
5. CPU 密集型任务如何优化
CPU 密集型任务主要受 CPU 核心数量限制。
建议:
GOMAXPROCS接近 CPU 核心数- 避免创建远超 CPU 核心数的大量计算 goroutine
- 拆分任务时控制并发度
- 使用 pprof 分析 CPU 热点
6. I/O 密集型任务如何优化
I/O 密集型任务主要瓶颈在网络、磁盘、数据库、下游服务。
建议:
- 设置超时
- 使用连接池
- 控制并发数量
- 避免慢请求堆积
- 做限流、熔断、降级
- 使用 pprof 和 trace 观察阻塞点
面试重点总结
1. GMP 分别是什么
G:Goroutine,协程,用户代码执行单元
M:Machine,操作系统线程,真正执行代码
P:Processor,逻辑处理器,负责调度 G
2. M 为什么必须绑定 P 才能执行 G
因为 P 维护了运行队列和调度资源。
M 只有绑定 P 后,才能从 P 的本地队列中获取 G 并执行。
3. Work Stealing 是什么
当某个 P 没有可运行的 G 时,会随机从其他 P 的本地队列中偷取一部分 G 执行。
目的是提升负载均衡和 CPU 利用率。
4. Hand Off 是什么
当 G 执行阻塞系统调用时,M 会带着 G 阻塞,同时释放 P。
P 会绑定其他 M 继续执行其他 G,避免 P 被浪费。
5. Go 如何避免某个 Goroutine 长时间霸占 CPU
Go 1.14 后引入了基于信号的异步抢占机制。
sysmon 会监控运行时间过长的 G,并尝试抢占它,让其他 G 有机会执行。
6. Netpoller 的作用是什么
Netpoller 用于管理网络 I/O。
当网络 I/O 没有准备好时,runtime 会挂起当前 G,让 M 去执行其他 G。
网络事件就绪后,再把对应 G 放回可运行队列。
7. 高并发 Go 服务怎么优化
常见方向:
- 控制 goroutine 数量
- 设置请求超时
- 避免 goroutine 泄漏
- 减少内存分配
- 使用
sync.Pool复用对象 - 预分配切片容量
- 合理使用 channel、mutex、atomic
- 使用 pprof、trace 定位性能瓶颈
Go 的高并发能力不是魔法,而是建立在 GMP 调度模型、轻量级 goroutine、netpoller、工作窃取、系统调用交接和抢占式调度之上。写好高并发 Go 程序,不仅要会 go func(),更要理解 goroutine 的生命周期、调度机制、资源限制和工程化优化方法。
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐
所有评论(0)