Daily Study
更新: 2/17/2026 字数: 0 字 时长: 0 分钟
Daily Plan
#todo
卡码笔记
卡码笔记 - 程序员八股文 | C++/Java/Go 面试题 | 操作系统/网络/数据库 面经题库
介绍一下go中的GMP模型
GMP首先代表三个核心结构:
- G:goroutine协程。(协程和线程的区别goroutine 和线程的区别 | Go 程序员面试笔试宝典)
- M:系统/内核线程,负责CPU的调度
- P:处理器,包含了G所需的资源和上下文(主要是一个本地运行队列)
核心调度策略:
- 两个队列:
- 本地队列:每一个P都有一个队列,存放待运行的G。M 优先从绑定的 P 的本地队列获取 G,无锁(或极少锁)速度极快。
- 全局队列:存放所有 P 都能获取的 G。当 P 的本地队列满时,多出来的 G 会放到全局队列(需要加锁)
- 工作窃取:当一个 P 的本地队列空了,且全局队列也空了。M 会尝试从其他 P 的本地队列中偷走一半的 G 到自己的队列中执行。充分利用多核 CPU,避免某些线程忙死,某些线程闲死。
追问1:为什么要有 P?直接 M 和 G 对应不行吗?
- 全局锁竞争(Global Lock Contention):所有 M 都去同一个全局队列取 G,锁竞争极其严重,限制了并发扩展。
- M 的内存缓存差:M 频繁切换 G,导致 CPU Cache 命中率低。
- 线程创建/销毁开销:没有 P 作为缓冲,M 容易频繁创建和销毁。 P 的引入带来了本地队列(减少锁)和 Work Stealing(负载均衡),解决了这些问题。
追问2:Go 的抢占式调度是怎么实现的? 基于信号的抢占:引入了 sysmon(系统监控线程),它独立于 GMP 之外。sysmon 会监控运行时间过长的 G(超过 10ms)。 sysmon 向该 M 发送 SIGURG 信号。M 收到信号后,中断当前的 G,将其放回全局队列,从而调度下一个 G。
追问3:Goroutine 发生系统调用阻塞时,M 和 P 会发生什么?
情况 A:网络 I/O 阻塞 (Network I/O)
- 场景:G 发起网络请求(如 TCP Read/Write)。
- 机制:Go 对网络 IO 做了封装(使用 Netpoller,基于 epoll/kqueue)。
- M 的状态:M 不会 阻塞。
- 过程:G 被移动到 Netpoller 中挂起,M 继续从 P 的队列中取下一个 G 执行。
- 结论:这种情况不涉及 OS 线程的阻塞,非常高效。
情况 B:系统调用阻塞 (System Call)
- 场景:G 调用了无法异步化的系统调用(如本地文件读写、CGO 调用)。这是真正的“硬阻塞”。
过程详解:
- 分离 (Handoff): 当 G1 执行系统调用时(syscall),M1(当前线程)会预感到自己将要进入内核态阻塞。此时,M1 会主动与 P1 解绑。
- M1:带着 G1 进入内核态,开始阻塞等待系统调用返回。M1 此时被 OS 挂起。
- P1:此时 P1 处于“无主”状态(Idle),但它的本地队列里可能还有 G2、G3 等着跑。
- 接管 (Re-scheduling): Go 的运行时(Runtime)或监控线程(sysmon)会检测到 P1 处于空闲但有任务的状态。它会确保有一个 M 来接管 P1。
- 寻找 M:调度器会尝试从休眠 M 列表中唤醒一个空闲的 M2。如果没有空闲的 M,且当前 M 的数量未达上限,则会新建一个 OS 线程 (M2)。
- 绑定:M2 绑定 P1,继续执行 G2、G3...。
关键点:这就是为什么 Go 程序在高并发文件 IO 时,OS 线程数(M)可能会上涨的原因。因为旧的 M 被阻塞在 Syscall 上了,需要新的 M 来维持 P 的运转。
- 回归: 当系统调用结束,G1 醒来,M1 从内核态返回。
- M1 尝试获取一个空闲的 P(优先尝试原来的 P1,如果被占了就找别的)。
- 如果找到了 P:M1 绑定 P,继续执行 G1。
- 如果没找到 P:M1 将 G1 放入 全局运行队列,然后 M1 将自己放入休眠 M 列表,等待下次被唤醒。
