我的大脑在并发中的体验:Goroutines、Mutexes 与共享办公空间类比
Source: Dev.to
工作者:理解 Goroutine
想象一个单线程程序就像一个只有一个人在工作的共享办公空间。这个人拥有整个空间,可以自由伸展、接电话、使用白板而不受打扰。环境很安静,但如果他因为等待客户的邮件而卡住,整个办公室就会闲置。
Goroutine 就像是邀请了更多自由职业者进入这个空间。现在你有多个人各自独立地进行自己的项目:一个人在设计标志,另一个人在写博客文章,第三个人在进行销售电话。他们都在同步推进工作。Go 的妙处在于,它不需要为每个自由职业者分配单独的操作系统线程。Go 运行时充当了一个高效的社区管理员,根据需要把自由职业者调度到可用的工作站上,从而让成千上万的任务共存,而不必租用整座摩天大楼。
实际使用场景:
想想一个 Web 服务器。在 Python 或 Ruby 等语言中,每个进入的用户请求通常会占用一个完整的 OS 线程,这既沉重又限制了能够同时处理的用户数量。而在 Go 中,每个请求都会交给一个轻量级的 goroutine。这也是 Go 在构建高吞吐量 API 时如此受欢迎的原因。当成千上万的用户同时访问你的应用时,你不会崩溃——你只是启动了更多的自由职业者。
问题:资源争用
雇佣更多自由职业者会带来一个新问题:当多个自由职业者需要使用同一个共享资源时会怎样?
在共享办公空间的类比中,想象只有一个配备视频设备的高级会议室。如果两位自由职业者在完全相同的时间预订了紧接着的客户通话,就会产生冲突。
我写了一个小程序来模拟这种情况。我设想了一个该会议室的预订系统。两个 goroutine(自由职业者,Alice 和 Bob)同时检查日历。他们都看到上午 10 点的时段是空的,于是都进行预订。结果突然出现了双重预订的混乱——两个客户出现在同一个会议链接上,而自由职业者显得不专业。
这就是一个 race condition(竞争条件):两个进程同时读取日历,认为资源可用,并基于这些过时的信息采取行动。
实际出现的场景:
这种情形在电商的闪购期间屡见不鲜。想象有 100 个人想购买最后一双运动鞋。如果没有保护措施,你的库存系统会一次性检查 100 人的请求,看到“仅剩 1 件”,于是批准所有购买。结果你超卖了库存,产生了 99 位不满的客户。竞争条件不仅是代码错误,更是一次金融灾难。
接待员:引入互斥锁
mutex(互斥锁)就像前台的接待员。
规则
- 上锁 – 当自由职业者想要检查或修改会议室日程表时,必须先去找接待员并请求使用实体预订日志本。接待员把日志本交给他,并让其他人等待。
- 工作 – 这位自由职业者现在可以安全地查看日程表,找到空闲时段,并在日志本上写下自己的名字。写字期间,其他人无法偷偷修改。
- 解锁 – 完成后,他把日志本交还给接待员,接待员随后可以把它交给排队等待的下一个自由职业者。
我重构了程序,引入了接待员(即互斥锁)。通过确保一次只能有一个自由职业者持有日志本,混乱得以终止。输出终于合情合理:Alice 抢到日志本,检查日程,预订了她的时段,然后归还。只有在此之后,Bob 才收到日志本;他看到 10 AM 的时段已被占用,于是预订了 11 AM 的时段。没有双重预订,没有混乱。
实际使用场景:
只要有需要保持一致性的共享资源,例如:
- 银行账户账本(防止两次取款导致账户透支)
- 数据库连接池(确保两个 goroutine 不会抢到同一个连接)
- 内存缓存(Go 的 map 在一个 goroutine 读取而另一个写入时会 panic)
互斥锁是关键数据的守门人。
优化:RWMutex(读写互斥锁)
标准互斥锁的工作做得太好——有时甚至太好。
在共享工作空间里,我们在接待台旁边添加了一块公告板。它列出了所有即将举行的社区活动。每小时都有数十位自由职业者查看这块板,但它只会由社区经理每周更新一次。
按照当前的接待规则,如果有人想阅读公告板,就必须拿起日志本并把其他人全部锁住。这意味着当一个人慢慢阅读活动信息时,另外二十个人甚至连瞄一眼的机会都没有。更糟的是,只要有人在阅读,社区经理就无法发布每周更新。所有操作都被阻塞,即使是简单的查询也不例外。我的程序运行缓慢,因为读取操作毫无必要地相互排队。
引入 RWMutex
RWMutex 是一种更聪明的接待员,它为读者和写作者制定了不同的规则:
- 多个读者 – 只要没有人持有 写锁,任意数量的自由职业者都可以同时持有 读锁。这允许多个 goroutine 并发读取共享资源。
- 单个写者 – 同一时间只能有一个自由职业者持有 写锁,且在写锁被持有期间 不允许任何读者。这保证了修改操作的独占访问。
通过使用 RWMutex,读密集型工作负载(如公告板)可以并行进行,而写入仍然保持安全且独占。这大幅降低了竞争,提高了吞吐量。
Source: …
Go 中的读写锁
类比
- 共享读取者: 访客只想看看公告板(读取 操作)。接待员允许许多访客同时查看。最多十个人可以同时读取公告板而不会相互阻塞。
- 独占写入者: 当社区管理员需要更新公告板(写入 操作)时,接待员会变得严格。它会先等待所有当前的读取者完成,然后 独占 锁定公告板。写入期间没有人可以读取,确保没有人看到半完成、状态不一致的公告板。
为什么 RWMutex 给我带来了性能提升
当我把代码改为使用 RWMutex 时,提升非常显著:
- 那些 频繁读取、但 很少写入 的数据可以被数百个 goroutine 同时访问。
- 系统只会在偶尔的写入时稍有减速,这正是我想要的行为。
实际使用场景
大型应用的配置服务
- 成千上万的微服务每秒读取配置值,以确定要连接哪个数据库。
- 开发者可能一天只更新一次配置。
- 使用普通的
Mutex将是极大的资源浪费——读取者之间会相互阻塞。RWMutex让所有读取操作并行进行,同时仍然保护偶尔的写入,使系统保持高速。
关键要点
| 概念 | 类比 |
|---|---|
| Goroutine | 自由职业者——它们让你的程序一次处理成千上万的任务,这也是 Go 在 Web 服务器和数据管道方面表现出色的原因。 |
| 竞争条件 | 双重预订——它们会导致数据损坏、库存超卖以及余额不准确。 |
| 互斥锁 | 接待员——确保一次只有一个任务可以修改关键资源,保障数据安全。 |
| 读写互斥锁 | 更聪明的接待员——允许多个读取者共享资源以提升性能,适用于配置数据或任何读密集型工作负载。 |
| 竞争检测器 | 监控摄像头——精确显示双重预订发生的位置,节省数小时的调试时间。 |
最后思考
并发非常强大,但它迫使你把程序视为一个独立行为体竞争共享资源的活系统,而不是线性脚本。这是一种范式转变,弄懂这些概念后会感到非常满足。
Thuku Samuel 是一名热衷于编写干净代码和 Go 编程的软件工程师。