Go sync 同步原语

笔记/Go/Go核心编程/Go sync 同步原语

背景与动机#

Go 经常强调“通过通信来共享内存”,但这并不代表所有并发问题都应该用 channel。只是在保护一个共享 map、计数器、缓存状态时,sync.Mutex 往往比 channel 更直接。

sync 包解决的是另一类问题:

  • 等一组 goroutine 结束:WaitGroup
  • 保护共享可变状态:Mutex / RWMutex
  • 只执行一次初始化:Once

这一篇只整理 sync 包中最常见的同步原语,不展开 channel 和 select 的通信模型。

核心原理拆解#

1. WaitGroup:只负责等待,不负责取消和错误#

WaitGroup 是计数器:

  • Add(n):增加待等待任务数量。
  • Done():任务完成,计数减一。
  • Wait():阻塞直到计数归零。

典型写法是先 Add,再启动 goroutine:

var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
doWork()
}()
wg.Wait()

WaitGroup 只负责“等完”。它不负责:

  • 收集错误。
  • 通知 goroutine 取消。
  • 设置超时。
  • 限制并发数量。

如果任务需要失败传播或取消,通常要配合 context.Context 和错误 channel,或者使用更高层的并发编排。

版本说明:WaitGroup.Go 是 Go 1.25 新增 API。为了兼容 Go 1.22+ 的基础笔记,这里仍以 Add/Done/Wait 写法为主。

2. Mutex:保护共享可变状态#

Mutex 的作用是保证同一时刻只有一个 goroutine 进入临界区:

var mu sync.Mutex
var count int
mu.Lock()
count++
mu.Unlock()

实际代码里通常用 defer 保证释放锁:

mu.Lock()
defer mu.Unlock()
count++

Mutex 不只是互斥,还提供内存可见性保证。一次 Unlock 先于后续成功的 Lock,所以后一个 goroutine 能看到前一个 goroutine 在临界区里完成的写入。这个语义来自 Go Memory Model 和 sync 包文档。

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

image.png
image.png

3. RWMutex:读多写少时才值得用#

RWMutex 把锁分成读锁和写锁:

  • 多个 goroutine 可以同时持有读锁。
  • 写锁和读锁互斥。
  • 写锁和写锁也互斥。
var mu sync.RWMutex
var cache = map[string]string{}
func get(key string) string {
mu.RLock()
defer mu.RUnlock()
return cache[key]
}
func set(key, value string) {
mu.Lock()
defer mu.Unlock()
cache[key] = value
}

RWMutex 不是 Mutex 的无脑升级版。只有读操作明显多于写操作、临界区足够稳定时,它才可能带来收益。否则更复杂的锁语义可能让代码更难维护。

4. Once:并发安全地只执行一次#

Once 用来保证某段初始化逻辑只执行一次:

var once sync.Once
var config Config
func loadConfig() Config {
once.Do(func() {
config = readConfig()
})
return config
}

多个 goroutine 同时调用 Do 时,只有一个会执行函数,其他调用者会等它执行完成。函数返回后,后续调用直接跳过。

需要注意:如果 Do 里的函数 panic,Once 仍然视为已经执行过,后续不会自动重试。如果初始化可能失败并且需要重试,就不能简单把整段逻辑塞进 Once.Do

5. sync 类型首次使用后不应复制#

MutexRWMutexWaitGroupOnce 这类类型内部都带状态。首次使用后复制它们,等于复制内部状态,行为会变得不可预期。

常见风险是把包含锁的结构体按值传递:

type Counter struct {
mu sync.Mutex
n int
}
func bad(c Counter) {
c.mu.Lock()
defer c.mu.Unlock()
c.n++
}

更稳妥的方式是传指针:

func good(c *Counter) {
c.mu.Lock()
defer c.mu.Unlock()
c.n++
}

最小可运行代码示例#

下面这个例子把 WaitGroupMutexOnce 放在一起,但各自职责保持清楚:

package main
import (
"fmt"
"sync"
)
type Counter struct {
mu sync.Mutex
n int
}
func (c *Counter) Add(v int) {
c.mu.Lock()
defer c.mu.Unlock()
c.n += v
}
func (c *Counter) Value() int {
c.mu.Lock()
defer c.mu.Unlock()
return c.n
}
var once sync.Once
func initResource() {
fmt.Println("init resource")
}
func main() {
var wg sync.WaitGroup
counter := &Counter{}
for i := 1; i <= 3; i++ {
wg.Add(1)
go func(v int) {
defer wg.Done()
once.Do(initResource)
counter.Add(v)
}(i)
}
wg.Wait()
fmt.Println("total:", counter.Value())
}

这个例子里:

  • WaitGroup 等待三个 goroutine 结束。
  • Mutex 保护 Counter.n 这个共享状态。
  • Once 保证资源初始化只发生一次。

常见陷阱与错误示例#

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

错误写法:

for i := 0; i < 3; i++ {
go func() {
wg.Add(1)
defer wg.Done()
doWork()
}()
}
wg.Wait()

Wait 可能先看到计数为 0 并返回,导致主流程误判任务已经结束。正确写法是先 Add,再 go

2. 忘记 Unlock#

mu.Lock()
if failed {
return // 锁没有释放
}
mu.Unlock()

临界区里有多个返回路径时,优先用 defer mu.Unlock(),避免漏解锁。

3. 持锁做慢操作#

mu.Lock()
defer mu.Unlock()
resp, err := http.Get(url)
_ = resp
_ = err

网络请求、磁盘 I/O、复杂计算不应该随手放在锁里。锁保护的范围越大,其他 goroutine 等待越久,也越容易形成复杂阻塞。

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

c2 := c1 // 如果 c1 已经使用过内部锁,这样复制很危险

包含 sync 原语的结构体通常应该通过指针传递。需要复制业务数据时,应明确避开锁状态。

5. 以为 Once 失败后会自动重试#

once.Do(func() {
panic("init failed")
})

这次 panic 后,once 仍然认为函数已经执行过。后续 Do 不会再次运行这个函数。

性能影响或设计取舍#

Mutex 适合保护小块共享状态。临界区越短、锁竞争越低,成本越可控。

RWMutex 适合读多写少。读写比例不明显时,它未必比 Mutex 更快,因为锁状态更复杂,写锁还要等待已有读锁释放。

WaitGroup 很适合“等一批任务结束”,但它不表达任务结果。需要结果就另建结果通道或受锁保护的数据结构;需要取消就配合 context.Context

Once 适合不可变资源初始化,例如只加载一次配置、只初始化一次客户端。它不适合需要失败重试、动态刷新或按参数多实例初始化的场景。

channel 和 sync 的选择可以简单记成:

  • 有数据流转、生产消费、生命周期通知:优先想 channel。
  • 只是保护共享状态:优先想 Mutex
  • 只是等待任务结束:优先想 WaitGroup
  • 只初始化一次:优先想 Once

面试高频问题#

  1. WaitGroup 为什么要先 Add 再启动 goroutine? 因为 Wait 可能在 Add 执行前观察到计数为 0 并提前返回。

  2. Mutex 只是互斥吗? 不只是。Unlock 先于后续成功的 Lock,因此也建立了内存可见性关系。

  3. RWMutex 一定比 Mutex 快吗? 不一定。它只在读多写少、临界区合适的场景下可能更有收益。

  4. Once 中的函数 panic 后会怎样? 这次 Do 会 panic,并且 Once 仍视为已经执行过,后续不会自动重试。

  5. 为什么 sync 类型首次使用后不能复制? 因为内部状态会被复制,多个副本之间不再代表同一个同步状态,行为不可预期。

一句话总结#

sync 包解决的是“共享状态如何安全落地”和“任务完成如何等待”,它不是 channel 的替代品,而是并发编程里另一组更直接的同步工具。

文章目录

文章目录