defer 与异步取消:一个跨年未修的坑
第 11 课说 defer「登记之后,离开作用域必执行」。这条承诺有一个已知的例外:当函数被 race 等结构化并发取消时,defer 可能整个被跳过。第 11 课说 defer「登记之后,离开它所在的那段图必执行」。这条承诺有一个已知的例外:当一根执行线被 race 这类并发结构中途剪断取消时,登记过的 defer 可能整个被跳过。本页拆解这份著名的 bug 报告,并给出自保守则。
一、defer 的承诺,和它失守的地方
先复述一遍 defer 的契约:控制流经过 defer 时登记,离开当前作用域时执行——正常走完、break、return,统统触发。这份契约在同步世界里非常可靠。但 Verse 的异步世界里还有第四种「离开」方式:被取消。当一个 <suspends> 函数在 race 里输给了别的分支,它会在挂起点被直接掐断——而多份社区报告证实:此时函数里已经登记的 defer,可能一次都不执行。
先复述一遍 defer 的约定:执行线走到 defer 时登记,离开它所在的那段图时补跑——正常跑完、Break 跳出、提前结束函数(return),统统触发。这个约定在不涉及等待的图里非常可靠。但 Verse 里还有第四种「离开」方式:被取消。当一个带 <suspends>(会等待)的函数在 race 里输给了别的分支,它会在正等着的那个节点上被直接剪断——而多份社区报告证实:此时函数里已经登记的 defer,可能一次都不跑。
最早的完整报告发在官方论坛,标题就叫「Verse defer not always working」。复现模式极其精炼,值得逐行读:
using { /Fortnite.com/Devices }
using { /Verse.org/Simulation }
using { /Verse.org/Concurrency }
using { /UnrealEngine.com/Temporary/Diagnostics }
defer_trap_device := class(creative_device):
TestEvent:event() = event(){}
Func1()<suspends>:void =
defer:
Print("Exit Func1") # 预期:离开 Func1 时必然打印
Sleep(1.0)
TestEvent.Signal()
Sleep(Inf) # 挂起等待,直到被 race 取消
OnBegin<override>()<suspends>:void =
race:
Func1()
TestEvent.Await()
Print("race 结束") # 这行会打印
剧本是这样的:race 同时起跑两个分支——Func1() 和 TestEvent.Await()。Func1 先睡 1 秒,然后 Signal() 发出事件,自己 Sleep(Inf) 挂起等待;另一分支的 Await() 收到事件立即完成,race 裁定它获胜,于是取消还在沉睡的 Func1。按 defer 的契约,Func1 被迫「离开作用域」,早已登记的 Print("Exit Func1") 应该执行——但日志里,这一行永远不会出现。
剧本是这样的:race 让两条线同时起跑——一条跑 Func1(),一条在 TestEvent.Await() 上等那个事件响(就像 Bind 了一个事件分发器在等它被 Call)。Func1 先睡 1 秒,然后用 Signal() 把事件喊出去(相当于 Call 那个分发器),自己接着 Sleep(Inf) 无限期睡下去;另一条线的 Await() 一收到事件立刻跑完,race 判它获胜,于是把还在沉睡的那条 Func1 线当场剪断取消。按 defer 的约定,Func1 被迫「离开它那段图」,早就登记好的 Print("Exit Func1") 应该补跑——但日志里,这一行永远不会出现。
二、跨年未修:这个坑的时间线
这不是孤例,而是一个有编号、有状态、被反复确认的已知缺陷:
- 2023 年 11 月,原始报告提交,随后被官方标记为 To Do;
- 其间陆续出现多份同类报告——包括「特定场景下 defer 不执行」与「嵌套 defer 只执行了一部分」;
- 截至 2025 年 10 月,状态仍未变成已修复。
换句话说,这个坑安然跨过了两个新年。对写教程的人,它是「文档语义 ≠ 实现行为」的经典案例;对写游戏的人,它是一条必须内化的工程红线——尤其是 race 在 UEFN 设备代码里极其常用(「倒计时 vs 玩家完成」「等事件 vs 超时」几乎全靠它),被取消的分支越多,defer 失约的机会就越多。
三、自保守则:关键清理放在 race 之后
结论只有一句:在可能被 race / rush 取消的异步函数里,不要把关键清理托付给 defer。清理逻辑应该上移一层,放到 race 表达式之后的调用点——race 本身结束后,后面的代码是一定会执行的:
结论只有一句:在可能被 race / rush 取消的「会等待」函数里,不要把关键清理托付给 defer。清理逻辑应该往上挪一层,放到 race 这段之后的位置——race 整段结束后,排在它后面的节点是一定会跑的:
OnBegin<override>()<suspends>:void =
race:
RunRound() # 可能中途被取消
TimeoutClock() # 也可能中途被取消
# 无论哪个分支获胜,race 结束后这里必然执行:
Print("统一收尾:恢复门、隐藏 UI、重置状态")
一个好用的分工原则:defer 负责锦上添花的收尾(调试日志、无关痛痒的视觉复位),race 之后的代码负责不做会坏档的清理(解锁门、归还道具、重置回合状态)。哪怕未来某个版本悄悄修好了这个 bug,这样的分工也只会让代码更清晰,不会有任何损失。
一个 <suspends> 函数「会等待」的函数(带 <suspends>)正在 Sleep,被 race 判负取消了。它里面已登记的 defer 会怎样?
四、来源
本文整理自 Epic 开发者论坛的以下讨论: