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

task 与 awaitable:spawn 到底返回什么

spawn 不只是「发射后不管」——它会递给你一张回执:task 对象。这一页讲清 task 怎么 Await、为什么 Fortnite 里的 task 没有 Cancel(),以及社区总结的「自制取消」标准姿势。

一、task:分身的回执

正课说 spawn「不等待」,但没说它一无所获。spawn{ Fn() } 这个表达式是有值的:一个 task 对象。如果 Fn 的返回类型是 t,你拿到的就是 task(t)——一张写着「分身正在干活,凭此回执领取成果」的凭条。task 实现了 awaitable 接口(官方 API 参考归在 /Verse.org/Concurrency 下),核心能力只有一个:Await()<suspends>:t——挂起等待这个任务完成,并拿到它的返回值。

正课说 spawn「不等待」,但没说它一无所获。spawn{ Fn() } 这个动作会往外递一个值:一个 task 对象。如果 Fn 完事后会送出类型 t 的东西,你拿到的就是 task(t)——一张写着「分身正在干活,凭此回执领取成果」的凭条。task 实现了 awaitable 接口(蓝图里最接近的就是「可以 Bind 之后等它响」的那类东西;官方 API 参考归在 /Verse.org/Concurrency 下),核心能力只有一个:Await()<suspends>:t——停下来等这个任务跑完,再把它送出的值接到手里。

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

potion_lab_device := class(creative_device):

    BrewPotion()<suspends>:int =
        Sleep(2.0)
        Print("药剂出炉")
        42

    OnBegin<override>()<suspends>:void =
        PotionTask := spawn{ BrewPotion() }
        Print("分身去炼药,主线先布置货架")
        Sleep(3.0)
        Result := PotionTask.Await()
        Print("取货:效力 {Result} 的药剂")

读图:BrewPotion 是个会等的自定义函数(Delay 2 秒、Print String、最后送出整数 42)。OnBeginspawn 岔出一条线去跑它,并把那张 task 回执接进变量 PotionTask(:= = 新建变量并接上);主线接着 Print String、再 Delay 3 秒;然后对 PotionTaskAwait() 领回结果,存进 Result,最后 Print String 打出来。

时间线:第 0 秒,分身领命出发(同步执行到第一个挂起点 Sleep(2.0)),主线打出「布置货架」;第 2 秒,药剂出炉,分身收工,成果 42 存进回执;第 3 秒,主线睡醒来 Await()——任务早已完成,所以 Await 立刻返回 42,一秒都不多等。这就是回执模式的妙处:「启动」和「取结果」可以分开,中间随便插别的活。你也可以一口气 spawn 好几个任务、把回执都存下来,稍后逐个 Await——「先全部发射、再统一等待」,并行加载的常用手筋。

时间线:第 0 秒,分身领命出发(先一口气跑到第一个会等的节点 Sleep(2.0)),主线打出「布置货架」;第 2 秒,药剂出炉,分身收工,成果 42 存进回执;第 3 秒,主线睡醒来 Await()——任务早已完成,所以 Await 立刻返回 42,一秒都不多等。这就是回执模式的妙处:「启动」和「取结果」可以分开,中间随便插别的活。你也可以一口气 spawn 好几个任务、把回执都存下来,稍后逐个 Await——「先全部发射、再统一等待」,并行加载的常用手筋。

二、没有 Cancel():自制取消事件

看到 task,老练的程序员会立刻找那个熟悉的按钮:Cancel()。坏消息——Fortnite 环境里公开的 task 接口没有 Cancel() 方法,这也是社区抱怨已久的一点。想中途叫停一个 spawn 出去的分身,标准姿势是:在被 spawn 的函数内部开一场 race,一条臂干正事,另一条臂等一个「取消事件」——外面想叫停时 Signal 一声,事件臂立刻获胜,干活臂在下一个挂起点被自动取消。等于用 race 的取消语义,亲手给任务装了个刹车。

看到 task,老练的开发者会立刻找那个熟悉的按钮:Cancel()。坏消息——Fortnite 环境里公开的 task 接口没有 Cancel() 方法,这也是社区抱怨已久的一点。想中途叫停一个 spawn 出去的分身,标准姿势是:在被 spawn 的那个函数内部开一场 race,一条线干正事,另一条线等一个「取消事件」——外面想叫停时,朝这个事件 Signal(喊)一声,事件那条线立刻获胜,干活那条线就在它下一个会等的节点处被自动取消。等于借 race 的取消规则,亲手给任务装了个刹车。

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

patrol_device := class(creative_device):

    StopEvent:event() = event(){}

    PatrolLoop()<suspends>:void =
        race:
            block:
                loop:
                    Print("巡逻中……")
                    Sleep(1.0)
            StopEvent.Await()

    OnBegin<override>()<suspends>:void =
        spawn{ PatrolLoop() }
        Sleep(3.5)
        StopEvent.Signal()
        Print("已叫停巡逻")

