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

Verse 并发全景:race、sync、rush、branch 与取消语义

毕业作品里 race 一招定音,但它只是并发工具箱的四分之一。这一页把整套家伙摊开:四种结构化表达式的语义、spawn 为什么被官方排在最后,以及「取消」这件事的边界在哪里。本文整理自 Epic 官方文档与 Unreal Fest 2023 演讲。

毕业作品里赛跑(race)一招定音,但它只是并发工具箱的四分之一。这一页把整套家伙摊开:四种「有结构、看得见头尾」的并发写法各是什么语义、「岔出去就不管」的 spawn 为什么被官方排在最后,以及「取消一条线」这件事的边界在哪里。本文整理自 Epic 官方文档与 Unreal Fest 2023 演讲。

一、两大阵营:结构化 vs 非结构化

官方的并发总览文档把 Verse 的并发表达式分成两个阵营。结构化并发四兄弟——syncracerushbranch——它们像语言内建的控制流一样,生命周期被代码结构框得死死的:块在哪开始、在哪结束、谁等谁,一眼可读。非结构化并发阵营则只有一个孤独的成员:spawn——派出去的分身脱离当前代码结构独立运行,你看不见它什么时候结束。

官方的并发总览文档把 Verse 的并发写法分成两个阵营。结构化并发四兄弟——syncracerushbranch——它们就像蓝图里内建的执行流控制节点,生命周期被图的结构框得死死的:哪里开始、哪里结束、谁等谁,一眼可读。非结构化并发阵营则只有一个孤独的成员:spawn——岔出去的那条线脱离当前结构独立跑,你看不见它什么时候结束。

官方给出的指导原则很直接:「能使用结构化并发表达式(sync、race、rush、branch)时,应优先于非结构化的 spawn」。毕业作品 2.0 的重构故事——从 Subscribe + spawn + 状态旗标,改写成 loop + Await + race——正是这条原则的实践。四兄弟的语义,一张桌子摆齐:

官方给出的指导原则很直接:「能用结构化并发写法(sync、race、rush、branch)时,应优先于非结构化的 spawn」。毕业作品 2.0 的重构故事——从「Bind 回调 + 岔线 + 状态旗标」,改写成「自绕循环 + 在线里等事件 + 赛跑」——正是这条原则的实践。四兄弟的语义,一张桌子摆齐:

表达式什么时候往下走没跑完的分支怎么办一句话记忆
sync:所有子表达式全部完成几条线全部跑完不存在,全都会跑完全员到齐再集合
race:第一个子表达式完成第一条线先跑完立即被取消第一名冲线,输家出局
rush:第一个子表达式完成第一条线先跑完继续在后台跑到自然结束第一名冲线,其余跑完
branch:立刻往下走,分支并行随所在 async 作用域结束而结束外层那条在等待的线一结束,它就跟着结束岔出一条支线陪跑

branch 有个容易踩的限制:它不能直接写在 loop / for 的循环体内——需要先把相关逻辑封装成一个 async 函数,再在循环里调用那个函数。而 spawn 有一项四兄弟都没有的特权:它可以在非 suspends 上下文里使用,这也是 Subscribe 回调(普通函数)里想启动定时流程时,spawn 成为唯一选择的原因。

branch 有个容易踩的限制:它不能直接写在循环(loop / for)的循环体里——需要先把相关逻辑封装成一个能等待的函数,再在循环里调用那个函数。而 spawn 有一项四兄弟都没有的特权:它可以用在没挂小钟表(非 suspends)的普通函数里,这也是 Bind 事件的回调(普通函数)里想启动一段定时流程时,spawn 成为唯一选择的原因。

二、race:被官方盖章的「结构化提前退出」

race 在官方文档里有个很讲究的定位:一种结构化的提前退出(early exit)。想理解这句话,先想一类特殊的表达式:永远不会自己完成的表达式——比如没有 break 的无限 loop,比如 Sleep(Inf)(官方文档明确:它会永远挂起,只有取消能停下它)。这类代码没法从内部停,唯一的下场是被取消

race 在官方文档里有个很讲究的定位:一种结构化的提前退出(early exit)。想理解这句话,先想一类特殊的东西:永远不会自己结束的流程——比如没有 Break 的无限循环(loop),比如 Sleep(Inf)(官方文档明确:它会永远停着等,只有「取消」能停下它)。这类流程没法从内部停,唯一的下场就是被外面取消

race 的妙处在于,它把「什么时候停」的判断,和「被停的那段代码」干干净净地分开了。看这个哨兵换岗的例子:

赛跑(race)的妙处在于,它把「什么时候停」的判断,和「被停的那段流程」干干净净地分开了。看这个哨兵换岗的例子:

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

patrol_demo := class(creative_device):

    OnBegin<override>()<suspends>:void =
        race:
            loop:            # 赛道 A:永不完成的巡逻
                Print("站岗中……")
                Sleep(1.0)
            Sleep(10.0)      # 赛道 B:10 秒闹钟
        Print("换岗!巡逻循环已被取消。")

赛道 A 的 loop 自己永远不会结束,里面一个 break 都没有,也完全不知道「10 秒」这回事;赛道 B 只是个安静的闹钟。10 秒一到,B 冲线,A 整条被取消,race 之后的代码照常执行。巡逻逻辑保持纯粹,停止条件独立存在——这就是「结构化的提前退出」。对比一下传统写法:在循环里塞一个 var ShouldStop:logic 旗标,每圈检查一次,再找个地方负责把旗标扳成 true……race 把这套散落各处的手工活压缩成了一个缩进块。

读图翻译:赛道 A 的循环自己永远不会结束,里面一个 Break 都没有,也完全不知道「10 秒」这回事;赛道 B 只是个安静的闹钟(一个 10 秒的 Delay)。10 秒一到,B 冲线,A 整条被剪断取消,赛跑之后往下的线照常走。巡逻逻辑保持纯粹,停止条件独立存在——这就是「结构化的提前退出」。对比一下老写法:在循环里塞一个布尔旗标 ShouldStop,每圈用 Branch 检查一次,再找个地方负责把它扳成 true……赛跑把这套散落各处的手工活压缩成了一个缩进块。

三、取消语义的边界:别赌没写进文档的事

「输家被取消」听着干脆,但工程上有两个边界问题值得知道分寸:

其一,两条赛道恰好同时完成,谁赢?官方文档写明了输家会被取消,但没有把「同一模拟更新里同时冲线时的裁决规则」作为承诺写死。所以别让玩法逻辑依赖「同帧平局时某一条一定赢」这种假设——真需要严格优先级,就把逻辑改写成不需要平局裁决的形状。

其二,取消在哪一刻生效?直觉上「取消」像一刀斩断,但一段没有任何挂起点、一口气跑完的同步代码,并没有给取消留下下刀的位置。稳妥的心智模型是:把每个 Sleep / Await 挂起点当作分支「可能被取消」的检查站,不要假设取消能精确落在两行普通代码之间。这两点官方都没有给出逐字保证,写关键逻辑时保守为上。

其二,取消在哪一刻生效?直觉上「取消」像一刀斩断,但一段中间不停下来等、一口气跑完的流程,并没有给取消留下下刀的位置。稳妥的心智模型是:把每个会停下来等的地方(Delay / 等事件)当作这条线「可能被取消」的检查站,不要假设取消能精确落在两个普通节点之间。这两点官方都没有给出逐字保证,写关键逻辑时保守为上。

想再深一层,推荐一手资料:Epic 首席工程师、Verse 并发模型的设计者 Conan Reis 在 Unreal Fest 2023 的演讲《Verse Concurrency — Time Flow: Everything, Everywhere in UEFN, All at Once》,系统讲解了 Verse 的时间流模型与各表达式的取消行为,并附有可下载的示例工程和幻灯片 PDF——毕业作品那扇门的 race 模式,理论源头就在这里。

想再深一层,推荐一手资料:Epic 首席工程师、Verse 并发模型的设计者 Conan Reis 在 Unreal Fest 2023 的演讲《Verse Concurrency — Time Flow: Everything, Everywhere in UEFN, All at Once》,系统讲解了 Verse 的时间流模型与各种并发写法的取消行为,并附有可下载的示例工程和幻灯片 PDF——毕业作品那扇门的赛跑(race)模式,理论源头就在这里。

四、小测验

Verse 里唯一的「非结构化并发」表达式是哪个?Verse 里唯一「岔出去就不管」的并发写法是哪个?

race 和 rush 的区别是什么?

来源与延伸

本文整理自 Epic 官方文档与官方演讲: