Verse Wiki — 写给 UE6 时代的 Verse 互动手册
第六章 · 第 20 课

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

上一课你让协程学会了睡觉;上一课你让那些「能中途等一等」的函数学会了睡觉;这一课直接解锁分身术。spawn 派出不等待的分身,race 让任务赛跑——输家当场被取消,sync 等全员到齐才放行。再学会 race + Sleep 这道超时配方,抢答、倒计时、并行加载统统拿下。

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

到这一课之前,你的代码一直是一条时间线跑到底:上一行干完,下一行才开工。到这一课之前,你的执行线一直是一根走到底:前一个节点跑完,后一个节点才开工。可游戏里到处是「同时」——烟花在天上炸,音乐在放,倒计时在走,玩家还在跑。靠一条时间线排队,游戏早就卡成幻灯片了。Verse 的第一件并发武器叫 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)),然后才把控制权还给主线。读一下输出顺序,有个细节值得敲黑板:被 spawn 岔出去的那条新线,会先一口气跑到它遇上的第一个「要等一会儿」的节点(这里是 Sleep(2.0) 这颗 Delay),然后才把执行权交回主线。所以日志是「准备点火」→「主线不等烟花」,两秒之后才轮到「烟花升空!」。

spawn 有两条铁律。第一,花括号里只能放一个异步函数调用——想同时干三件事?先把它们包进一个 <suspends> 辅助函数,再 spawn 那个函数。第二,它是五个并发表达式里唯一可以出现在非异步上下文的:普通函数、事件回调里都能用。下一课你会看到它的高光时刻——在 Subscribe 的回调里启动异步逻辑,全靠它。

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

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

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

race 是一场淘汰赛:块里每个子表达式都是一名选手,同时开跑;第一名冲线的瞬间,比赛结束——其余选手全部被取消。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) 里——取消就发生在那个挂起点上:输家不会被从某行代码中间硬生生掐断,而是在它下一次 Sleep / Await 的地方安静退场。这条「取消发生在挂起点」的规则非常重要,拓展页「取消与 defer」会带你做实验亲眼看到它。此刻乌龟还卡在 Sleep(3.0) 这颗 Delay 上——取消就发生在这种「等一会儿」的节点上:输家不会被从半个节点中间硬生生掐断,而是等它走到下一个 Sleep / Await(会等的节点)时,才安静退场。这条「取消只发生在会等的节点上」的规则非常重要,拓展页「取消与 defer」会带你做实验亲眼看到它。

两条规矩:race 至少要有两个子表达式;它只能出现在异步上下文(<suspends> 函数体或其他并发表达式内部)里。另外,因为 race 是有值的表达式,各条臂的结果类型要兼容——一条臂返回 string、另一条返回 int,编译器会当场翻脸。想在一条臂里写多行逻辑?用 block: 把它们打包,第四节的超时配方就是这么写的。

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

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

sync 是 race 的反面:块里所有子表达式并发开跑,要全部完成,sync 才放行。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),顺序和书写顺序一一对应。sync 和 race 一样,底下至少要有两条线、只能待在「能等的执行线」上;它也带一个输出,吐出来的是各条线结果打包成的一个元组(tuple),顺序跟你从上到下写的顺序一一对应。

剩下两位成员,各领一句话。branch:是「先斩后奏版 spawn」——启动一个异步块,主线立刻继续;但它启动的任务命运绑在包围作用域上,作用域一退出,任务当场被撤销。rush:是「不淘汰人的 race」——第一名冲线时 rush 立刻带着第一名的结果返回,但其余选手不被取消,继续跑,直到包围作用域结束才被清场。

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

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

表格里「非异步上下文可用?」那一列,暴露了官方的设计哲学:结构化并发。sync / race / rush / branch 启动的任务,生命周期都被所在的异步作用域管着——作用域散场,任务跟着收摊,一个都跑不丢;只有 spawn 的任务能「逃出」作用域独立存活。所以官方的建议非常直白:能用结构化的四兄弟,就不用 spawn;只有在非异步上下文(比如事件回调)里别无选择时,才请出 spawn。

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

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

把 race 和上一课的 Sleep 拼在一起,就得到 Verse 里最经典的并发配方——超时。需求:玩家要在 10 秒内按下按钮,按到了算挑战成功,没按到算超时。传统写法要定时器、标志位、状态清理三件套;在 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 的值就是答案。

注意两条臂都以 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 亮红时,报错往往看起来很凶,病根却都很小。这几条提前踩过,能帮你省下大把排查时间:

六、关卡挑战

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

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

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

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

拓展阅读

技巧 · EXTRA

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

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

进入拓展 →

拔高 · EXTRA

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

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

进入拓展 →

拓展 · EXTRA

task 与 awaitable:spawn 到底返回什么

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

进入拓展 →