6.Go 并发编程

背景与动机#

Go 并发的核心不是“开很多线程”,而是把任务拆成大量 goroutine,再通过运行时调度器、channel 和同步原语把它们安全地组织起来。真正需要掌握的不是几个 API,而是三件事:

  1. goroutine 为什么轻、怎么被调度、阻塞时谁会被挂起。
  2. channel 不只是队列,它还承载同步语义和内存可见性语义。
  3. sync 包不是 channel 的替代品,而是另一套更低层、更直接的并发控制工具。

本文默认按 Go 1.22+ 理解;涉及版本差异的地方会单独标注。相关官方资料可对照 Go Memory ModelGo 语言规范中的 selectsync 包文档Go 1.22 Release Notes

核心原理拆解#

1. goroutine 调度模型:G、M、P 不是一一对应#

Go 运行时调度器的核心是 G-M-P 模型:

  • G:goroutine,本质是待执行任务,带有栈、程序计数器和状态。
  • M:machine,对应操作系统线程,真正被 CPU 调度。
  • P:processor,调度上下文,持有本地运行队列。M 必须拿到 P 才能执行 Go 代码。
  • GOMAXPROCS 决定的是 P 的数量,也就是“同一时刻最多有多少个 goroutine 并行执行 Go 代码”,不是 goroutine 总数。

【并发编程 - G-M-P 调度拓扑与阻塞流转】

image.png
image.png

调度器为什么能把大量 goroutine 跑起来:

  • goroutine 初始栈很小,按需增长,因此创建成本远低于线程。
  • 每个 P 有本地运行队列,优先从本地取任务,减少全局竞争。
  • 本地队列空了,会先看全局队列;再没有,就从别的 P 窃取一半 goroutine,这就是 work stealing。
  • 因此 Go 的并发扩展性很大程度来自“本地优先 + 负载均衡”,而不是简单线程池。

阻塞时要分清“谁阻塞”:

  • channel、MutexWaitGroup.Wait 这类阻塞,通常是 goroutine 被挂起,MP 还能去跑别的 goroutine。
  • 同步 syscall 可能阻塞 M。这时运行时会把 P 从当前 M 手里拿走,交给别的 M 继续跑。
  • 网络 I/O 通常结合 netpoller,让 goroutine 等待事件,而不是长期卡死一个线程。

版本说明:

  • 自 Go 1.14 起,运行时支持异步抢占;Go 1.22+ 中这是默认能力。长时间运行的 goroutine 不再只能依赖显式阻塞点才让出 CPU,但这不等于“任何死循环都没问题”。

2. channel:它既是通信管道,也是同步点#

Go 的并发哲学是“通过通信来共享内存”。channel 的价值不只是传值,更重要的是定义 goroutine 之间的同步关系。

【图:并发编程 - 无缓冲与有缓冲 channel 对比】

image.png
image.png

无缓冲 channel 的本质:

  • 发送和接收必须同时就绪。
  • 发送值的同时完成一次同步握手。
  • 它适合表达“你收到,我才算发完”。

有缓冲 channel 的本质:

  • 只要缓冲区没满,发送方就能先走。
  • 只要缓冲区不空,接收方就能继续取。
  • 它适合生产者和消费者节奏不一致的场景。

内存语义上要抓住三条:

  • 一次 send 先于对应 receive 的完成,这保证接收方能看到发送前已经写好的数据。
  • 对无缓冲 channel,receive 也先于 send 的完成,所以它是双向同步点。
  • 对容量为 C 的缓冲 channel,第 k 次 receive 先于第 k+C 次 send 完成,这解释了“缓冲区允许发送方最多领先 C 步”(见 Go Memory Model)。

关闭 channel 的语义:

  • 应由发送方关闭,不应由接收方随意关闭。
  • 关闭后,缓冲区里的值仍可继续读。
  • 缓冲读空后再接收,会得到元素零值和 ok == false
  • 向已关闭 channel 发送一定 panic;重复关闭也一定 panic。

nil channel 也很关键:

  • nil channel 的发送和接收会永久阻塞。
  • select 里,常用 nil channel 动态禁用某个 case。

3. select:不是“按顺序 if-else”,而是多路就绪选择#

