1. Go 并发基础:Goroutine 概念、GMP 模型与并发并行区别
1.1 并发与并行的本质区别
并发(Concurrency)与并行(Parallelism)是 Go 并发编程的基石概念,但常被混淆。并发是逻辑上同时处理多个任务的能力,强调结构;并行是物理上同时执行多个任务,强调执行。Go 通过 Goroutine 和 GMP 模型,以并发结构驱动并行执行。
| 维度 | 并发(Concurrency) | 并行(Parallelism) |
|---|---|---|
| 核心定义 | 多个任务在重叠时间段内交替执行 | 多个任务在同一时刻同时执行 |
| 硬件依赖 | 单核 CPU 即可实现(时间片轮转) | 必须多核/多处理器支持 |
| 关注点 | 任务分解、协调、通信(设计层面) | 吞吐量、加速比(执行层面) |
| Go 实现 | Goroutine + Channel 通信 | GOMAXPROCS 控制 OS 线程数 |
| 典型场景 | Web 服务器处理大量请求 | 大规模矩阵运算、数据并行 |
Go 设计哲学:“不要通过共享内存来通信,而应通过通信来共享内存。” 并发模型以 CSP(Communicating Sequential Processes)为理论基础,Goroutine 是轻量级执行体,Channel 是通信原语。
1.2 Goroutine:Go 的轻量级执行单元
Goroutine 是 Go 运行时管理的用户态线程,相比 OS 线程,其创建成本极低(初始栈仅 2KB),调度由 Go 运行时完成,而非内核。通过 go 关键字即可启动一个 Goroutine:
package main
import (
"fmt"
"time"
)
func say(msg string) {
for i := 0; i < 3; i++ {
fmt.Println(msg, i)
time.Sleep(100 * time.Millisecond)
}
}
func main() {
go say("Hello from Goroutine") // 启动新 Goroutine
say("Hello from Main") // 主 Goroutine 执行
// 等待 Goroutine 完成(实际应使用 sync.WaitGroup)
time.Sleep(1 * time.Second)
}
关键特性:
- 轻量级:栈初始 2KB,可动态增长至 1GB(64 位系统),百万级 Goroutine 可行。
- 非阻塞:Goroutine 在系统调用或 Channel 操作时自动让出 M,不阻塞 OS 线程。
- 独立栈:每个 Goroutine 拥有独立调用栈,栈空间隔离,无需锁保护局部变量。
1.3 GMP 调度模型:Go 运行时的核心引擎
GMP 模型是 Go 调度器的核心架构,由三个抽象组件构成:
- G(Goroutine):代表一个待执行的任务,包含栈、指令指针、状态等信息。
- M(Machine):代表 OS 线程,由 Go 运行时封装,负责执行 G 中的代码。
- P(Processor):代表逻辑处理器,是 G 和 M 之间的调度上下文。P 持有本地可运行 G 队列(LRQ),数量由
GOMAXPROCS决定。
调度流程:P 从本地队列或全局队列(GRQ)获取 G,绑定到 M 上执行;当 G 发生阻塞(如系统调用、Channel 等待),M 与 P 解绑,P 寻找新的 M 继续执行其他 G,阻塞的 G 完成后重新入队。
1.4 GMP 调度流程详解
Go 调度器采用 M:N 调度模型,将 M 个 Goroutine 映射到 N 个 OS 线程上执行。调度循环由 schedule() 函数驱动,核心步骤:
- 获取 G:P 优先从本地队列(LRQ)获取 G;若 LRQ 为空,则从全局队列(GRQ)获取一批 G 放入 LRQ;若 GRQ 也为空,则从其他 P 的 LRQ 偷取一半 G(Work Stealing)。
- 执行 G:P 将 G 绑定到 M 上,M 执行 G 的函数体。G 执行过程中可能发生:主动让出(
runtime.Gosched())、阻塞(Channel、系统调用)、或正常结束。 - 处理阻塞:若 G 因系统调用阻塞,M 与 P 解绑,P 寻找空闲 M 或创建新 M 继续执行其他 G;阻塞结束后,G 重新入队,M 进入空闲池。
- 抢占调度:Go 1.14 引入基于信号的抢占式调度,防止长时间运行的 G 饿死其他 G。调度器在 G 执行超过 10ms 后发送信号,强制 G 让出 M。
常见陷阱:Goroutine 虽然轻量,但无限创建仍会导致内存耗尽。生产环境应使用 Goroutine 池(如
ants 库)或信号量控制并发数。另外,GOMAXPROCS 默认等于 CPU 核心数,但 I/O 密集型场景可适当调高,CPU 密集型场景保持默认即可。
1.5 GMP 模型与并发安全的关系
GMP 模型本身不保证数据安全。多个 Goroutine 并发访问共享变量时,必须使用同步机制:
| 同步方式 | 适用场景 | 性能特征 |
|---|---|---|
sync.Mutex |
保护临界区,写多读少 | 锁竞争时 Goroutine 阻塞,上下文切换成本高 |
sync.RWMutex |
读多写少场景 | 读锁可共享,写锁独占,减少读 Goroutine 阻塞 |
sync/atomic |
简单计数器、标志位 | 无锁操作,CPU 指令级原子性,性能最优 |
| Channel | Goroutine 间通信、数据传递 | 无锁设计,但 Channel 本身有内存分配和拷贝开销 |
安全编码原则:
- “不共享可变状态” 优于 “共享状态加锁”。优先使用 Channel 传递数据副本。
- 若必须共享,使用
sync.Mutex或sync.RWMutex保护,并尽量缩小锁范围。 - 使用
go run -race或go build -race开启数据竞争检测,在测试阶段暴露并发问题。
1.6 本章小结
- 并发是设计,并行是执行。 Go 以 Goroutine 表达并发结构,GMP 模型在运行时将并发映射到并行执行。
- Goroutine 是轻量级用户态线程,创建成本低,栈动态增长,适合大规模并发任务。
- GMP 模型通过 P 的本地队列、Work Stealing、抢占调度实现高效 M:N 调度,减少锁竞争和上下文切换。
- 并发安全需要显式同步,GMP 模型不提供数据保护,开发者需使用 Mutex、Channel 或 atomic 操作。