数据竞争与同步
数据竞争与同步 · 阅读后完成下方实验与检查题,再用「标记完成」记录进度。
学习目标
这课先制造一次真正的数据竞争,再用竞争检测器抓住它,最后用互斥锁和原子操作修好。读完你能把「跑得慢」和「跑得错」区分开,并把 -race 变成日常命令。
问题场景
给书签服务加个访问统计:每个接口被访问就计数。测试全绿、平时跑得好好的,压测一开数字开始飘,偶尔还崩。这就是数据竞争的典型画像:两个 goroutine 同时读写同一个变量,结果取决于调度时序——它不报错、不总是复现,是最阴的一类 bug。
核心解释
先造一个坏的:
var visits int64 // 裸全局可变状态
func main() {
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := 0; j < 1000; j++ {
visits++ // 读-改-写三步,交错执行就丢更新
}
}()
}
wg.Wait()
fmt.Println("final:", visits) // 期望 100000,常常更少
}
visits++ 不是一步:读出值、加一、写回。100 个 goroutine 交错执行,两次「读到同一个旧值」就丢一次更新。用竞争检测器跑:
go run -race .
# ==================
# WARNING: DATA RACE
# Read at 0x00c000014098 by goroutine 8:
# main.main.func1()
# main.go:22 +0x3a
# ...
-race 让运行时记录每次内存访问的读写集合,检测到「并发访问且至少一个是写」就报警,连同两个访问点的堆栈一起给出来。它有运行时开销(内存 5-10 倍、速度慢 2-20 倍),适合测试和排查,不适合生产常开——但 CI 里的 go test -race 应该是标配。
修复第一式:互斥锁保护临界区,而且锁跟着数据走:
type counter struct {
mu sync.Mutex
n int64
}
func (c *counter) add() {
c.mu.Lock()
defer c.mu.Unlock()
c.n++
}
func (c *counter) value() int64 {
c.mu.Lock()
defer c.mu.Unlock()
return c.n
}
纪律是「谁的数据谁配锁」:mu 是 counter 的私有字段,调用方根本看不见它——比一把全局锁包住大段代码安全得多。
atomic 只适合无复合操作的单变量(计数、标志位):声明 var visits atomic.Int64,用 visits.Add(1) 自增、visits.Load() 读取;map 或多字段结构体这类「两步更新要原子」的复合状态必须 mutex,atomic 救不了。比选锁更重要的是消灭裸全局变量:把 counter 做成 store 的字段或显式注入,测试并行跑也不打架。
示例拆解
go run -race . # 修复前:WARNING: DATA RACE,final 常小于 100000
go vet ./... # 部分问题 vet 也能发现(如带锁结构体被拷贝)
go run -race . # 修复后:安静,final 恒等于 100000
go test -race ./... # 这条命令进 CI
验证修复要跑够次数:竞争是概率性的,改完只跑一次就宣布修好不可信。把循环量加大、用 go test -count=10 重复跑,持续安静才算证据。第二个观察点:竞争修复后数字不止不崩,还精确等于 100000——丢更新是静默的,普通测试根本测不出来,这正是必须用检测器的原因。
动手任务
- 用 map 写个带竞争的路径计数(两个 goroutine 同时写同一 key),-race 抓出来,再用 sync.Mutex 修复。
- 把 visits 换成 atomic.Int64,确认 -race 安静,并解释为什么 map 计数不能照搬 atomic。
- 把 counter 改成 NewCounter() 构造注入,写一个 10 goroutine 并发 add 的测试,用 go test -race 验证。
常见错误
- 「测试过了就是没竞争」:不加 -race 的测试对竞争几乎零检出,必须显式跑 race 版本。
- 锁粒度过大:一把全局锁包住整个 handler,并发优势归零,锁应贴着被保护数据。
- 值拷贝带锁的结构体:sync.Mutex 不可复制(vet 会报 CopyLock),传指针、方法用指针接收者。
- 修了计数忘了 map:同一段代码里每个共享变量都要过一遍「谁保护它」的审问。
检查与复盘
完成下方检查题。自查清单:
- go test -race 在本地和 CI 都是标配,不是出事才跑
- 共享可变状态有唯一属主(类型内建锁或显式注入),没有裸全局变量
- atomic 与 mutex 的选择有依据:单变量无复合操作用 atomic,复合状态用 mutex
参考资料
- Go 博客:Race Detector —— 检测器原理与使用边界
- 标准库 sync.Mutex —— 互斥锁语义
- 标准库 sync/atomic —— atomic.Int64 等类型化原子操作
- Go 内存模型 —— happens-before 的正式定义
读完这一课了?
标记完成后,工作台会安排接下来的学习。