GMP 调度与内存模型
Go 并发代码能写得很短,但面试和线上问题经常问到更底层的问题:goroutine 到底怎么跑到 CPU 上,为什么阻塞 IO 不一定阻塞整个进程,为什么并发读写变量会有数据竞争,为什么加锁和 channel 能保证可见性。
这一章把两个核心问题讲清楚:
- GMP 调度模型:goroutine 如何被 Go runtime 调度执行。
- Go 内存模型:多个 goroutine 之间读写变量时,什么时候能保证可见性和顺序。
学习目标
学完本章你应该能回答:
- G、M、P 分别是什么。
- goroutine 从创建到运行经历了什么。
- 为什么
GOMAXPROCS影响同时执行 Go 代码的并行度。 - 本地队列、全局队列、work stealing 解决什么问题。
- sysmon、抢占、网络轮询器大概做什么。
- 数据竞争为什么危险。
- Mutex、channel、atomic 为什么能建立同步关系。
- 线上 goroutine 数暴涨、CPU 高、锁竞争应该怎么查。
为什么需要 GMP
如果每创建一个 goroutine 就创建一个系统线程,Go 就失去了轻量并发的优势。系统线程创建和切换成本高,数量多了以后操作系统调度压力会很大。
Go 的思路是:由 runtime 自己管理大量 goroutine,再把它们调度到少量系统线程上执行。
flowchart TD
A["大量 G: goroutine"] --> B["P: 可运行队列和调度上下文"]
B --> C["M: OS Thread"]
C --> D["CPU Core"]G、M、P 分别是什么
| 名称 | 全称 | 可以理解为 | 主要职责 |
|---|---|---|---|
| G | goroutine | 一个待执行任务 | 保存函数、栈、状态、调度信息 |
| M | machine | 系统线程 | 真正被操作系统调度到 CPU 上执行 |
| P | processor | 调度上下文 | 持有本地运行队列,提供执行 Go 代码所需资源 |
关键关系:
- G 不是线程,它只是 Go runtime 的任务。
- M 是真实系统线程。
- P 决定同时有多少 M 可以执行 Go 代码。
GOMAXPROCS控制 P 的数量,默认通常等于可用 CPU 核数。
goroutine 创建到运行流程
当你写:
go doWork()runtime 大概会做这些事:
- 创建一个 G,记录要执行的函数、参数、栈等信息。
- 把 G 放入当前 P 的本地运行队列。
- 当前 M 执行完手头任务后,从 P 的运行队列取出 G。
- M 运行这个 G。
- G 阻塞、让出、完成或被抢占时,调度器再切换到其他 G。
flowchart TD
A["go doWork()"] --> B["创建 G"]
B --> C["放入当前 P 的本地队列"]
C --> D["M 从 P 队列取 G"]
D --> E["执行 goroutine"]
E --> F{"完成、阻塞、让出、抢占"}
F --> G["调度下一个 G"]本地队列和全局队列
每个 P 都有自己的本地运行队列。这样做的原因是减少所有线程抢同一个全局队列的锁竞争。
flowchart TD
A["P1 本地队列"] --> B["M1 执行"]
C["P2 本地队列"] --> D["M2 执行"]
E["全局队列"] --> A
E --> C为什么还需要全局队列:
- 本地队列满时,一部分 G 会放到全局队列。
- 某些场景下创建的 G 会进入全局队列。
- 调度器会定期从全局队列取任务,避免全局队列饥饿。
work stealing
如果某个 P 的本地队列空了,而其他 P 队列里还有很多 G,空闲的 P 会尝试从其他 P 偷取一部分任务来执行。
flowchart TD
A["P1 队列很多 G"] --> B["P2 队列为空"]
B --> C["P2 从 P1 偷取部分 G"]
C --> D["两个 P 都有任务执行"]这样做的好处:
- 提高 CPU 利用率。
- 避免任务集中在某一个 P 上。
- 减少全局锁竞争。
不这样会怎样:
- 有的线程忙死,有的线程空闲。
- 整体吞吐下降。
- 某些 goroutine 等很久才执行。
阻塞时会发生什么
goroutine 可能阻塞在很多地方:
- channel 发送或接收。
- Mutex 锁。
- 网络 IO。
- 文件 IO。
- 系统调用。
- sleep 或 timer。
不同阻塞对调度影响不同。
channel、锁、timer 等 runtime 可感知阻塞
runtime 知道当前 G 暂时不能运行,就把它挂起,让 M 去执行其他可运行 G。
flowchart TD
A["G1 阻塞在 channel"] --> B["runtime 挂起 G1"]
B --> C["M 继续执行 G2"]
D["channel 就绪"] --> E["G1 变为可运行"]网络 IO
Go 网络库集成了网络轮询器。网络读写等待时,G 可以被挂起,M 不需要一直傻等。
商业意义:
- 大量 HTTP 请求、TCP 连接、RPC 调用可以用较少线程承载。
- 这也是 Go 适合高并发网络服务的重要原因。
- 但下游慢仍然会占用 goroutine、连接池、内存,所以必须设置 timeout。
系统调用
如果 M 进入可能阻塞的系统调用,runtime 会把 P 从这个 M 上分离出来,交给其他 M 继续执行 Go 代码。这样一个线程阻塞不至于拖住整个 P。
flowchart TD
A["M1 执行 G1"] --> B["G1 进入阻塞 syscall"]
B --> C["P 与 M1 分离"]
C --> D["P 绑定 M2"]
D --> E["M2 继续执行其他 G"]抢占和 sysmon
早期 Go 版本里,如果某个 goroutine 长时间执行纯计算且不发生函数调用,可能让其他 goroutine 等很久。后续 Go 不断改进抢占能力,让长时间运行的 goroutine 更容易被暂停,把执行机会让给别人。
sysmon 可以理解为 runtime 的后台监控线程之一,它会做一些监控和协助调度相关的工作,例如:
- 发现长时间运行的 G,触发抢占。
- 处理网络轮询相关唤醒。
- 处理 timer。
- 发现阻塞 syscall 后协助调度。
不用背实现细节,但要理解工程后果:
- CPU 密集任务仍然会抢 CPU,需要控制并发。
- 不要以为 goroutine 多就一定快。
- 长循环里适当检查 context,有助于快速停止。
GOMAXPROCS
GOMAXPROCS 控制同时执行 Go 代码的 P 的数量。
查看:
fmt.Println(runtime.GOMAXPROCS(0))设置:
runtime.GOMAXPROCS(4)一般不建议业务代码随意设置它。容器环境里更要关注 CPU limit,现代 Go 版本已经逐渐优化对容器 CPU 的感知,但实际生产仍要结合压测和监控判断。
容易误解的点:
GOMAXPROCS=4不代表只能有 4 个 goroutine。- 它代表同时并行执行 Go 代码的 P 数量大致为 4。
- 阻塞 syscall、cgo、网络 IO 等场景下,M 的数量可能超过 P。
- IO 密集服务的 goroutine 数通常远大于 CPU 核数,但真正同时跑 CPU 的有限。
Go 内存模型解决什么问题
多个 goroutine 并发读写同一变量,如果没有同步,结果是不可靠的。
错误示例:
package main
import (
"fmt"
"sync"
)
func main() {
done := false
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
done = true
}()
for !done {
}
wg.Wait()
fmt.Println("finished")
}这段代码有数据竞争。你不能认为一个 goroutine 写了 done = true,另一个 goroutine 就一定能及时、正确地看到。
原因包括:
- 编译器可能优化读写。
- CPU 有缓存和乱序执行。
- 不同 goroutine 在不同线程上执行。
- 没有同步关系时,可见性和顺序没有保证。
happens-before
Go 内存模型里的核心概念是 happens-before。可以粗略理解为:如果 A happens-before B,那么 B 能看到 A 在这个同步关系之前完成的写入。
常见能建立同步关系的方式:
Mutex.Unlockhappens-before 后续成功的Mutex.Lock。- channel 发送 happens-before 对应的接收。
- channel close happens-before 接收方观察到关闭。
sync.Once保证初始化只执行一次,并对后续调用可见。- atomic 操作提供原子性和相应的内存顺序保证。
- goroutine 启动前的写入,对新 goroutine 可见。
Mutex 为什么能保证可见性
正确示例:
package main
import (
"fmt"
"sync"
)
func main() {
var mu sync.Mutex
done := false
go func() {
mu.Lock()
done = true
mu.Unlock()
}()
for {
mu.Lock()
ok := done
mu.Unlock()
if ok {
break
}
}
fmt.Println("finished")
}这里写 goroutine 在 Unlock 前写入 done=true,读 goroutine 后续 Lock 成功后能看到同步前的写入。
不建议这样忙等,真实代码更应该用 channel 或 context 表达完成信号。
channel 为什么能同步
用 channel 表达完成信号:
package main
import "fmt"
func main() {
done := make(chan struct{})
result := ""
go func() {
result = "ok"
close(done)
}()
<-done
fmt.Println(result)
}close(done) 对接收方 <-done 可见,所以主 goroutine 读取 result 时可以看到关闭前的写入。
这比共享变量忙等更清晰:
- 不浪费 CPU。
- 同步关系明确。
- 代码意图是“等完成”,而不是“反复读一个变量”。
atomic 适合什么
atomic 适合简单计数、开关状态、指标值,不适合复杂业务对象。
package main
import (
"fmt"
"sync"
"sync/atomic"
)
func main() {
var count int64
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
atomic.AddInt64(&count, 1)
}()
}
wg.Wait()
fmt.Println(count)
}不要用多个 atomic 字段拼复杂状态。例如一个订单对象有状态、金额、时间、版本,这些字段之间有一致性要求,用锁或单 goroutine 串行处理更清楚。
常见误区
误区一:goroutine 多就一定快
不一定。CPU 密集任务开太多 goroutine 会争抢 CPU;IO 密集任务开太多 goroutine 会打爆下游连接池和限流。
误区二:读 bool 不加锁也没事
不对。只要一个 goroutine 写,另一个 goroutine 读,并且没有同步,就可能数据竞争。用 go test -race ./... 能发现很多此类问题。
误区三:map 并发读写概率低就可以
不可以。Go 原生 map 并发写可能直接 panic:fatal error: concurrent map writes。读写共享 map 要加锁、用 sync.Map 或通过 channel 串行化。
误区四:channel 一定比锁高级
不是。channel 适合表达任务流、事件通知和同步信号;锁适合保护共享状态。为了“Go 风格”强行用 channel,反而会让代码绕。
生产排查
goroutine 数暴涨
排查流程:
flowchart TD
A["goroutine 数持续上涨"] --> B["抓 goroutine pprof"]
B --> C{"主要阻塞点"}
C --> D["chan send / receive"]
C --> E["Mutex.Lock"]
C --> F["net/http 或 RPC"]
C --> G["database/sql"]
D --> H["检查 channel 关闭、接收方、context"]
E --> I["检查锁范围、死锁、热点资源"]
F --> J["检查超时、连接池、下游慢"]
G --> K["检查慢 SQL、事务、连接池"]命令:
go tool pprof http://127.0.0.1:6060/debug/pprof/goroutineCPU 高
可能原因:
- 死循环或
select default空转。 - CPU 密集任务并发过高。
- JSON、压缩、加密、正则等计算过重。
- 锁竞争导致大量 goroutine 反复抢锁。
排查:
go tool pprof http://127.0.0.1:6060/debug/pprof/profile锁竞争
如果大量 goroutine 卡在 sync.Mutex.Lock:
- 看锁保护的临界区是否太大。
- 是否在持锁期间调用外部接口、数据库、磁盘 IO。
- 是否把全局大锁拆成分片锁。
- 是否可以把热点读改为只读快照或
RWMutex。
商业场景:高并发接口聚合
一个资产详情接口需要查询用户、资产、订单、权限、统计。每个查询都开 goroutine 看起来很快,但没有边界会出问题。
正确设计:
- 入口 context 设置总超时,例如 800ms。
- 使用
errgroup.WithContext并发查询强依赖。 - 弱依赖允许降级。
- 每个下游 client 设置连接池和 timeout。
- 通过指标观察 goroutine 数、下游耗时、错误率。
商业场景:采集 Agent
采集 Agent 常常同时读文件、调接口、解析、上报。GMP 让它可以用少量线程承载很多 IO 等待,但业务仍然要做并发控制。
落地建议:
- 读取阶段限速。
- 解析阶段使用固定 worker pool。
- 上报阶段设置 HTTP timeout 和重试上限。
- 停止服务时通过 context 通知所有 goroutine 退出。
- pprof 暴露 goroutine 和 CPU profile。
面试标准回答
GMP 是什么
GMP 是 Go runtime 的调度模型。G 代表 goroutine,是待执行任务;M 代表系统线程,是真正被操作系统调度执行的线程;P 代表调度上下文,持有本地运行队列并提供执行 Go 代码所需资源。runtime 通过 P 把大量 G 调度到较少的 M 上执行,从而降低创建和切换线程的成本。GOMAXPROCS 控制什么
GOMAXPROCS 控制 P 的数量,也就是同时并行执行 Go 代码的调度上下文数量。它不限制 goroutine 总数,也不一定等于系统线程数。IO 阻塞、系统调用等情况下 M 的数量可能超过 P,但真正同时跑 Go 代码的并行度主要由 P 决定。work stealing 解决什么问题
每个 P 都有本地运行队列。如果某个 P 队列为空,而其他 P 队列里有很多 goroutine,空闲 P 会从其他 P 偷取一部分任务执行。这样可以提高 CPU 利用率,避免某些 P 忙、某些 P 闲,也减少所有任务都争抢全局队列的锁竞争。Go 内存模型解决什么问题
Go 内存模型说明多个 goroutine 读写共享变量时,什么同步操作能保证可见性和顺序。如果没有同步,读写共享变量会产生数据竞争,结果不可预测。Mutex、channel、atomic、sync.Once 等都能建立同步关系,让一个 goroutine 的写入对另一个 goroutine 可见。为什么 channel close 后能作为通知
channel close 会和接收方观察到关闭建立同步关系。一个 goroutine 在 close 前完成的写入,另一个 goroutine 在接收到关闭信号后可以看到。因此 close 一个 chan struct{} 经常用于广播完成或停止信号。关联知识点
本章小结
GMP 解释的是 goroutine 怎么被调度到 CPU 上,内存模型解释的是 goroutine 之间共享数据什么时候可靠。生产级 Go 并发代码不能只会创建 goroutine,还要理解调度边界、同步关系、数据竞争和排查方法。
