HiHuo
首页
博客
手册
工具
关于
首页
博客
手册
工具
关于

面试官: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
    }
}

调度优先级:

  1. 每61次检查全局队列(防止饥饿)
  2. 本地队列
  3. 全局队列
  4. 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的引入解决了几个问题:

  1. 减少锁竞争:没有P时,所有G都在全局队列,M需要竞争锁。有P后,每个P有自己的本地队列,减少竞争。

  2. Work Stealing的基础:P持有本地队列,才能实现高效的任务窃取。

  3. 控制并行度: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模型的核心设计思想:

  1. 两级队列:全局队列 + P本地队列,减少锁竞争
  2. Work Stealing:负载均衡,提高CPU利用率
  3. 抢占式调度:防止单个G长时间占用CPU
  4. M:N调度:少量线程运行大量goroutine

面试时记住这个回答框架:

  1. 先画图说清楚G/M/P的关系
  2. 讲清楚调度时机和优先级
  3. 举例说明Work Stealing
  4. 提一下Go 1.14的信号抢占

能讲到这个程度,这道题就稳了。


GMP只是Go并发的冰山一角,后续还会讲Channel源码、sync包实现原理。关注不迷路。