目录

Go-14 goroutine 与 GMP 调度模型

1. 进程、线程与协程

要理解 goroutine 为什么"轻",先要弄清它在整个并发抽象体系里所处的位置。从操作系统到语言运行时,并发执行单元大致分三层:进程(Process)→ 线程(Thread)→ 协程(Coroutine)

1.1 三者的定位

┌────────────────────────── Operating System ──────────────────────────┐
│                                                                      │
│  Process ( independent address space, fd table, isolation )          │
│  ┌────────────────────────────────────────────────────────────────┐  │
│  │  Thread ( kernel scheduling unit, shares process addr space )  │  │
│  │  ┌───────────────────────────────────────────────────────┐     │  │
│  │  │  goroutine ( user-mode scheduling, runtime managed )  │     │  │
│  │  │  G1   G2   G3   G4   ...   Gn                         │     │  │
│  │  └───────────────────────────────────────────────────────┘     │  │
│  └────────────────────────────────────────────────────────────────┘  │
└──────────────────────────────────────────────────────────────────────┘
  • 进程:操作系统资源分配的基本单位,拥有独立的虚拟地址空间、文件描述符表、信号处理等。进程间隔离性最强,切换开销也最大(要切换页表、刷 TLB)。
  • 线程:操作系统 CPU 调度的基本单位。同一进程内的线程共享地址空间,切换比进程轻,但仍需陷入内核态完成寄存器保存/恢复、调度器选取下一个线程。
  • 协程:由**用户态程序(语言运行时或库)**自己调度的执行单元,操作系统完全感知不到它的存在。切换只是普通的函数调用级别的寄存器保存,不进内核,因此极其廉价。

1.2 内核态切换 vs 用户态切换

线程切换必须经过内核,一次典型的上下文切换要做这些事:

  1. 触发陷入(时钟中断 / 系统调用),从用户态切到内核态;
  2. 保存当前线程的完整 CPU 上下文(通用寄存器、SP、PC、浮点状态等)到内核栈;
  3. 内核调度器挑选下一个可运行线程;
  4. 恢复新线程上下文,切回用户态;
  5. 可能伴随 TLB 失效、缓存冷启动。

一次内核级线程切换的直接开销通常在 1~2 微秒量级,加上缓存污染的间接成本可能更高。而 goroutine 的切换完全在用户态完成,只需保存/恢复少量寄存器(PC、SP、几个被调用者保存寄存器),开销约几十到一两百纳秒,相差一个数量级以上。

1.3 goroutine 的初始栈:2KB 可增长

线程的栈通常是固定大小且很大(Linux 默认 8MB),无论你用不用都预留着。这意味着开 1 万个线程光栈就要占 80GB 虚拟内存,根本不现实。

goroutine 则采用可增长/可收缩的分段栈(连续栈)

  • 初始栈仅 2KBruntime 常量 _StackMin);
  • 函数调用时会在序言(prologue)检查栈是否够用,不够就触发 morestack:分配一块 2 倍大的新栈,把旧栈数据整体拷贝过去,并调整指针(栈拷贝,Go 1.4 起用连续栈取代早期的分段栈,避免"热点分裂"性能抖动);
  • GC 期间栈也可能被收缩。

因此百万级 goroutine 只需约 GB 级内存,且大多数 goroutine 的栈始终很小。

1.4 对比表

维度 进程 线程 goroutine
调度者 操作系统内核 操作系统内核 Go 运行时(用户态)
地址空间 独立 进程内共享 进程内共享
创建开销 最大(复制页表等) 较大(内核对象+栈) 极小(分配 G 结构+2KB 栈)
切换开销 微秒级(切页表/TLB) ~1μs(陷内核) 纳秒级(用户态寄存器)
默认栈大小 —— 固定 8MB(Linux) 初始 2KB,动态伸缩
通信方式 IPC/管道/共享内存 共享内存+锁 channel(推荐)/ 共享内存+锁
数量级 几百 几千 几十万~百万

一句话:goroutine = 用户态调度的、栈可伸缩的、极廉价的执行单元,Go 运行时把成千上万个 goroutine 复用(multiplex)到少量内核线程上运行,这正是 GMP 模型要解决的问题。


2. goroutine 基础

2.1 go 关键字启动

在任意函数调用前加 go,就把它变成一个 goroutine 异步执行:

package main

import (
	"fmt"
	"sync"
)

func main() {
	var wg sync.WaitGroup
	for i := 0; i < 3; i++ {
		wg.Add(1)
		go func(id int) { // Go 1.22 起每次循环 i 是独立变量,但传参仍是最稳妥写法
			defer wg.Done()
			fmt.Printf("goroutine %d 执行\n", id)
		}(i)
	}
	wg.Wait() // 等待所有 goroutine 完成
	fmt.Println("main 结束")
}

go f() 的语义是:运行时创建一个新的 G,把 f 的入口和参数拷贝进去,放入调度队列,然后立即返回main 继续往下走。何时真正执行由调度器决定。

2.2 轻量到什么程度:启动百万 goroutine

下面这段程序直接开一百万个 goroutine,观察内存占用:

package main

import (
	"fmt"
	"runtime"
	"sync"
	"time"
)

func main() {
	const N = 1_000_000
	var wg sync.WaitGroup
	wg.Add(N)

	start := time.Now()
	for i := 0; i < N; i++ {
		go func() {
			defer wg.Done()
			time.Sleep(time.Second) // 让它们同时"存活",观察内存峰值
		}()
	}

	var m runtime.MemStats
	runtime.ReadMemStats(&m)
	fmt.Printf("已启动 %d 个 goroutine\n", runtime.NumGoroutine())
	fmt.Printf("当前堆分配: %.1f MB\n", float64(m.Alloc)/1024/1024)

	wg.Wait()
	fmt.Printf("耗时: %v\n", time.Since(start))
}

典型输出(数值随机器不同):

已启动 1000001 个 goroutine
当前堆分配: 约 数百 MB
耗时: 约 1.x s

一百万个 goroutine 只消耗几百 MB 内存、创建耗时不到一秒。换成一百万个线程,操作系统早就 OOM 或拒绝创建了。

2.3 main 退出,所有 goroutine 立即结束

goroutine 不是守护线程,主 goroutine(main 函数)一旦返回,整个进程退出,其余 goroutine 无论执行到哪里都被强制终止,没有任何清理机会:

package main

import (
	"fmt"
	"time"
)

func main() {
	go func() {
		time.Sleep(100 * time.Millisecond)
		fmt.Println("这行大概率打印不出来") // main 已经退出了
	}()
	fmt.Println("main 直接结束")
	// 没有等待,进程立刻退出
}

输出通常只有 main 直接结束。因此需要用 sync.WaitGroup、channel 或 context 来显式同步,绝不能靠 time.Sleep “碰运气"等待子 goroutine。


3. 为什么需要 GMP

3.1 早期的 GM 模型(Go 1.0)

最初的 Go 调度器只有两个角色:G(goroutine)M(machine,内核线程),所有可运行的 G 放在一个全局运行队列里,M 从队列取 G 执行:

        全局运行队列 (受一把全局锁保护)
        ┌─────────────────────────────┐
        │  G  G  G  G  G  G  G  G ... │
        └─────────────────────────────┘
           ▲    ▲    ▲    ▲
           │    │    │    │   (所有 M 抢同一把锁)
          M1   M2   M3   M4

这个模型能跑,但有致命的性能问题:

  1. 全局锁争用严重:每次创建、调度、结束 G 都要抢全局锁,M 数量一多,锁竞争成为瓶颈。
  2. G 在 M 之间频繁迁移,破坏局部性:一个 G 在 M1 上跑到一半让出,下次可能在 M3 上恢复,CPU 缓存(cache)和内存局部性全丢了。
  3. 每个 M 都要维护内存缓存(mcache):M 阻塞在系统调用时也占着 mcache,内存浪费严重,且数据局部性差。

3.2 引入 P(Processor)

Go 1.1(2013 年,Dmitry Vyukov 的方案)引入了中间层 P(Processor,逻辑处理器),形成 GMP:

  • P 是"执行 G 的资源/许可证",数量固定为 GOMAXPROCS
  • 每个 P 拥有自己的本地运行队列(local run queue),大部分调度操作只碰本地队列,无需全局锁
  • mcache 挂到 P 上而不是 M 上,M 阻塞时把 P 连同 mcache 交给别的 M,内存不再浪费;
  • M 必须先获得一个 P 才能执行 G,P 的数量天然限制了并行执行 G 的线程数。

引入 P 之后:全局锁退化为极少走的慢路径,绝大多数调度在无锁的本地队列完成;G 优先在同一个 P 上被调度,保住了缓存局部性;work-stealing 则解决了负载均衡。这就是现代 Go 调度器的基石。


4. GMP 三大组件

4.1 G — goroutine

G 是 goroutine 在运行时的表示(runtime.g 结构体),保存了它的栈、执行上下文和状态。关键字段(简化):

// 摘自 runtime/runtime2.go(简化并加注释)
type g struct {
	stack       stack   // 栈的 [lo, hi) 边界
	stackguard0 uintptr // 栈溢出检查的警戒线(morestack 判断用)

	m         *m       // 当前正在执行本 G 的 M(没在执行则为 nil)
	sched     gobuf    // 调度上下文:保存 SP/PC/BP 等寄存器,切换时用它恢复现场
	atomicstatus atomic.Uint32 // G 的状态:_Grunnable/_Grunning/_Gwaiting...
	goid      uint64   // goroutine id
	waitreason waitReason // 处于 _Gwaiting 时的原因(chan 阻塞、GC 等)
	// ... 还有 defer、panic 链表等
}

