亲手造出丢失更新和死锁
目标
不再只是阅读竞争条件与死锁,而是**亲手制造,再亲手消除。**完成八个步骤后,你将亲自验证:关键区究竟要用锁保护到什么范围、为什么必须统一加锁顺序,以及什么时候干脆不共享反而更好,而不只是笼统地说‘加锁就行’。
为什么重要
并发错误在绝大多数运行中都不会引发问题,因此它们能通过测试,却在生产环境中爆发。本实验第 2 步会用数字展示这一点。同一个程序运行五次,会得到五个不同的值。
Python 有全局解释器锁,所以线程并不真正并行执行。即便如此,更新仍会丢失,因为竞争条件的成因并不是并行执行,而是读取、修改、写回这一过程被分开了。只要解释器在这些操作之间切换线程就足够了。这一点反而让概念更加清晰。
死锁也是如此。第 5 步与第 6 步之间唯一改变的,就是获取锁的顺序。既不增加锁,也不延长等待时间。
步骤
- 用
/root/conc/race.py重现更新丢失,并把结果保存到/root/conc/01-race.txt。 - 将同一个程序运行五次,把每次结果保存到
/root/conc/02-repeat.txt。 - 用
/root/conc/lock.py加锁来消除丢失,并把结果保存到/root/conc/03-lock.txt。 - 用
/root/conc/local.py实现完全不共享的方案,并把结果保存到/root/conc/04-local.txt。 - 用
/root/conc/deadlock.py reverse制造死锁,并把结果保存到/root/conc/05-deadlock.txt。 - 为同一个程序添加
same分支来消除死锁,并把结果保存到/root/conc/06-order.txt。 - 汇总第 3 步和第 4 步的耗时,并保存到
/root/conc/07-cost.txt。 - 总结所学内容,并保存到
/root/conc/08-notes.md。
提示
- 所有产物都放在
/root/conc/下。请先执行mkdir -p /root/conc。 - 线程数设为 4,每个线程递增 50,000 次。评分器会以这些值为准。
- 如果看不到丢失,请加入
sys.setswitchinterval(0.000001)。这会使线程切换更加频繁,从而暴露原本就存在的空隙。 - 常见错误 1:将递增写成一行
counter += 1。空隙太窄,不容易看到丢失。请把读取和写入拆成两条语句。 - 常见错误 2:获取第二把锁时不设超时。程序会永远停止,屏幕上也不会显示任何内容。请使用
acquire(timeout=2)。 - 常见错误 3:在第 6 步把两把锁合并成一把。这样死锁虽然消失了,但也学不到该学的内容。保留两把锁,只改变顺序。
重现更新丢失
创建 /root/conc/race.py。4 个线程分别将同一个全局变量递增 50,000 次,并将操作拆成 v = counter 与 counter = v + 1 两条语句。程序必须输出 expected 200000 和 actual <실제값> 两行,并把输出保存到 /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 200000、actual <값>、elapsed <밀리초> 三行,并把输出保存到 /root/conc/03-lock.txt。
必须用 with lock: 块把读取和写入一起包住。如果只包住读取或只包住写入,两者之间仍然敞开,更新依然会丢失。
elapsed 记录从即将启动线程到所有线程完成汇合之后的耗时,单位为毫秒。第 7 步会使用这个值。
完全不共享的方案
创建 /root/conc/local.py。每个线程使用自己的局部变量计数,最后合并结果,得到相同的数值。不要使用锁或信号量。输出格式与第 3 步相同,并把输出保存到 /root/conc/04-local.txt。
每个线程只递增自己的局部变量,结束时把结果放入列表中属于自己的位置;主线程等待所有线程汇合后再求和即可。没有共享,也就没有需要保护的东西。
评分器还会检查这个文件中是否不含 Lock、Semaphore、Condition。不用锁也能得到同样的准确结果,正是这一步的重点。
以相反顺序获取锁来制造死锁
创建 /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.txt。blocked 必须为 0。
既不移除锁,也不取消停顿,更不延长超时。只统一获取顺序。这样就打破了死锁四个必要条件中的循环等待。
这正是实际工作中需要将加锁顺序写入文档的原因。如果每个地方都按自己方便的顺序获取锁,两个执行路径迟早会发生交叉。
测量锁的代价
分别取出第 3 步和第 4 步程序的 elapsed,在 /root/conc/07-cost.txt 中写入 lock <밀리초>、local <밀리초> 两行。
两个程序都以相同次数计算相同的数。唯一的区别是用锁保护共享变量,还是完全不共享。
锁不是免费的。因此,实际工作的方向与其说是把锁用好,不如说是让锁不再必要。但也请记住,将计数拆开并不总是可行。
总结什么阻止了什么
在 /root/conc/08-notes.md 中至少写三行。分别用一句话说明更新丢失的原因、锁阻止了什么,以及消除死锁的方法。
正文中必须包含 임계、교착、순서。
尤其要准确写出第 5 步与第 6 步之间改变的是什么。既没有增加锁,也没有延长超时。