Verse Wiki — 写给蓝图作者的 Verse 手册
拔高 · EXTRA

时间流的底层模型:并发是时间维度上的控制流

蓝图里想让几件事同时发生,你得用事件分发器、定时器、Timeline 各管一摊地拼;Verse 却能把「同时」做成关键字。Epic 在 Unreal Fest 2023 的官方演讲给出了原因:把「时间流」提升为与「控制流」同级的语言概念。本页整理演讲与官方文档的核心模型,配一个可推演的执行顺序小实验。

一、控制流 vs 时间流

Epic 官方演讲《Verse Concurrency — Time Flow: Everything, Everywhere in UEFN, All at Once》提出了一个漂亮的类比:平时你连节点,是用 Branch / ForEach 决定「哪些节点会被执行」,这叫控制流;而 Verse 的并发节点(spawn / race / sync / branch / rush)决定「节点在什么时候执行」——它们就是时间维度上的 Branch / ForEach。在蓝图里,想让几件事同时发生,往往得靠事件分发器、Timeline、Delay 这些零件各管一摊地拼出来;在 Verse 里,时间流是直接长在语言骨头里的头等公民,像 Branch 一样内建就能用。

支撑这套设计的是协作式调度(cooperative scheduling):同一时刻只有一个任务在执行,任务只在挂起点(Sleep、Await 等)主动让出控制权,不存在「抢占」——没有谁能中途强行打断谁。没有抢占,就没有「值改到一半被别人插队」的数据打架,自然也不用上锁;也不用绑一层套一层的事件回调,「先等 A 再等 B」直接顺着执行线接两个节点就行。第 23 课学的 <suspends>,正是这套体系发给每个函数的入场券。

二、两条铁律:启动即冲刺、恢复有秩序

铁律一:任何被并发节点启动的新执行线,都会当场一口气跑到它的第一个「会等的节点」,然后才把控制权还给启动它的那条线。也就是说,新线不是「排队稍后再跑」,而是立刻冲刺一段,直到撞上第一个 Sleep(Delay)/ Await 才停。看这个小实验:

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

order_demo_device := class(creative_device):

    Talker()<suspends>:void =
        Print("1. Talker 出生,冲刺到第一个挂起点")
        Sleep(0.0)
        Print("3. 下一帧,Talker 醒来")

    OnBegin<override>()<suspends>:void =
        spawn{ Talker() }
        Print("2. spawn 之后,主线才继续")

输出顺序永远是 1 → 2 → 3:spawn 岔出 Talker 的瞬间,Talker 当场一口气跑到 Sleep(0.0)(Delay)才挂起,控制权这才回到 OnBegin(Event BeginPlay)打印第 2 句;下一个模拟帧,Talker 醒来,打印第 3 句。理解了「启动即冲刺」,很多看似玄学的先后顺序问题都能一步推出来。

铁律二:恢复顺序是确定性的。官方 Signal API 文档明确:被事件唤醒(相当于事件分发器的 Signal / Call)的那些线,按当初挂起的先后顺序一条条恢复,每条线都一直跑到自己下一个「会等的节点」,才轮到下一条。确定性意味着同样的输入永远得到同样的执行顺序——并发 bug 可以稳定复现,而不是「昨天能跑今天崩」的薛定谔式灾难。

三、取消,也发生在挂起点

演讲的另一块拼图是取消(cancellation):每个并发节点都对应一条任务线(task),当这条线被取消——最典型的是输掉了下一课的 race——它并不会在任意一个节点上被当场掐断,而是跑到下一个「会等的节点」才安静退出。换句话说,「会等的节点」不仅是让出控制权的地方,也是一条线生死交接的关口。这个设计保证了两个「会等的节点」之间的那一段永远完整跑完,不会留下改到一半的烂摊子——这正是「结构化并发」可预测性的来源,第 24 课会反复用到。

把三块拼图合起来:每条线只在「会等的节点」让出、恢复顺序确定、取消也发生在「会等的节点」——Verse 的时间流模型就是「一切以会等的节点为界」。连异步这类图时,盯住每一个 Sleep(Delay)和 Await,你就能像读故事一样读出整张图在时间轴上的走位。

在一个能等的函数里放一个 spawn{ Talker() }(岔出一条新线),紧接着下一个节点是 Print String「主线」。Talker 这个函数里第一个节点是 Print String「出生」,第二个是 Sleep(0.0)(Delay)。哪句先打印?

来源

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