面试官:GMP 模型中,如果一个 Goroutine 发生阻塞(如系统调用),会发生什么?
面试回答
“在 Go 语言中,Goroutine 发生阻塞主要分为三种情况,Go 运行时的处理方式各不相同,核心目的都是为了不让底层的 M(内核线程)闲置。
第一种是系统调用阻塞 (Syscall)。比如读写本地文件。这时候底层的 M 会和 G 一起进入阻塞状态。为了不让当前 P 里的其他 G 被饿死,P 会和这个 M 解绑(这就是 Handoff 机制),然后 P 会去寻找一个新的 M(或者唤醒一个休眠的 M)来接管自己,继续执行本地队列里的其他 G。当系统调用结束后,原来的 M 会被唤醒,它会尝试获取一个 P 来继续执行 G;如果获取不到,就会把 G 放到全局队列里,然后 M 自己进入休眠。
第二种是网络 I/O 阻塞。Go 并没有让 M 阻塞在网络调用上,而是引入了 Netpoller(网络轮询器)。当 G 发起网络请求时,G 会被挂起并交给 Netpoller 监控,而 M 不会阻塞,它会立刻去执行其他的 G。当网络数据就绪后,Netpoller 会把对应的 G 唤醒,重新放入可运行队列中等待调度。
第三种是用户态阻塞。比如对 Channel 的读写、获取 Mutex 锁或者调用 time.Sleep。这种情况下,G 会主动调用底层的 gopark 将自己挂起,M 同样不会阻塞,而是继续去调度其他的 G。当阻塞条件满足(比如 Channel 里有数据了),其他 G 会调用 goready 将这个阻塞的 G 唤醒,重新加入队列。
总结来说,除了少部分系统调用会真正阻塞 M 之外,绝大多数情况(网络 I/O、Channel 等)都只是在用户态挂起 G,M 始终保持高效运转,这也是 Go 并发性能极高的原因。”
系统讲解
在 GMP 模型中,Goroutine 的阻塞处理是调度的核心难点。Go 运行时通过精细的分类处理,最大化了 CPU 的利用率。
1. 系统调用阻塞 (Syscall)
当 G 执行普通的系统调用(如文件 I/O、CGO 调用)时,由于操作系统的限制,执行该调用的内核线程(M)必然会被阻塞。
- 进入系统调用 (
entersyscall):- M 将自身的状态标记为阻塞。
- 解绑 P:M 会释放它绑定的 P。
- Handoff 机制:P 发现自己没有 M 了,为了保证本地队列中的其他 G 能继续运行,P 会尝试唤醒一个空闲的 M,或者由后台的
sysmon监控线程发现 P 处于空闲状态,为其分配一个新的 M。
- 退出系统调用 (
exitsyscall):- M 醒来,尝试重新获取一个 P(优先获取原来的 P,或者从空闲 P 列表中获取)。
- 如果获取到 P,M 继续执行该 G。
- 如果没有空闲的 P,M 会将 G 放入全局队列,然后 M 将自己放入空闲 M 列表并进入休眠状态。
2. 网络 I/O 阻塞 (Netpoller)
网络 I/O 是高并发场景下最常见的阻塞类型。Go 运行时使用底层的多路复用技术(如 Linux 的 epoll、macOS 的 kqueue)实现了一个异步的网络轮询器(Netpoller)。
- 当 G 执行网络 I/O 且数据未就绪时,G 会被挂起(状态变为
_Gwaiting)。 - G 被注册到 Netpoller 中等待事件。
- M 不会阻塞,而是继续从 P 的本地队列中获取下一个 G 来执行。
- 当网络事件就绪(如 socket 可读/可写),后台的
sysmon线程或调度循环中的轮询操作会从 Netpoller 中获取就绪的 G,将其状态改为_Grunnable,并放入全局队列或某个 P 的本地队列中等待再次调度。
3. 用户态同步阻塞 (Channel / Mutex / Sleep)
这类阻塞完全发生在用户态,不涉及操作系统的系统调用。
- 当 G 尝试读取一个空的 Channel 或获取一个已被占用的 Mutex 时,会调用
runtime.gopark函数。 gopark会将当前 G 的状态修改为_Gwaiting,并将其从 M 上剥离。- M 不会阻塞,M 会立刻执行一次调度循环(
schedule()),寻找下一个可运行的 G。 - 当阻塞条件解除(例如另一个 G 向 Channel 发送了数据),发送数据的 G 会调用
runtime.goready函数。 goready会将阻塞的 G 状态改回_Grunnable,并将其放入当前 P 的本地队列(优先放入runnext位置,以便尽快执行)。
对比总结
| 阻塞类型 | 触发场景 | M 是否阻塞 | P 的行为 | 恢复机制 |
|---|---|---|---|---|
| 系统调用 | 文件 I/O、CGO | 是 (M 与 G 一起阻塞) | 与 M 解绑,寻找新的 M (Handoff) | 系统调用返回,M 尝试获取 P,失败则 G 进全局队列 |
| 网络 I/O | Socket 读写 | 否 (M 继续执行其他 G) | 正常运行其他 G | Netpoller 监听到事件就绪,唤醒 G 放入队列 |
| 用户态阻塞 | Channel、Mutex、Sleep | 否 (M 继续执行其他 G) | 正常运行其他 G | 其他 G 触发条件(如发送数据)后调用 goready 唤醒 |
亮点与深度:sysmon 监控线程
在处理阻塞时,Go 运行时的后台监控线程 sysmon 扮演了关键角色。它是一个不绑定任何 P、直接运行在独立 M 上的死循环线程。它的职责包括:
- 抢占长时间运行的 G:防止 CPU 密集型任务饿死其他 G。
- 处理网络就绪事件:定期轮询 Netpoller,唤醒就绪的 G。
- 接管被系统调用阻塞的 P:如果发现某个 P 因为系统调用阻塞超过一定时间(10ms),
sysmon会强制将 P 从 M 上剥离,分配给其他 M。