LabHub
学习 学习路径 课程

操作系统

亲手造出丢失更新和死锁

在 LabHub 中继续学习

目标

不再只是阅读竞争条件与死锁,而是**亲手制造,再亲手消除。**完成八个步骤后,你将亲自验证:关键区究竟要用锁保护到什么范围、为什么必须统一加锁顺序,以及什么时候干脆不共享反而更好,而不只是笼统地说‘加锁就行’。

为什么重要

并发错误在绝大多数运行中都不会引发问题,因此它们能通过测试,却在生产环境中爆发。本实验第 2 步会用数字展示这一点。同一个程序运行五次,会得到五个不同的值。

Python 有全局解释器锁,所以线程并不真正并行执行。即便如此,更新仍会丢失,因为竞争条件的成因并不是并行执行,而是读取、修改、写回这一过程被分开了。只要解释器在这些操作之间切换线程就足够了。这一点反而让概念更加清晰。

死锁也是如此。第 5 步与第 6 步之间唯一改变的,就是获取锁的顺序。既不增加锁,也不延长等待时间。

步骤

  1. /root/conc/race.py 重现更新丢失,并把结果保存到 /root/conc/01-race.txt
  2. 将同一个程序运行五次,把每次结果保存到 /root/conc/02-repeat.txt
  3. /root/conc/lock.py 加锁来消除丢失,并把结果保存到 /root/conc/03-lock.txt
  4. /root/conc/local.py 实现完全不共享的方案,并把结果保存到 /root/conc/04-local.txt
  5. /root/conc/deadlock.py reverse 制造死锁,并把结果保存到 /root/conc/05-deadlock.txt
  6. 为同一个程序添加 same 分支来消除死锁,并把结果保存到 /root/conc/06-order.txt
  7. 汇总第 3 步和第 4 步的耗时,并保存到 /root/conc/07-cost.txt
  8. 总结所学内容,并保存到 /root/conc/08-notes.md

提示

重现更新丢失

创建 /root/conc/race.py。4 个线程分别将同一个全局变量递增 50,000 次,并将操作拆成 v = countercounter = v + 1 两条语句。程序必须输出 expected 200000actual <실제값> 两行,并把输出保存到 /root/conc/01-race.txt

一次递增必须拆成读取和写入两条语句,这样其他线程才能在两者之间插入执行。如果写成一行 counter += 1,可插入的空隙太窄,不容易看到丢失。

如果仍然看不到丢失,请用 sys.setswitchinterval(0.000001) 让线程频繁切换。这不是制造错误,而是在创造让已经存在的错误暴露出来的条件

将同一个程序运行五次

将第 1 步的程序运行五次,在 /root/conc/02-repeat.txt 中将第一行写为 expected 200000,接下来的五行按照 <회차> <actual 값> 格式记录。

可以用 for i in 1 2 3 4 5; do ...; done 运行,并只提取每次执行的 actual 值写入文件。

每次得到不同的值,正是这一步的重点。竞争条件不是存在或不存在的问题,而是暴露或未暴露的问题;即使测试运行十次并十次通过,也不代表没有错误。

加锁消除丢失

创建 /root/conc/lock.py。在与第 1 步相同的结构中使用 threading.Lock 来消除丢失。输出 expected 200000actual <값>elapsed <밀리초> 三行,并把输出保存到 /root/conc/03-lock.txt

必须用 with lock: 块把读取和写入一起包住。如果只包住读取或只包住写入,两者之间仍然敞开,更新依然会丢失。

elapsed 记录从即将启动线程到所有线程完成汇合之后的耗时,单位为毫秒。第 7 步会使用这个值。

完全不共享的方案

创建 /root/conc/local.py。每个线程使用自己的局部变量计数,最后合并结果,得到相同的数值。不要使用锁或信号量。输出格式与第 3 步相同,并把输出保存到 /root/conc/04-local.txt

每个线程只递增自己的局部变量,结束时把结果放入列表中属于自己的位置;主线程等待所有线程汇合后再求和即可。没有共享,也就没有需要保护的东西。

评分器还会检查这个文件中是否不含 LockSemaphoreCondition。不用锁也能得到同样的准确结果,正是这一步的重点。

以相反顺序获取锁来制造死锁

创建 /root/conc/deadlock.py。使用 python3 /root/conc/deadlock.py reverse 调用时,两个线程必须以相反顺序获取两把锁,从而导致死锁。第二把锁要用 acquire(timeout=2) 获取,以免永久停住;程序输出 mode <갈래>blocked <못 잡은 스레드 수> 两行。把输出保存到 /root/conc/05-deadlock.txt

获取第一把锁后,用 time.sleep(0.05) 短暂停顿,让另一个线程也能获取自己的第一把锁。如果没有这个空隙,其中一方可能先完成全部操作,死锁就不会发生。

如果不加 timeout,程序会永远停住,因为死锁不会自行解除。设置超时并不是修复死锁的方法,而是观察死锁已经发生的方法

统一顺序以消除死锁

deadlock.py 中添加 same 分支。只把两个线程获取锁的方式改为相同顺序,其余部分保持不变。把 python3 /root/conc/deadlock.py same 的输出保存到 /root/conc/06-order.txtblocked 必须为 0。

既不移除锁,也不取消停顿,更不延长超时。只统一获取顺序。这样就打破了死锁四个必要条件中的循环等待。

这正是实际工作中需要将加锁顺序写入文档的原因。如果每个地方都按自己方便的顺序获取锁,两个执行路径迟早会发生交叉。

测量锁的代价

分别取出第 3 步和第 4 步程序的 elapsed,在 /root/conc/07-cost.txt 中写入 lock <밀리초>local <밀리초> 两行。

两个程序都以相同次数计算相同的数。唯一的区别是用锁保护共享变量,还是完全不共享。

锁不是免费的。因此,实际工作的方向与其说是把锁用好,不如说是让锁不再必要。但也请记住,将计数拆开并不总是可行。

总结什么阻止了什么

/root/conc/08-notes.md 中至少写三行。分别用一句话说明更新丢失的原因、锁阻止了什么,以及消除死锁的方法。

正文中必须包含 임계교착순서

尤其要准确写出第 5 步与第 6 步之间改变的是什么。既没有增加锁,也没有延长超时。