任务系统中的阻塞与资源耗尽
这里区分两类容易混淆的并发故障。第一类是任务在有限的工作线程池中互相等待,导致没有线程能够继续执行,这属于死锁或线程饥饿。第二类是一次创建过多任务或底层线程,耗尽操作系统资源。下面的程序可能因运行时和系统限制表现为其中任一种,因此诊断时必须查看实际错误,不能只凭“程序卡住”判断原因。
在此之前,若想了解 Task 的基础,请先查看 派生任务与工作线程。
什么是死锁?(卡住的比萨店)
想象一家只有 4 位厨师的比萨店。这些厨师就是工作线程,只有他们才能真正做菜。
当所有厨师都因为在互相等待而停止工作时,就发生了死锁。设想 4 位顾客点了一份“神秘比萨”。
-
每位厨师都开始和面。
-
然后,每位厨师都意识到自己需要另一位厨师做的一种秘制酱料。
-
错误所在: 每位厨师不去做其他工作,而是伸着手一动不动地站着,说:“拿不到我的酱料我就不动!”
由于 4 位厨师都站着不动地等着,就没人去真正做酱料了。这家店就永远卡住了。在编程中,我们称之为线程饥饿(thread starvation)。
会阻塞或耗尽线程资源的代码
本例尝试运行 100000 个任务,并在每个任务中等待另一个子任务。实际结果取决于运行时实现和系统资源:程序可能因工作线程全部阻塞而停滞,也可能在创建足够多的底层线程之前就因资源耗尽而失败。下方记录的 failed to create thread 属于后一种情况。
def potentialDeadlock (n : Nat := 100000) : IO Unit := do
-- We try to start n tasks
let tasks ← (List.range n).mapM fun i =>
IO.asTask do
let subTask ← IO.asTask (pure i)
-- ERROR: IO.wait blocks the Chef (Thread).
-- If all Chefs are waiting here,
-- nobody can start the subTask!
match (← IO.wait subTask) with
| .ok val => pure (val+1)
| .error e => throw e
-- The program will likely hang here forever
for t in tasks do
match (← IO.wait t) with
| .ok res => IO.println s!"Result: {res}"
| .error e => IO.println s!"Error: {e}"
/-
libc++abi: terminating due to uncaught exception of type
lean::exception: failed to create thread
-/
-- #eval potentialDeadlock
为什么会失败:
在任务内部调用 IO.wait 会占住当前工作线程等待子任务。如果有限线程池中的线程都这样等待,子任务便得不到执行机会,形成线程饥饿。另一方面,大量创建任务也可能先触发操作系统线程或内存上限;示例中的错误正是资源耗尽。两种故障的修复方向相近:不要为等待而长期占住工作线程,也不要无界地创建并发工作。
使用 IO.bindTask 的解决方案(“便利贴”方式)
针对嵌套等待,更安全的方案是使用异步组合。我们不让厨师去等,而是给他一张“便利贴”。
当一位厨师和好面后,写一张便条:“等酱料好了,谁有空谁来把这份比萨做完。”然后这位厨师离开厨房,好让另一位厨师用他的位置去做酱料!
def safeFromDeadlock (n : Nat := 1000000) : IO Unit := do
let tasks ← (List.range n).mapM fun i => do
let t1 ← IO.asTask (pure i)
-- Use bindTask to "chain" the next part.
-- does NOT block a thread but registers a callback.
IO.bindTask t1 fun
| .ok val => IO.asTask (pure (val + 1))
| .error e => throw e
-- Now it's safe to wait from the "Outside" (Main Thread)
for t in tasks do
match (← IO.wait t) with
| .ok res => IO.println s!"Result: {res}"
| .error e => IO.println s!"Error: {e}"
-- No error here, but it will take time since `n` is huge.
-- #eval safeFromDeadlock
为什么这样更好
-
无需等待:
IO.bindTask不会让厨师站着不动。它让店经理稍后处理这次交接。 -
线程回收: 一旦任务的第一部分完成,工作线程就会被释放。它可以立即回到线程池去处理下一个任务或子任务。
-
高效: 这避免把线程浪费在单纯等待上。不过并发任务本身仍会占用内存和调度资源,实际程序还应限制任务数量。