读图:StopEvent 是一个不带负载的事件分发器(event())——专门用来喊「停」。PatrolLoop 里摆一个 race,一条线是「loop:每圈 Print String『巡逻中』再 Delay 1 秒」,另一条线是 StopEvent.Await()(Bind 上去等它响)。OnBegin 里 spawn 出这条巡逻线,主线 Delay 3.5 秒后朝 StopEvent Signal 一声,巡逻就被叫停。

拆解一下:loop 臂每圈都有一次 Sleep(1.0)——这既是让出控制权的礼貌(上一课的规矩),也是取消能够落地的挂起点;StopEvent.Await() 臂平时安静沉睡,直到第 3.5 秒主线 Signal(),它瞬间完成并赢下 race,巡逻循环随即在自己的 Sleep 处被取消。从外面看,效果就是「这个 spawn 的任务被取消了」。最后提醒一句边界:如果任务本来就该跟作用域同生共死,直接用 branch 就好,犯不着自己造取消——自制刹车是留给「必须 spawn、又想收得回来」的场合的。

拆解一下:loop 那条线每圈都有一次 Sleep(1.0)——这既是「让出控制权喘口气」的礼貌(上一课的规矩),也是取消能够落地的会等节点;StopEvent.Await() 那条线平时安静沉睡,直到第 3.5 秒主线朝它 Signal(),它瞬间完成并赢下 race,巡逻循环随即在自己的 Sleep 处被取消。从外面看,效果就是「这个 spawn 的任务被取消了」。最后提醒一句边界:如果任务本来就该跟那段图同生共死,直接用 branch 就好,犯不着自己造取消——自制刹车是留给「必须 spawn、又想收得回来」的场合的。

spawn{ BrewPotion() } 这个表达式的值是什么?spawn{ BrewPotion() } 这个动作往外吐出来的值是什么?

三、常见坑:spawn 与 branch,谁管分身的命

拿着回执上路之前,先绕开两个新手常撞的坑。坑一:spawn 的花括号里只能放一个异步函数调用,不能塞多行逻辑——想「先打印、再 Sleep、再打印」?写 spawn{ 一堆语句 } 会直接吃编译错误。改法很朴素:把这几行装进一个 <suspends> 辅助函数,再 spawn 那个函数。坑二:别指望 spawn 的任务会随作用域一起谢幕。它是彻底逃逸的:启动它的函数早就返回了,分身还在跑;等回合结束、设备被销毁,残留任务再去访问已经不存在的对象,就会撞出运行时错误。也正因如此,官方的指导原则是:能用结构化并发(sync / race / rush / branch)就不用 spawn。

拿着回执上路之前,先绕开两个新手常撞的坑。坑一:spawn 的花括号里只能放一个「带小钟表」的函数调用,不能塞一堆节点——想「先打印、再 Delay、再打印」?写 spawn{ 一串节点 } 会直接让 Compile 亮红。改法很朴素:把这几步装进一个 <suspends> 函数,再 spawn 那个函数。坑二:别指望 spawn 的线会随那段图一起谢幕。它是彻底逃逸的:启动它的函数早就结束了,分身还在跑;等回合结束、设备被销毁,残留的线再去碰那些已经不存在的对象,就会撞出运行时错误。也正因如此,官方的指导原则是:能用结构化并发(sync / race / rush / branch)就不用 spawn。

对比项 spawn branch
可用位置 非异步上下文也能用(如 Subscribe 回调)在「不能等的执行线」上也能用(如绑定事件的响应) 只能在异步上下文里用只能在「能等的执行线」上用
块内写法 仅限单个异步函数调用只收一个「带小钟表」的函数调用 块内可写多行代码块内可以放好几个节点
任务寿命 逃逸——启动它的作用域返回后继续独立运行逃逸——启动它的那段图结束后,它照样独立跑 包围的异步作用域一退出,任务即被撤销外面那段图一退出,这条线就被撤销
回执 返回 task 对象,稍后可 Await() 不给回执,后续代码立即继续不给回执,后面的节点立即继续

一句话记法:branch 的分身拴着绳,作用域散场就被收走;spawn 的分身没有绳,但你手里多一张 task 回执。该拴绳的场合拴绳,该要回执的场合要回执——顺带还能解释一个「灵异现象」:在函数最后一行写 branch,任务看起来「什么都没干」,其实是函数随即返回、作用域退出,分身刚出门就被收走了。

一句话记法:branch 的分身拴着绳,那段图散场就被收走;spawn 的分身没有绳,但你手里多一张 task 回执。该拴绳的场合拴绳,该要回执的场合要回执——顺带还能解释一个「灵异现象」:在函数最后一步写 branch,任务看起来「什么都没干」,其实是函数随即结束、那段图退出,分身刚出门就被收走了。

本文整理自官方文档与社区教程:Spawn in Verse ↗awaitable — Verse API Reference ↗、社区视频 What does spawn return?(UEFN Verse Boost Ep. 13)↗