技巧 · EXTRA
对动态数量的任务做 race:事件聚合模式
race 的臂数是写代码时定死的,race 的臂数在连图时就定死了,想等「N 个按钮里最先被按的那个」怎么办?社区实战出的标准解法:spawn 哨兵 + event 聚合。
进入拓展 →上一课你让协程学会了睡觉;上一课你让那些「能中途等一等」的函数学会了睡觉;这一课直接解锁分身术。spawn 派出不等待的分身,race 让任务赛跑——输家当场被取消,sync 等全员到齐才放行。再学会 race + Sleep 这道超时配方,抢答、倒计时、并行加载统统拿下。
到这一课之前,你的代码一直是一条时间线跑到底:上一行干完,下一行才开工。到这一课之前,你的执行线一直是一根走到底:前一个节点跑完,后一个节点才开工。可游戏里到处是「同时」——烟花在天上炸,音乐在放,倒计时在走,玩家还在跑。靠一条时间线排队,游戏早就卡成幻灯片了。Verse 的第一件并发武器叫 spawn:派出一个分身去干活,主线自己接着走,连头都不回。
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 本身还带一个输出引脚,吐出来的就是冠军的成绩:
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}!")
读图:Turtle 和 Rabbit 是两个自定义函数,各自 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 是 race 的反面:块里所有子表达式并发开跑,要全部完成,sync 才放行。sync 是 race 的反面:底下列出的所有线一起开跑,要全部跑完,sync 才放行。适合「几件事都得办完才能进下一幕」的场景:
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("全员到齐,开席!")
读图:CookMainDish 和 BakeDessert 是两个自定义函数,分别 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 拼在一起,就得到 Verse 里最经典的并发配方——超时。需求:玩家要在 10 秒内按下按钮,按到了算挑战成功,没按到算超时。传统写法要定时器、标志位、状态清理三件套;在 Verse 里只是一场两人赛跑:一条臂等按钮,一条臂睡 10 秒——谁先完成谁赢,输家自动取消,一行清理代码都不用写。换别的做法,你得摆一个 Timer、一个开关变量、再手动清理状态,三样都少不了;在 Verse 里只是一场两人赛跑:一条线等按钮被按,一条线 Delay 10 秒——谁先完成谁赢,输家自动取消,一个善后节点都不用连。点「运行下一步」,看这场赛跑怎么分出胜负。
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 的输出就是答案。
轮到你了。下面这台「谢幕控制台」被挖掉了两个关键词:一个是能在普通回调里派分身的表达式,一个是等全员到齐的表达式。把它们填回去,再点「检查答案」。轮到你了。下面这台「谢幕控制台」被挖掉了两个关键词:一个是能在「不能等的执行线」(比如绑定事件的响应)里岔出新线的表达式,一个是等全员到齐的表达式。把它们填回去,再点「检查答案」。
读图:上半张图里 Fireworks 和 Music 是两个各挂了 Delay 的自定义函数。OnEncore 是一段「绑到按钮上、按一下就跑」的响应——它是条不能等的线,所以想在里面再放一轮烟花,只能岔出一条新线(第一个空)。下半张 OnBegin 里要「烟花和音乐都跑完才谢幕」,得用等全员到齐的那个块(第二个空)。
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 亮红时,报错往往看起来很凶,病根却都很小。这几条提前踩过,能帮你省下大把排查时间:
<suspends>。这三位只能住在异步上下文里。<suspends>(没声明「我这里挂了会等的节点」)。这三位只能待在能等的执行线里。<suspends> 辅助函数,再 spawn 它。<suspends> 函数,再 spawn 它。三道小关卡,验收你的分身指挥术。答错零惩罚,可以一直重试。
race 里输掉的那一方,什么时候真正停下来?
在 Subscribe 的回调(普通的非 suspends 函数)里想启动一段异步逻辑,应该用哪个?在把设备事件绑过来(Subscribe)的响应里——它是条不能等的普通线——想启动一段「会等」的逻辑,应该用哪个?
rush 和 race 都在第一个子表达式完成时返回,它们的区别是?rush 和 race 都在第一条线完成时返回,它们的区别是?
技巧 · EXTRA
race 的臂数是写代码时定死的,race 的臂数在连图时就定死了,想等「N 个按钮里最先被按的那个」怎么办?社区实战出的标准解法:spawn 哨兵 + event 聚合。
进入拓展 →拔高 · EXTRA
race 输家究竟何时、如何退场?defer 为什么是并发清理的标准工具?一个小实验看懂取消语义。
进入拓展 →拓展 · EXTRA
spawn 会递给你一张回执:task 对象。怎么 Await 它?为什么没有 Cancel()?叫停一个分身的标准姿势。
进入拓展 →