Verse Wiki — 写给 UE6 时代的 Verse 互动手册
拓展 · EXTRA

取消与 defer:结构化并发的另一半

race 赢家冲线的那一刻,输家并不会瞬间蒸发——它会在下一个挂起点安静退场,而且 defer 块照常执行。race 赢家冲线的那一刻,输家并不会瞬间蒸发——它会等到下一个「会等的节点」才安静退场,而且 defer(那根「离开前必跑的线」)照常执行。搞懂「取消在哪发生、defer 何时兜底」,你才算真正握住了结构化并发的全部筹码。

一、取消发生在挂起点

先纠正一个直觉:被取消不等于被掐断。Verse 的任务是协作式调度的——它们只在挂起点(Sleep、Await 这些地方)交出控制权,所以取消也只可能发生在这些点上。输掉 race 的任务如果正睡在 Sleep(5.0) 里,那一刻就是它的退场时刻:Sleep 之后的代码不再执行,任务就地解散。

先纠正一个直觉:被取消不等于被掐断。Verse 的任务是「协作式」的——它们只在会等的节点(Sleep、Await 这些地方)才交出执行权,所以取消也只可能发生在这些节点上。输掉 race 的任务如果正卡在 Sleep(5.0) 这颗 Delay 上,那一刻就是它的退场时刻:Sleep 后面的节点不再执行,任务就地解散。

这条规则的价值在于可预测:任务永远不会在「半条指令」处被撕开,你在两个挂起点之间写的代码要么整段执行、要么整段没跑,状态一致性天然有保证。官方 Time Flow 文档与 Unreal Fest 2023 的并发演讲都反复强调这一点——结构化并发的取消是自动且可预测的,你不需要手写任何「杀任务」的代码,只需要选对表达式。

这条规则的价值在于可预测:任务永远不会在「半个动作」处被撕开,你在两个「会等的节点」之间放的那串节点,要么整段跑完、要么整段没跑,状态一致性天然有保证。官方 Time Flow 文档与 Unreal Fest 2023 的并发演讲都反复强调这一点——结构化并发的取消是自动且可预测的,你不用手连任何「杀任务」的逻辑,只需要选对表达式。

二、defer:退场时必办的手续

光知道「会被取消」还不够——被取消的任务手里可能攥着没善后的东西:改了一半的 UI、点亮的特效、占着的状态位。Verse 给的答案是 defer:块——它在离开所在作用域时执行,不管是正常跑完还是中途被取消,一视同仁。这让 defer 成为并发清理的标准工具:恢复 UI、解除订阅、归还状态,全塞进 defer,谁也赖不掉。做个实验,亲眼看看输家的 defer 什么时候响:

光知道「会被取消」还不够——被取消的任务手里可能攥着没善后的东西:改了一半的 UI、点亮的特效、占着的状态开关。Verse 给的答案是 defer:块——它在离开所在的那段图时执行,不管是正常跑完还是中途被取消,一视同仁。这让 defer 成为并发清理的标准工具:恢复 UI、解除事件绑定、归还状态,全塞进 defer,谁也赖不掉。做个实验,亲眼看看输家的 defer 什么时候响:

cancel_lab_device.verse
using { /Fortnite.com/Devices }
using { /Verse.org/Simulation }
using { /UnrealEngine.com/Temporary/Diagnostics }

cancel_lab_device := class(creative_device):

    SlowWorker()<suspends>:void =
        defer:
            Print("SlowWorker:清理完毕(被取消也会执行)")
        Print("SlowWorker:开工")
        Sleep(5.0)
        Print("SlowWorker:干完了")    # 这行永远不会打印

    FastWorker()<suspends>:void =
        Sleep(1.0)
        Print("FastWorker:第一名冲线")

    OnBegin<override>()<suspends>:void =
        race:
            SlowWorker()
            FastWorker()
        Print("race 结束,主线继续")

先读图:SlowWorker 里,函数一开头先摆一个 defer(登记「离开前必跑」的那根清理线),再 Print String『开工』、Delay 5 秒、最后 Print String『干完了』。FastWorker 只 Delay 1 秒再 Print String。OnBegin 里把这两个函数放进一个 race。

推演一遍日志顺序。race 启动时,两条臂按书写顺序各自同步执行到第一个挂起点:SlowWorker 先注册 defer、打出「开工」,在 Sleep(5.0) 挂起;FastWorker 在 Sleep(1.0) 挂起。第 1 秒,FastWorker 冲线获胜;SlowWorker 此刻正挂在 Sleep 上——取消发生,defer 兑现,「清理完毕」上屏;而「干完了」永无出头之日。最后主线打出「race 结束」。

推演一遍日志顺序。race 启动时,两条线按从上到下的顺序各自一口气跑到第一个「会等的节点」:SlowWorker 先登记 defer、打出「开工」,卡在 Sleep(5.0);FastWorker 卡在 Sleep(1.0)。第 1 秒,FastWorker 冲线获胜;SlowWorker 此刻正卡在 Sleep 上——取消发生,登记过的 defer 兑现,「清理完毕」上屏;而「干完了」永无出头之日。最后主线打出「race 结束」。

一个容易忽略的细节:defer 必须先被执行到(完成登记),之后作用域退出时才会触发。把 defer 写在第一个挂起点之前——像上面那样放在函数开头——才能保证任务无论死在哪个挂起点,清理都跑得掉。

一个容易忽略的细节:defer 必须先被执行到(完成登记),之后离开那段图时才会触发。把 defer 放在第一个「会等的节点」之前——像上面那样搁在函数开头——才能保证任务无论死在哪个会等的节点上,清理都跑得掉。

三、四个表达式,四种「生命归谁管」

把取消语义放回全局,你会发现正课的五个表达式其实是同一个问题的五种答案——任务的生命周期归谁管:race 由表达式自己管,赢家定局、输家在挂起点取消;sync 也由表达式自己管,全员跑完才散;branch 和 rush 交给包围作用域管,作用域退出,未完成者被撤销;spawn 则没人管,任务自治,活到自己收工。选表达式,本质上就是在回答「这个任务该活多久、谁负责送它走」。想明白这一层,「能结构化就不 spawn」的官方建议就不再是教条,而是一句大实话:让作用域替你管生死,永远比自己追着野马跑省心。

把取消语义放回全局,你会发现正课的五个表达式其实是同一个问题的五种答案——这条任务线的寿命归谁管:race 由表达式自己管,赢家定局、输家在会等的节点处取消;sync 也由表达式自己管,全员跑完才散;branch 和 rush 交给外面那段图管,图一退出,没跑完的被撤销;spawn 则没人管,任务自治,活到自己收工。选表达式,本质上就是在回答「这条线该活多久、谁负责送它走」。想明白这一层,「能结构化就不 spawn」的官方建议就不再是教条,而是一句大实话:让那段图替你管生死,永远比自己追着野马跑省心。

race 输家函数里的 defer 块,会执行吗?

本文整理自官方文档与 Unreal Fest 2023 演讲:Time Flow and Concurrency in Verse ↗Structured Concurrency ↗Verse Concurrency — Time Flow(Unreal Fest 2023)↗