Skip to Content
Go 语言GMP 调度模型

GMP 调度模型

为什么 M 需要绑定 P 才能执行 G?

问题分析

在 Go 语言的并发模型中,M(操作系统线程)是 G(goroutine)执行的载体,而 P(Processor)是管理 G 调度的逻辑单元。M 不能直接执行 G,而是必须先绑定 P,然后 P 决定 M 需要执行哪个 G。那么,为什么 M 不能直接执行 G,而必须通过 P 呢?

解答

  1. P 维护了 Goroutine 队列

    • 每个 P 维护一个本地运行队列(local run queue),存储等待执行的 G。
    • M 需要 P 才能获取要执行的 G,否则 M 只能空转。
  2. P 负责管理 Go 运行时资源

    • P 维护了一些运行时资源,例如:
    • 本地缓存(mcache):用于加速小对象内存分配,避免全局锁竞争。
    • 调度信息:管理 goroutine 调度策略,包括 work-stealing、全局队列等。
    • 绑定 P 使 M 能够高效利用这些资源。
  3. 减少全局竞争,提高调度效率

    • 如果 M 直接从全局队列取 G,所有 M 可能会争抢任务,导致严重的竞争。
    • 绑定 P 后,每个 M 主要从 P 的本地队列获取 G,减少全局锁竞争,提高性能。
  4. 避免过度创建 M

    • M 不能无限创建(受 runtime.maxmcount 限制)。
    • P 充当了 M 和 G 之间的缓冲层,防止 M 因 G 过多而无限扩张,导致线程爆炸。

总结

M 需要绑定 P 是因为:

  • P 负责管理 G,M 需要 P 才能获取任务。
  • P 提供必要的运行时资源,M 不能绕过 P 直接执行 G。
  • P 减少了全局竞争,提高了调度效率。

M 发生 syscall 阻塞时,谁发现 M 阻塞?谁负责解绑 P?

问题分析

当 M 执行 syscall(系统调用)或阻塞操作(如网络 I/O)时,它会进入 内核态等待,不能继续执行 G。此时,Go 运行时需要解绑 P,并寻找新的 M 来执行 G。那么,谁发现 M 发生阻塞?谁负责解绑 P 呢?

解答

  1. M 进入 syscall,导致阻塞

    • M1 在执行 G1,调用 syscall(),进入 内核态,无法执行 Go 代码。
    • M1 进入挂起状态,但 P1 仍然存在。
  2. 谁发现 M1 阻塞?

    • M1 自己不会发现自己阻塞,因为它已经进入 syscall 不能执行 Go 代码。
    • P1 也不会主动执行代码,因为 P 只是一个数据结构。
    • 真正的调度行为由运行时调度器(Scheduler)负责:
    • 运行中的 其他 M(比如 M2)可能会发现 P1 没有 M 执行任务,从而触发调度。
    • Go 运行时的 Sysmon 线程(系统监控线程)定期检查 M 是否阻塞:
    • 如果发现 M1 长时间未返回,触发调度器逻辑,解绑 P1。
  3. P1 解绑 M1

    • P1.m = nil,解绑 M1。
    • P1 去 空闲 M 池 找新的 M:
    • 如果有 M2,就绑定 M2 继续执行任务。
    • 如果没有可用 M,尝试创建新的 M。
  4. M1 解除 syscall

    • M1 解除 syscall 后,尝试获取新的 P 继续执行。
    • 如果找不到 P,进入 空闲 M 列表,等待下一次调度。

总结

  • M 进入 syscall 后,它自己不会主动解绑 P,因为它已进入内核态,不能执行 Go 代码。
  • 其他 M 或 Sysmon 线程会发现 M 长时间未返回,并触发调度逻辑,解绑 P 并寻找新的 M。

调度器(Scheduler)本身是一个线程吗?

问题分析

在传统的操作系统中,调度器通常是一个单独的线程或进程,负责管理任务调度。那么,Go 的调度器是一个独立的线程吗?

解答

Go 调度器不是一个独立的线程,而是一个分布式调度系统,运行在所有 M 之上。

  1. 调度器逻辑嵌入在 M 的执行过程中

    • M 在运行 G 时,周期性地执行调度逻辑,检查是否需要切换 G,或者从全局队列获取任务。
    • 这意味着调度器是 分布式执行 的,而不是一个独立的 OS 线程。
  2. Sysmon 线程

    • Go 运行时有一个 后台监控线程(Sysmon),负责:
    • 监测 syscall 阻塞的 M。
    • 检查长时间运行的 G,触发 抢占调度(preemption)。
    • 但 Sysmon 线程 只是调度的辅助工具,而不是主调度器。
  3. 分布式调度 vs 传统调度

    • 传统操作系统的调度器是 单一线程,运行在内核态,管理所有线程。
    • Go 调度器是 分布式的,由多个 M 共同执行,没有固定的“调度线程”。

总结

  • Go 调度器不是一个独立的线程,而是多个 M 共同执行的逻辑。
  • Sysmon 线程负责监控阻塞情况,但不是主调度器。

Work-Stealing(工作窃取)是如何工作的?

问题分析

在 Go 中,每个 P 维护自己的本地 G 队列,但当一个 P 的队列为空时,它如何继续执行任务?Go 采用 工作窃取(Work-Stealing) 来均衡负载。这个机制是如何工作的?

