Lean 4(元)编程 Cookbook

任务系统中的阻塞与资源耗尽🔗

这里区分两类容易混淆的并发故障。第一类是任务在有限的工作线程池中互相等待,导致没有线程能够继续执行,这属于死锁或线程饥饿。第二类是一次创建过多任务或底层线程,耗尽操作系统资源。下面的程序可能因运行时和系统限制表现为其中任一种,因此诊断时必须查看实际错误,不能只凭“程序卡住”判断原因。

在此之前,若想了解 Task 的基础,请先查看 派生任务与工作线程

什么是死锁?(卡住的比萨店)🔗

想象一家只有 4 位厨师的比萨店。这些厨师就是工作线程,只有他们才能真正做菜。

当所有厨师都因为在互相等待而停止工作时,就发生了死锁。设想 4 位顾客点了一份“神秘比萨”。

  1. 每位厨师都开始和面。

  2. 然后,每位厨师都意识到自己需要另一位厨师做的一种秘制酱料。

  3. 错误所在: 每位厨师不去做其他工作,而是伸着手一动不动地站着,说:“拿不到我的酱料我就不动!”

由于 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 不会让厨师站着不动。它让店经理稍后处理这次交接。

  • 线程回收: 一旦任务的第一部分完成,工作线程就会被释放。它可以立即回到线程池去处理下一个任务或子任务。

  • 高效: 这避免把线程浪费在单纯等待上。不过并发任务本身仍会占用内存和调度资源,实际程序还应限制任务数量。