Skip to content

Go 运行时 G/M/P 调度模型、网络 I/O 与并发安全研究

概览

系统研究 Go goroutine 与运行时 G/M/P 调度模型,覆盖 G、M、P 定义,用户态调度、Linux task_struct 映射、goroutine 生命周期、netpoll、网络 I/O、GOMAXPROCS、系统调用、channel 通信、context 取消、WaitGroup、Mutex、RWMutex、Go 内存模型与竞态检测。

摘要

Go 语言通过 goroutine 提供并发执行单元,并由运行时调度器在用户态管理 goroutine 与操作系统线程之间的执行关系。Go 官方运行时文档将调度模型中的三类核心实体描述为 G、M、P:G 表示 goroutine,M 表示操作系统线程,P 表示执行 Go 用户代码所需的调度与资源上下文。该模型的目标是在语言层面提供大量并发任务的表达能力,同时通过运行时调度、网络轮询器、系统调用处理和同步原语,将 goroutine 的创建、阻塞、恢复、执行和销毁过程与操作系统线程调度相衔接。本文依据 Go 官方规范、运行时文档、运行时源码说明、Go 内存模型和 Linux 调度文档,对 G/M/P 的定义、作用、用户态关联、内核态映射、goroutine 生命周期、网络 I/O 路径以及并发安全使用方式进行阐述。[S1][S2][S3][S4][S5][S6][S7][S9]

关键词

Go;goroutine;G/M/P;运行时调度器;netpoll;task_struct;并发安全;内存模型

1. 引言

Go 语言规范规定,go 语句会在同一地址空间内启动一个独立的并发执行单元,即 goroutine。调用表达式及其参数在发起 go 语句的 goroutine 中求值,而被调用函数在新的 goroutine 中执行;当该函数返回时,对应 goroutine 终止,返回值被丢弃。[S2]

Go 的并发执行不是直接将每个 goroutine 固定映射为一个操作系统线程。Go 运行时在用户态维护 goroutine、操作系统线程和执行资源上下文之间的关系。官方运行时文档将三类对象分别称为 G、M 和 P,并说明调度器的职责是将 G、M、P 匹配起来,使可运行的 goroutine 能够在可执行 Go 代码的线程上运行。[S1]

因此,G/M/P 模型是 Go 运行时连接语言级并发和操作系统线程调度的核心机制。它既影响普通 goroutine 的调度,也影响阻塞系统调用、网络 I/O、同步等待和并发安全实践。

2. G/M/P 模型的定义与作用

Go 官方运行时文档将 G 定义为 goroutine,由运行时中的 g 类型表示。G 保存 goroutine 的栈、调度上下文、当前状态以及与执行线程相关的运行时字段。goroutine 退出后,G 对象可以被放回空闲池复用。[S1][S5]

M 表示操作系统线程。M 可以执行 Go 用户代码、运行时代码、系统调用,也可以处于空闲状态。M 是 Go 运行时中与内核线程相对应的实体。在 Linux 上,内核调度的基本对象是 task,Linux 文档说明 pid、tid 和 task 在 taskstats 文档语境中均指由 task_struct 表示的标准 Linux task;同一进程内的线程组共享地址空间。[S1][S9]

P 表示执行 Go 用户代码所需的资源上下文。官方运行时文档说明,P 包含调度器和内存分配器状态,且 P 的数量等于 GOMAXPROCS。一个 M 要执行 Go 用户代码,通常需要持有一个 P;当 M 进入系统调用时,会释放或交还 P,使其他 M 可以继续执行可运行的 goroutine。[S1][S3]

由此可得,G/M/P 的作用不是简单地创建线程,而是在用户态建立如下关系:

实体Go 运行时含义是否对应内核对象主要作用
Ggoroutine不直接对应保存 goroutine 的栈、状态和调度上下文
MOS thread对应操作系统线程;Linux 上表现为 task_struct 所代表的 task承载实际 CPU 执行
Pprocessor/resource context不对应内核对象持有运行 Go 代码所需的调度和分配资源

3. G/M/P 解决的问题

G/M/P 模型解决的第一类问题是 goroutine 数量与操作系统线程数量之间的解耦。Go 官方博客说明,Go 可以为每个 goroutine 分配专用操作系统线程,但 Go 实际采用运行时调度器,使多个 goroutine 复用一组由 Go 管理的线程;创建新的 goroutine 不要求创建新的操作系统线程。[S3]

第二类问题是并行度控制。GOMAXPROCS 表示 Go 同时执行用户级 Go 代码所使用的最大 CPU 数或线程执行额度。Go FAQ 说明,GOMAXPROCS 限制的是同时执行 goroutine 的 CPU 数,而运行时仍然可能为了 I/O 或系统调用分配超过该数量的线程。[S3]

第三类问题是阻塞行为处理。当 goroutine 因通道、锁、网络 I/O、计时器或系统调用阻塞时,运行时需要避免整个进程被单个阻塞操作拖住。G/M/P 模型通过 goroutine 状态切换、P 的释放、网络轮询器唤醒和运行队列调度,使未阻塞的 goroutine 继续获得执行机会。[S1][S5][S6]

第四类问题是用户态调度与内核态调度的分工。Go 运行时决定哪个 G 绑定到哪个 M 上运行;Linux 内核调度器决定哪个 M 对应的 task 在 CPU 上执行。G 不直接进入内核调度队列,M 才是内核可见的调度对象。[S1][S4][S9]

4. 用户态关联、通信机制与内核态映射

Go 语言层面不存在“主线程与协程”之间的特殊通信机制。main 函数运行在 main goroutine 中,其他 goroutine 与 main goroutine 一样处于同一地址空间。Go 规范定义的通信机制主要是 channel;channel 通过发送和接收操作在并发函数之间传递值,未缓冲 channel 在发送和接收双方准备好时完成通信,缓冲 channel 按容量进行排队。规范还说明,同一 channel 可以被任意数量的 goroutine 使用,不需要额外同步即可完成 channel 本身的并发访问。[S2]

除 channel 之外,goroutine 还可以通过共享内存配合同步原语进行协作。Go 内存模型规定,当多个 goroutine 同时访问同一数据且至少一个访问是写入时,必须使用 channel、syncsync/atomic 等机制序列化访问;不存在数据竞争的程序具有顺序一致性保证。[S7]

在运行时用户态结构中,G 和 M 的关联发生在调度执行阶段。运行时 execute 逻辑会将当前 M 的 curg 设置为即将运行的 G,同时将 G 的 m 字段设置为当前 M,并将 G 的状态从 _Grunnable 切换为 _Grunning。这表示该 goroutine 正在某个操作系统线程上执行。[S4][S5]

这种关联是临时的。goroutine 阻塞、让出、系统调用、抢占或结束后,运行时会解除当前 G 与 M 的关联。等待中的 goroutine 可以在后续被调度到相同 M,也可以被调度到不同 M。因此,普通 goroutine 不拥有固定线程。Go 运行时提供 runtime.LockOSThread,用于将当前 goroutine 固定到当前操作系统线程;在未调用该机制时,goroutine 与 M 之间不存在稳定的一对一绑定。[S3][S10]

在内核态,Linux 调度器处理的是线程级调度对象。Linux 文档说明,调度器决定下一个由 CPU 执行的可运行线程;Linux 源码中 task_struct 表示核心任务结构。因此,Go 的映射关系可以表述为:G 是用户态运行时对象,不直接对应 task_struct;M 是操作系统线程,在 Linux 上对应内核 task;P 是 Go 运行时资源令牌,不对应内核实体。当某个 G 正在某个 M 上运行时,CPU 实际执行的是该 M 对应的内核 task;当该 G 被挂起或迁移后,它与该 task_struct 的执行关系结束。[S9]

5. goroutine 从创建到执行及其与 task_struct 的生命周期关系

一个 goroutine 的生命周期可以分为创建、入队、调度、执行、阻塞或系统调用、恢复、终止和复用几个阶段。

第一,创建阶段。Go 规范规定,go f() 会启动一个新的 goroutine 执行函数 f。在编译后的运行时路径中,Go 运行时源码注释说明,编译器会将 go 语句转换为对 runtime.newproc 的调用;newproc 创建一个新的 G,并把它放入等待运行的 goroutine 队列。[S2][S4]

