♟️GMP
type
status
date
slug
summary
category
tags
icon
password
AI summary
Blocked by
Blocking
Category
进程 线程 协程
进程是资源分配单位,有独立内存空间; 线程是调度单位,共享进程内存,但有独立栈; 协程是用户态轻量线程,由程序调度,切换极快,一个线程可跑几万协程
- 进程(Process) 是操作系统资源分配的最小单位。 每个进程拥有独立的地址空间、文件描述符、堆、栈等资源。 进程间切换代价最大(需要切换页表、TLB刷新等),一个进程崩溃通常不影响其他进程。 适合需要隔离性强的场景,比如浏览器多标签页。
- 线程(Thread) 是操作系统调度和CPU执行的最小单位,也叫轻量级进程。 同一个进程内的线程共享地址空间、文件描述符、全局变量等,但每个线程有独立的栈和寄存器上下文。 线程切换比进程轻(不需要切换地址空间),但仍然涉及内核态切换,开销较大(微秒级)。 适合CPU密集型 + 需要并行的场景,但线程数一般控制在几百到几千,多了上下文切换和内存开销会很大。
- 协程(Coroutine / Goroutine) 是用户态的轻量级线程,由程序(或语言运行时)自己调度,不依赖操作系统内核。 切换完全在用户态完成(只是保存/恢复栈指针和寄存器),开销极小(纳秒级)。 一个线程可以承载成千上万个协程(M:N调度模型),创建和销毁成本非常低。 典型代表:Go的goroutine、Kotlin协程、Python的asyncio。 特别适合IO密集型、高并发场景,比如Web服务器、网络爬虫。
维度 | 进程 | 线程 | 协程(用户态) |
资源分配单位 | 是 | 否(共享进程资源) | 否 |
调度单位 | 否 | 是(内核调度) | 否(用户/运行时调度) |
切换开销 | 最大(毫秒级) | 中等(微秒级) | 最小(纳秒级) |
并发数量 | 几十~几百 | 几百~几千 | 几万~几十万 |
典型适用 | 强隔离、多实例 | CPU密集 + 并行 | IO密集 + 超高并发 |
GMP模型
GMP 是 Go 语言调度器的核心模型,解决了传统协程调度在多核利用率和阻塞问题上的痛点。
首先简单说一下 GMP 三个核心组件:
- G:Goroutine,就是我们写的 go func() 创建出来的用户态轻量级协程,栈初始只有 2KB(可以自动增长),创建成本很低。
- M:Machine,对应内核线程(OS Thread),真正执行代码的实体。
- P:Processor,逻辑处理器,数量默认等于 GOMAXPROCS(通常等于 CPU 核心数),负责调度 G 的执行队列。P 决定了程序最大并行度。
GMP 的核心思想是:G 不直接绑定 M,而是通过 P 来调度,实现 M:N 的多对多映射,既能利用多核,又能处理阻塞场景。
调度策略
早期(Go 1.0 ~ 1.13)Go 的调度器主要是协作式调度(cooperative scheduling)
- 什么叫协作式?Goroutine 只有在主动让出执行权时才会发生切换,比如:
- 调用了 runtime.Gosched()
- 发生系统调用(网络、文件IO、sleep 等阻塞操作)
- 通道收发阻塞
- 运行时主动插入的检查点(函数调用、循环回跳等)
- 优点:实现简单,开销低,用户态切换快。
- 缺点:恶意的/长时间计算密集型 Goroutine 会长时间霸占 P 和 M,导致同一个 P 下的其他 G 饿死,甚至整个程序卡住(尤其 GOMAXPROCS=1 时更明显)。另外垃圾回收的 STW 也会被拖得很长。
为了解决这个问题,Go 逐步引入了抢占式调度(preemptive scheduling):
- Go 1.14 引入真正的异步抢占(基于信号的抢占): sysmon 发现某个 G 运行超过一定时间(默认 10ms),会向对应的 M 线程发送 SIGURG 信号,在安全点打断正在运行的 Goroutine(即使它在紧循环里不调用函数也能被抢占)。 这大大改善了计算密集型任务的公平性,也让 GC 的 STW 时间更可控。
总结对比一下:
维度 | 协作式调度(Go 早期) | 抢占式调度(Go 1.14+ 主流) |
切换时机 | Goroutine 主动让出 | runtime 可以强制打断 |
长循环/死循环 | 容易饿死其他 G | 基本能被抢占(10ms 量级) |
GC STW 时间 | 可能较长 | 显著缩短 |
实现复杂度 | 低 | 较高(信号、安全点、异步抢占) |
适用场景 | IO 密集型占优 | 计算密集 + IO 密集都能较好支持 |
现在(2025–2026 年)绝大多数生产环境都在用 Go 1.18+,所以默认可以认为 Go 的调度器已经是带异步抢占的协作式调度,更接近于抢占式,但仍然保留了协作的低开销特性。
sysmon(系统监控线程)的作用
sysmon 是一个独立的后台线程(不绑定任何 P),由 runtime 在程序启动时创建,周期性运行(间隔从 20μs 到 10ms 不等,逐渐拉长以降低开销)。
它的主要职责可以总结为“监控 + 干预 + 辅助”,具体包括:
- 抢占(Preemption): 发现某个 G 运行超过 ~10ms(schedtick 计数),就会设置抢占标志(preempt = true)。如果是 1.14+ 的异步抢占模式,还会直接向对应 M 发送信号(POSIX 用 SIGURG,Windows 用 SuspendThread),强制打断长运行的 G,即使它在紧循环里不做函数调用也能被切走。
- netpoller 轮询: 如果上次 netpoll 超过 10ms,sysmon 会主动调用 netpoll(非阻塞),把已经就绪的网络事件对应的 G 注入全局运行队列(injectglist),防止网络密集型任务饿死。
- GC 相关辅助:
- 强制触发 GC:如果 2 分钟没发生过 GC,sysmon 会强制启动一次(防止内存无限增长)。
- 唤醒/管理 GC 后台 worker(bgsweep、bgmark 等)。
- 监控 GC 辅助(assist)比例,必要时干预。
- 其他:
- 回收长时间阻塞在系统调用里的 P(retake),交给其他 M 用。
- 处理 deadlock 检测(所有 G 都在等待)。
- 更新一些统计(如调度跟踪 schedtrace)。
简单说,sysmon 是 Go runtime 的“心跳 + 看门狗”,专门解决协作式调度遗留的公平性、及时性问题。
work stealing(工作窃取)如何实现负载均衡
Go 调度器采用分布式工作窃取(work-stealing scheduler),每个 P 都有自己的本地运行队列(runq,通常最多 256 个 G),优先从本地 runq 取 G 执行,降低锁竞争和缓存失效。
当一个 P 的本地队列空了,它会尝试“偷”别人队列里的 G,步骤大致是:
- 先尝试偷全局运行队列(global runq)的 G(全局队列通常只放新创建的 G 或从 netpoll 来的 G)。
- 如果全局也空,就随机选其他 P(通常尝试 2~4 次或更多,视版本),从它们的本地 runq 后半部分 grab 一半 G(runqgrab),放到自己队列。
- 偷的时候用原子操作(cas)保证线程安全。
- 如果偷不到,还会尝试偷 runnext(P 的下一个优先 G,通常是刚解锁的 G)。
这种机制的好处是:负载不均时,忙的 P 被偷,闲的 P 快速获得工作,实现自然负载均衡,尤其在多核 + 突发流量场景下效果显著。缺点是高 GOMAXPROCS 时,findrunnable 循环可能成为热点。
小结
GMP的核心优势
- 高效的内存使用
- g的栈空间可以动态伸缩
- 充分复用空闲资源
- 灵活的调度
- g可以动态绑定 P 和 M
- 用户态的调度
- 阻塞控制在g级别
- 避免内核调度的开销
Prev
我的MBTI
Next
版本控制规范
Loading...