1. 并发基础:Goroutine 与并发模型概述
1.1 并发与并行的本质区别
在 Go 语言性能优化中,理解并发(Concurrency)与并行(Parallelism)的区别是构建高性能系统的第一道门槛。并发是程序的结构属性,指多个任务在逻辑上同时执行;并行是程序的执行属性,指多个任务在物理上同时运行。
| 维度 | 并发(Concurrency) | 并行(Parallelism) |
|---|---|---|
| 核心定义 | 任务交替执行,逻辑上同时 | 任务真正同时运行,物理上同时 |
| 硬件依赖 | 单核 CPU 即可实现 | 必须多核 CPU 或多处理器 |
| 关注点 | 任务分解与协调 | 吞吐量与加速比 |
| Go 实现 | Goroutine + Channel | GOMAXPROCS > 1 |
| 典型场景 | Web 服务处理多个请求 | 大规模矩阵运算 |
性能优化启示:并发是设计问题,并行是执行问题。先设计正确的并发结构,再通过调整 GOMAXPROCS 获得并行加速。盲目增加并行度不会修复糟糕的并发设计。
1.2 Goroutine:轻量级并发单元
Goroutine 是 Go 语言并发编程的核心抽象,它比操作系统线程轻量得多。一个 Goroutine 的初始栈仅 2KB,而线程栈通常为 1MB 以上。这意味着你可以轻松创建数十万个 Goroutine 而不会耗尽内存。
// 启动一个 Goroutine
go func() {
fmt.Println("Hello from Goroutine")
}()
// 使用 WaitGroup 等待完成
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
fmt.Printf("Goroutine %d 执行\n", id)
}(i)
}
wg.Wait()
关键指标对比:
- Goroutine 创建成本:约 4KB 内存,纳秒级
- OS 线程创建成本:约 1MB 内存,微秒级
- 上下文切换成本:Goroutine 约 50ns,线程约 1-10μs
1.3 GMP 调度原理
Go 运行时实现了 M:N 调度模型,将 M 个 Goroutine 映射到 N 个 OS 线程上执行。GMP 是调度器的三个核心组件:
- G(Goroutine):代表一个待执行的任务,包含栈、程序计数器、状态等信息。
- M(Machine):代表 OS 线程,负责执行 Goroutine 的代码。
- P(Processor):代表逻辑处理器,是 G 和 M 之间的调度上下文,持有本地运行队列。
GMP 调度器的核心设计目标是:减少线程阻塞和上下文切换,充分利用多核 CPU。
1.4 调度流程与工作窃取
GMP 调度器的工作流程遵循以下步骤:
- 创建 G:通过
go关键字创建 Goroutine,初始放入 P 的本地队列或全局队列。 - 绑定 P 与 M:每个 P 绑定一个 M,M 从 P 的本地队列获取 G 执行。
- 工作窃取(Work Stealing):当 P 的本地队列为空时,从其他 P 的队列尾部窃取一半 G,或从全局队列获取。
- 系统调用处理:G 进入系统调用时,P 与 M 解绑,P 寻找空闲 M 继续执行其他 G。
- 抢占式调度:Go 1.14 后引入基于信号的抢占,防止长时间运行的 G 阻塞其他 G。
性能陷阱:当 Goroutine 数量远大于 P 的数量时,调度开销会显著增加。建议将 Goroutine 数量控制在合理范围内,避免创建过多 Goroutine 导致调度器过载。
1.5 并发模型对比
| 模型 | 代表语言 | 通信方式 | 共享内存 | 调度粒度 |
|---|---|---|---|---|
| CSP(通信顺序进程) | Go | Channel | 不共享,通过通信共享 | Goroutine 级 |
| Actor 模型 | Erlang, Akka | 消息传递 | 完全不共享 | 进程级 |
| 共享内存 + 锁 | Java, C++ | 共享变量 + 锁 | 直接共享 | 线程级 |
| Future/Promise | Scala, JavaScript | 回调/链式调用 | 不直接共享 | 任务级 |
Go 的设计哲学:"Do not communicate by sharing memory; instead, share memory by communicating."(不要通过共享内存来通信,而应通过通信来共享内存。)这是 Go 并发模型区别于传统锁模型的核心思想。
1.6 GOMAXPROCS 与并行控制
GOMAXPROCS 控制同时执行用户级 Go 代码的 OS 线程数量,即 P 的数量。默认值为 CPU 核心数,但可以通过环境变量或 runtime.GOMAXPROCS() 调整。
// 获取当前 GOMAXPROCS
current := runtime.GOMAXPROCS(0)
fmt.Printf("当前 GOMAXPROCS: %d\n", current)
// 设置 GOMAXPROCS 为 4
runtime.GOMAXPROCS(4)
// 在 CPU 密集型任务中,GOMAXPROCS 应接近 CPU 核心数
// 在 I/O 密集型任务中,可以适当增加 Goroutine 数量
1.7 本章小结
并发基础是理解 Go 性能优化的起点。Goroutine 的轻量特性使得大规模并发成为可能,GMP 调度器通过工作窃取和抢占式调度实现了高效的 M:N 映射。理解并发与并行的区别,有助于在后续章节中正确分析锁竞争和原子操作的性能影响。
核心要点回顾:
- 并发是结构,并行是执行;先设计并发,再优化并行。
- Goroutine 初始栈 2KB,创建成本极低,适合大规模使用。
- GMP 调度器通过 P 的本地队列和全局队列实现高效调度。
- 工作窃取机制平衡负载,减少线程阻塞。
- GOMAXPROCS 控制并行度,需根据任务类型合理设置。