第二,初始化阶段。运行时会从空闲 G 列表获取 G,或分配新的 G,并初始化其栈、调度寄存器上下文、入口函数、父 goroutine 信息和起始程序计数器。随后,G 的状态会从 _Gdead 切换为 _Grunnable。[S4][S5]

第三,入队与唤醒阶段。新创建的 G 会被放入当前 P 的本地运行队列,运行时可能调用唤醒逻辑使空闲 M 参与调度。如果当前已经存在可运行的 M/P 组合,该 G 会等待调度;如果需要更多执行线程,运行时可能唤醒或创建 M。[S4]

第四,调度与执行阶段。M 进入调度循环后,运行时从本地队列、全局队列、网络轮询器或其他来源寻找可运行 G。找到 G 后,execute 将 G 绑定到当前 M,并将其状态切换为 _Grunning。此时,G 仍是 Go 运行时用户态对象,而实际被内核调度执行的是 M 对应的操作系统线程。[S4][S5][S9]

第五,运行中的内核关联阶段。当 Linux 调度器选择该 M 对应的 task_struct 在 CPU 上运行时,CPU 执行的是当前 M 上绑定的 G 的机器指令。因此,goroutine 与 task_struct 的关系不是持久对象关系,而是运行时刻的执行承载关系:G → M → task_struct。G 的执行可以在之后被调度到另一个 M 上,因此同一个 goroutine 在其生命周期内可能先后由不同 task_struct 承载执行。[S4][S9]

第六,阻塞或系统调用阶段。当 G 因 channel、锁、网络 I/O、计时器或其他等待条件阻塞时,运行时会将其状态切换为 _Gwaiting,解除 G 与 M 的当前执行关系,并让 M 继续调度其他可运行 G。当 G 进入系统调用时,状态可切换为 _Gsyscall;如果系统调用阻塞,M 可能仍被内核阻塞,而 P 可被释放给其他 M 继续执行 Go 代码。[S1][S4][S5]

第七,恢复阶段。当等待条件满足,例如 channel 收到数据、锁可用、计时器到期或网络 fd 就绪,运行时会将对应 G 重新置为可运行状态,并放回运行队列。之后该 G 会再次由某个持有 P 的 M 执行;该 M 可能与上一次执行它的 M 不同。[S4][S5][S6]

第八,终止与复用阶段。当 goroutine 函数返回时,运行时执行 goroutine 退出路径。运行时源码显示,退出流程会销毁当前 G 的运行状态,将其状态从 _Grunning 转换为 _Gdead,解除与 M 的关联,并将 G 放回空闲池。该过程不会要求对应操作系统线程同时退出;M 通常继续被运行时复用。[S4][S5]

6. 网络 I/O 从接收到 goroutine 处理再到读写的过程

Go 的网络 I/O 由 net 包、内部 poll 层和运行时网络轮询器协同完成。运行时网络轮询器集成在 Go runtime 中,不同操作系统上使用不同实现,例如 Linux 上使用 epoll,BSD/macOS 上使用 kqueue,Windows 上使用 IOCP。运行时文档说明,网络轮询器可以返回已就绪的 goroutine 列表,供调度器重新调度。[S6]

以 TCP 连接读取为例,一个 goroutine 调用 conn.Read 后,会进入 internal/poll.FD.Read 路径。该结构中保存底层文件描述符以及 poll 描述符。读操作会先尝试执行非阻塞 read 系统调用。如果数据已经就绪,系统调用直接返回数据,goroutine 继续处理业务逻辑。[S6]

如果底层 fd 暂无数据并返回 EAGAIN,且该 fd 被运行时 poller 管理,当前 goroutine 不会长期占用一个操作系统线程等待数据。它会通过 pollDesc.waitRead 进入等待状态。运行时 poll 描述符中包含读等待和写等待信号位,分别关联可能被阻塞的读 goroutine 和写 goroutine。此时,G 被挂起,M 与 P 可以继续执行其他可运行 goroutine。[S5][S6]

当内核通知该 fd 可读时,运行时 netpoll 机制接收事件,将对应等待的 goroutine 置为可运行状态。调度器随后把该 G 放入运行队列,并在某个 M/P 组合上恢复执行。恢复后的 goroutine 会继续之前的读路径,再次尝试系统调用并取得数据。业务 goroutine 获得数据后执行应用层处理;如果需要写回响应,则进入写路径。写路径与读路径在调度语义上相同:如果 fd 可写,系统调用推进;如果返回 EAGAIN,goroutine 等待写就绪事件,并由 netpoll 在可写时唤醒。[S6]

该机制提升并发的原因在于,等待网络 I/O 的 goroutine 以用户态 G 的形式挂起,而不是为每个阻塞连接长期占用一个内核线程。Go 运行时可以让少量正在执行的 M/P 处理大量处于等待状态的 goroutine。GOMAXPROCS 限制同时执行 Go 代码的并行度,而被 I/O 阻塞的 goroutine 不等同于同等数量的正在运行线程。[S1][S3][S6]

因此,一个完整网络请求的运行过程可以抽象为:连接事件进入内核事件机制;运行时 netpoll 得到 fd 就绪事件;等待该 fd 的 goroutine 被置为可运行;调度器将其绑定到某个 M/P;业务代码读取数据、处理请求并写回响应;读写期间若再次遇到 EAGAIN,goroutine 再次挂起并等待下一次 fd 就绪事件。[S4][S6]

7. goroutine 的限制、滥用防止与并发安全

goroutine 是轻量级并发单元,但不是零成本对象。G 保存栈、调度状态和运行时元数据;阻塞但未退出的 goroutine 仍会持有其栈、引用对象和等待资源。Go 运行时文档说明 G 对象由运行时管理并可复用,Go 发行说明也说明 goroutine 栈存在最大限制。因此,无限制创建 goroutine 会增加内存、调度和外部资源压力。[S1][S3]

防止滥用 goroutine 的基本约束方式包括限定并发度、明确生命周期和设置取消条件。对于外部请求、后台任务和 I/O 任务,应使用 context.Context 传递取消信号、截止时间和请求范围数据。Go 官方文档说明,Context 可跨 API 边界携带 deadline、取消信号和值,并且其方法可被多个 goroutine 同时调用;在设置 timeout 或 deadline 时,应调用取消函数释放相关资源。[S8]

对于任务集合,应使用 sync.WaitGroup 等待 goroutine 完成,并确保计数增加发生在等待之前。官方 sync 文档说明,WaitGroup 是用于等待 goroutine 集合完成的计数信号量;WaitGroup.GoAdd 所代表的新任务必须与 Wait 正确排序。[S7]

防止并发问题的核心依据是 Go 内存模型。内存模型规定,当多个 goroutine 并发访问共享数据且至少一个访问为写入时,必须通过同步机制序列化访问。数据竞争被定义为对同一内存位置的读写或写写并发,且这些访问不是同步操作;无数据竞争程序具有顺序一致性保证。[S7]

在共享内存场景下,sync.Mutex 提供互斥访问。官方文档说明,Mutex 的零值是未加锁状态,首次使用后不得复制;一次 Unlock 会同步先行于之后的某次 Lock。因此,安全使用锁应满足以下事实性约束:锁对象不应被复制;加锁和解锁应成对出现;共享数据的所有访问路径应由同一把锁或同一套同步规则保护;持锁期间应避免执行无法确定时长的阻塞操作,以减少等待链;多把锁同时使用时应维持固定顺序以避免循环等待。[S7]

sync.RWMutex 适用于共享数据存在读写区分的场景。官方文档说明,RWMutex 允许任意数量读者或一个写者持有锁,首次使用后不得复制。它不与特定 goroutine 绑定,锁的持有与释放由程序逻辑保证。[S7]

channel 的使用也存在边界条件。Go 规范规定,向已关闭 channel 发送会触发 panic;nil channel 上的通信会永久阻塞;select 在多个通信分支可执行时会伪随机选择一个分支执行。Go race detector 文档还指出,未同步的发送与关闭可能构成数据竞争。因此,channel 的关闭应由发送方或明确的所有者负责,并通过同步关系保证不会与发送操作并发冲突。[S2][S7]

检测并发错误时,Go 官方 race detector 可通过 go test -racego run -race 等方式检测执行路径上的数据竞争;官方文档同时说明,race detector 只能发现实际执行路径中发生的竞争,因此测试覆盖范围会影响检测结果。[S7]

8. 结论

