文章目录
- 引言:Mutex与数据竞争
- 一、本篇学习目标与准备
- 二、核心概念与实现思路
- 1. 这个程序在模拟什么
- 2. 什么是数据竞争
- 3. `sync.Mutex` 是什么
- 4. 为什么 `Unlock` 要配合 `defer`
- 5. 为什么读也要加锁
- 6. 为什么不要把内部 map 直接暴露出去
- 7. `Reserve` 的执行过程
- 8. 锁保护的代码越短越好
- 9. 什么是 `go test -race`
- 三、课程实战:运行、源码与测试
- 1. 怎么运行
- 核心源码拆解:`Reserve`
- 源码中的测试:`TestProcessOrdersNeverOversells`
- 四、实战练习
- 1. 小练习
- 练习 1:修改库存数量
- 练习 2:修改订单数量
- 练习 3:跑数据竞争检测
- 练习 4:观察忘记解锁的后果
- 扩展练习 1:验证快照不会修改库存
- 扩展练习 2:增加非法购买数量
- 五、常见问题与排错
- 问题 1:已经给扣库存加锁,为什么还是会超卖?
- 问题 2:函数返回以后,其他请求一直等待?
- 六、与前文的联系和工程应用
- 1. 和第二课的关系
- 七、本篇小结
- 下期预告
✍创作者:全栈弄潮儿
🏡 个人主页:全栈弄潮儿的个人主页
🏙️ 个人社区,欢迎你的加入:全栈开发社区
📙 专栏:Go零基础实战
引言:Mutex与数据竞争
只剩一张票,两个请求却都判断“库存足够”,最终卖出两张,这就是典型的超卖。本篇模拟多个订单并发抢票,学习如何用 Mutex 把“检查库存”和“扣减库存”放在同一个临界区,并通过库存快照保护内部数据。
一、本篇学习目标与准备
- 识别共享可变数据和 data race。
- 用 Mutex 保护完整的检查与修改流程。
- 理解 map 复制、封装和 race 检测。
课程目录:backend-training/Go/03-mutex-race。配套示例按课独立组织,每个目录都有自己的go.mod,使用 Go 1.22 或更高版本。运行和测试前进入该课目录。
正文代码包含源码节选与机制示意,节选依赖同课的类型和辅助函数;完整程序与测试放在配套源码中。
阅读提示:-race只能发现本次执行触达路径中的数据竞争,通过检测不代表所有并发路径都正确。参见 Go 数据竞争检测文档。
二、核心概念与实现思路
1. 这个程序在模拟什么
这个程序模拟“10 个用户同时抢 5 张演唱会门票”。
真实后端里,这类场景很多:
- 抢票。
- 秒杀。
- 扣库存。
- 抢优惠券。
- 更新账户余额。
- 更新同一个用户的积分。
核心问题是:
多个请求同时修改同一份数据,如果不做保护,就可能改错。
比如只剩 1 张票时,如果两个请求同时看到库存是 1,它们都觉得可以买,最后就可能卖出 2 张票。
这就叫“超卖”。
2. 什么是数据竞争
数据竞争,英文叫 data race。
简单理解:
多个 goroutine 同时访问同一个变量,其中至少一个 goroutine 在写,而且没有加锁保护。
例如:
count++这行看起来很简单,但它不是一步完成的。它大概包含:
- 读取旧值。
- 在旧值基础上加 1。
- 把新值写回去。
如果两个 goroutine 同时执行count++,就可能发生这种情况:
- goroutine A 读到
count = 0。 - goroutine B 也读到
count = 0。 - A 写回
1。 - B 也写回
1。
明明加了两次,最后结果却是1。
这就是并发里很经典的问题。
3.sync.Mutex是什么
代码里有:
mu sync.MutexMutex是互斥锁。
可以这样理解:
同一时间,只允许一个 goroutine 进入被锁保护的代码区域。
使用方式是:
i.mu.Lock()deferi.mu.Unlock()意思是:
Lock():我要开始操作共享数据了,先上锁。defer Unlock():函数返回前一定解锁。
如果锁已经被别人拿走,当前 goroutine 会在Lock()这里等待。
等别人Unlock()之后,它才能继续。
4. 为什么Unlock要配合defer
代码里是:
i.mu.Lock()deferi.mu.Unlock()这样写的好处是:函数不管从哪里返回,都会解锁。
比如:
current:=i.stock[item]ifcurrent<quantity{returncurrent,false}如果库存不够,函数会提前返回。
如果没有defer i.mu.Unlock(),这个提前返回可能导致锁永远不释放。
一旦锁不释放,其他 goroutine 就会一直卡住。
5. 为什么读也要加锁
你可能会想:写库存要加锁可以理解,只读取库存为什么也要加锁?
代码里:
func(i*Inventory)Stock(itemstring)int{i.mu.Lock()deferi.mu.Unlock()returni.stock[item]}原因是:如果一个 goroutine 正在写 map,另一个 goroutine 同时读 map,也是不安全的。
Go 的普通map不支持无同步的并发读写或并发写入。
所以只要这份 map 存在并发写入,就要统一保护相关读写;初始化完成后只有并发读取的 map 不需要为读取额外加锁。
6. 为什么不要把内部 map 直接暴露出去
代码里NewInventory会复制一份传入的 map:
stock:=make(map[string]int,len(initialStock))foritem,quantity:=rangeinitialStock{stock[item]=quantity}Snapshot也会复制一份 map 再返回:
snapshot:=make(map[string]int,len(i.stock))foritem,quantity:=rangei.stock{snapshot[item]=quantity}原因是:如果直接把内部 map 暴露给外部,外部代码就可以绕过锁直接修改库存。
这样Inventory里的锁就失去意义了。
这是一种很重要的封装思维:
数据在哪里被拥有,就应该在哪里被保护。
7.Reserve的执行过程
核心代码:
func(i*Inventory)Reserve(itemstring,quantityint)(remainingint,okbool){i.mu.Lock()deferi.mu.Unlock()current:=i.stock[item]ifcurrent<quantity{returncurrent,false}i.stock[item]=current-quantityreturni.stock[item],true}翻译成大白话:
- 先上锁。
- 查看当前库存。
- 如果库存不够,返回失败。
- 如果库存足够,扣减库存。
- 返回扣减后的库存。
- 函数返回前自动解锁。
因为整个检查和扣减都在锁里面,所以不会出现两个 goroutine 同时看到同一份旧库存的问题。
8. 锁保护的代码越短越好
虽然锁很好用,但不要把所有代码都塞进锁里。
锁保护的区域越大,其他 goroutine 等待的时间就越长。
通常只把真正访问共享数据的部分放进锁里。
比如本篇中,Reserve只保护库存检查和库存扣减。
不要在锁里做很慢的事情,例如:
- 调用第三方 API。
- 发送邮件。
- 查询很慢的数据库。
- 做大量计算。
time.Sleep很久。
否则并发性能会变差。
9. 什么是go test -race
Go 自带数据竞争检测工具。
运行:
gotest-race./...如果代码里存在明显的数据竞争,它会输出DATA RACE相关信息。
建议你养成习惯:写了并发代码以后,至少跑一次:
gotest-race./...它不是万能的,但非常有用。
三、课程实战:运行、源码与测试
1. 怎么运行
在当前目录执行:
go run.你会看到类似输出:
订单 01:成功,剩余库存:3 订单 02:成功,剩余库存:1 订单 03:成功,剩余库存:4 订单 04:成功,剩余库存:2 订单 05:失败,剩余库存:0 订单 06:成功,剩余库存:0 订单 07:失败,剩余库存:0 订单 08:失败,剩余库存:0 订单 09:失败,剩余库存:0 订单 10:失败,剩余库存:0 最终成功订单数:5 最终库存:0每次运行时,每个订单看到的“剩余库存”可能不同,这是正常的。因为订单是并发处理的。
再运行测试:
gotest./...也可以运行 Go 的数据竞争检测:
gotest-race./...-race会让 Go 帮你检查代码里有没有明显的数据竞争。
核心源码拆解:Reserve
以下节选自本篇的main.go,需要与同课的类型和辅助函数一起使用。
func(i*Inventory)Reserve(itemstring,quantityint)(remainingint,okbool){i.mu.Lock()deferi.mu.Unlock()current:=i.stock[item]ifcurrent<quantity{returncurrent,false}i.stock[item]=current-quantityreturni.stock[item],true}源码中的测试:TestProcessOrdersNeverOversells
测试节选来自同课的测试文件,辅助函数也在该文件中。阅读时关注输入条件与断言,不只看是否调用了某个函数。
funcTestProcessOrdersNeverOversells(t*testing.T){inventory:=NewInventory(map[string]int{"ticket":10})orders:=make([]Order,0,100)forid:=1;id<=100;id++{orders=append(orders,Order{ID:id,Item:"ticket",Quantity:1})}results:=ProcessOrders(inventory,orders)successCount:=0for_,result:=rangeresults{ifresult.Success{successCount++}}ifsuccessCount!=10{t.Fatalf("expected exactly 10 successful orders, got %d",successCount)}ifstock:=inventory.Stock("ticket");stock!=0{t.Fatalf("expected final stock 0, got %d",stock)}}四、实战练习
1. 小练习
练习 1:修改库存数量
把初始库存:
"演唱会门票":5,改成:
"演唱会门票":3,再运行:
go run.观察最终成功订单数是不是变成 3。
练习 2:修改订单数量
把某个订单改成一次买 2 张:
{ID:1,Item:"演唱会门票",Quantity:2},再运行程序,观察哪些订单成功、哪些订单失败。
练习 3:跑数据竞争检测
执行:
gotest-race./...确认没有数据竞争。
练习 4:观察忘记解锁的后果
只做观察,做完要恢复。
把:
deferi.mu.Unlock()临时注释掉,再运行:
go run.程序很可能会卡住,并报类似死锁的信息。
原因是:第一个拿到锁的 goroutine 没有释放锁,其他 goroutine 永远等不到锁。
练习完一定把代码恢复。
扩展练习 1:验证快照不会修改库存
获取 Snapshot 后修改返回的 map,再读取 Inventory.Stock,确认内部库存不变。
扩展练习 2:增加非法购买数量
构造数量为 0 或负数的订单,验证不能增加库存,也不能被当作正常预订。
五、常见问题与排错
问题 1:已经给扣库存加锁,为什么还是会超卖?
如果读取库存和检查余额发生在锁外,两个请求仍可能同时通过检查。锁应覆盖读取、判断和扣减这整个业务动作,不能只锁最后的赋值。
问题 2:函数返回以后,其他请求一直等待?
检查是否每次 Lock 都有对应的 Unlock。本课把 defer Unlock 紧跟在 Lock 后面,提前返回也会释放锁;不要在持锁时再次调用会获取同一把锁的方法。
六、与前文的联系和工程应用
1. 和第二课的关系
第二课讲的是:怎么启动多个 goroutine 并发处理任务。
第三课讲的是:多个 goroutine 同时操作共享数据时,怎么保护数据。
连起来就是:
能并发,不代表一定安全。并发读写共享数据时,要么用 channel 组织所有权,要么用锁保护共享状态。
七、本篇小结
通过本篇,我们完成了:
- 识别共享可变数据和 data race。
- 用 Mutex 保护完整的检查与修改流程。
- 理解 map 复制、封装和 race 检测。
请记住这三个结论:
- 共享数据的读取和写入都要遵守同一套同步规则。
- 防止超卖需要保护完整的检查和扣减流程。
- race 检测是工具,业务正确性还需要专门断言。
下期预告
下一篇,我们将学习Go错误处理:从error到接口状态码,继续把 Go 的基本机制放进真实后端场景中练习。
敬请期待!
✍坚持原创,求关注,点赞,收藏