// gobuf 就是"寄存器快照",切换 goroutine 本质是换一组 gobuf
type gobuf struct {
	sp   uintptr // 栈指针
	pc   uintptr // 程序计数器(下一条指令)
	g    guintptr
	ret  uintptr
	bp   uintptr // 帧指针
}

G 的主要状态(状态机):

状态 含义
_Gidle 刚分配,尚未初始化
_Grunnable 在运行队列中,等待被调度执行
_Grunning 正在某个 M 上执行(占用 P)
_Gsyscall 正在执行系统调用
_Gwaiting 阻塞中(等 channel、锁、timer、GC 等),不在运行队列
_Gdead 执行完毕或刚被分配,等待复用(放入空闲 G 池)
_Gcopystack 正在进行栈拷贝(栈增长/收缩)

状态流转示意:

   go f()          调度器选中它         时间片到/主动让出
_Gidle ──▶ _Grunnable ──────────▶ _Grunning ──────────▶ _Grunnable
                  ▲                    │  │
        唤醒(ready)│         chan block │  │ 系统调用
                  │            _Gwaiting│  │_Gsyscall
                  └────────────────────┘  └────────┐
                                                     ▼
                                          返回后重新入队 _Grunnable
                        执行结束 ──▶ _Gdead(回收进 gFree 复用)

4.2 M — machine(内核线程)

M 代表一个真正的操作系统线程runtime.m),是实际干活、占用 CPU 的实体。M 由运行时通过 clone(Linux)创建,其入口是调度循环 schedule()

type m struct {
	g0        *g      // 特殊的调度 goroutine,跑调度代码、用系统栈(不是用户 G 的栈)
	curg      *g      // 当前正在运行的用户 G
	p         puintptr // 当前绑定的 P(执行用户代码时必须持有 P)
	nextp     puintptr // 即将绑定的 P
	oldp      puintptr // 进入系统调用前的 P
	spinning  bool     // 是否处于"自旋找活"状态
	// ... 信号处理、锁、线程本地存储等
}

关键点:

  • 每个 M 都有一个 g0:这是运行时专用的 goroutine,栈很大(约 8KB),专门用来执行调度器代码、垃圾回收、栈增长等运行时逻辑。用户 G 与 g0 之间来回切换,就是"业务代码"与"调度代码"的切换。
  • M 数量不固定,默认上限 10000runtime.SetMaxThreads 可调)。M 阻塞在系统调用时运行时会按需再创建 M,保证有足够线程干活。
  • M 想执行用户 G,必须先绑定一个 P

4.3 P — processor(逻辑处理器)

Pruntime.p)是介于 G 和 M 之间的调度上下文,持有执行 G 所需的本地资源:

type p struct {
	id     int32
	status uint32 // _Pidle / _Prunning / _Psyscall / _Pgcstop ...
	m      muintptr // 关联的 M

	// —— 本地运行队列(无锁访问,核心) ——
	runqhead uint32
	runqtail uint32
	runq     [256]guintptr // 固定 256 长度的环形数组
	runnext  guintptr      // "下一个执行"槽,存刚被唤醒/创建的 G,优先级最高

	mcache *mcache // 内存分配缓存(原来挂在 M 上,现在挂 P)
	// ... timer 堆、GC 相关字段等
}

三者关系用一张图串起来:

                    ┌─────────────────────────────────────────┐
  全局队列 globrunq │  G  G  G  G   (all P share; needs lock) │
                    └─────────────────────────────────────────┘
                              ▲                    ▲
                              │                    │   ← 队列空时来这偷
   ┌──────────────────┐   ┌──────────────────┐
   │  P0              │   │  P1              │
   │  runnext: G_a    │   │  runnext: G_x    │
   │  runq: [Gb Gc..] │   │  runq: [Gy Gz..] │   ← 每个 P 一条本地队列
   │  mcache          │   │  mcache          │
   └────────┬─────────┘   └────────┬─────────┘
            │                      │   ← 绑定
        ┌───┴─────┐            ┌───┴─────┐
        │  M0     │            │  M1     │      ← 内核线程,真正占 CPU
        │(g0+curg)│            │(g0+curg)│
        └─────────┘            └─────────┘

数量关系:P 的数量 = GOMAXPROCS(固定)M 的数量 ≥ P(动态,可能因系统调用暂时增多)G 的数量不限(几十万都行)。运行时里还有全局的空闲 P 链表(idle P)和空闲 M 链表(idle M)用于复用。


5. 调度流程

5.1 G 创建后放在哪里

执行 go f() 时,运行时调用 newproc 创建 G,放置策略是:

  1. 优先放入当前 P 的 runnext(这是"插队"槽,意味着新建的 G 很可能马上被执行,利于父子 goroutine 的局部性);
  2. runnext 已有 G,则把原来的挤到本地 runq 环形队列尾部;
  3. 若本地 runq 已满(256 个),则把本地队列的前一半 + 新 G 一起打包,批量移到全局队列,给其他 P 匀负载。
go f()  ──▶  newproc  ──▶  ┌─ P.runnext 空? ── 是 ──▶ 放 runnext
                           │
                           └─ 否 ──▶ runq 满? ─ 否 ─▶ 放 runq 尾
                                        │
                                        └─ 是 ─▶ 拿出一半到 globrunq

5.2 M 的调度循环 schedule()

每个绑定了 P 的 M 都在跑一个永不停歇的调度循环,核心逻辑(伪代码):

// 极简伪代码,表达 findRunnable 的取 G 优先级
func schedule() {
	for {
		var g *g
		// 1. 每调度 61 次,强制从全局队列取一次,防止全局队列饿死
		if schedtick%61 == 0 {
			g = globrunqget()
		}
		// 2. 先看本地 runnext / 本地队列
		if g == nil {
			g = runqget(p) // 含 runnext
		}
		// 3. 本地空了,去全局队列取一批
		if g == nil {
			g = globrunqget()
		}
		// 4. 还没有?检查 netpoller 是否有网络就绪的 G
		if g == nil {
			g = netpoll(false)
		}
		// 5. 仍没有 —— work-stealing:从其他 P 偷
		if g == nil {
			g = stealWork()
		}
		// 6. 真的没活干:解绑 P,M 进入休眠,等待被唤醒
		if g == nil {
			stopm()
			continue
		}
		execute(g) // 切到 g 的栈,运行用户代码
	}
}

取 G 的优先级顺序总结:

runnext ▶ 本地 runq ▶ 全局 globrunq ▶ netpoller ▶ 偷其他 P ▶ 休眠
(且每 61 次调度插一次全局队列,避免全局队列被本地队列饿死)

execute(g) 会把 G 状态置为 _Grunning,用 gogo(汇编)从 g0 栈切到 G 的栈,恢复 g.sched 里保存的 SP/PC,开始跑用户代码。G 让出时再切回 g0 栈继续 schedule()


6. work-stealing 工作窃取

当一个 P 的本地队列和全局队列都空了,它不会闲着,而是去**“偷"其他 P 的活**,这就是 work-stealing,保证各 CPU 核心负载均衡。

6.1 偷取规则

  • 随机选一个受害者 P 开始(随机化避免多个 M 同时盯上同一个 P);
  • 一次偷取目标 P 本地队列中一半的 G(“偷一半"是经典策略:既拿到足够多的活减少偷取频率,又不会把对方掏空导致对方立刻反过来偷你);
  • 遍历所有 P 若干轮仍偷不到,才考虑检查 netpoller、最后休眠;
  • 偷取时还会尝试偷对方的 runnext(但会稍作等待,因为 runnext 的 G 通常马上要被本 P 执行)。
偷取前:
  P0(空)                 P1: [G1 G2 G3 G4 G5 G6]
       │ 随机选中 P1,偷一半
       ▼
偷取后:
  P0: [G1 G2 G3]         P1: [G4 G5 G6]

6.2 自旋线程(spinning M)

为了让"有活马上有人干”,运行时会保留少量自旋 M:这些 M 没有 G 可跑,但不立即休眠,而是自旋地反复尝试 work-stealing 和查 netpoller。当有新 G 产生时,自旋 M 能立刻抢到,避免"新建线程/唤醒线程"的延迟。自旋 M 的数量受控(不超过忙碌 P 的数量),在响应速度和 CPU 空转之间取平衡。


7. 调度时机与抢占

Go 调度器是协作式 + 抢占式混合。理解在哪些点会发生调度,是排查"某个 goroutine 卡死不让出"问题的关键。

7.1 主动让出(gopark / Gosched)

大多数调度是 G 自愿让出 CPU 的:

  • channel 收发阻塞sync.Mutex 抢锁失败、time.Sleep、等待 WaitGroup 等:调用 gopark,把 G 置为 _Gwaiting,解绑当前 M,M 转去跑别的 G。条件满足时通过 goready 把 G 重新变 _Grunnable 入队。
  • runtime.Gosched():程序员显式让出,把当前 G 放回全局队列,立刻触发一次调度,但 G 仍是 _Grunnable(不阻塞)。
package main

import (
	"fmt"
	"runtime"
)

func main() {
	go func() {
		for i := 0; i < 3; i++ {
			fmt.Println("子 goroutine:", i)
			runtime.Gosched() // 主动让出,给别人机会
		}
	}()
	for i := 0; i < 3; i++ {
		fmt.Println("main:", i)
		runtime.Gosched()
	}
}

7.2 抢占式调度的演进

如果一个 G 既不做系统调用、也不碰 channel,就在那里 for {} 死循环,纯协作式调度它永远不会让出,其他 G 就会饿死。Go 的抢占经历了三个阶段:

① Go 1.0:完全协作式——只有函数调用序言的栈检查点才可能被"标记抢占”。死循环里若没有函数调用,无从插入检查点,无法抢占。

② Go 1.2 ~ 1.13:基于栈标记的协作式抢占——sysmon 监控线程发现某 G 运行超过 10ms,就把它的 stackguard0 设成一个特殊值(stackPreempt)。当该 G 下次发生函数调用、执行栈检查序言时,发现这个标记就主动让出。缺陷依旧:没有函数调用的紧密循环无法被抢占

经典的"卡死"例子(Go 1.13 及以前,GOMAXPROCS=1 时会一直打印不出 “done”):

package main

import (
	"fmt"
	"runtime"
	"time"
)

func main() {
	runtime.GOMAXPROCS(1)
	go func() {
		for {
		} // 紧密死循环,无函数调用,Go 1.13 及以前无法被抢占
	}()
	time.Sleep(time.Millisecond)
	fmt.Println("done") // Go 1.14+ 能打印,1.13- 卡死
}

③ Go 1.14+:基于信号的异步抢占(asynchronous preemption)——这是关键突破。sysmon 发现 G 运行超时后,向该 G 所在的 M(线程)发送一个 SIGURG 信号。信号处理程序在被中断的线程上跑,运行时借机检查并在安全点把当前 G 挂起、切回调度器。由于信号可以打断任意执行中的指令流(不再依赖函数调用),紧密死循环也能被抢占了。上面那段代码在 Go 1.14 及以后能正常打印 done

抢占演进对比:

版本 机制 触发条件 能否抢占死循环
Go 1.0 协作式 栈检查点
Go 1.2~1.13 协作式 + 栈标记 运行 >10ms 且有函数调用 ❌(无函数调用时不行)
Go 1.14+ 异步抢占(信号 SIGURG) 运行 >10ms

8. 系统调用与网络轮询

阻塞是调度器最需要小心处理的场景:一个 G 阻塞了,绝不能让它把整个 M(线程)连同 P 一起拖住,导致 P 上其他 G 无法运行。

8.1 阻塞式系统调用与 handoff

当 G 要执行一个可能长时间阻塞的系统调用(如读写文件、syscall.Read)时:

进入 syscall 前 (entersyscall):
   M0 ── P (runq: G2 G3 G4)      G1 即将 syscall

系统调用阻塞后,sysmon 或 M 自己触发 handoff:
   M0 ── (脱离 P,陷在 syscall)   G1 (阻塞在内核)
          │
          └─ P 被交给空闲/新建的 M1
   M1 ── P (runq: G2 G3 G4)      ← G2/G3/G4 继续被执行,不受 G1 拖累

系统调用返回后 (exitsyscall):
   M0 尝试重新获取一个 P:
     - 抢到原来的 P 或空闲 P → 继续执行 G1
     - 抢不到 → G1 变 _Grunnable 放入全局队列,M0 休眠进空闲池

具体流程:

  1. G 进入系统调用前调用 entersyscall,把 P 的状态标记为 _Psyscall,并记录 P 与 M 的解绑意图(此时 M 仍暂时持有 P)。
  2. 若系统调用很快返回,M 直接拿回 P 继续跑,零开销。
  3. 若系统调用阻塞较久,sysmon 监控线程发现 P 处于 _Psyscall 且超时(约 20μs),就执行 handoff:把 P 从阻塞的 M 上剥离,交给一个空闲 M(没有就新建),让 P 上其余的 G 继续跑。
  4. 阻塞的系统调用返回后(exitsyscall),原 M 尝试重新获取一个 P;获取不到就把当前 G 放回全局队列,自己进入空闲 M 池睡眠。

这套 handoff 机制保证了:即使成百上千个 G 同时阻塞在系统调用上,只要有 P 空出来就能被别的线程接管,不会浪费 CPU

8.2 netpoller — 网络 I/O 不阻塞线程

如果每个网络读写都走上面的"阻塞系统调用 + handoff + 新建线程"路径,高并发下会创建海量线程。Go 用 netpoller(网络轮询器) 把网络 I/O 变成非阻塞的:

  • Go 把所有 socket 都设为非阻塞模式,底层统一注册到操作系统的 I/O 多路复用机制:Linux 用 epoll、macOS/BSD 用 kqueue、Windows 用 IOCP
  • 当一个 G 对未就绪的 socket 做读/写,会得到 EAGAIN。运行时不让线程阻塞,而是把该 G 挂起(_Gwaiting),并在 netpoller 里注册"这个 fd 可读/可写时唤醒这个 G”,然后 M 立刻转去执行 P 上的其他 G
  • 调度循环里(findRunnable)会周期性调用 netpollsysmon 也会调用;一旦 epoll/kqueue 报告某些 fd 就绪,就把对应的 G 批量变回 _Grunnable、重新入队。
       用户代码看起来是"阻塞"的:
       conn.Read(buf)  ← 写起来同步、直观

       运行时底层实际:
       ┌──────────────────────────────────────────────────────────┐
       │ socket not ready -> G parked (_Gwaiting)                 │
       │ fd registered into epoll/kqueue, M runs other G          │
       │ ... (M never idles) ...                                  │
       │ epoll reports fd readable -> netpoll wakes G -> requeue  │
       └──────────────────────────────────────────────────────────┘

这就是 Go 网络编程的魔法:你用同步阻塞的代码风格conn.Read / conn.Write)写业务,运行时在底层帮你转成 epoll 事件驱动的非阻塞 I/O,用少数几个线程扛住海量连接(类似 C10K 里 epoll 的效果,但对开发者完全透明)。

8.3 sysmon 监控线程

sysmon 是一个不绑定 P 的特殊后台 M,在后台独立循环(初始每 20μs 一次,逐步退避到最长 10ms 一次),承担全局巡检职责:

  • 抢占:给运行超过 10ms 的 G 发信号,触发异步抢占;
  • syscall handoff:把长时间卡在系统调用的 P 从 M 上剥离;
  • netpoll:定期轮询网络就绪事件,唤醒等待 I/O 的 G;
  • 强制 GC:距上次 GC 超过 2 分钟则强制触发;
  • 归还内存:把长期空闲的内存 span 交还给操作系统。

sysmon 是整个调度器的"看门狗",很多"全局公平性"的保证都靠它。


9. GOMAXPROCS

GOMAXPROCS 决定了 P 的数量,也就是同一时刻最多有多少个 G 在并行执行用户代码(真正的 CPU 并行度)。

9.1 默认值与设置

从 Go 1.5 起,GOMAXPROCS 默认等于机器的逻辑 CPU 核数runtime.NumCPU())。可以在运行时查询和设置:

package main

import (
	"fmt"
	"runtime"
)

func main() {
	fmt.Println("逻辑 CPU 数:", runtime.NumCPU())
	fmt.Println("当前 GOMAXPROCS:", runtime.GOMAXPROCS(0)) // 传 0 表示只查询不修改

	old := runtime.GOMAXPROCS(2) // 设为 2,返回旧值
	fmt.Println("旧 GOMAXPROCS:", old, "→ 新值:", runtime.GOMAXPROCS(0))
}

也可用环境变量 GOMAXPROCS=4 ./app 设置。

9.2 容器里的大坑

这是生产环境高频事故点:在 Kubernetes / Docker 里,即使给容器设了 limits.cpu: 2(2 核),runtime.NumCPU() 读到的仍是宿主机的物理核数(比如 64),于是 GOMAXPROCS 被设成 64。

后果:

  • 运行时以为有 64 个核,创建 64 个 P、大量并行调度;
  • 但 CFS 配额只允许用 2 核的 CPU 时间,一旦超额就被内核限流(throttling)、强制暂停;
  • 结果是频繁的上下文切换、调度抖动、P99 延迟飙升,性能反而下降。

解决方案:uber-go/automaxprocs。它读取 cgroup 的 CPU quota,把 GOMAXPROCS 自动设成与容器配额匹配的值。用法极简,匿名导入即可:

import _ "go.uber.org/automaxprocs" // 匿名导入,init 时自动按 cgroup quota 设置 GOMAXPROCS

补充:Go 1.25 起标准运行时已内置容器感知,会读取 cgroup CPU 限额自动调整默认 GOMAXPROCS。但很多线上项目仍跑在旧版本 Go 上,automaxprocs 仍是稳妥选择。

9.3 该设多少

  • CPU 密集型:设为 CPU 核数(默认值即可),P 太多只会徒增调度和缓存开销。
  • I/O 密集型:GOMAXPROCS 主要影响并行计算度,I/O 阻塞由 netpoller 和 handoff 处理,与 P 数量关系不大;一般保持默认。并发度靠开更多 goroutine,而不是调大 GOMAXPROCS。
  • 容器内:务必让它匹配 cgroup 配额(automaxprocs 或 Go 1.25+)。

10. 优化与实践

10.1 控制 goroutine 数量:用 worker pool

goroutine 虽廉价,但无限制地开依然会耗尽内存、拖垮调度器,尤其是"每个请求/每条数据开一个 goroutine"的写法遇到流量洪峰时。用带缓冲的信号量或 worker pool 限流:

package main

import (
	"fmt"
	"sync"
)

func main() {
	tasks := make([]int, 100)
	for i := range tasks {
		tasks[i] = i
	}

	const maxConcurrency = 10
	sem := make(chan struct{}, maxConcurrency) // 信号量:最多 10 个并发
	var wg sync.WaitGroup

	for _, t := range tasks {
		wg.Add(1)
		sem <- struct{}{} // 占一个名额,满了就阻塞(限流)
		go func(task int) {
			defer wg.Done()
			defer func() { <-sem }() // 释放名额
			_ = task                 // 处理任务...
		}(t)
	}
	wg.Wait()
	fmt.Println("全部完成,峰值并发不超过", maxConcurrency)
}

生产中更推荐成熟库如 golang.org/x/sync/errgroup(配合 SetLimit)或 ants 协程池。

10.2 协程泄漏(goroutine leak)

goroutine 阻塞在永远不会满足的条件上(channel 无人收发、无超时的等待),就永远不退出,栈内存无法回收——这就是协程泄漏,是 Go 服务内存缓慢增长、最终 OOM 的头号原因。

// ❌ 泄漏示例:ch 无人接收,goroutine 永远阻塞在发送
func leak() {
	ch := make(chan int) // 无缓冲
	go func() {
		ch <- 42 // 阻塞在此,因为没有接收方,函数返回后这个 G 永久泄漏
	}()
	// 忘记接收,或提前 return
}
// ✅ 用 context 控制生命周期,可被取消
func worker(ctx context.Context, ch <-chan int) {
	for {
		select {
		case <-ctx.Done(): // 收到取消信号,干净退出,避免泄漏
			return
		case v := <-ch:
			_ = v // 处理
		}
	}
}

排查手段

import "runtime"
fmt.Println("当前 goroutine 数:", runtime.NumGoroutine()) // 持续上涨即怀疑泄漏

配合 net/http/pprof 访问 /debug/pprof/goroutine?debug=1 看每个 goroutine 卡在哪一行;测试里可用 go.uber.org/goleak 自动检测。

10.3 观测调度:GODEBUG schedtrace

GODEBUG=schedtrace=1000 让运行时每 1000ms 打印一次调度器状态摘要

GODEBUG=schedtrace=1000 ./app
# 输出示例:
# SCHED 1005ms: gomaxprocs=8 idleprocs=6 threads=12 spinningthreads=1 idlethreads=4 runqueue=0 [0 1 0 0 2 0 0 0]

字段含义:

字段 含义
gomaxprocs P 的数量
idleprocs 空闲的 P 数量(越多说明越闲)
threads M(线程)总数
spinningthreads 正在自旋找活的 M 数
idlethreads 空闲的 M 数
runqueue 全局运行队列中的 G 数量
[...] 各 P 本地队列中的 G 数量(判断负载是否均衡)

scheddetail=1(配合 schedtrace)能打印每个 P、M、G 的详细状态,用于深度调试。runqueue 或某个本地队列长期堆积,说明 CPU 打满或存在调度不均。


11. 高频面试题

Q1:GMP 模型是什么?G、M、P 各代表什么?

GMP 是 Go 运行时的调度模型。G 是 goroutine,代表一个用户态执行单元(含栈和执行上下文);M 是 machine,即操作系统内核线程,是真正占用 CPU 干活的实体;P 是 processor(逻辑处理器),是"执行 G 所需的资源/许可证",数量固定为 GOMAXPROCS,持有本地运行队列和 mcache。M 必须绑定一个 P 才能执行 G。这样 Go 把海量 G 复用(M:N 调度)到少量线程上,兼顾了轻量、并行和缓存局部性。

Q2:为什么要引入 P?没有 P 的 GM 模型有什么问题?

早期 GM 模型只有一个受全局锁保护的全局运行队列,所有 M 抢锁调度 G,锁竞争严重;G 在 M 间频繁迁移破坏 CPU 缓存局部性;mcache 挂在 M 上导致阻塞的 M 也白占内存。引入 P 后:每个 P 有自己的本地队列,绝大多数调度无锁;G 优先在同 P 上调度,保住局部性;mcache 挂到 P,M 阻塞时把 P 连同 mcache 交给别的 M,内存不浪费。

Q3:work-stealing 是什么?为什么偷一半?

工作窃取:当某个 P 的本地队列和全局队列都空了,它随机选另一个 P,偷取其本地队列一半的 G 来执行,实现负载均衡、避免 CPU 空转。偷一半是权衡:偷太少则很快又要再偷、频繁偷取有开销;偷太多会把对方掏空,导致对方立刻反过来偷你,来回震荡。取一半最稳。

Q4:goroutine 和线程有什么区别?

① 调度者:线程由操作系统内核调度,goroutine 由 Go 运行时在用户态调度;② 栈:线程栈固定且大(默认 8MB),goroutine 初始仅 2KB 且可动态伸缩;③ 切换开销:线程切换要陷内核(~μs),goroutine 切换在用户态(~ns);④ 创建成本:线程涉及内核对象和大栈,goroutine 只需分配 g 结构和小栈;⑤ 数量级:线程几千顶天,goroutine 可达百万。本质是 M:N 模型——多个 goroutine 复用到少量线程上。

Q5:抢占式调度是怎么演进的?Go 1.14 解决了什么问题?

Go 1.0 是纯协作式,只有栈检查点能被抢占;Go 1.2~1.13 引入基于栈标记的协作式抢占(sysmon 发现 G 跑超 10ms 就打标记,G 下次函数调用时让出),但没有函数调用的紧密死循环无法被抢占,会饿死其他 G(尤其 GOMAXPROCS=1 时)。Go 1.14 引入基于信号(SIGURG)的异步抢占:sysmon 向目标线程发信号,信号处理程序在安全点挂起当前 G,因为信号能打断任意指令流,死循环也能被抢占,彻底解决了这个问题。