Go 的 G/M/P 模型将 goroutine、操作系统线程和执行资源上下文分离。G 表示语言级并发任务,M 表示承载执行的操作系统线程,P 表示执行 Go 用户代码所需的运行时资源。该模型使大量 goroutine 可以复用有限数量的操作系统线程,并通过 GOMAXPROCS 控制同时执行 Go 代码的并行度。

在用户态,goroutine 通过 channel、共享内存加同步原语、Context 和 WaitGroup 等机制协作;运行时通过 G、M、P 的状态关系完成调度。在内核态,Linux 调度器只感知 M 对应的 task_struct,不直接调度 G。goroutine 与 task_struct 的关系只在 goroutine 被某个 M 执行期间间接存在。

在网络 I/O 中,Go runtime 的 netpoll 机制将 fd 就绪事件与等待中的 goroutine 关联起来,使等待 I/O 的 goroutine 挂起而不长期占用操作系统线程。该机制配合 G/M/P 调度,使网络服务可以在大量连接等待 I/O 时维持较少的活跃执行线程。

goroutine 的安全使用依赖明确的生命周期管理、并发度限制、取消机制和同步规则。无界创建 goroutine、未同步访问共享数据、错误关闭 channel、复制锁对象或不受控地持锁执行阻塞操作,均会导致资源泄漏、数据竞争、死锁或不可预期阻塞。Go 官方内存模型、sync 包、context 包和 race detector 提供了对应的约束和检测依据。

参考文献

[S1] Go Runtime HACKING:Scheduler structures。 [S2] The Go Programming Language Specification:Go statements、Channels、Send statements、Select statements。 [S3] Go FAQ、Go Blog 与 runtime 包文档:GOMAXPROCS、goroutine scheduling、preemption。 [S4] Go runtime proc.go:newproc、schedule、findRunnable、execute、goexit。 [S5] Go runtime runtime2.go:G 结构与 goroutine 状态。 [S6] Go runtime netpoll 与 internal/poll:网络轮询、pollDesc、FD.Read。 [S7] Go Memory Model、sync 包文档、Data Race Detector。 [S8] Go context 包文档。 [S9] Linux Kernel Documentation 与 Linux scheduler/task_struct 相关文档。 [S10] Go runtime.LockOSThread 文档。

上文参考文献对应的官方依据如下:

  • [S1] Go 官方运行时文档说明调度器管理 G、M、P,G 是 goroutine,M 是 OS thread,P 是执行 Go 用户代码所需资源,调度器负责匹配 G/M/P。(Go开发)
  • [S2] Go 语言规范定义了 go 语句、channel 通信、send 与 select 行为。(Go开发)
  • [S3] Go FAQ、Go Blog 和 runtime 文档说明 GOMAXPROCS、goroutine 与 OS thread 复用、并行度限制、I/O/syscall 线程行为。(Go开发)
  • [S4] Go runtime 源码说明 newproc 创建 G 并入队,调度循环查找可运行 G,execute 建立 G 与 M 的运行关联,goroutine 退出后进入销毁和复用流程。(Go开发)
  • [S5] Go runtime runtime2.go 定义了 G 的关键字段以及 _Grunnable_Grunning_Gwaiting_Gsyscall_Gdead 等状态。(Go开发)
  • [S6] Go runtime netpoll 和 internal/poll 源码说明了 epoll/kqueue/IOCP 等网络轮询实现、pollDesc 的读写等待字段,以及 FD.ReadEAGAIN 时等待可读事件的路径。(Go开发)
  • [S7] Go 内存模型、sync 包和 race detector 文档定义了数据竞争、同步规则、Mutex/RWMutex/WaitGroup 语义以及 -race 的检测范围。(Go开发)
  • [S8] Go context 包文档说明 Context 用于跨 API 边界传递 deadline、取消信号和值,CancelFunc 用于通知操作放弃工作。(Go Packages)
  • [S9] Linux 文档说明 Linux 调度器调度线程,task_struct 表示核心 task 结构,线程组共享地址空间。(Linux 内核文档)
  • [S10] runtime.LockOSThread 文档说明 goroutine 可以被固定到当前 OS thread;否则普通 goroutine 不具备稳定线程绑定。(Go Packages)
GitHub Discussions

参与讨论

评论会同步到 stellhub/stell-web 仓库的 GitHub Discussions。

Powered by VitePress and GitHub Discussions.