sync — 同步原语
sync 提供了 Go 并发编程的基础构件。理解它们的底层语义和边界条件是写出正确并发代码的前提。
1. Mutex — 互斥锁
1.1 基础用法
type Counter struct {
mu sync.Mutex
value int
}
func (c *Counter) Inc() {
c.mu.Lock()
defer c.mu.Unlock() // defer 保证即使 panic 也解锁
c.value++
}🔬 深入原理:
sync.Mutex在 Go 1.18+ 添加了TryLock()方法。正常模式(非饥饿模式)下,等待的 goroutine 以 FIFO 队列等待,但新到达的 goroutine 有优势(它们可能自旋并直接获取锁)。如果一个等待者超过 1ms 仍未获取,锁进入饥饿模式,使用 FIFO 直接交接。
1.2 🚨 不可复制
type SafeStruct struct {
mu sync.Mutex // 陷阱:包含 Mutex 的结构体不能被值复制
data map[string]string
}
// 错误做法
func (s SafeStruct) BadMethod() { // 值接收者 → 复制了 Mutex
s.mu.Lock()
defer s.mu.Unlock()
}
// 正确做法
func (s *SafeStruct) GoodMethod() { // 指针接收者
s.mu.Lock()
defer s.mu.Unlock()
}🚨 顶级陷阱:一旦 Mutex 被复制,副本和原始值各自独立,起不到保护作用。用
go vet可以检测这类问题。
1.3 锁的粒度
// ❌ 锁粒度过大 — 把不相关的操作放在锁里
func (c *Counter) BadUpdate() {
c.mu.Lock()
defer c.mu.Unlock()
c.value++ // 需要保护
log.Printf(...) // 不需要保护,但也被锁了
metrics.Report() // 可能造成死锁(如果 metrics 也获取这个锁)
}
// ✅ 正确的粒度
func (c *Counter) GoodUpdate() {
c.mu.Lock()
c.value++
c.mu.Unlock()
// 锁外操作
log.Printf(...)
metrics.Report()
}2. RWMutex — 读写锁
💡 核心原则:读共享,写独占。多读不互斥,有写时禁止读。
2.1 基础用法
type SafeMap struct {
mu sync.RWMutex
data map[string]string
}
func (m *SafeMap) Get(key string) (string, bool) {
m.mu.RLock() // 读锁(可以多个并发)
defer m.mu.RUnlock()
val, ok := m.data[key]
return val, ok
}
func (m *SafeMap) Set(key, value string) {
m.mu.Lock() // 写锁(独占)
defer m.mu.Unlock()
m.data[key] = value
}2.2 🔬 深入原理:读锁不能升级为写锁
// 以下代码造成死锁:
func (m *SafeMap) DeadlockedUpdate(key string, fn func(string) string) {
m.mu.RLock()
old := m.data[key]
// 注意:这里试图在持有读锁时获取写锁
m.mu.Lock() // ← 死锁!RWMutex 禁止读锁升级
m.data[key] = fn(old)
m.mu.Unlock()
m.mu.RUnlock()
}
// 正确做法:先释放读锁再获取写锁
func (m *SafeMap) CorrectUpdate(key string, fn func(string) string) {
m.mu.RLock()
old, exists := m.data[key]
m.mu.RUnlock()
if !exists {
return
}
m.mu.Lock()
m.data[key] = fn(old)
m.mu.Unlock()
}🔬 **为什么 RWMutex 不允许升级?**因为如果允许多个等待升级的 goroutine 同时存在,就没有一个明确的调度方案。RWMutex 选择了简单的语义:Read Lock → Write Lock = 死锁。
2.3 何时用 RWMutex 而不是 Mutex?
- 读多写少 → RWMutex(典型比例 > 10:1 才有明显收益)。
- 写多读少 → Mutex(RWMutex 的内部开销更大,反而更慢)。
- 不确定 → 用 Mutex,简单且不容易出错。
3. WaitGroup — 等待组
3.1 基础用法
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
wg.Add(1) // 必须在 goroutine 外调用
go func(id int) {
defer wg.Done() // = Add(-1)
doWork(id)
}(i)
}
wg.Wait() // 阻塞直到计数器归零🚨 陷阱:
wg.Add(1)必须在 goroutine 启动之前调用。如果放在 goroutine 里面,可能在wg.Wait()已经返回之后才执行,导致 panic(在计数归零之后不能再 Add 负数)。
3.2 WaitGroup 不能复制
func bad(wg sync.WaitGroup) { // 值传递 — 复制了计数器!
wg.Done()
}
func good(wg *sync.WaitGroup) {
wg.Done()
}4. Once — 一次性执行
4.1 经典用法:单例模式
var (
instance *Singleton
once sync.Once
)
func GetInstance() *Singleton {
once.Do(func() {
instance = &Singleton{data: "init"}
})
return instance
}4.2 🔬 深入原理:Once 的原子性保证
sync.Once 保证:
Do中的函数恰好执行一次。- 在函数返回之前,所有
Do调用者都会阻塞。 - 如果函数 panic,
Once认为它已经执行过了,不会重试。
// Once 的实现原理(简化版)
type Once struct {
done uint32 // 原子标志
m Mutex
}
func (o *Once) Do(f func()) {
if atomic.LoadUint32(&o.done) == 0 {
o.doSlow(f)
}
}
func (o *Once) doSlow(f func()) {
o.m.Lock()
defer o.m.Unlock()
if o.done == 0 { // double-check
defer atomic.StoreUint32(&o.done, 1)
f()
}
}🚨 陷阱:如果
f()内部调用了同一个Once.Do(f2),形成递归调用 — 由于Mutex不可重入,这会造成死锁。
5. Cond — 条件变量
Cond 是 sync 中最容易用错的类型,但也是最强大的。它实现了"等待某个条件成立"的模式。
5.1 为什么需要 Cond?
// ❌ 忙等 — 浪费 CPU
for !condition() {
time.Sleep(10 * time.Millisecond)
}
// ❌ channel — 但需要反复通知的场景难以表达
// 如果有多个 goroutine 都在等待"队列非空"
// ✅ Cond — 高效等待 + 广播唤醒5.2 🔬 深入原理:Cond 的正确使用范式
type Queue struct {
items []int
mu sync.Mutex
cond *sync.Cond
}
func NewQueue() *Queue {
q := &Queue{}
q.cond = sync.NewCond(&q.mu) // ← Cond 绑定一个 Locker
return q
}
func (q *Queue) Put(item int) {
q.mu.Lock()
q.items = append(q.items, item)
q.mu.Unlock()
q.cond.Signal() // 唤醒一个等待者(可以在锁外调用)
}
func (q *Queue) Get() int {
q.mu.Lock()
defer q.mu.Unlock()
// 关键模式:用 for 而不是 if!
for len(q.items) == 0 {
q.cond.Wait() // 原子地:释放锁 + 阻塞 + 被唤醒后重新获取锁
}
item := q.items[0]
q.items = q.items[1:]
return item
}🚨 三个关键点:
- Wait 必须在锁内调用 — 因为条件检查需要锁保护。
- 用 for 而不是 if — 被唤醒不等于条件成立(虚假唤醒)。
- Signal 可以在锁外调用 — 减少锁竞争。
5.3 Signal vs Broadcast
// Signal — 唤醒一个等待的 goroutine
q.cond.Signal()
// Broadcast — 唤醒所有等待的 goroutine
q.cond.Broadcast()💡 最佳实践:
- 状态变化只影响一个等待者 →
Signal。- 状态变化使所有等待者都有机会 →
Broadcast(如连接池关闭、全局状态切换)。- 不确定时 → 用
Broadcast(更安全,不会有 goroutine 被永久忽略)。
6. Pool — 对象池
复用临时对象,减少 GC 压力。但 Pool 的行为不是你能完全控制的。
6.1 基础用法
var bufferPool = sync.Pool{
New: func() interface{} {
return &bytes.Buffer{}
},
}
// 获取
buf := bufferPool.Get().(*bytes.Buffer)
// 使用
buf.WriteString("hello")
result := buf.String()
// 归还前重置
buf.Reset()
bufferPool.Put(buf)6.2 🔬 深入原理:Pool 的关键特性
- Pool 中的对象可能随时被 GC 清除 — 你不能假设 Put 进去的对象一定还在。
- 不要对 Pool.Get 返回值的状态做任何假设 — 始终在使用前重置。
- Pool 是 goroutine 安全的,但不是为长期持有优化的。
- Pool 的大小会随着 GC 动态缩减 — 每次 GC 会清空 Pool。
// ❌ 错误的用法 — 假设对象被保留
var pool sync.Pool
func init() {
pool.Put(expensiveSetup()) // 没用!可能随时被 GC 清掉
}
// ✅ 正确的用法 — 用 New 函数懒加载
var pool = sync.Pool{
New: func() interface{} {
return expensiveSetup()
},
}💡 Pool 适用场景:
- 频繁创建和销毁的临时对象(buffer、encoder/decoder)。
- 对象创建成本高,但可以无状态地复用。
不适用场景:
- 需要维护固定数量的长连接(用 channel pool)。
- 对象有状态或需要初始化(Pool.Get 返回的状态是未知的)。
7. TryLock (Go 1.18+)
var mu sync.Mutex
if mu.TryLock() {
defer mu.Unlock()
// 获取到锁
} else {
// 没获取到,做其他事
}💡 最佳实践:
TryLock主要用于死锁避免(如需要同时获取多个锁时),而不是作为通用的"非阻塞版本"。大多数情况下应该用普通的Lock。
8. 🚨 sync 包常见陷阱总汇
| 陷阱 | 后果 | 检测方法 |
|---|---|---|
| 复制 Mutex/WaitGroup | 锁失效/panic | go vet |
| 读锁内获取写锁 | 死锁 | -race |
wg.Add 放在 goroutine 内 |
计数错误/panic | -race |
| Once.Do 内调用自己 | 死锁 | 运行时阻塞 |
| Pool 依赖 Put 的对象一直存在 | 逻辑错误 | 仅在 GC 后出现 |
defer mu.Unlock() 前检查 TryLock 返回值 |
Unlock 未持有的锁 | review |