Verse Wiki — 写给蓝图作者的 Verse 手册
第七章 · 第 24 课

并发:spawn / race / sync,指挥你的分身军团

这是蓝图里没有对应物的一课:蓝图没有真正的并发原语,「谁先到算谁」「都完成再继续」全靠你手写标志位和计数器。Verse 把这些直接做成了语言关键字——spawn 派分身、race 赛跑并取消输家、sync 等全员到齐。

一、spawn:派出分身,主线不等

先说清这一课的位置:蓝图没有真正的并发原语。你在蓝图里做「同时」,靠的是把逻辑拆到多个 Event Dispatcher、多个定时器、多个 Custom Event 上各跑各的,再用变量把它们的进度对起来——能做,但拼装全靠人力。Verse 把这套东西直接做进了语言:五个并发表达式,像 Branch 一样是关键字。第一件是 spawn——派出一个分身去干活,主线自己接着走,连头都不回。

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

firework_device := class(creative_device):

    LaunchFirework()<suspends>:void =
        Sleep(2.0)
        Print("烟花升空!")

    OnBegin<override>()<suspends>:void =
        Print("准备点火……")
        spawn{ LaunchFirework() }
        Print("主线不等烟花,继续布置会场")

先读一遍这张图:firework_device 是一台以 creative_device 为父类的蓝图设备。里面 LaunchFirework 是个自定义函数(名字带小钟表——因为它挂了会等的节点),内容是「Delay 2 秒 → Print String 打『烟花升空!』」。OnBegin(相当于 Event BeginPlay)里做三件事:Print String 打『准备点火』;用 spawn 岔出一条新线去跑 LaunchFirework,但主线不等它;紧接着 Print String 打『主线不等烟花』。

读一下输出顺序,有个细节值得敲黑板:被 spawn 岔出去的那条新线,会先一口气跑到它遇上的第一个「要等一会儿」的节点(这里是 Sleep(2.0) 这颗 Delay),然后才把执行权交回主线。所以日志是「准备点火」→「主线不等烟花」,两秒之后才轮到「烟花升空!」。

spawn 有两条铁律。第一,花括号里只能放一个「带小钟表」的函数调用——想同时干三件事?先把这三件事打包进一个 <suspends> 函数(标了这个记号就代表它里面挂了会等的节点),再 spawn 那个函数。第二,它是五个并发表达式里唯一能出现在「不能等的执行线」上的:普通函数、绑定事件的响应里都能用。下一课你会看到它的高光时刻——在把设备事件绑过来(Subscribe)的响应里启动一段会等的逻辑,全靠它。

还有一点先记牢:spawn 出去的分身是「放出去的野马」——启动它的函数结束了,它也不会被取消,会一直跑到自己收工。另外,spawn 这个动作其实会往外递一个输出:一张叫 task 的「回执」,可以稍后拿它去 Await()(等它跑完、领取结果),拓展页有完整拆解。

二、race:谁先完成听谁的,输家被取消

race 是一场淘汰赛:race 底下列出的每一条线都是一名选手,同时开跑;第一名冲线的瞬间,比赛结束——其余选手全部被取消。race 本身还带一个输出引脚,吐出来的就是冠军的成绩:

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

turtle_rabbit_device := class(creative_device):

    Turtle()<suspends>:string =
        Sleep(3.0)
        "乌龟"

    Rabbit()<suspends>:string =
        Sleep(1.0)
        "兔子"

    OnBegin<override>()<suspends>:void =
        Winner := race:
            Turtle()
            Rabbit()
        Print("冠军是{Winner}!")

读图:TurtleRabbit 是两个自定义函数,各自 Delay 一段时间(3 秒 / 1 秒)后,把一个字符串(『乌龟』/『兔子』)送到自己的输出引脚。OnBegin 里摆一个 race,底下挂这两条线;race 跑完,把冠军那条线的输出接进一个新变量 Winner(:= 就是「新建一个变量、把这个值接上去」),最后 Print String 打出冠军是谁。

兔子睡 1 秒,乌龟睡 3 秒,所以第 1 秒 race 就分出胜负:Winner 拿到 "兔子"。此刻乌龟还卡在 Sleep(3.0) 这颗 Delay 上——取消就发生在这种「等一会儿」的节点上:输家不会被从半个节点中间硬生生掐断,而是等它走到下一个 Sleep / Await(会等的节点)时,才安静退场。这条「取消只发生在会等的节点上」的规则非常重要,拓展页「取消与 defer」会带你做实验亲眼看到它。

