1. Go 并发基础回顾:goroutine 与 channel 的底层原理、GMP 调度模型、并发与并行的区别

1.1 并发与并行的本质区别

在进入 Go 并发模型之前,必须厘清两个常被混淆的核心概念:并发(Concurrency)与并行(Parallelism)。Go 之父 Rob Pike 曾用一句精辟的话概括:"并发是结构,并行是执行。"

维度 并发(Concurrency) 并行(Parallelism)
定义 多个任务在逻辑上同时推进,通过时间片切换实现 多个任务在物理上同时执行,依赖多核 CPU
硬件依赖 单核 CPU 即可实现 必须多核 CPU 或多处理器
关注点 任务分解、组合、交互(设计层面) 吞吐量、加速比(执行层面)
Go 中的体现 goroutine + channel 组合 GOMAXPROCS 控制 OS 线程数
典型场景 事件驱动、流水线、异步 I/O 矩阵乘法、图像处理、数据并行
关键认知:Go 程序天然是并发的——你只需用 go 关键字启动 goroutine,用 channel 协调它们。但只有运行在多核机器上且 GOMAXPROCS > 1 时,这些 goroutine 才可能真正并行执行。

1.2 goroutine 底层原理:轻量级用户态线程

goroutine 不是 OS 线程,而是 Go 运行时管理的用户态协程。其核心优势在于极低的创建开销和高效的上下文切换。

对比项 OS 线程 goroutine
初始栈大小 ~1 MB(固定) ~2 KB(动态伸缩)
创建成本 系统调用,微秒级 用户态分配,纳秒级
上下文切换 内核态,~1 μs 用户态,~0.2 μs
调度器 OS 内核调度器 Go 运行时 GMP 调度器
标识 全局唯一 TID 运行时内部 ID,对用户透明

goroutine 的栈采用动态增长策略:初始仅分配 2 KB,当栈空间不足时,运行时通过 stack copying 机制将栈迁移到更大的连续内存区域。这使得百万级 goroutine 成为可能。

1.3 channel 底层原理:CSP 通信模型

channel 是 Go 实现 CSP(Communicating Sequential Processes) 模型的核心原语。其底层是一个带锁的环形缓冲区,配合等待队列实现 goroutine 间的同步与通信。

channel 内部结构(简化):
  • buf:环形缓冲区,存储已发送但未接收的元素
  • sendx / recvx:发送/接收索引
  • sendq / recvq:等待发送/接收的 goroutine 队列(双向链表)
  • lock:互斥锁,保护所有字段
// 无缓冲 channel:同步通信
ch := make(chan int)
go func() {
    ch <- 42  // 阻塞,直到有接收者
}()
val := <-ch   // 接收,配对成功

// 有缓冲 channel:异步通信
chBuf := make(chan int, 3)
chBuf <- 1    // 不阻塞(缓冲区未满)
chBuf <- 2
val = <-chBuf // 不阻塞(缓冲区非空)

无缓冲 channel 的发送和接收必须同时就绪,否则阻塞——这是天然的同步点。有缓冲 channel 则允许发送方在缓冲区未满时继续执行,实现解耦与流量控制。

1.4 GMP 调度模型:Go 并发性能的基石

GMP 是 Go 运行时调度器的核心架构,它解决了用户态协程到 OS 线程的高效映射问题。理解 GMP 是进行并发性能建模的前提。

GMP 调度模型架构图 G(goroutine) 用户态协程 G1(运行中) G2(可运行) G3(阻塞) M(machine) OS 线程 M1(运行 G) M2(自旋) M3(阻塞) P(processor) 逻辑处理器 本地队列 G 运行槽 P 数量 = GOMAXPROCS 调度 绑定 全局运行队列(Global Run Queue) 所有 P 的本地队列满时,G 放入全局队列;P 定期从全局队列窃取 G 工作窃取(Work Stealing) 当 P 的本地队列为空时,从其他 P 的本地队列或全局队列窃取 G 来执行 系统调用处理:M 阻塞时,P 解绑并绑定到新的 M,继续执行其他 G

1.5 GMP 调度流程详解

GMP 调度器的工作流程可以概括为以下步骤:

  1. G 创建:通过 go func() 创建一个 G,初始放入当前 P 的本地队列(容量 256)。
  2. P 调度:P 从本地队列头部取出一个 G,绑定到 M 上执行。
  3. 系统调用阻塞:如果 G 发起阻塞式系统调用,M 会进入阻塞状态,P 解绑该 M,从空闲 M 池中获取一个新 M 继续执行。
  4. 工作窃取:当 P 的本地队列为空时,它会尝试从其他 P 的本地队列尾部窃取一半的 G,或从全局队列获取。
  5. G 阻塞:如果 G 因 channel 操作或锁阻塞,G 会被移出 M,放入等待队列,M 继续执行下一个 G。
  6. G 结束:G 执行完毕后,回收 G 结构体,M 继续从 P 的本地队列获取下一个 G。
性能陷阱:如果程序中大量使用 runtime.Gosched() 或频繁创建短生命周期 goroutine,会导致 P 频繁进行调度决策,增加调度器开销。在性能建模时,需要将调度延迟纳入考虑。

1.6 GOMAXPROCS 与并行度控制

GOMAXPROCS 控制同时执行用户态 Go 代码的 OS 线程数量,即 P 的数量。默认值为 CPU 核心数。

// 查看当前 GOMAXPROCS
fmt.Println(runtime.GOMAXPROCS(0)) // 输出:8(假设 8 核)

// 设置 GOMAXPROCS(通常不推荐手动修改)
runtime.GOMAXPROCS(4) // 限制最多 4 个 P
GOMAXPROCS 设置 并发行为 并行行为 适用场景
= 1 所有 goroutine 在单线程上并发 无并行 调试、单核环境、避免竞态
= N(N > 1) goroutine 在 N 个线程上并发 最多 N 个 goroutine 并行 CPU 密集型任务
> CPU 核心数 线程数超过核心数 仍受限于物理核心 I/O 密集型任务(减少阻塞影响)

1.7 并发性能建模的起点

理解 goroutine、channel 和 GMP 调度模型,是进行事件溯源与并发回放优化的基础。在后续章节中,我们将基于这些底层机制,构建性能模型来预测和优化 Go 程序的并发行为。

本章核心要点:
  • 并发是设计结构,并行是执行方式——Go 的 CSP 模型天然支持并发。
  • goroutine 是轻量级用户态协程,栈动态伸缩,创建成本极低。
  • channel 底层是带锁的环形缓冲区 + 等待队列,实现 CSP 通信。
  • GMP 调度器通过 P(逻辑处理器)将 G(goroutine)映射到 M(OS 线程),支持工作窃取和系统调用解耦。
  • GOMAXPROCS 控制并行度,默认等于 CPU 核心数。