news 2026/10/15 3:37:55

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Go零基础实战第3期:Mutex与数据竞争

文章目录

    • 引言: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 把“检查库存”和“扣减库存”放在同一个临界区,并通过库存快照保护内部数据。


一、本篇学习目标与准备

  1. 识别共享可变数据和 data race。
  2. 用 Mutex 保护完整的检查与修改流程。
  3. 理解 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. 读取旧值。
  2. 在旧值基础上加 1。
  3. 把新值写回去。

如果两个 goroutine 同时执行count++,就可能发生这种情况:

  1. goroutine A 读到count = 0。
  2. goroutine B 也读到count = 0。
  3. A 写回1。
  4. B 也写回1。

明明加了两次,最后结果却是1。

这就是并发里很经典的问题。

3.sync.Mutex是什么

代码里有:

mu sync.Mutex

Mutex是互斥锁。

可以这样理解:

同一时间,只允许一个 goroutine 进入被锁保护的代码区域。

使用方式是:

i.mu.Lock()deferi.mu.Unlock()

意思是:

  1. Lock():我要开始操作共享数据了,先上锁。
  2. 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}

翻译成大白话:

  1. 先上锁。
  2. 查看当前库存。
  3. 如果库存不够,返回失败。
  4. 如果库存足够,扣减库存。
  5. 返回扣减后的库存。
  6. 函数返回前自动解锁。

因为整个检查和扣减都在锁里面,所以不会出现两个 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 组织所有权,要么用锁保护共享状态。


七、本篇小结

通过本篇,我们完成了:

  1. 识别共享可变数据和 data race。
  2. 用 Mutex 保护完整的检查与修改流程。
  3. 理解 map 复制、封装和 race 检测。

请记住这三个结论:

  1. 共享数据的读取和写入都要遵守同一套同步规则。
  2. 防止超卖需要保护完整的检查和扣减流程。
  3. race 检测是工具,业务正确性还需要专门断言。

下期预告

下一篇,我们将学习Go错误处理:从error到接口状态码,继续把 Go 的基本机制放进真实后端场景中练习。

敬请期待!


✍坚持原创,求关注,点赞,收藏

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/15 3:37:44

supabase-postgres-best-practices - schema-lowercase-identifiers

title: Use Lowercase Identifiers for Compatibility impact: MEDIUM impactDescription: Avoid case-sensitivity bugs with tools, ORMs, and AI assistants tags: naming, identifiers, case-sensitivity, schema, conventions 使用小写标识符以保证兼容性 PostgreSQL 会…

作者头像 李华
网站建设 2026/10/15 3:37:30

Deep Agents 学习笔记(一):从 Agent Harness 到第一个 Deep Agent

最近开始系统学习 Datawhale 的《Deep Agents in Action》。前两章主要解决两个问题&#xff1a;Deep Agents 到底是什么&#xff1f;它和 LangChain、LangGraph 有什么区别&#xff1f;一个最简单的 Deep Agent 是怎么构建和运行起来的&#xff1f;如果只是看 API&#xff0c;…

作者头像 李华
网站建设 2026/10/15 3:35:55

给你一套超实用的知识库搭建方法,真正让你事半功倍

把我这个文章链接直接丢给WorkBuddy/Codex等附加一句话「请根据这篇文章的方法帮我搭建个人知识库」你有没有发现一个很奇怪的现象&#xff1f;现在用 AI 的人越来越多&#xff0c;收藏的干货越来越多&#xff0c;下载的资料越来越多&#xff0c;可真正能拿来解决问题的东西&am…

作者头像 李华
网站建设 2026/10/15 3:35:42

N_m3u8DL-RE 快速上手指南:DASH/HLS 流媒体下载一次跑顺

N_m3u8DL-RE 快速上手指南&#xff1a;DASH/HLS 流媒体下载一次跑顺 【免费下载链接】N_m3u8DL-RE Cross-Platform, modern and powerful stream downloader for MPD/M3U8/ISM. English/简体中文/繁體中文. 项目地址: https://gitcode.com/GitHub_Trending/nm3/N_m3u8DL-RE …

作者头像 李华
网站建设 2026/10/15 3:35:28

Elasticsearch深分页限制破解:三种方案对比与实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/15 3:33:41

foobar2000 皮肤 foobox 十分钟装完:深色浅色双主题,装完就能听

foobar2000 皮肤 foobox 十分钟装完&#xff1a;深色浅色双主题&#xff0c;装完就能听 【免费下载链接】foobox-cn DUI 配置 for foobar2000 项目地址: https://gitcode.com/GitHub_Trending/fo/foobox-cn foobar2000 的播放内核一直口碑不错&#xff0c;可惜自带界面十…

作者头像 李华