Q6:一个 goroutine 执行阻塞系统调用,会不会阻塞其他 goroutine?

不会。G 进入系统调用前(entersyscall)P 被标记为 _Psyscall。若系统调用短暂返回,M 直接拿回 P 继续跑。若阻塞较久,sysmon 会执行 handoff:把 P 从阻塞的 M 上剥离,交给空闲或新建的 M,让 P 上其余 G 继续执行。系统调用返回后,原 M 尝试重新获取 P,拿不到就把 G 放回全局队列、自己休眠。因此单个 G 的阻塞系统调用不会拖累同 P 上的其他 G。

Q7:netpoller 是什么?Go 如何用同步代码写出高性能网络程序?

netpoller 是 Go 运行时集成的网络轮询器,底层基于 epoll(Linux)/ kqueue(BSD/macOS)/ IOCP(Windows)。所有 socket 被设为非阻塞,当 G 读写未就绪的 socket 时,运行时不阻塞线程,而是把 G 挂起(_Gwaiting)并把 fd 注册进 netpoller,M 立刻去跑别的 G;fd 就绪后 netpoll 唤醒对应 G 重新入队。于是开发者用同步阻塞风格的代码(conn.Read),运行时底层帮你转成非阻塞事件驱动 I/O,用少量线程扛住海量连接。

Q8:GOMAXPROCS 应该设多少?容器里有什么坑?

GOMAXPROCS 决定 P 的数量即并行执行用户代码的最大线程数,默认等于 CPU 逻辑核数,通常保持默认即可(CPU 密集型设为核数,I/O 密集靠多开 goroutine 而非调大它)。容器里的坑:runtime.NumCPU() 读到的是宿主机核数而非 cgroup 限额,导致 P 过多、被 CFS 限流、调度抖动、延迟飙升。解决办法是用 uber-go/automaxprocs 按 cgroup quota 自动设置,或升级到 Go 1.25+(运行时已内置容器感知)。

Q9:什么是协程泄漏?如何排查?

goroutine 阻塞在永远无法满足的条件(无人收发的 channel、无超时的等待、未取消的循环)上而永不退出,其栈内存无法回收,导致内存持续增长最终 OOM。排查:runtime.NumGoroutine() 观察数量是否持续上涨;net/http/pprof/debug/pprof/goroutine 看 goroutine 都卡在哪;测试用 go.uber.org/goleak。预防:channel 操作配合 select + context 取消或超时,确保每个 goroutine 都有明确的退出路径。

Q10:g0 是什么?runnext 又是什么?

g0 是每个 M 专属的调度 goroutine,栈较大,专门用来执行调度器代码、GC、栈增长等运行时逻辑;用户 G 与 g0 之间切换就是"业务代码"与"运行时代码"的切换。runnext 是每个 P 上一个特殊的"下一个执行"槽,优先级高于本地队列——新建或刚被唤醒的 G 会先放这里,让它极可能被立即执行,利于父子 goroutine 的缓存局部性和低延迟唤醒。


小结

本章围绕 Go 的并发引擎,自底向上讲透了 goroutine 与 GMP 调度模型:

  • 三级抽象:进程 → 线程 → 协程。goroutine 是用户态调度、初始栈仅 2KB 且可伸缩的廉价执行单元,切换开销比线程低一个数量级,因此能轻松启动百万级。
  • GMP 三组件:G(goroutine,含栈与状态机)、M(内核线程,真正占 CPU,每个 M 有 g0)、P(逻辑处理器,持本地队列与 mcache,数量 = GOMAXPROCS)。M 必须绑 P 才能跑 G,实现 M:N 调度。
  • 调度流程:新 G 优先进 P 的 runnext / 本地 runq,满了溢出到全局队列;M 的 schedule 循环按 runnext ▶ 本地队列 ▶ 全局队列 ▶ netpoller ▶ 偷取 的优先级找活,并每 61 次插一次全局队列保公平。
  • 负载均衡:work-stealing 从其他 P 偷一半 G;自旋 M 让新活能被立即执行。
  • 抢占演进:协作式 → 栈标记协作式 → Go 1.14 基于 SIGURG 信号的异步抢占,终于能抢占无函数调用的死循环。
  • 阻塞处理:系统调用用 handoff 把 P 交给别的 M,避免拖累同 P 的 G;netpoller 用 epoll/kqueue 把网络 I/O 变非阻塞,让你用同步代码写出高并发;sysmon 后台看门狗统管抢占、handoff、netpoll、GC。
  • 实践要点:容器里用 automaxprocs 修正 GOMAXPROCS;用 worker pool / 信号量控制 goroutine 数量;警惕协程泄漏,用 context 保证退出路径;用 GODEBUG=schedtrace 与 pprof 观测调度。

理解 GMP,你才能真正解释清楚"为什么 Go 能用同步代码写出百万并发",也能在遇到调度抖动、协程泄漏、容器限流时快速定位。下一章我们将深入 channel 与 select 的实现原理。