select 用来同时等待多个通信操作,是 Go 处理超时、取消、多路复用的基础。

【并发编程 - select 就绪选择与阻塞路径】

image.png
image.png

要点有四个:

  • 所有 case 的 channel 表达式和发送值会先按源码顺序求值。
  • 如果某个通信已就绪,才有资格被选中。
  • 如果多个 case 同时就绪,会做伪随机选择。
  • 如果都没就绪且没有 default,当前 goroutine 阻塞。

因此要注意:

  • select 不保证“写在前面的 case 优先级更高”。
  • default 会让 select 变成非阻塞;循环里滥用 default 容易造成忙等。
  • case 表达式中的副作用即使最终没选中也可能已经发生,因为求值在前。

常见场景:

  • 超时控制:case <-time.After(...)
  • 取消传播:case <-ctx.Done()
  • 扇入:同时接收多个 channel
  • 动态开关:把暂时不想监听的 channel 设为 nil

4. sync 包:WaitGroup、Mutex、Once 的职责边界#

sync 适合解决“共享状态如何安全访问”这类问题,它和 channel 的侧重点不同。

【并发编程 - WaitGroup、Mutex、Once 职责边界】

image.png
image.png

WaitGroup

  • 作用是等待一组 goroutine 结束。
  • Add(n) 增加计数,Done() 减一,Wait() 阻塞到计数归零。
  • 它只负责“等完”,不负责错误收集、取消传播、超时控制。
  • 兼容 Go 1.22+ 的基础写法仍应以 Add/Done/Wait 为主;WaitGroup.Go 是 Go 1.25 新增 API,不应当成 1.22 的基线。

Mutex

  • 作用是保护共享可变状态。
  • 同一时刻只允许一个 goroutine 进入临界区。
  • Unlock 先于后续成功的 Lock,这不仅是互斥,也是内存可见性保证(见 sync.MutexGo Memory Model)。
  • 适合“共享状态很小、修改很频繁”的场景,通常比“为了保护一个整数而上 channel”更直接。

Once

  • 作用是保证某段初始化逻辑只执行一次。
  • 多个 goroutine 同时调用 Do(f) 时,只会有一个真正执行 f,其余调用者会等它结束。
  • f 返回后,后续 Do(f) 直接跳过。
  • 如果 f panic,Once 仍视为已经执行过;后续不会再重试,这是很多人会忽略的点。

最小可运行代码示例#

下面这个例子把 goroutine、缓冲/无缓冲 channel、selectWaitGroupMutexOnce 串在一起:

package main
import (
"fmt"
"sync"
"time"
)
var once sync.Once
func initResource() {
fmt.Println("init resource only once")
}
func worker(id int, jobs <-chan int, results chan<- int, wg *sync.WaitGroup, mu *sync.Mutex, total *int) {
defer wg.Done()
once.Do(initResource)
for {
select {
case job, ok := <-jobs:
if !ok {
return
}
v := job * job
mu.Lock()
*total += v
mu.Unlock()
results <- v // 无缓冲:发送时必须有接收方
case <-time.After(200 * time.Millisecond):
fmt.Printf("worker %d idle timeout\\n", id)
return
}
}
}
func main() {
jobs := make(chan int, 3) // 有缓冲:生产者可先写入
results := make(chan int) // 无缓冲:结果交付时顺带同步
var wg sync.WaitGroup
var mu sync.Mutex
total := 0
for i := 1; i <= 2; i++ {
wg.Add(1)
go worker(i, jobs, results, &wg, &mu, &total)
}
for _, job := range []int{1, 2, 3} {
jobs <- job
}
close(jobs)
go func() {
wg.Wait()
close(results)
}()
for r := range results {
fmt.Println("result:", r)
}
fmt.Println("total:", total)
}

这个例子值得看三点:

  • jobs 是有缓冲 channel,生产者和消费者解耦。
  • results 是无缓冲 channel,结果发送和主协程接收形成同步点。
  • total 是共享状态,因此用 Mutex,而不是强行把一切都改成 channel。

常见陷阱与错误示例#

1. 在 goroutine 内部调用 wg.Add(1)#

这是 WaitGroup 最常见的错误之一,Wait 可能先跑到,导致主协程过早结束。

