两个 goroutine 同时执行 count++,底层概念上都要经历读取、加一、写回。若二者都读取旧值 0,再各自写回 1,执行了两次却只得到 1,这叫丢失更新。数据竞争是多个 goroutine 并发访问同一内存,至少一个写且缺少同步。
var mu sync.Mutex
var count int
func add() {
mu.Lock()
defer mu.Unlock()
count++
}
goroutine 是由 Go 运行时调度的轻量并发任务。Mutex(Mutual Exclusion,互斥锁)一次只允许一个 goroutine 进入临界区;RWMutex(Read-Write Mutex,读写锁)允许多个读者同时进入,但写者必须独占。临界区是必须整体保护的共享状态代码;饥饿模式则是当前运行时让等待过久者更直接接管锁的公平性策略。
两个 goroutine 同时执行 count++,底层概念上都要经历读取、加一、写回。若二者都读取旧值 0,再各自写回 1,执行了两次却只得到 1,这叫丢失更新。数据竞争是多个 goroutine 并发访问同一内存,至少一个写且缺少同步。
var mu sync.Mutex
var count int
func add() {
mu.Lock()
defer mu.Unlock()
count++
}
| 规则 | 原因 |
|---|---|
| 零值就是未加锁状态 | 无需构造函数,可直接作为结构体字段 |
| 第一次使用后不能复制 | 复制会把内部状态分叉成两把表面相似、实际独立的锁 |
| 解锁未锁定的 Mutex 是运行时错误 | Unlock 不是“确保已解锁”的幂等操作 |
| 锁不绑定 goroutine 身份 | 可以一个 goroutine Lock、另一个 Unlock,但通常会让所有权难以推理 |
| TryLock 只在少数算法有价值 | 失败后通常仍需处理,循环尝试容易忙等 |
type Cache struct {
mu sync.Mutex
data map[string]string
}
func (c *Cache) Put(k, v string) {
c.mu.Lock()
defer c.mu.Unlock()
c.data[k] = v
}
go vet 检查常见 copylocks 问题。快速路径会用原子操作尝试把未锁定状态改为已锁定。失败后进入慢路径,runtime 可能短暂自旋、排队和休眠。自旋表示暂不休眠、在处理器上短暂重试,适合锁很快释放的情况;等待太久时,当前实现会使用更公平的饥饿保护策略。
下面用 G0、G1、G2、G3 给四个 goroutine 编号;编号只用于沿图追踪,不是运行时提供的身份。G0 已持锁,G1、G2、G3 按顺序到达。
读锁 RLock 允许多个读者共享;写锁 Lock 等待现有读者退出后独占。为避免写者永远被连续新读者饿死,一旦写者正在等待,新来的读者会被阻塞。
happens-before(先行发生)是内存模型里的顺序关系:若写入先行发生于读取,读取就必须观察到相应结果。对 Mutex,某次 Unlock 会先行发生于之后成功的 Lock;锁内写入因此能被后来持锁者看见。
// goroutine A mu.Lock() config.Ready = true mu.Unlock() // goroutine B mu.Lock() fmt.Println(config.Ready) // 同一把锁建立可见顺序 mu.Unlock()
这不是说 CPU 永远按源码顺序执行,而是同步原语给编译器和处理器建立了程序可依赖的观察保证。只“看起来不会同时执行”而没有同步,仍可能是数据竞争。
atomic 是标准库提供的原子操作:单次读取、写入或比较交换不会被其他 goroutine 看见中间状态,适合非常小的共享状态。
| 需求 | 优先考虑 | 原因 |
|---|---|---|
| 保护多个字段的不变量 | Mutex | 简单、临界区清楚 |
| 读临界区长、读并发高、写很少 | 基准验证 RWMutex | 允许多个读者并行 |
| 单个计数或状态位 | atomic | 避免锁,但组合不变量困难 |
| 所有权转移、任务编排 | Channel | 表达数据和生命周期流动 |
| 复制后只读快照 | atomic.Pointer / atomic.Value | 读路径不修改共享对象 |
func (c *Cache) Snapshot() map[string]string {
c.mu.RLock()
defer c.mu.RUnlock()
out := make(map[string]string, len(c.data))
for k, v := range c.data { out[k] = v }
return out // 返回隔离副本,不泄漏内部 map
}
测试时运行 go test -race ./...。Race Detector(数据竞争检测器)通过运行时插入检查逻辑,发现实际执行中缺少同步的冲突访问;它没报告不等于证明所有调度都安全,但报告的问题必须修。
内存缓存、连接状态、限流器和指标聚合适合用锁保护进程内共享状态。若数据能通过消息传递明确转移所有权,或能构造不可变快照,可减少共享锁;不要用锁掩盖跨进程一致性问题。