解答

  1. P 发现自己的任务队列为空

    • P1 运行完所有 G,发现本地队列为空。
  2. P 试图从其他 P 窃取 G

    • P1 随机选择另一个 P2,尝试从 P2 的本地队列取一半 G 过来执行。
  3. P2 仍有任务,允许窃取

    • P2 有多个 G,允许 P1 窃取部分任务,保证 P1 不会闲置。
  4. P1 执行被窃取的 G

    • P1 继续执行新获取的 G,提高并发利用率。

总结

  • 工作窃取可以防止某些 P 长时间空闲,提升 CPU 利用率。
  • P 之间的负载均衡是动态的,不是固定的任务分配。

“阻塞”分为两大类,它们在底层调度方式上有所差异?

在 Go 里,“阻塞”分为两大类,它们在底层调度方式上有所差异,从调度器视角来看,这两种场景的根本差别在于是否真正进入内核阻塞**(也就是 OS 级别的阻塞)。

  1. 当 Goroutine 真正调用了阻塞系统调用 (syscall)

比如一个 Goroutine 调用了阻塞 IO(非 runtime/netpoll 机制托管的 IO)或某些阻塞式系统调用,那么:

  1. M(OS 线程)也会随之阻塞
    • 因为这类阻塞调用会让调用它的线程卡在内核态(直到 syscall 返回)。
    • 此时 Goroutine(G)和这个线程(M)都处于阻塞中,无法继续执行其它 Goroutine。
  2. G 不会单独“切走”——因为 M 被卡住了
    • 某个 Goroutine 阻塞在 syscall 里,就等于把所在的 M 一同拖进阻塞状态;这个时候没法在用户态用常规手段切换 G。
    • 这种场景下 runtime 会尽快让该阻塞的 M 把手里的 P 交出来(如果可能的话),这样就可以用别的 M+P 去执行其它就绪的 G,避免整条执行链都卡死。
  3. 后续调度
    • 被阻塞的 M(以及在它上面的那个 G)要等系统调用返回,才会恢复到 runtime 中。
    • 一旦 syscall 返回,runtime 会让这个恢复的 M 再次获取一个 P(如果需要的话)或者从空闲 P 列表获取或等待调度,继续运行后面的代码。

这种真正的内核阻塞下,Go runtime 并不能在用户态直接让 Goroutine “切走”,只能把阻塞线程从调度层面排除出去,同时(视需要)创建或唤醒别的线程来执行未阻塞的协程。

  1. 当 Goroutine 在用户态阻塞(channel recv、锁阻塞等)

与上面不同,如果 Goroutine 是阻塞在 Go 语言内置的同步原语上,比如:

  • chan 的发送、接收,但对端还没就绪
  • sync.Mutex 的加锁等待
  • select 等待分支就绪 - 以及运行时能自行管理的 “network poll” IO(使用非阻塞套接字 + epoll/kqueue 事件循环)

这类场景下,阻塞仅仅是协程级别的,Go runtime 自己“知道”怎么处理:

  1. 不会导致 OS 线程真正卡死 - 这类阻塞大多是通过 runtime 的调度器或 netpoll 轮询机制实现的。 - runtime 能够将那个 G 的状态标记为“Waiting”(阻塞),并把它放到对应的等待队列里(例如 channel 等待队列、锁等待队列或 netpoll 事件队列),而不是让 OS 线程阻塞在内核态。

  2. 把 G 放入“等待队列” - 比如一个 G 想从某个空 channel 中接收元素,runtime 知道它还没就绪,就会把这个 G 记录在 channel 的等待链表上。 - 此时,G 被剥离出了运行队列,标记成阻塞状态;M 立刻就可以去执行别的可运行的 Goroutine 了(同一个 P 上的 run queue 或去全局队列拿别的 G)。 - 整个阻塞过程仅仅体现在用户态协程的状态变化,线程(M)本身还空着,因此可以马上拿去跑别的 Goroutine。

  3. 等到阻塞条件消失 - 当另一个 Goroutine 向 channel 写入数据,或锁被释放时,runtime 发现能唤醒这个阻塞的 G,就把它重新标记为就绪 (Runnable),放回到 run queue 里。 - 这时候该 G 就可以由某个 M 再次调度执行。

这种是用户态调度能处理的典型场景,它让阻塞只停留在协程级,不占用 OS 线程,让同一个线程可以去执行更多别的 Goroutine。

核心区别 - Syscall 堵塞: 线程( M )+协程( G )一起“锁死”在内核态,runtime 无法只切走 Goroutine,必须把线程(M)本身视为“不可用”;需要等 syscall 返回后才能恢复执行。 - 用户态阻塞(chan、锁、select 等): 只阻塞协程(G),并不真正占用 OS 线程。runtime 可以在用户态把 G 标记成等待、让 M 去执行别的 G,从而实现 “轻量级阻塞”。

可以说,“让出 M”一般是指“线程真的进了内核阻塞”,只能把这个 M 整个排除在外;“把 G 放到等待队列”指的是“协程自己阻塞在 Go runtime 管理的同步原语”,不会占用操作系统的阻塞状态,M 立刻可继续干别的活儿。这正是 Go 异步调度、轻量级协程机制的主要优势之一。

与 ChatGPT 的对话

Last updated on