Go 同步进阶:锁、原子状态与 sync.Map
本轮只做理论正文与源码静态复核;下列命令、示例和测试均未执行。已有输出按代码推演或历史记录理解,不作为本轮运行证据。
前置:并发基础与测试和竞态检测。先写出受保护的状态与必须一起成立的不变量,再选择锁、原子操作或专用容器。单个方法没有数据竞争,不代表多个操作组合后具有原子性。
Mutex 与 RWMutex
锁保护一组访问,而不只是某个写语句。对同一可变状态的读写都应遵循相同同步约定。零值 Mutex 可用,首次使用后不得复制;同一 goroutine 重复 Lock 不会自动成为可重入锁。不要通过值接收者复制带锁对象。
需要锁多个对象时必须统一获取顺序,并处理同一对象重复获取的情况;只给两个账户依次加锁,不能保证反向转账不会死锁。阻塞网络操作通常不宜放在长临界区中。RWMutex 允许并发读,但读锁区域不能写,也不能依赖锁升级。是否优于 Mutex 需要测量。
原子状态
sync/atomic 为单个支持类型的操作提供原子性。所有并发访问需遵循同一约定,不能一边原子写一边普通读。多个字段各自原子操作,也不自动维持跨字段约束。Go 1.19 起的类型化原子值能减少指针与对齐误用,值首次使用后同样不能复制。
package main
import (
"fmt"
"sync"
"sync/atomic"
)
func main() {
var count atomic.Int64
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Add(1)
go func() {
defer wg.Done()
count.Add(1)
}()
}
wg.Wait()
fmt.Println(count.Load())
}预期输出 100。原子操作不等于所有算法都无竞态、无死锁或更快;例如先 Load 再 Store 的读改写组合就不是自动原子的,应使用 Add/CAS 或锁表达整个更新。
sync.Map
多数有固定键值类型的应用可以先用普通 map 加锁。sync.Map 更适合某些写入一次后多读、或不同 goroutine 操作不重叠键集的模式,不能仅凭“读多写少”四字决定性能。
Load、Store、LoadOrStore 等保证各自的并发契约;Range 不提供某一时刻的一致快照,多个键上的业务事务也不会自动原子化。存进去的指针对象若被继续修改,还需保护对象自身的状态;映射安全不等于其指向对象安全。键和值的类型约束由调用者维持。
条件等待
需要等待共享状态发生变化时,看Cond 与可关闭队列。等待和修改条件必须使用同一把锁,唤醒后重新检查条件。简单完成通知可用 channel 或 WaitGroup,无须增加条件变量。
运行时源码怎么读
Mutex 的快速路径、慢速路径、正常与饥饿模式属于运行时实现。旧版源码文章中的 state 位、信号量和阈值不是公开 API,不能将简化伪代码称为完整 Go 1.19 实现,也不能据此推断新版本公平性承诺。阅读时固定源码 tag,从 Lock/Unlock 进入等待与唤醒路径,并配合 mutex/block profile 验证应用行为。
同步语义以 sync 文档、atomic 文档 和 Go 内存模型 为依据,性能观察见pprof。
练习与验收
为“检查库存后扣减”写一个必须整体加锁的操作,说明分开原子读取和扣减为何不足。对原子计数器确认最终值,并在支持的平台运行 race;比较 map+Mutex 与 sync.Map 时使用同一真实读写比例的基准,避免凭工具名称判断快慢。
库存练习的参考边界
题目:库存1,两个任务各买1。提示:比较“读取、检查、扣减”整个临界区与两个独立原子调用。
参考答案
锁应覆盖检查stock>=1与stock--,只允许一次成功,最终stock=0。两个Load都可能读1,之后各自Add(-1)会到-1;单次原子操作不维持组合约束。CAS循环也必须重读/重算并解释失败分支,不能仅把普通int换成atomic就称正确。
继续阅读
- 开发实践
- 知识专题
- 知识专题