资讯动态

Go零基础实战第3期:Mutex与数据竞争

发布时间:2026/10/15 3:37:56 来源:尧图企业网站定制
文章目录引言Mutex与数据竞争一、本篇学习目标与准备二、核心概念与实现思路1. 这个程序在模拟什么2. 什么是数据竞争3. sync.Mutex 是什么4. 为什么 Unlock 要配合 defer5. 为什么读也要加锁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]ifcurrentquantity{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会复制一份传入的 mapstock: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]ifcurrentquantity{returncurrent,false}i.stock[item]current-quantityreturni.stock[item],true}翻译成大白话先上锁。查看当前库存。如果库存不够返回失败。如果库存足够扣减库存。返回扣减后的库存。函数返回前自动解锁。因为整个检查和扣减都在锁里面所以不会出现两个 goroutine 同时看到同一份旧库存的问题。8. 锁保护的代码越短越好虽然锁很好用但不要把所有代码都塞进锁里。锁保护的区域越大其他 goroutine 等待的时间就越长。通常只把真正访问共享数据的部分放进锁里。比如本篇中Reserve只保护库存检查和库存扣减。不要在锁里做很慢的事情例如调用第三方 API。发送邮件。查询很慢的数据库。做大量计算。time.Sleep很久。否则并发性能会变差。9. 什么是go test -raceGo 自带数据竞争检测工具。运行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]ifcurrentquantity{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;id100;id{ordersappend(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 的基本机制放进真实后端场景中练习。敬请期待✍坚持原创求关注点赞收藏

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