两条规矩:race 底下至少要有两条线;它只能待在「能等的执行线」上(标了 <suspends> 的函数体里,或别的并发块内部)。另外,因为 race 会往外吐一个值,各条臂送出的类型得对得上——一条臂送字符串、另一条送整数,Compile 会当场亮红。想在一条臂里放好几个节点?用 block: 把它们打包成一条线,第四节的超时配方就是这么写的。

三、sync:全员到齐;branch 与 rush:各领一句话

sync 是 race 的反面:底下列出的所有线一起开跑,要全部跑完,sync 才放行。适合「几件事都得办完才能进下一幕」的场景:

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

banquet_device := class(creative_device):

    CookMainDish()<suspends>:void =
        Sleep(3.0)
        Print("主菜上桌")

    BakeDessert()<suspends>:void =
        Sleep(2.0)
        Print("甜点出炉")

    OnBegin<override>()<suspends>:void =
        sync:
            CookMainDish()
            BakeDessert()
        Print("全员到齐,开席!")

读图:CookMainDishBakeDessert 是两个自定义函数,分别 Delay 3 秒 / 2 秒后各自 Print String 一句。OnBegin 里摆一个 sync,把这两条线挂上去;要等两条都跑完,sync 后面那句『全员到齐,开席!』的 Print String 才会执行。

主菜要 3 秒,甜点要 2 秒——但它们是并行做的,所以「开席」在第 3 秒就打印了,而不是 3 + 2 = 5 秒。这正是并发的意义:总耗时取决于最慢的那位,而不是所有人加起来。sync 和 race 一样,底下至少要有两条线、只能待在「能等的执行线」上;它也带一个输出,吐出来的是各条线结果打包成的一个元组(tuple),顺序跟你从上到下写的顺序一一对应。

剩下两位成员,各领一句话。branch:是「先斩后奏版 spawn」——岔出一条会等的新线,主线立刻继续;但这条线的命拴在外面那段图上,那段图一结束,它当场被撤销(可以理解成「拴了绳的 spawn」)。rush:是「不淘汰人的 race」——第一名冲线时 rush 立刻带着第一名的结果往下走,但其余选手不被取消,继续跑,直到外面那段图结束才被清场。

表达式 等待谁? 其余任务的命运 能在「不能等的线」上用? 表达式的值
race 等第一名 在各自下一个会等的节点处被取消 胜者的结果
sync 等全部完成 ——(都会跑完) 各结果组成的元组
rush 等第一名 继续跑,外面那段图结束时才取消 最先完成者的结果
branch 不等待 外面那段图退出时被撤销 ——(不取它的值)
spawn 不等待 独立存活,那段图结束也不取消 是(唯一) task 对象

表格里「能不能在『不能等的线』上用」那一列,暴露了官方的设计哲学:结构化并发。sync / race / rush / branch 岔出去的线,寿命都被它们所在的那段图管着——那段图散场,这些线跟着收摊,一个都跑不丢;只有 spawn 的线能「逃出」那段图独立存活。所以官方的建议非常直白:能用结构化的四兄弟,就不用 spawn;只有在「不能等的执行线」上(比如绑定事件的响应里)别无选择时,才请出 spawn。

四、经典配方:race + Sleep = 超时判定

把 race 和上一课的 Sleep 拼在一起,就得到 Verse 里最经典的并发配方——超时。需求:玩家要在 10 秒内按下按钮,按到了算挑战成功,没按到算超时。换别的做法,你得摆一个 Timer、一个开关变量、再手动清理状态,三样都少不了;在 Verse 里只是一场两人赛跑:一条线等按钮被按,一条线 Delay 10 秒——谁先完成谁赢,输家自动取消,一个善后节点都不用连。点「运行下一步」,看这场赛跑怎么分出胜负。

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

timeout_device := class(creative_device):

    @editable
    Button:button_device = button_device{}

    OnBegin<override>()<suspends>:void =
        Print("10 秒内按下按钮,快!")
        race:
            block:
                Button.InteractedWithEvent.Await()
                Print("及时按下,挑战成功!")
            block:
                Sleep(10.0)
                Print("超时,机会溜走了……")
        Print("race 落幕,输家已退场")
输出日志

点「运行下一步」,看代码怎么一行行执行。

注意两条臂都以 block: 开头——block 负责把好几个节点打包成一条线。这里两条线最后都不吐值(void),类型天然对得上;如果想让 race 直接告诉你「是成功还是超时」,可以让两条线各自送出一个 logic(布尔真/假),race 的输出就是答案。

轮到你了。下面这台「谢幕控制台」被挖掉了两个关键词:一个是能在「不能等的执行线」(比如绑定事件的响应)里岔出新线的表达式,一个是等全员到齐的表达式。把它们填回去,再点「检查答案」。

