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

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

为什么 Verse 不用回调地狱,也不用线程锁?Epic 在 Unreal Fest 2023 的官方演讲给出了完整答案:把「时间流」提升为与「控制流」同级的语言概念。本页整理演讲与官方文档的核心模型,配一个可推演的执行顺序小实验。

一、控制流 vs 时间流

Epic 官方演讲《Verse Concurrency — Time Flow: Everything, Everywhere in UEFN, All at Once》提出了一个漂亮的类比:传统语言用 if / for 决定「哪些表达式被执行」,这叫控制流;而 Verse 的并发表达式(spawn / race / sync / branch / rush)决定「表达式在什么时候执行」——它们就是时间维度上的 if / for。在大多数语言里,并发是靠回调、线程、锁拼凑出来的「库特性」;在 Verse 里,时间流是内建在语法中的一等公民。平时你连节点,是用 Branch / ForEach 决定「哪些节点会被执行」,这叫控制流;而 Verse 的并发节点(spawn / race / sync / branch / rush)决定「节点在什么时候执行」——它们就是时间维度上的 Branch / ForEach。在别的做法里,想让几件事同时发生,往往得靠事件回调、开线程、上锁这些外挂零件拼出来;在 Verse 里,时间流是直接长在语言骨头里的头等公民,像 Branch 一样内建就能用。

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

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

铁律一:任何被并发表达式启动的异步任务,都会同步地立刻执行到它的第一个挂起点,然后才把控制权还给启动者。也就是说,新任务不是「排队稍后运行」,而是当场冲刺一段,直到撞上第一处 Sleep / Await 才停。看这个小实验:

铁律一:任何被并发节点启动的新执行线,都会当场一口气跑到它的第一个「会等的节点」,然后才把控制权还给启动它的那条线。也就是说,新线不是「排队稍后再跑」,而是立刻冲刺一段,直到撞上第一个 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) 处挂起,控制权才回到 OnBegin 打印第 2 句;下一个模拟帧,Talker 恢复,打印第 3 句。理解了「启动即冲刺」,很多看似玄学的执行顺序问题都能一步推演出来。

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

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

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

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

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

把三块拼图合起来:任务只在挂起点让出、恢复顺序确定、取消也在挂起点发生——Verse 的时间流模型就是「一切以挂起点为界」。写异步代码时,盯住每一个 Sleep 和 Await,你就能像读故事一样读出整段代码在时间轴上的走位。

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

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

来源

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