Go 互斥锁与短临界区:共享指针怎么读才安全
前言
在并发里保护共享状态,最常见的工具是 sync.Mutex。真正写起来容易踩坑的不是「要不要加锁」,而是:
- 锁护住的是字段访问,还是整段业务
- 网络 IO / 慢操作能不能放进临界区
- 读共享指针时,为什么常写成「短锁拷贝再解锁」
本文以「多个 goroutine 共用一个连接上的 channel 指针」这类场景为主(例如消息队列的 Publisher 持有 *amqp.Channel),整理互斥锁用法、短临界区技巧,以及相关配套手法。
问题从哪来
假设结构体里有一个会被多 goroutine 碰的字段:
type publisher struct {
mu sync.Mutex
ch *amqp.Channel // 或任意共享指针 / 连接句柄
}
并发来源可能是:
- 多个 HTTP 请求同时调用
Enqueue/Publish - 后台任务与请求路径同时读
ch - 关停路径
Close与发送路径交错
若无同步地写:
return p.ch.Publish(...) // 危险:data race
可能:
- 读到别的 goroutine 正在写入的
p.ch(Go 内存模型不允许) Close把p.ch置nil的同时另一边解引用 → panic- race detector 直接报错
结论:对共享可变字段的读写,必须串行化。
互斥锁在保护什么
p.mu.Lock()
// 这里才能安全地读/写 p.ch
p.mu.Unlock()
锁保护的是 对共享内存的访问约定,不是「这个对象永远线程安全」。
| 保护的 | 不自动保护的 |
|---|---|
p.ch 字段的赋值与读取 | 指向对象内部是否允许并发(如某 SDK 的 Channel API) |
与 p.ch 相关的「创建 / 置 nil / 替换」 | 已经拷贝出去的局部变量所指向对象的生命周期细节 |
持锁期间应只做 快、局部、与共享状态直接相关 的事。
短临界区:短锁读取副本
常见正确写法:
// 1. 先保证有可用资源(内部自己持锁创建)
if err := p.ensureChannel(); err != nil {
return err
}
// 2. 短锁 :只拷贝指针
p.mu.Lock()
ch := p.ch
p.mu.Unlock()
// 3. 锁外:用局部副本做慢操作(网络 IO)
return ch.PublishWithContext(ctx, exchange, key, false, false, msg)
这三步分别在干什么
| 步骤 | 作用 |
|---|---|
ensureChannel(在锁内) | 若 ch == nil 或已关闭,创建并写回 p.ch |
ch := p.ch(在锁内) | 在互斥下读出当前指针,赋给局部变量 |
ch.Publish...(锁外) | 不占用互斥锁做可能阻塞的 IO |
为什么要「拷贝」而不是持锁到 Publish 结束
也可以:
p.mu.Lock()
defer p.mu.Unlock()
return p.ch.PublishWithContext(...) // 全程持锁
问题:
Publish/ HTTP / DB 可能阻塞很久- 其它发送方、
Close、重建 channel 都会排队 - 吞吐变成「全局单线程发消息」
短锁目标:
互斥的是「读/写
p.ch这个字段」,不是「整个发送过程」。
局部变量语义
ch := p.ch
ch是指针的值拷贝,指向同一底层对象- 之后
p.ch = nil不会让局部ch变成nil - 但若另一 goroutine
Close了该 channel 对象,用ch再 Publish 可能失败——这是可接受的竞态窗口;失败返回 error,由上层重试或报错
A: Lock → ch=p.ch → Unlock → Publish(ch) 耗时中...
B: Lock → Close(p.ch); p.ch=nil → Unlock
A: Publish 可能 error(对象已关)
若要「Close 时绝不让进行中的 Publish 踩到已关 channel」,需要引用计数、epoch、或关闭前 drain,复杂度明显上升;多数基础设施封装接受「Close 与 in-flight IO 竞态 → 返回错误」。
配套技巧整理
1. 创建路径也要持锁(ensure / lazy)
func (p *publisher) ensureChannel() error {
p.mu.Lock()
defer p.mu.Unlock()
if p.ch != nil && !p.ch.IsClosed() {
return nil
}
ch, err := p.conn.Channel()
if err != nil {
return err
}
// 可选:声明拓扑、配置 QoS 等
p.ch = ch
return nil
}
多个 goroutine 同时发现 ch == nil 时,只有一个会创建,避免泄漏多个 channel。