Go 的并发原语”小而精”,标准库 sync + sync/atomic 就覆盖了绝大多数场景。本文从数据竞争讲起,把 9 种核心同步原语的用法、适用场景和常见坑一次讲清,最后附上面试回答框架。
互斥锁类
Mutex · RWMutex
协作类
WaitGroup · Once · Cond
无锁类
atomic · Channel
并发容器
sync.Map · sync.Pool
为什么需要锁:数据竞争
Go 的哲学是”不要通过共享内存来通信,而要通过通信来共享内存“,但业务里共享可变状态不可避免。当多个 goroutine 同时读写同一个变量,就产生了 数据竞争(data race):
1 | var counter int |
counter++ 在底层是”读取 → 加一 → 写回”三步,两个 goroutine 可能同时读到同一个旧值,导致更新丢失。
检测工具
运行 go run -race main.go 打开竞态检测器,能自动定位数据竞争的确切位置。生产代码必开,CI 里也建议默认跑一遍。
解决数据竞争有三条路:加锁(Mutex / RWMutex / Cond)、原子操作(atomic)、通信(Channel)。下面逐个讲清楚。
sync.Mutex:互斥锁
sync.Mutex 是最基础的互斥锁:同一时刻只允许一个 goroutine 进入临界区。
1 | var mu sync.Mutex |
要点:
- 零值可用,不需要初始化
Lock/Unlock必须成对,务必用 defer——临界区里一旦 panic 或提前 return,漏掉 unlock 会永久卡死其他 goroutine- 不可重入:同一 goroutine 对同一个 Mutex 重复
Lock会直接死锁(不像 Java 的synchronized) - 带锁的结构体不要复制,
go vet会报copies lock value
对共享数据的写操作、临界区很短且热点集中、需要保护的对象是”读和写都频繁”。
sync.RWMutex:读写锁
sync.RWMutex 区分 读锁 / 写锁:读锁可以多个 goroutine 共享,写锁独占。
1 | var rw sync.RWMutex |
要点:
- 读锁用
RLock/RUnlock,写锁用Lock/Unlock - 写锁优先:当有写者在等待时,新来的读锁会被阻塞,避免写者饿死
- 适合读多写少的场景:缓存、配置表、路由表等
如果你的场景”写多读少”,RWMutex 反而比 Mutex 慢——读锁本身也有开销,且写锁会阻塞所有读者。不要盲目用 RWMutex。
sync.WaitGroup:等待组
sync.WaitGroup 用来等待一组 goroutine 全部结束,本质是一个计数信号量:
1 | var wg sync.WaitGroup |
要点:
Add增加计数、Done减少计数、Wait阻塞到归零Add要在 goroutine 启动前调用,否则可能 Wait 先返回- 不能复制;计数器不能减到负数(会 panic)
并发任务全部完成后汇总结果、主 goroutine 等待后台任务收尾。
sync.Once:只执行一次
sync.Once 保证函数在所有 goroutine 中只执行一次,内部用互斥锁 + 标志位实现,是单例初始化的标准写法:
1 | var once sync.Once |
懒加载单例、全局初始化(连接池、配置文件)、init 之外的延迟初始化。
sync.Cond:条件变量
sync.Cond 是 goroutine 之间的等待/通知机制,必须配合 Mutex 使用。典型场景:队列空时消费者等待,队列有货时生产者通知。
1 | var mu sync.Mutex |
要点:
Wait内部会释放锁再阻塞,被唤醒后重新抢锁,所以外层要先Lock- 条件检查必须用
for循环,不能用if(存在虚假唤醒) Signal唤醒一个,Broadcast唤醒所有- 适用:队列空/满时的等待唤醒,比”轮询 +
time.Sleep“更高效、不空转
sync/atomic:原子操作,无锁的轻量级方案
如果只是计数器、状态标志这类单变量场景,连锁都不用,用 atomic 包就够了——性能最好,且不存在锁竞争:
1 | var counter atomic.Int64 |
要点:
- 无锁、开销最小,适合高频计数(指标统计、限流计数)
- 只能保证单个变量的原子性;多个变量组合的”一致性”保护不了,那种场景还是要锁
- 常用类型:
atomic.Int32 / Int64 / Uint64 / Bool / Pointer,以及任意类型的atomic.Value
atomic.Value 支持任意类型的无锁读写,经典用法是无锁读配置:
1 | var config atomic.Value |
为什么 atomic 比锁快
锁会让线程进入内核态阻塞/唤醒(涉及调度),而原子操作直接由 CPU 指令(如 LOCK CMPXCHG)完成,用户态内就结束了,没有上下文切换。
sync.Map:并发安全的 map
普通的 map 并发读写直接 panic,sync.Map 是官方提供的并发安全版本:
1 | var cache sync.Map |
要点:
- 内部是”读用原子操作 + 写用 dirty 加锁“两份数据(分片锁思想),读快路径无锁
- 适用:读多写少、key 不重叠、只需要
Store / Load / LoadOrStore / Delete / Range的简单场景 - 不是万能:写多场景反而比
RWMutex + 手写 map慢;需要更多 API 可用第三方concurrent-map
sync.Pool:对象池
sync.Pool 用于复用高频临时对象,减少 GC 压力,内部是 per-P 缓存 + 锁:
1 | var bufPool = sync.Pool{ |
要点:
- 目的:避免高频创建/销毁临时对象带来的 GC 压力
- 内部 per-P 无锁快路径 + 加锁兜底;对象随时可能被回收,不要假设能拿回上次放的那个
- 适用:JSON 编解码 buffer、字符串拼接等高频临时对象
- 注意 Pool 不是缓存/连接池!不保证对象存活,不适合放数据库连接等有状态资源
Channel 信号量:另一种锁
Go 更推荐用 Channel 做同步。带缓冲的 channel 天然就是一个计数信号量,常用来限制并发数:
1 | type Semaphore struct { |
用 channel 做互斥锁:
1 | ch := make(chan struct{}, 1) |
选 channel 还是 Mutex?
用 Channel
纯 goroutine 间协作、传递数据、限流并发数、生产者-消费者模式
用 Mutex
保护共享数据写操作、临界区逻辑简单直接、需要读锁(RWMutex)时
数据流用 Channel,数据栅栏用 Mutex。Channel 传递的是 值,Mutex 保护的是 状态。
死锁与常见坑
- 忘记 Unlock / 忘记 defer
1 | func inc() { |
解法: Lock 后立刻 defer mu.Unlock(),没有例外。
- Mutex 不可重入
1 | func outer() { |
解法: 拆成 lockInner / unlockInner 两个内部函数(不重复加锁),或重构临界区。
- 加锁顺序不一致(ABBA 死锁)
1 | // goroutine A |
解法: 全局约定固定的加锁顺序(如按地址排序),或尽量持有单把锁。
- 复制带锁的结构体
1 | type SafeMap struct { mu sync.Mutex; m map[string]int } |
解法: 始终用指针传递,go vet 默认检查 copies lock value。
- 锁内做重活
锁内做网络请求、磁盘 IO、大循环,会让所有其他 goroutine 排队变成”伪单线程”。
解法: 临界区尽可能短——把耗时的 IO 移到锁外,锁只保护共享状态的最小片段。
扩展:x/sync 与分布式锁
以上 9 种是标准库,企业级项目还会用到 golang.org/x/sync 扩展包和跨进程的分布式锁。
singleflight:合并相同请求,防缓存击穿
1 | var g singleflight.Group |
典型场景:热点 key 的缓存失效瞬间,成千上万个请求同时打到数据库。singleflight 让它们合并成一次。
errgroup:带错误传播的 WaitGroup
1 | g, ctx := errgroup.WithContext(ctx) |
适合把一个大任务拆成多个并行子任务,还能在第一个出错时整体取消。
semaphore:加权信号量
1 | sem := semaphore.NewWeighted(10) |
带权重的信号量,比计数 channel 更灵活——可以一次申请多个”份额”。
其他值得知道的:
TryLock(Go 1.18+):mu.TryLock()非阻塞抢锁,抢不到立刻返回false,适合”能抢到就做,抢不到就放弃”的优化路径- 可重入锁:Go 的 Mutex 不可重入,需要时得自己包装(记录 owner goroutine),标准库没有
- 分段锁(Sharded Lock):
map[uint64]*sync.Mutex按 key 哈希取锁,把竞争分散到多把锁上,常见于缓存分片 - 死锁检测:
github.com/sasha-s/go-deadlock,包装 Mutex/RWMutex,运行时检测锁环 - 分布式锁:跨进程/跨机器,基于 Redis(
redsync)、etcd(concurrency)、ZooKeeper;和单机 Mutex 是不同维度,还要额外考虑锁过期、续租、误删等问题
面试怎么回答:答 5~7 种,先分类再逐条
结论
不要机械数数字。面试官考的是你对并发模型的理解和选型能力,答 5~7 种核心原语 + 典型场景就够了,比背 10 个名字更有说服力。
三步框架:
- 先分类(30 秒):”Go 的同步原语分三类——互斥锁类(Mutex、RWMutex)、协作类(WaitGroup、Once、Cond)、无锁与容器类(atomic、Channel、Map、Pool)。”
- 逐条说明(2~3 分钟):每种说清”作用一句话 + 一个典型场景”:
| 原语 | 一句话说明 | 典型场景 |
|---|---|---|
sync.Mutex | 互斥锁,保护临界区 | 共享变量、计数器 |
sync.RWMutex | 读共享、写独占 | 缓存、配置(读多写少) |
sync.WaitGroup | 等待一组 goroutine 完成 | 并发任务完成后汇总 |
sync.Once | 保证函数只执行一次 | 单例、全局初始化 |
sync.Cond | 条件变量,等待/通知 | 队列空/满时的唤醒 |
sync/atomic | 无锁原子操作 | 高性能计数器、状态标志 |
Channel | 通信同步,可当信号量 | 生产者-消费者、限流 |
| 3. 收尾总结(30 秒):”实际开发中 80% 的并发问题用 Mutex + WaitGroup + Channel 就能解决。选型原则是:能用 Channel 就用 Channel,不适合再用锁。” |
面试官追问时再补充:sync.Map(并发安全 map)、sync.Pool(对象池)、errgroup / singleflight(错误传播与请求合并)、Mutex 不可重入、分布式锁与单机锁的区别。
选型总结
计数器 / 状态标志atomic
读多写少(缓存、配置)sync.RWMutex
写为主的临界区sync.Mutex
并行任务收尾sync.WaitGroup
只初始化一次sync.Once
条件等待 / 通知sync.Cond
并发安全的 mapsync.Map
高频临时对象sync.Pool
限流 / goroutine 协作Channel 信号量
一切并发问题,先跑 go run -race 定位,再决定用哪种原语。能锁小就别锁大,能无锁就别加锁。