goroutine 和线程有什么区别?GMP 调度模型是怎样的?

深入高频原理约 8 分钟读完

一句话回答

goroutine 是 Go 运行时在用户态调度的轻量级线程:初始栈只有几 KB、不够时自动扩容,创建和切换都不用进入内核,所以能轻松开出成千上万个。Go 用 GMP 模型把大量 goroutine 分配到少量系统线程上执行:G 是 goroutine,M 是系统线程,P 是执行 Go 代码必须持有的"处理器",数量由 GOMAXPROCS 决定,默认等于 CPU 核数。每个 P 有自己的本地队列,空了就去全局队列取,或者从别的 P 偷走一半;G 阻塞在系统调用里时,P 会交给其他 M 继续干活;Go 1.14 起还能通过信号抢占长时间运行的 G。

详细解析

goroutine 和线程的区别

系统线程 goroutine
由谁调度 操作系统内核 Go 运行时,在用户态完成
栈 创建时确定大小,通常是 MB 级 初始只有几 KB(Linux 上最小 2KB,Go 1.19 起会参考历史平均用量调整),不够时自动扩容,GC 时还会收缩
创建和销毁 需要系统调用,开销大 只需分配一个 G 结构和一小块栈
切换 进入内核,保存和恢复完整的线程上下文 在用户态完成,只保存 PC、SP 等少量寄存器
身份 有线程 ID,由操作系统管理 没有对外暴露的 ID,不能从外部强制终止,只能通知它自己退出

同样的内存能容纳的 goroutine 比线程多几个数量级,所以 Go 服务习惯"每个请求、每个任务一个 goroutine"。

和 Node.js 对比:Node.js 只用一个线程执行 JS,靠事件循环和回调、Promise 实现并发,一段 CPU 密集的同步代码会拖慢所有请求(见 Node.js 是单线程的吗)。goroutine 写起来是普通的同步代码,遇到网络 I/O 时运行时把它挂起,让线程去执行别的 goroutine;多个 P 还能同时用满多个 CPU 核。

G、M、P 分别是什么

  • G(goroutine):保存自己的栈、状态、要执行的函数,以及被切换出去时的上下文
  • M(machine):一个操作系统线程,真正执行代码的是它。M 必须先绑定一个 P 才能执行 Go 代码
  • P(processor):逻辑处理器,持有本地运行队列、内存分配缓存等资源。P 的数量等于 GOMAXPROCS,决定了同一时刻最多有几个 G 在并行执行
文本
全局队列(所有 P 共享,加锁访问): [G] [G] [G]
   ^ 本地队列满了,挪一半过来
   v 本地队列空了,取一批回去

P0 ─── 绑定 ─── M0(线程)─── 正在执行一个 G
 ├─ runnext:  [G]              下一个优先执行的 G
 └─ 本地队列: [G] [G] [G] [G]   用原子操作访问,容量 256

P1 ─── 绑定 ─── M1(线程)─── 正在执行一个 G
 └─ 本地队列: 空                从 P0 的本地队列偷走一半

M2(线程)─── 和它执行的 G 一起阻塞在系统调用里,原来的 P 已经交给别的 M

调度流程

go f() 创建的新 G 会放进当前 P 的 runnext,原来在 runnext 的 G 挤到本地队列尾部;本地队列满了,就把一半挪到全局队列。每个 M 绑定 P 后,不停地执行调度循环:

文本
┌─> schedule():为当前 P 找一个可运行的 G
│     1. 每调度 61 次先查一次全局队列,防止全局队列里的 G 饿死
│     2. 本地队列(先看 runnext)
│     3. 全局队列,顺便取一批放进本地队列
│     4. 网络轮询器(netpoller)中 I/O 已经就绪的 G
│     5. 随机挑选其他 P,偷走它本地队列的一半(work stealing)
│     6. 都没有:M 把 P 放回空闲列表,自己休眠
│   切换到 G 的栈上执行
│   G 执行结束、阻塞、主动让出或被抢占
└── 回到 schedule()

G 阻塞时会发生什么

  • channel、锁、time.Sleep:G 被挂起,M 不阻塞,直接去执行下一个 G;条件满足后(比如 channel 有了数据),G 被放回运行队列
  • 网络 I/O:socket 被设为非阻塞,读不到数据时 G 挂在网络轮询器上(Linux 上基于 epoll),M 去执行别的 G,数据就绪后 G 重新变为可运行。代码看起来是同步阻塞的,底层和 Node.js 一样是 I/O 多路复用
  • 阻塞的系统调用(如 Linux 上读写普通文件、cgo 调用):线程真的被内核挂起了。调用很快返回时,M 继续用原来的 P;迟迟不返回时,后台监控线程 sysmon 会把 P 收回来,如果还有等待运行的 G,就把 P 交给另一个 M(没有空闲的 M 就新建一个),这就是 hand off,其他 G 不受影响。系统调用返回后,M 先尝试拿回原来的 P,再尝试拿空闲的 P,都拿不到就把 G 放进全局队列,自己休眠

抢占

  • Go 1.14 之前只有协作式抢占:编译器在函数入口插入检查,sysmon 发现某个 G 连续运行超过 10ms,就给它打上抢占标记,等它下次调用函数时才让出。没有函数调用的紧密循环(如 for {})永远不会让出:GOMAXPROCS 为 1 时其他 G 会饿死,GC 需要暂停所有 G 时也会一直等它,整个程序卡住
  • Go 1.14 起加入基于信号的异步抢占:sysmon 向运行该 G 的线程发送信号(Unix 上是 SIGURG),信号处理函数确认 G 停在可以安全暂停的位置后,把它挂起并放回队列。现在紧密循环也能被抢占

GOMAXPROCS

  • 默认等于 CPU 逻辑核数,可以用环境变量 GOMAXPROCS 或 runtime.GOMAXPROCS(n) 修改
  • 它限制的是同时执行 Go 代码的 M 数,不是线程总数:阻塞在系统调用里的 M 不占 P,所以线程数可以多于 GOMAXPROCS(默认最多 10000 个线程)
  • 容器中:Go 1.25 之前读到的通常是宿主机的核数,容器的 CPU 限额很小时 P 过多,容易被 CPU 限流(throttling),常用 uber-go/automaxprocs 修正;Go 1.25 起在 Linux 上默认会参考 cgroup 的 CPU 限额,运行中还会定期更新

面试官可能追问

为什么需要 P?只有 G 和 M 不行吗?

Go 1.1 之前就是 G-M 模型:所有 G 放在一个全局队列里,每个 M 取任务都要抢同一把锁,核数一多竞争就很严重;M 阻塞在系统调用里时,它持有的内存缓存等资源也跟着闲置。引入 P 之后,运行队列和内存分配缓存都挂在 P 上,大部分调度不需要全局锁;M 阻塞时只要把 P 交给别的 M,资源就跟着 P 转移了。P 的数量还顺带控制了并行度。

GOMAXPROCS 设为 1,还需要考虑并发安全吗?

需要。只有一个 P 时同一时刻只有一个 G 在执行,但 G 可能在任何位置被抢占或因为阻塞而切换,两个 G 对同一个变量的"读-改-写"照样会交错。这和 Node.js 不同:Node.js 中两个 await 之间的同步代码不会被其他 JS 代码打断,Go 没有这种保证。共享数据照样要加锁,go test -race 也照样会报告数据竞争。

goroutine 的栈是怎么扩容的?

编译器在函数入口插入栈空间检查,空间不够时进入运行时的 morestack:分配一块两倍大小的新栈,把旧栈的内容整个复制过去,并修正指向栈内的指针。Go 1.3 之前用的是分段栈,在栈的边界附近反复调用函数时会频繁分配和释放栈段,所以改成了现在的连续栈。64 位系统上单个 goroutine 的栈最大 1GB,无限递归超过上限时程序会以 fatal error: stack overflow 退出。

goroutine 是不是开得越多越好?

不是。每个 goroutine 至少占一块栈内存,数量太多会增加内存和调度开销;更常见的问题是无限制地为每个任务开 goroutine,把下游的数据库或接口压垮。批量任务要限制并发数,比如用带缓冲的 channel 当信号量,或者用 errgroup 的 SetLimit(见 并发同步手段)。还要确保每个 goroutine 都能退出,否则就是 goroutine 泄漏。

易错点

  • GOMAXPROCS 限制的是同时执行 Go 代码的线程数,不是线程总数,更不是 goroutine 数
  • 网络 I/O 不会占住线程;Linux 上的文件读写和 cgo 调用会占住线程,这类调用并发很高时线程数会暴涨
  • main 函数返回时程序直接退出,不会等待其他 goroutine,需要用 WaitGroup 等方式显式等待
  • "死循环会卡住整个调度器"是 Go 1.14 之前的情况,现在有了异步抢占

AI 模拟面试官

用自己的话回答,AI 对照参考答案打分、指出遗漏,再追问,最多 3 轮

登录后就可以和 AI 面试官对练,面试记录也会保存下来。登录

这道题你掌握了吗?

选一个最接近的状态,没掌握的题会出现在"我的进度 · 待复习"里。

学习记录暂存在本机浏览器。登录后自动同步到账号,换设备也能看到。