背景与动机
Go 并发的核心不是“开很多线程”,而是把任务拆成大量 goroutine,再通过运行时调度器、channel 和同步原语把它们安全地组织起来。真正需要掌握的不是几个 API,而是三件事:
- goroutine 为什么轻、怎么被调度、阻塞时谁会被挂起。
- channel 不只是队列,它还承载同步语义和内存可见性语义。
sync包不是 channel 的替代品,而是另一套更低层、更直接的并发控制工具。
本文默认按 Go 1.22+ 理解;涉及版本差异的地方会单独标注。相关官方资料可对照 Go Memory Model、Go 语言规范中的 select、sync 包文档 与 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 调度拓扑与阻塞流转】

调度器为什么能把大量 goroutine 跑起来:
- goroutine 初始栈很小,按需增长,因此创建成本远低于线程。
- 每个
P有本地运行队列,优先从本地取任务,减少全局竞争。 - 本地队列空了,会先看全局队列;再没有,就从别的
P窃取一半 goroutine,这就是 work stealing。 - 因此 Go 的并发扩展性很大程度来自“本地优先 + 负载均衡”,而不是简单线程池。
阻塞时要分清“谁阻塞”:
- channel、
Mutex、WaitGroup.Wait这类阻塞,通常是 goroutine 被挂起,M和P还能去跑别的 goroutine。 - 同步 syscall 可能阻塞
M。这时运行时会把P从当前M手里拿走,交给别的M继续跑。 - 网络 I/O 通常结合 netpoller,让 goroutine 等待事件,而不是长期卡死一个线程。
版本说明:
- 自 Go 1.14 起,运行时支持异步抢占;Go 1.22+ 中这是默认能力。长时间运行的 goroutine 不再只能依赖显式阻塞点才让出 CPU,但这不等于“任何死循环都没问题”。
2. channel:它既是通信管道,也是同步点
Go 的并发哲学是“通过通信来共享内存”。channel 的价值不只是传值,更重要的是定义 goroutine 之间的同步关系。
【图:并发编程 - 无缓冲与有缓冲 channel 对比】

无缓冲 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 也很关键:
- 对
nilchannel 的发送和接收会永久阻塞。 - 在
select里,常用nilchannel 动态禁用某个 case。
3. select:不是“按顺序 if-else”,而是多路就绪选择
select 用来同时等待多个通信操作,是 Go 处理超时、取消、多路复用的基础。
【并发编程 - select 就绪选择与阻塞路径】

要点有四个:
- 所有 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 职责边界】

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.Mutex 和 Go Memory Model)。- 适合“共享状态很小、修改很频繁”的场景,通常比“为了保护一个整数而上 channel”更直接。
Once:
- 作用是保证某段初始化逻辑只执行一次。
- 多个 goroutine 同时调用
Do(f)时,只会有一个真正执行f,其余调用者会等它结束。 f返回后,后续Do(f)直接跳过。- 如果
fpanic,Once仍视为已经执行过;后续不会再重试,这是很多人会忽略的点。
最小可运行代码示例
下面这个例子把 goroutine、缓冲/无缓冲 channel、select、WaitGroup、Mutex、Once 串在一起:
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. 复制包含 Mutex 或 Once 的结构体
Mutex 和 Once 都不应在首次使用后被复制,否则内部状态会被复制走,行为不可预期。
5. 误解 Go 1.22 的循环变量修复范围
Go 1.22 修复了 for i := ... 和 for _, v := range ... 这类“每次迭代共享同一变量”的常见闭包坑,但如果你复用了外部变量,问题仍可能存在:
var v intfor _, 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 吞吐更高、耦合更低,但也更容易把问题拖延到“缓冲区打满时才暴露”。
channel 和 Mutex 不是谁先进谁落后,而是职责不同。需要表达“任务流转、所有权转移、生产消费关系”时优先考虑 channel;只是保护一个 map、计数器、缓存状态时,Mutex 往往更直接、开销更低。
WaitGroup 适合“等一批任务结束”,但不适合承载错误处理和取消语义;一旦任务需要失败传播、超时控制,通常要结合 context.Context,或者在更高层引入更完整的并发编排方案。
select 中带 default 可以降低等待延迟,但循环里如果没有退避或阻塞点,很容易把 CPU 打满。time.After 写在热循环里也会反复分配定时器,频繁路径下更适合复用 time.Timer。
面试高频问题
GOMAXPROCS控制的是什么? 控制的是P的数量,也就是同时并行执行 Go 代码的上限,不是 goroutine 数量,也不是线程总数。- 无缓冲 channel 和有缓冲 channel 的本质区别是什么? 无缓冲是“通信发生时顺带同步”,有缓冲是“允许发送方最多领先若干步”,更偏解耦。
select多个 case 同时就绪时怎么选? 伪随机选一个,不保证严格公平,更不保证按源码顺序优先。- 为什么说
Mutex也提供内存可见性,而不只是互斥? 因为一次Unlock先于后续成功的Lock,后者能看到前者临界区内已完成的写入。 WaitGroup为什么强调先Add再go? 因为如果Wait先观察到计数为 0,就可能提前返回,造成主流程误判任务已结束。Once和“自己写一个布尔标记 + if”有什么本质区别?Once不只是“只执行一次”,还保证并发下只有一个执行者,其余调用者要么等待完成,要么看到已完成状态。- Go 1.22 对循环变量闭包捕获做了什么改进?
对常见的
for/range迭代变量声明场景,改成“每次迭代一个新变量”;但复用外部变量时仍可能出问题。
一句话总结
Go 并发真正要掌握的是:调度器决定 goroutine 怎么跑,channel 决定 goroutine 怎么同步与通信,sync 原语决定共享状态怎么安全落地。