6.8 KiB
tags, create time
| tags | create time | ||||||
|---|---|---|---|---|---|---|---|
|
2026-06-07 14:50 |
sync 包
概述
当 channel 的"通信共享内存"哲学不完全适用时,sync 包提供了底层同步原语来保护共享资源的并发访问。本文覆盖 WaitGroup、Once、Mutex/RWMutex、Map、Atomic 和 Pool 六大组件的使用场景与最佳实践。
正文
sync.WaitGroup — 等待一组 goroutine 完成
[!question] 💭 思考 你启动了 10 个 goroutine 并行处理任务,怎么知道它们全部执行完毕?用
time.Sleep?这显然是不靠谱的。
var wg sync.WaitGroup
wg.Add(3) // 等待 3 个 goroutine
go func() {
defer wg.Done() // 完成后计数器 -1
doTask1()
}()
go func() {
defer wg.Done()
doTask2()
}()
go func() {
defer wg.Done()
doTask3()
}()
wg.Wait() // 阻塞直到计数器归零
fmt.Println("all done")
[!warning] ⚠️ 三个常见陷阱
- Add 必须在 goroutine 启动前调用——否则可能漏计数
- Done 要用 defer——确保即使 panic 也能正确递减
- 计数器不能为负数——否则会 panic
// ❌ 危险:Add 在 goroutine 内,main 可能先执行 Wait
go func() {
wg.Add(1) // 太晚了!
defer wg.Done()
task()
}()
wg.Wait()
// ✅ 正确:Add 在启动前
wg.Add(1)
go func() {
defer wg.Done()
task()
}()
wg.Wait()
sync.Once — 只执行一次
[!question] 💭 思考 配置文件加载、数据库连接初始化这类操作,如何保证在多线程环境下只执行一次且线程安全?
var instance *Config
var once sync.Once
func GetConfig() *Config {
once.Do(func() {
instance = loadConfig() // 仅执行一次
})
return instance
}
| 对比项 | init() |
sync.Once |
|---|---|---|
| 执行时机 | package 首次加载时 | 第一次调用 Do() 时 |
| 延迟性 | 不支持 | 支持懒加载 |
| 可控性 | 不可控 | 完全可控 |
[!tip] 💡 使用场景
- 单例模式初始化
- 配置加载(懒加载避免启动开销)
- 资源注册(如驱动注册)
sync.Mutex / RWMutex — 互斥锁
基础用法
[!question] 💭 思考 多个 goroutine 同时对
counter++操作,为什么结果总是不对?
counter++ 不是原子操作,它等价于:读取 → 加 1 → 写回。两个 goroutine 可能同时读到相同的旧值,导致其中一次修改被覆盖。
var (
counter int
mu sync.Mutex
)
func increment() {
mu.Lock()
counter++ // 临界区
mu.Unlock()
}
[!tip] 💡 必记习惯:defer + Unlock
mu.Lock() defer mu.Unlock() // 临界区代码这样可以确保任何退出路径(包括 return / panic)都能释放锁。
读写锁 RWMutex
当读多写少时,RWMutex 可以显著提升并发度:
var (
data map[string]string
rwmu sync.RWMutex
)
func Read(key string) (string, bool) {
rwmu.RLock()
defer rwmu.RUnlock() // 允许多个读并发
return data[key], true
}
func Write(key, val string) {
rwmu.Lock()
defer rwmu.Unlock() // 写时独占
data[key] = val
}
| 锁类型 | 读+读 | 读+写 | 写+写 | 说明 |
|---|---|---|---|---|
| Mutex | ❌ | ❌ | ❌ | 全部互斥 |
| RWMutex | ✅ | ❌ | ❌ | 读共享,写独占 |
[!warning] ⚠️ 死锁:两种经典模式
模式1:锁拷贝
func main() {
var mu sync.Mutex
mu.Lock()
copyMu := mu // ❌ 复制了锁的状态
// mu 仍持有锁,但 copyMu 是全新的未锁定锁
copyMu.Lock() // 这里永远不会返回(如果外层没 Unlock)
mu.Unlock()
}
模式2:循环等待
go func() {
mu1.Lock(); defer mu1.Unlock()
time.Sleep(100 * time.Millisecond)
mu2.Lock(); defer mu2.Unlock() // 等 mu2
}()
go func() {
mu2.Lock(); defer mu2.Unlock()
time.Sleep(100 * time.Millisecond)
mu1.Lock(); defer mu1.Unlock() // 等 mu1 —— 死锁!
}()
[!tip] 💡 避免死锁的规则
- 始终通过指针传递 mutex
- 统一锁的获取顺序(全局约定 mu1 < mu2 < mu3)
- 尽量减少持锁时间,不要在临界区内做 IO 或网络调用
sync.Map — 并发安全的 Map
Go 原生 map 不是并发安全的,并发读写会 panic:
m := make(map[string]int)
go m["key"] = 1 // ❌ concurrent map writes: panic!
_ = m["key"] // ❌ concurrent map reads AND writes: panic!
解决方案有两种:
// 方案1:map + mutex(适合频繁读写)
mu.Lock(); m["key"] = 1; mu.Unlock()
// 方案2:sync.Map(适合以下场景)
var sm sync.Map
sm.Store("key", 1)
v, ok := sm.Load("key")
[!note] 📝 sync.Map 的适用场景
- 读远多于写:如缓存查找、路由表
- 不相交的 key 集合:每个 goroutine 只访问自己的 key
- 不适合的场景:频繁写入、需要遍历计数、单一 key 高频竞争
非并发场景下,普通 map + mutex 通常性能更好。
sync/atomic — 原子操作
[!question] 💭 思考 如果只是对一个整数做累加,用 Mutex 是不是太重了?
原子操作由 CPU 指令直接支持,无需操作系统介入,性能远高于 mutex:
var counter int64
// 原子累加
atomic.AddInt64(&counter, 1)
// 原子读取
val := atomic.LoadInt64(&counter)
// 原子比较并交换
atomic.CompareAndSwapInt64(&counter, oldVal, newVal)
对于复合类型的原子操作,使用 atomic.Value:
var config atomic.Value
// 初始化
config.Store(loadConfig())
// 读取(线程安全)
cfg := config.Load().(ConfigStruct)
[!tip] 💡 Mutex vs Atomic 选择指南
- 单个变量的简单操作(累加、交换)→ atomic
- 一段逻辑的互斥执行 → Mutex
- 复杂结构体的原子更新 → atomic.Value
sync.Pool — 对象复用池
var pool = sync.Pool{
New: func() interface{} {
return &Buffer{data: make([]byte, 0, 1024)}
},
}
func useBuffer() {
buf := pool.Get().(*Buffer)
defer pool.Put(buf) // 用完放回
// 使用 buf...
// 如果有修改,放回前需 Reset
buf.Reset()
}
[!warning] ⚠️ sync.Pool 的重要限制
- Pool 中的对象可能被 GC 随时回收,不能依赖池中对象一定存在
- 不适合存储有状态的对象(如 DB 连接、Socket)
- Put 之前务必 Reset 对象状态,否则下次 Get 拿到的是脏数据
[!tip] 💡 最佳实践 在高并发场景中,Pool 能显著降低 GC 压力——尤其是频繁创建销毁的大对象(如 byte slice、JSON encoder)。