读图:上半张图里 FireworksMusic 是两个各挂了 Delay 的自定义函数。OnEncore 是一段「绑到按钮上、按一下就跑」的响应——它是条不能等的线,所以想在里面再放一轮烟花,只能岔出一条新线(第一个空)。下半张 OnBegin 里要「烟花和音乐都跑完才谢幕」,得用等全员到齐的那个块(第二个空)。

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

finale_device := class(creative_device):

    Fireworks()<suspends>:void =
        Sleep(2.0)
        Print("烟花齐放")

    Music()<suspends>:void =
        Sleep(3.0)
        Print("音乐落幕")

    # 这个回调被某个按钮 Subscribe(下一课细讲)
    OnEncore(Agent:agent):void =
        # 回调不是异步上下文,想再放一轮烟花,只能派分身
        ____{ Fireworks() }

    OnBegin<override>()<suspends>:void =
        # 烟花和音乐都结束,才算谢幕
        ____:
            Fireworks()
            Music()
        Print("谢幕完成")

五、常见坑:并发新手的雷区

并发表达式让 Compile 亮红时,报错往往看起来很凶,病根却都很小。这几条提前踩过,能帮你省下大把排查时间:

蓝图对照

这一课的对照表和别的课不太一样:左边那一列大多不是「某个节点」,而是「一套你手写出来的土办法」。蓝图没有并发原语——这正是 Verse 在这一章最值钱的地方。

蓝图里的做法 Verse 里的写法 差异
把逻辑拆到几个 Event Dispatcher / Custom Event 上,各自被触发、各跑各的 spawn{ MyAsyncFn() } 蓝图的「同时」是分散触发的副产品,没有一个地方能读出「这里岔了一条线」;spawn 把这件事写成一行,谁岔的、岔了什么,一目了然
「谁先到算谁」:两条线各自跑,手写一个 bool 标志位,谁先到就置位,后到的用 Branch 判断标志位后自行退出 race: 下面并列两条线 race 自带「第一名冲线,其余全部取消」的语义。标志位那套你要自己保证不重入、自己写退出分支;race 只要缩进两行
「都完成后继续」:加一个计数器变量,每条线完成时 +1,再判断是否等于 N sync: 下面并列几条线 sync 把计数器变成了语言结构,而且直接把各条线的结果打包成一个元组交给你;计数器方案还要你另外找地方存结果
Sequence 节点 顺着缩进往下写,或用 block: 打包 Sequence 不是并发。它只是把几根执行线按 Then 0 → Then 1 的顺序依次跑完;要真正的并行,Verse 这边才有 sync
Set Timer by Event 起一条后台逻辑,离场时记得 Clear Timer,忘了就出鬼 branch: branch 岔出的线拴在所在作用域上,作用域一结束它自动收场;定时器句柄的手动清理在 Verse 里被结构化并发接管了
在自定义 Function 里想启动一段会等的逻辑 —— 做不到,Delay 放不进去 在任何函数(包括事件回调)里 spawn spawn 是五个并发表达式里唯一能出现在「不能等的执行线」上的;下一课的 Subscribe 回调里,它是唯一出路

差异从何而来?蓝图的执行线模型里,「一条线」是画出来的连线,引擎没有一个可以命名、可以取消、可以等待的「任务」对象。Verse 从语言层面就有任务(task)这个概念,于是「取消输家」「等全员到齐」这些操作才有可能被写成关键字,而不是每次都由你重新拼一遍。

反过来也有蓝图更顺手的地方:蓝图的 Event Dispatcher 天然是「一对多广播」,连几个监听者只是多拖几根线;Verse 这边要做同样的事,得自己声明事件、自己订阅——那正是下一课的内容。

六、关卡挑战

三道小关卡,验收你的分身指挥术。答错零惩罚,可以一直重试。

race 里输掉的那一方,什么时候真正停下来?

在把设备事件绑过来(Subscribe)的响应里——它是条不能等的普通线——想启动一段「会等」的逻辑,应该用哪个?

rush 和 race 都在第一条线完成时返回,它们的区别是?

拓展阅读

技巧 · EXTRA

对动态数量的任务做 race:事件聚合模式

race 的臂数在连图时就定死了,想等「N 个按钮里最先被按的那个」怎么办?社区实战出的标准解法:spawn 哨兵 + event 聚合。

进入拓展 →

拔高 · EXTRA

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

race 输家究竟何时、如何退场?defer 为什么是并发清理的标准工具?一个小实验看懂取消语义。

进入拓展 →

拓展 · EXTRA

task 与 awaitable:spawn 到底返回什么

spawn 会递给你一张回执:task 对象。怎么 Await 它?为什么没有 Cancel()?叫停一个分身的标准姿势。

进入拓展 →