Go 并发编程:从 Goroutine 到并发模式,你该知道的一切
Go 并发编程:从 Goroutine 到并发模式,你该知道的一切
你写了一个简单的 Go 程序,加了个 go 关键字,以为它在并发运行——但 go run -race 报出了 12 条 WARNING。你盯着满屏的竞态检测输出,不知道问题出在哪里。
这就是 Go 并发的真实写照:用起来简单,用对需要理解。Go 的并发原语数量不多——一双手指就能数完——但它们组合出来的威力极强。这篇文章会带你从底层模型到实战模式,把 Go 并发真正吃透。
一、Goroutine:一个 go 关键字背后是什么
Goroutine 是 Go 运行时管理的轻量级执行单元。与操作系统线程(OS 线程)相比,它占用的资源非常少,一个程序可以轻松创建数以万计的 goroutine。
1.1 不只是"轻量级线程"
当你写下 go doSomething(),Go 运行时做了什么?它在用户态创建了一个 goroutine,而不是在操作系统层面创建线程。
Go 使用的是 M:N 调度模型(G-P-M 模型):
- G(Goroutine):协程,用户态的轻量级线程,包含栈、指令指针等信息。
- P(Processor):逻辑处理器,数量由
GOMAXPROCS决定(默认 = CPU 核心数)。P 持有本地运行队列,goroutine 在 P 上被调度。 - M(Machine):操作系统线程,负责实际执行计算。M 必须"绑定"一个 P 才能执行 G。
1 | 创建 10,000 个并发单元: |
创建数万甚至数十万 goroutine 是家常便饭——这是 Go 编写高并发服务的底气。
1.2 并发执行
多个 goroutine 可以并发执行,Go 运行时的调度器负责将这些 groutine 合理地分配到操作系统线程上,从而实现并发处理。
在单核 CPU 上,goroutine 通过时间片轮转实现并发;在多核 CPU 上,多个 goroutine 可以真正并行执行。
1.3 何时阻塞,何时让出
理解调度器何时介入,是写出正确并发代码的前提。以下操作会导致 goroutine 阻塞(触发调度器重新分配 P):
- 系统调用(文件 I/O、网络 I/O)
- Channel 操作(无缓冲 channel 的收发、缓冲 channel 满时发 / 空时收)
- 同步原语(
Mutex.Lock()、WaitGroup.Wait()等)
以下操作导致 goroutine 主动让出 CPU 但进入可运行队列:
runtime.Gosched()显式让出- Go 1.14+ 引入的异步抢占——即使 goroutine 陷入死循环从不调用函数,调度器也能在 10ms 内抢占它。
1 | // 直观感受调度器行为 |
1.4 协程执行顺序
1.4.1 主协程与子协程
主协程(main goroutine)是程序入口所在的 goroutine。只有在 main 函数及其调用的函数代码中才能够使用 go + func() 的方式启动新的协程,这些新启动的协程被称为子协程。
一旦 main 函数执行完毕,主协程就会退出,整个程序也会随之结束,无论子协程是否完成了它们的任务。
1 | package main |
输出:
1 | hello, world |
在 Go 中,go 关键字只是将函数调度为 goroutine 执行,并不会立即执行该函数。
主 goroutine 会继续向下运行,因此通常主流程的输出会先发生。
如果没有同步机制(如 WaitGroup 或 channel),子 goroutine 的执行时机是不可预测的。

