面试官:Go的GMP模型讲一下?这样回答让他连问5个问题
这道题区分度极高。说清楚GMP基础只有60分,能讲清楚调度细节才能拿满分。
开场:一个常见的翻车场景
面试官问:"Go的并发模型讲一下?"
候选人A回答:"Go用goroutine实现并发,比线程轻量,启动成本低..."
面试官追问:"goroutine是怎么调度的?"
候选人A:"用的GMP模型...G是goroutine,M是线程,P是处理器..."
面试官继续:"P的数量是怎么定的?调度时机有哪些?"
候选人A卡住了。
这篇文章,帮你准备好面试官可能追问的所有细节。
GMP模型全景图
┌─────────────────────────────────────────┐
│ Global Run Queue │
│ ┌───┐ ┌───┐ ┌───┐ ┌───┐ ┌───┐ │
│ │ G │ │ G │ │ G │ │ G │ │ G │ │
│ └───┘ └───┘ └───┘ └───┘ └───┘ │
└───────────────────┬─────────────────────┘
│
┌───────────────────────────────┼───────────────────────────────┐
│ │ │
▼ ▼ ▼
┌─────────┐ ┌─────────┐ ┌─────────┐
│ P │ │ P │ │ P │
│ ┌─────┐ │ │ ┌─────┐ │ │ ┌─────┐ │
│ │ LRQ │ │ Local Run Queue │ │ LRQ │ │ │ │ LRQ │ │
│ │G G G│ │ │ │G G G│ │ │ │G G G│ │
│ └─────┘ │ │ └─────┘ │ │ └─────┘ │
└────┬────┘ └────┬────┘ └────┬────┘
│ │ │
▼ ▼ ▼
┌─────────┐ ┌─────────┐ ┌─────────┐
│ M │ │ M │ │ M │
│ (Thread)│ │ (Thread)│ │ (Thread)│
└─────────┘ └─────────┘ └─────────┘
三个核心角色
G (Goroutine)
- goroutine的运行时表示
- 包含栈、指令指针、调度信息
- 初始栈只有2KB,按需增长
M (Machine)
- 操作系统线程
- 执行G的载体
- 数量受
GOMAXPROCS限制
P (Processor)
- 逻辑处理器
- 持有G的本地队列(Local Run Queue)
- 默认数量=CPU核心数
调度流程详解
1. goroutine的创建
当执行go func()时:
func newproc(fn *funcval) {
gp := getg() // 获取当前G
pc := getcallerpc() // 获取调用者PC
systemstack(func() {
newg := newproc1(fn, gp, pc) // 创建新G
pp := getg().m.p.ptr()
runqput(pp, newg, true) // 放入P的本地队列
if mainStarted {
wakep() // 尝试唤醒空闲的P
}
})
}
关键点:新G优先放入当前P的本地队列,而不是全局队列。
2. 获取可运行的G
调度器需要获取下一个要执行的G:
func schedule() {
gp, inheritTime, tryWakeP := findRunnable() // 核心函数
execute(gp, inheritTime)
}
func findRunnable() (gp *g, inheritTime, tryWakeP bool) {
// 每61次调度,优先检查全局队列,防止饥饿
if pp.schedtick%61 == 0 && sched.runqsize > 0 {
if gp := globrunqget(pp, 1); gp != nil {
return gp, false, false
}
}
// 从本地队列获取
if gp, inheritTime := runqget(pp); gp != nil {
return gp, inheritTime, false
}
// 从全局队列获取
if sched.runqsize != 0 {
gp := globrunqget(pp, 0)
if gp != nil {
return gp, false, false
}
}
// Work Stealing: 从其他P偷取
if gp := stealWork(); gp != nil {
return gp, false, true
}
}
调度优先级:
- 每61次检查全局队列(防止饥饿)
- 本地队列
- 全局队列
- Work Stealing
3. Work Stealing机制
当P的本地队列为空时,会尝试从其他P"偷"G:
func stealWork() *g {
// 随机选择起始P
for i := 0; i < 4; i++ {
pp := allp[fastrand() % nprocs]
if pp == getg().m.p.ptr() {
continue
}
// 偷取一半的G
if gp := runqsteal(pp); gp != nil {
return gp
}
}
return nil
}
偷取策略:偷取目标P本地队列的一半G,而不是只偷一个。
4. 调度时机
G让出CPU的场景:
| 场景 | 触发方式 | 是否抢占 |
|---|---|---|
| 主动让出 | runtime.Gosched() | 否 |
| Channel阻塞 | <-ch或ch<- | 否 |
| 系统调用 | syscall.Read()等 | 否 |
| 同步原语 | sync.Mutex.Lock() | 否 |
| 抢占式调度 | 运行超过10ms | 是 |
| 栈检查 | 函数调用时检查 | 是 |
源码级理解
P的状态机
┌──────────┐
┌────│ _Pidle │────┐
│ └──────────┘ │
│ │ │
│ acquirep() │ releasep()
│ ▼ │
│ ┌──────────┐ │
└───▶│_Prunning │◀───┘
└──────────┘
│
│ entersyscall()
▼
┌──────────┐
│_Psyscall │
└──────────┘
│
│ handoffp() / exitsyscall()
▼
┌──────────┐
│ _Pidle │
└──────────┘
抢占式调度实现
Go 1.14引入了基于信号的抢占:
// runtime/signal_unix.go
func doSigPreempt(gp *g, ctxt *sigctxt) {
if !canPreempt(gp) {
return
}
// 修改G的PC,让它下次调度时进入asyncPreempt
ctxt.pushCall(abi.FuncPCABI0(asyncPreempt), ctxt.rip())
}
原理:sysmon协程定期检查运行超过10ms的G,发送SIGURG信号,触发抢占。
高频面试题
Q1:P的数量是怎么确定的?
答:默认等于GOMAXPROCS环境变量,如果未设置则等于CPU逻辑核心数。可以通过runtime.GOMAXPROCS(n)动态修改。
import "runtime"
func main() {
// 获取当前P的数量
n := runtime.GOMAXPROCS(0)
// 设置为CPU核心数
runtime.GOMAXPROCS(runtime.NumCPU())
}
Q2:G和M的数量关系是什么?
答:
- G的数量没有限制,理论上可以创建百万级
- M的数量默认最大10000(可通过
runtime/debug.SetMaxThreads修改) - P的数量固定,等于GOMAXPROCS
- 同一时刻运行的G数量 ≤ P的数量
Q3:为什么需要P?直接G-M调度不行吗?
答:P的引入解决了几个问题:
减少锁竞争:没有P时,所有G都在全局队列,M需要竞争锁。有P后,每个P有自己的本地队列,减少竞争。
Work Stealing的基础:P持有本地队列,才能实现高效的任务窃取。
控制并行度:P的数量决定了真正并行的G数量,避免过度调度。
Q4:goroutine泄漏怎么排查?
答:
import "runtime"
// 方法1:查看goroutine数量
fmt.Println(runtime.NumGoroutine())
// 方法2:pprof分析
import _ "net/http/pprof"
// 访问 /debug/pprof/goroutine?debug=1
常见泄漏原因:
- Channel发送无接收者
- HTTP请求未关闭Body
- Context未取消
Q5:如何让goroutine绑定到固定的线程?
答:使用runtime.LockOSThread():
func bindThread() {
runtime.LockOSThread()
defer runtime.UnlockOSThread()
// 这里的代码会在固定的OS线程上执行
// 用于CGO、GUI等需要线程亲和性的场景
}
实战:调度可视化
使用GODEBUG环境变量查看调度器运行情况:
GODEBUG=schedtrace=1000 ./myapp
输出示例:
SCHED 0ms: gomaxprocs=8 idleprocs=7 threads=2 spinningthreads=0
idlethreads=0 runqueue=0 [0 0 0 0 0 0 0 0]
SCHED 1001ms: gomaxprocs=8 idleprocs=0 threads=9 spinningthreads=0
idlethreads=1 runqueue=0 [15 12 18 9 14 11 16 13]
字段解释:
gomaxprocs:P的数量idleprocs:空闲P的数量threads:M的数量runqueue:全局队列长度[15 12 ...]:每个P本地队列的长度
总结
GMP模型的核心设计思想:
- 两级队列:全局队列 + P本地队列,减少锁竞争
- Work Stealing:负载均衡,提高CPU利用率
- 抢占式调度:防止单个G长时间占用CPU
- M:N调度:少量线程运行大量goroutine
面试时记住这个回答框架:
- 先画图说清楚G/M/P的关系
- 讲清楚调度时机和优先级
- 举例说明Work Stealing
- 提一下Go 1.14的信号抢占
能讲到这个程度,这道题就稳了。
GMP只是Go并发的冰山一角,后续还会讲Channel源码、sync包实现原理。关注不迷路。