学习目标
- 理解 Go runtime 在程序中的角色
- 理解 goroutine 是如何被调度和执行的
- 掌握阻塞、系统调用与抢占的基本原理
- 从运行时视角理解并发性能问题
一、什么是 Go runtime
Go runtime 是:
- Go 程序运行时的“操作系统层”
- 介于 Go 代码与操作系统之间
- 负责并发、内存、调度、GC 等核心能力
与 Java / JVM 类似,但:
- Go runtime 更轻量
- 直接编译为本地代码
工程认知:写 Go,不理解 runtime,就很难理解性能与并发问题。
二、Go 调度器要解决什么问题
Go 调度器的核心任务是:
- 在有限的 OS 线程上
- 高效运行大量 goroutine
调度器需要处理:
- goroutine 的创建与销毁
- 阻塞与唤醒
- 多核并行执行
- 避免线程过度创建
工程目标:让并发“便宜而可控”。
三、回顾 G-P-M 模型(从 runtime 视角)
调度器基于 G-P-M 模型:
- G(Goroutine):要执行的任务(函数 + 栈 + 状态)
- P(Processor):调度上下文,持有本地运行队列
- M(Machine):操作系统线程,真正执行指令
运行规则概括:
- M 必须拿到 P 才能运行 G
- P 的数量决定最大并行度
- G 在 P 的本地队列中等待执行
四、GOMAXPROCS 的含义
GOMAXPROCS 表示:
同时允许执行 Go 代码的最大 P 数量
通常:
- 默认值 = CPU 核心数
- 并不等于 goroutine 数量
修改方式:
runtime.GOMAXPROCS(4)工程建议:
- 大多数场景使用默认值
- 只有在性能调优时才考虑调整
五、goroutine 的创建与调度
当你写:
go f()runtime 会:
- 创建一个新的 G
- 放入当前 P 的本地队列
- 等待被某个 M 调度执行
如果本地队列为空:
- P 会从全局队列取 G
- 或从其它 P “偷取”一半任务(work stealing)
工程意义:Go 调度器通过工作窃取提高负载均衡。
六、阻塞与系统调用
阻塞分为两类:
1. Go 级别阻塞(channel / mutex)
- goroutine 被挂起
- M 可以继续执行其它 G
- P 不会被占用
2. 系统调用阻塞(syscall / IO)
- M 会被 OS 阻塞
- runtime 会创建新的 M 接管 P
工程结论:Go 能很好地隐藏阻塞系统调用的成本。
七、抢占式调度(preemption)
早期 Go 的调度是“协作式”的:
- goroutine 需要主动让出执行权
现代 Go 引入了抢占式调度:
- runtime 可以在安全点打断 goroutine
- 防止长时间占用 CPU
抢占发生的典型场景:
- 函数调用
- 内存分配
- channel / mutex 操作
工程意义:避免“死循环 goroutine”拖垮整个程序。
八、调度器与性能问题
常见性能问题:
- goroutine 数量爆炸
- 大量阻塞导致调度开销增大
- CPU 利用率异常
调优方向:
- 控制 goroutine 数量(worker pool)
- 避免频繁创建/销毁 goroutine
- 减少锁竞争
工程结论:性能问题往往是“调度压力”的体现。
九、runtime 视角下的并发编程原则
- goroutine 很轻,但不是免费的
- 阻塞不是问题,失控的阻塞才是问题
- 调度器是帮你兜底的,不是替你兜逻辑的
十、runtime 与调度器总结
- runtime 是 Go 并发与性能的基石
- G-P-M 模型决定了调度行为
- 理解调度器,有助于写出高性能 Go 代码
一句话总结:
并发写得好不好,runtime 最清楚。
预告:Day 21|内存分配与 GC 基础
下一天将系统讲解:
- Go 的内存分配策略
- 栈与堆的区别
- GC 的基本原理
- 常见内存问题的运行时视角