for i := 0; i < 3; i++ {
go func() {
wg.Add(1) // 错误:Add 和 Wait 可能并发竞态
defer wg.Done()
}()
}
wg.Wait()

正确做法是先 Add,再启动 goroutine。

2. 误以为 select 按 case 顺序选择#

select {
case x := <-fast:
fmt.Println("fast", x)
case y := <-slow:
fmt.Println("slow", y)
}

如果两个 channel 同时就绪,不会因为 fast 写在前面就优先选它。select 的多就绪分支是伪随机选择,不能拿来做严格优先级调度。

3. 接收方关闭 channel,或者向已关闭 channel 发送#

close(ch)
ch <- 1 // panic: send on closed channel

原则上“谁发送,谁关闭”。关闭是生产侧生命周期的声明,不是消费侧的权力。

4. 复制包含 MutexOnce 的结构体#

MutexOnce 都不应在首次使用后被复制,否则内部状态会被复制走,行为不可预期。

5. 误解 Go 1.22 的循环变量修复范围#

Go 1.22 修复了 for i := ...for _, v := range ... 这类“每次迭代共享同一变量”的常见闭包坑,但如果你复用了外部变量,问题仍可能存在:

var v int
for _, v = range []int{1, 2, 3} {
go func() {
fmt.Println(v) // 这里仍可能读到同一个外部变量
}()
}

版本说明:这是 Go 1.22 的语义改进点,见 Go 1.22 Release Notes

6. 以为 Once 失败后还能自动重试#

Once.Do(f) 中如果 f panic,后续调用不会再次执行 f。如果初始化可能失败且你需要重试,就不该直接用 Once 包住整段“可能失败”的逻辑。

性能影响或设计取舍#

goroutine 很轻,但绝不是零成本。大量 goroutine 会占用栈、调度队列和 GC 元数据;如果 goroutine 长期阻塞又没有退出条件,本质上就是泄漏。

无缓冲 channel 的同步语义最强,适合严格交接;有缓冲 channel 吞吐更高、耦合更低,但也更容易把问题拖延到“缓冲区打满时才暴露”。

channelMutex 不是谁先进谁落后,而是职责不同。需要表达“任务流转、所有权转移、生产消费关系”时优先考虑 channel;只是保护一个 map、计数器、缓存状态时,Mutex 往往更直接、开销更低。

WaitGroup 适合“等一批任务结束”,但不适合承载错误处理和取消语义;一旦任务需要失败传播、超时控制,通常要结合 context.Context,或者在更高层引入更完整的并发编排方案。

select 中带 default 可以降低等待延迟,但循环里如果没有退避或阻塞点,很容易把 CPU 打满。time.After 写在热循环里也会反复分配定时器,频繁路径下更适合复用 time.Timer

面试高频问题#

  1. GOMAXPROCS 控制的是什么? 控制的是 P 的数量,也就是同时并行执行 Go 代码的上限,不是 goroutine 数量,也不是线程总数。
  2. 无缓冲 channel 和有缓冲 channel 的本质区别是什么? 无缓冲是“通信发生时顺带同步”,有缓冲是“允许发送方最多领先若干步”,更偏解耦。
  3. select 多个 case 同时就绪时怎么选? 伪随机选一个,不保证严格公平,更不保证按源码顺序优先。
  4. 为什么说 Mutex 也提供内存可见性,而不只是互斥? 因为一次 Unlock 先于后续成功的 Lock,后者能看到前者临界区内已完成的写入。
  5. WaitGroup 为什么强调先 Addgo? 因为如果 Wait 先观察到计数为 0,就可能提前返回,造成主流程误判任务已结束。
  6. Once 和“自己写一个布尔标记 + if”有什么本质区别? Once 不只是“只执行一次”,还保证并发下只有一个执行者,其余调用者要么等待完成,要么看到已完成状态。
  7. Go 1.22 对循环变量闭包捕获做了什么改进? 对常见的 for/range 迭代变量声明场景,改成“每次迭代一个新变量”;但复用外部变量时仍可能出问题。

一句话总结#

Go 并发真正要掌握的是:调度器决定 goroutine 怎么跑,channel 决定 goroutine 怎么同步与通信,sync 原语决定共享状态怎么安全落地。

文章目录

文章目录