1.4.2 多协程执行顺序
1 | package main |
子协程的执行顺序是由 Go 运行时调度器决定的,通常是交替执行的,但并不保证严格的轮流执行。
输出:
1 | In goroutine 协程2号 |
1.5 协程的生命周期
Goroutine 的生命周期包括:创建、运行、阻塞、唤醒以及结束。
-
创建:当你使用
go关键字调用一个函数时,Go 运行时会创建一个新的 Goroutine。此时,新的 Goroutine 被创建并进入可运行状态,等待 Go 运行时调度器将其分配到一个操作系统线程上执行。 -
运行:当调度器将 Goroutine 分配到一个操作系统线程上时,Goroutine 开始运行其关联函数的代码。在运行过程中,Goroutine 会顺序执行函数中的语句,直到遇到阻塞操作、函数返回或者发生异常。
-
阻塞:当 Goroutine 执行到某些特定的操作时(如等待 channel、等待锁、进行 I/O 操作等),它会进入阻塞状态,暂停执行,直到满足条件后被唤醒。
-
唤醒:当阻塞的条件满足时(如 channel 有数据可读、锁被释放、I/O 操作完成等),Goroutine 会被唤醒,重新进入可运行状态,等待调度器再次分配它到一个操作系统线程上继续执行。
-
结束:Goroutine的生命周期在以下几种情况下结束:
主协程结束: 主Goroutine的结束意味着整个程序的结束,无论此时子Goroutine是否执行完成。
函数正常返回:Goroutine协程会一直运行,直到函数中的所有语句都执行完毕并返回。
发生未捕获的异常:如果Goroutine在执行过程中发生了未被recover捕获的panic,Goroutine会终止,并导致程序崩溃
二、Channel:用通信来共享内存
Go 并发哲学的第一条诫律:
不要通过共享内存来通信,而要通过通信来共享内存。
Channel 就是这条哲学的核心实现。它同时做两件事:传递数据 + 同步 goroutine。
无缓冲 vs 有缓冲
1 | ch := make(chan int) // 无缓冲:发送和接收必须同时就绪 |
无缓冲 channel 的本质是同步点:发送方必须等接收方准备好,接收方必须等发送方有数据。这其实是 Go 中最强的同步保证。
1 | // 无缓冲:天然同步 |
有缓冲 channel 则允许发送方"领先"接收方一定步数——这是一种性能优化,但不要为了"怕阻塞"而随意加大缓冲。先用无缓冲,确认有性能瓶颈后再考虑缓冲。
关闭 Channel 的铁律
三条规则,违反就 panic:
- 只有发送方应该关闭 channel——向已关闭的 channel 发送会 panic。
- 从已关闭且排空的 channel 接收——返回零值,且
ok为false。 - 不要重复关闭同一个 channel——会 panic。
1 | func main() { |
select:多路复用的核心
select 是 Go 并发的瑞士军刀——它可以同时等待多个 channel 操作:
1 | // 超时控制 |
最后那点是很多 Go 开发者不知道的技巧:nil channel 在 select 中对应的 case 永远不会被选中。你可以利用这个特性实现"读完某个 channel 后自动禁用对应 case"。
同步三件套:WaitGroup、Mutex、RWMutex
WaitGroup:等大家都干完
sync.WaitGroup 解决一个朴素的问题:启动 N 个 goroutine,然后等它们全部完成。
1 | func main() { |
三个最容易犯的错:
- 值拷贝 —
WaitGroup必须传指针,拷贝会导致计数器永远不归零。 - Add 放在 go 后面 — 可能
Wait()先于Add()执行。 - Done 比 Add 多 — 计数器变负直接 panic。
Mutex:保护共享状态
当你确实需要多个 goroutine 读写同一块内存时,用 sync.Mutex 保护临界区:
1 | type Counter struct { |
defer Unlock 不是可选的——是必须的。 如果你在 early return 前手动 unlock,漏一次就是死锁。
注意 Go 的 Mutex 不可重入:同一个 goroutine 不能对同一个 Mutex 加两次锁,否则直接死锁。如果你的函数调用链中可能出现重复加锁,需要重构代码避免这种设计。
RWMutex:读多写少时的性能利器
当你的数据 80% 以上的操作是读、写很少时,sync.RWMutex 比 Mutex 性能好得多:
1 | type Cache struct { |
选择依据很简单:读比例 > 80% → RWMutex,否则 → Mutex。RWMutex 内部有更复杂的读写计数逻辑,写多读少时反而更慢。
实战:日常开发中的并发模式
Worker Pool
需要并发处理一批任务,但不想创建无限制的 goroutine:
1 | func workerPool(numWorkers int, jobs <-chan int, results chan<- int) { |
Context:超时和取消的传播
1 | func doWork(ctx context.Context, id int) error { |
用 -race 检测竞态条件
Go 内置的 race detector 是并发编程的第一道防线:
1 | go test -race ./... |
它会插桩所有内存访问,运行时检测到竞态就报 WARNING。代价是 5-10 倍慢 + 5-10 倍内存,只在测试和开发环境中使用。
避坑清单
编写和审查 Go 并发代码时,对着这份清单逐条检查:
- Goroutine 泄漏 — 是否存在"发送到一个没人接收的 channel"或"range 一个永不关闭的 channel"?每个 goroutine 必须有退出路径。
- WaitGroup 值拷贝 — 检查所有
Add/Done/Wait的调用方是否用的是同一个*WaitGroup。 - Channel 关闭者 — 确认只有发送方关闭 channel,接收方不会
close。 - Mutex 不可重入 — 同一个 goroutine 是否可能对同一个 Mutex 加两次锁?
- select 缺少取消分支 — 循环内的
select是否包含了ctx.Done()? - defer Unlock — 是否每个
Lock()都有对应的defer Unlock()? - CI 中跑了
-race— 是否在 CI pipeline 中执行了go test -race?
总结
Go 并发的力量来自于一个简单的公式:少数原语 × 清晰的哲学 = 无限组合。
- Goroutine 给了你廉价到几乎免费的并发——大胆用,但记得给每个 goroutine 一个退出路径。
- Channel 是 Go 并发的心脏——优先用它传递数据所有权,而非用锁共享内存。
- WaitGroup、Mutex、RWMutex 各司其职——选对工具,用对模式。
-race是你最好的朋友——每一次go test都带上它。
当你下次看到满屏的 WARNING: DATA RACE 时,希望你能一眼看出问题所在——并且知道怎么改。
深入阅读:Go Concurrency Patterns (Rob Pike)、Share Memory By Communicating、errgroup 包文档。