面试官: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/OSocket 读写 (M 继续执行其他 G)正常运行其他 GNetpoller 监听到事件就绪,唤醒 G 放入队列
用户态阻塞Channel、Mutex、Sleep (M 继续执行其他 G)正常运行其他 G其他 G 触发条件(如发送数据)后调用 goready 唤醒

亮点与深度:sysmon 监控线程

在处理阻塞时,Go 运行时的后台监控线程 sysmon 扮演了关键角色。它是一个不绑定任何 P、直接运行在独立 M 上的死循环线程。它的职责包括:

  1. 抢占长时间运行的 G:防止 CPU 密集型任务饿死其他 G。
  2. 处理网络就绪事件:定期轮询 Netpoller,唤醒就绪的 G。
  3. 接管被系统调用阻塞的 P:如果发现某个 P 因为系统调用阻塞超过一定时间(10ms),sysmon 会强制将 P 从 M 上剥离,分配给其他 M。