Go 的并发原语”小而精”,标准库 sync + sync/atomic 就覆盖了绝大多数场景。本文从数据竞争讲起,把 9 种核心同步原语的用法、适用场景和常见坑一次讲清,最后附上面试回答框架。

互斥锁类
Mutex · RWMutex

协作类
WaitGroup · Once · Cond

无锁类
atomic · Channel

并发容器
sync.Map · sync.Pool


为什么需要锁:数据竞争

Go 的哲学是”不要通过共享内存来通信,而要通过通信来共享内存“,但业务里共享可变状态不可避免。当多个 goroutine 同时读写同一个变量,就产生了 数据竞争(data race)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var counter int

func main() {
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
counter++ // 竞态:counter++ 并不是原子操作
}()
}
wg.Wait()
fmt.Println(counter) // 结果不稳定,经常 < 1000
}

counter++ 在底层是”读取 → 加一 → 写回”三步,两个 goroutine 可能同时读到同一个旧值,导致更新丢失。

检测工具

运行 go run -race main.go 打开竞态检测器,能自动定位数据竞争的确切位置。生产代码必开,CI 里也建议默认跑一遍。

解决数据竞争有三条路:加锁(Mutex / RWMutex / Cond)、原子操作(atomic)、通信(Channel)。下面逐个讲清楚。


sync.Mutex:互斥锁

sync.Mutex 是最基础的互斥锁:同一时刻只允许一个 goroutine 进入临界区。

1
2
3
4
5
6
7
8
var mu sync.Mutex
var counter int

func inc() {
mu.Lock()
defer mu.Unlock() // 别忘了 defer,保证解锁一定执行
counter++
}

要点:

  • 零值可用,不需要初始化
  • Lock / Unlock 必须成对,务必用 defer——临界区里一旦 panic 或提前 return,漏掉 unlock 会永久卡死其他 goroutine
  • 不可重入:同一 goroutine 对同一个 Mutex 重复 Lock 会直接死锁(不像 Java 的 synchronized
  • 带锁的结构体不要复制go vet 会报 copies lock value
适用场景

对共享数据的写操作、临界区很短且热点集中、需要保护的对象是”读和写都频繁”。


sync.RWMutex:读写锁

sync.RWMutex 区分 读锁 / 写锁:读锁可以多个 goroutine 共享,写锁独占。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var rw sync.RWMutex
var cache = map[string]string{}

func get(key string) string {
rw.RLock() // 读锁:可并发
defer rw.RUnlock()
return cache[key]
}

func set(key, val string) {
rw.Lock() // 写锁:独占
defer rw.Unlock()
cache[key] = val
}

要点:

  • 读锁用 RLock / RUnlock,写锁用 Lock / Unlock
  • 写锁优先:当有写者在等待时,新来的读锁会被阻塞,避免写者饿死
  • 适合读多写少的场景:缓存、配置表、路由表等
注意

如果你的场景”写多读少”,RWMutex 反而比 Mutex 慢——读锁本身也有开销,且写锁会阻塞所有读者。不要盲目用 RWMutex。


sync.WaitGroup:等待组

sync.WaitGroup 用来等待一组 goroutine 全部结束,本质是一个计数信号量

1
2
3
4
5
6
7
8
9
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1) // 计数器 +1,必须在 goroutine 启动前调用
go func() {
defer wg.Done() // 计数器 -1
doWork()
}()
}
wg.Wait() // 阻塞,直到计数器归零

要点:

  • Add 增加计数、Done 减少计数、Wait 阻塞到归零
  • Add 要在 goroutine 启动前调用,否则可能 Wait 先返回
  • 不能复制;计数器不能减到负数(会 panic)
适用场景

并发任务全部完成后汇总结果、主 goroutine 等待后台任务收尾。


sync.Once:只执行一次

sync.Once 保证函数在所有 goroutine 中只执行一次,内部用互斥锁 + 标志位实现,是单例初始化的标准写法:

1
2
3
4
5
6
7
8
9
var once sync.Once
var cfg *Config

func GetConfig() *Config {
once.Do(func() {
cfg = loadConfig() // 并发调用时也只执行一次
})
return cfg
}
适用场景

懒加载单例、全局初始化(连接池、配置文件)、init 之外的延迟初始化。


sync.Cond:条件变量

sync.Cond 是 goroutine 之间的等待/通知机制,必须配合 Mutex 使用。典型场景:队列空时消费者等待,队列有货时生产者通知。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
var mu sync.Mutex
cond := sync.NewCond(&mu)
queue := []int{}

// 消费者:等待条件满足
mu.Lock()
for len(queue) == 0 { // 必须用 for 循环,处理虚假唤醒
cond.Wait() // 内部会先释放锁并阻塞,被唤醒后重新加锁
}
item := queue[0]
queue = queue[1:]
mu.Unlock()

// 生产者:修改条件后通知
mu.Lock()
queue = append(queue, item)
cond.Signal() // 唤醒一个等待者;想唤醒全部用 Broadcast()
mu.Unlock()

要点:

  • Wait 内部会释放锁再阻塞,被唤醒后重新抢锁,所以外层要先 Lock
  • 条件检查必须用 for 循环,不能用 if(存在虚假唤醒)
  • Signal 唤醒一个,Broadcast 唤醒所有
  • 适用:队列空/满时的等待唤醒,比”轮询 + time.Sleep“更高效、不空转

sync/atomic:原子操作,无锁的轻量级方案

如果只是计数器、状态标志这类单变量场景,连锁都不用,用 atomic 包就够了——性能最好,且不存在锁竞争:

1
2
3
4
5
6
var counter atomic.Int64

counter.Add(1) // 原子自增
n := counter.Load() // 读取
counter.Store(100) // 写入
counter.CompareAndSwap(old, new) // CAS:比较并交换

要点:

  • 无锁、开销最小,适合高频计数(指标统计、限流计数)
  • 只能保证单个变量的原子性;多个变量组合的”一致性”保护不了,那种场景还是要锁
  • 常用类型:atomic.Int32 / Int64 / Uint64 / Bool / Pointer,以及任意类型的 atomic.Value

atomic.Value 支持任意类型的无锁读写,经典用法是无锁读配置:

1
2
3
var config atomic.Value
config.Store(newConfig) // 原子整包替换
cfg := config.Load().(*Config) // 无锁读取,读方完全不加锁

为什么 atomic 比锁快

锁会让线程进入内核态阻塞/唤醒(涉及调度),而原子操作直接由 CPU 指令(如 LOCK CMPXCHG)完成,用户态内就结束了,没有上下文切换。


sync.Map:并发安全的 map

普通的 map 并发读写直接 panic,sync.Map 是官方提供的并发安全版本:

1
2
3
4
5
6
7
8
9
var cache sync.Map

cache.Store("key", value) // 写
v, ok := cache.Load("key") // 读
cache.LoadOrStore("key", value) // 读,没有则写入
cache.Delete("key") // 删除
cache.Range(func(k, v any) bool { // 遍历
return true // 返回 false 提前停止
})

要点:

  • 内部是”读用原子操作 + 写用 dirty 加锁“两份数据(分片锁思想),读快路径无锁
  • 适用:读多写少、key 不重叠、只需要 Store / Load / LoadOrStore / Delete / Range 的简单场景
  • 不是万能:写多场景反而比 RWMutex + 手写 map 慢;需要更多 API 可用第三方 concurrent-map

sync.Pool:对象池

sync.Pool 用于复用高频临时对象,减少 GC 压力,内部是 per-P 缓存 + 锁:

1
2
3
4
5
6
7
8
9
10
var bufPool = sync.Pool{
New: func() any {
return bytes.NewBuffer(make([]byte, 0, 1024))
},
}

b := bufPool.Get().(*bytes.Buffer) // 取一个(没有则走 New)
b.Reset()
// ... 使用 b
bufPool.Put(b) // 用完归还(不归还也行,GC 会回收)

要点:

  • 目的:避免高频创建/销毁临时对象带来的 GC 压力
  • 内部 per-P 无锁快路径 + 加锁兜底;对象随时可能被回收,不要假设能拿回上次放的那个
  • 适用:JSON 编解码 buffer、字符串拼接等高频临时对象
  • 注意 Pool 不是缓存/连接池!不保证对象存活,不适合放数据库连接等有状态资源

Channel 信号量:另一种锁

Go 更推荐用 Channel 做同步。带缓冲的 channel 天然就是一个计数信号量,常用来限制并发数:

1
2
3
4
5
6
7
8
9
10
type Semaphore struct {
ch chan struct{}
}

func NewSemaphore(n int) *Semaphore {
return &Semaphore{ch: make(chan struct{}, n)}
}

func (s *Semaphore) Acquire() { s.ch <- struct{}{} } // 获取:满了就阻塞
func (s *Semaphore) Release() { <-s.ch } // 释放

用 channel 做互斥锁:

1
2
3
4
5
ch := make(chan struct{}, 1)

ch <- struct{}{} // 加锁
counter++
<-ch // 解锁

选 channel 还是 Mutex?

用 Channel
纯 goroutine 间协作、传递数据、限流并发数、生产者-消费者模式

用 Mutex
保护共享数据写操作、临界区逻辑简单直接、需要读锁(RWMutex)时

一句话经验

数据流用 Channel,数据栅栏用 Mutex。Channel 传递的是 ,Mutex 保护的是 状态


死锁与常见坑

  1. 忘记 Unlock / 忘记 defer
1
2
3
4
5
6
7
func inc() {
mu.Lock()
if bad {
return // 提前返回,漏掉 Unlock → 其他 goroutine 永久阻塞
}
mu.Unlock()
}

解法: Lock 后立刻 defer mu.Unlock(),没有例外。

  1. Mutex 不可重入
1
2
3
4
5
6
7
8
9
10
func outer() {
mu.Lock()
defer mu.Unlock()
inner() // inner 里又 Lock → 同一 goroutine 死锁自己
}

func inner() {
mu.Lock()
defer mu.Unlock()
}

解法: 拆成 lockInner / unlockInner 两个内部函数(不重复加锁),或重构临界区。

  1. 加锁顺序不一致(ABBA 死锁)
1
2
3
4
5
6
7
// goroutine A
lockA.Lock()
lockB.Lock()

// goroutine B
lockB.Lock()
lockA.Lock() // A 拿着 A 等 B,B 拿着 B 等 A → 死锁

解法: 全局约定固定的加锁顺序(如按地址排序),或尽量持有单把锁。

  1. 复制带锁的结构体
1
2
3
4
type SafeMap struct { mu sync.Mutex; m map[string]int }

a := SafeMap{}
b := a // 复制了已存在的 Mutex → go vet 直接报错

解法: 始终用指针传递,go vet 默认检查 copies lock value

  1. 锁内做重活

锁内做网络请求、磁盘 IO、大循环,会让所有其他 goroutine 排队变成”伪单线程”。
解法: 临界区尽可能短——把耗时的 IO 移到锁外,锁只保护共享状态的最小片段。


扩展:x/sync 与分布式锁

以上 9 种是标准库,企业级项目还会用到 golang.org/x/sync 扩展包和跨进程的分布式锁。

singleflight:合并相同请求,防缓存击穿

1
2
3
4
5
var g singleflight.Group

data, err, _ := g.Do("user:1", func() (any, error) {
return fetchUserFromDB(1) // 同一 key 的并发请求只执行一次,其余共享结果
})

典型场景:热点 key 的缓存失效瞬间,成千上万个请求同时打到数据库。singleflight 让它们合并成一次

errgroup:带错误传播的 WaitGroup

1
2
3
4
g, ctx := errgroup.WithContext(ctx)
g.Go(func() error { return fetchA(ctx) })
g.Go(func() error { return fetchB(ctx) })
if err := g.Wait(); err != nil { ... } // 第一个出错会通过 ctx 取消其他任务

适合把一个大任务拆成多个并行子任务,还能在第一个出错时整体取消

semaphore:加权信号量

1
2
3
sem := semaphore.NewWeighted(10)
sem.Acquire(ctx, 1)
defer sem.Release(1)

带权重的信号量,比计数 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 个名字更有说服力。

三步框架:

  1. 先分类(30 秒):”Go 的同步原语分三类——互斥锁类(Mutex、RWMutex)、协作类(WaitGroup、Once、Cond)、无锁与容器类(atomic、Channel、Map、Pool)。”
  2. 逐条说明(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

并发安全的 map
sync.Map

高频临时对象
sync.Pool

限流 / goroutine 协作
Channel 信号量

最后一条

一切并发问题,先跑 go run -race 定位,再决定用哪种原语。能锁小就别锁大,能无锁就别加锁。