事件驱动 vs 轮询:两种架构的代价对比
「别把逻辑放进 Event Tick」是 Unreal 讲了几十年的老话。这一页把同一个需求用轮询和事件各写一遍,逐项算账:每秒开销、响应延迟、会不会漏掉事件、代码怎么读——顺带说清轮询仍然是正确选择的那几种场景。
一、同一个需求,两种写法
需求来自 Epic 开发者论坛的一个真实提问:一条巡逻循环在后台跑着,玩家按下停止按钮时要让它停下来。新手的直觉是在订阅上去的那张图里写 break——此路不通,break 只能待在循环体内部,而处理函数是另一段代码,够不着那个 loop。
于是有了两条真正可行的路。方案 A(轮询式):处理函数只负责升起一面旗子,循环每圈自查一次。
using { /Fortnite.com/Devices }
using { /Verse.org/Simulation }
using { /UnrealEngine.com/Temporary/Diagnostics }
patrol_flag_device := class(creative_device):
@editable
StopButton:button_device = button_device{}
var ShouldStop:logic = false
OnBegin<override>()<suspends>:void =
StopButton.InteractedWithEvent.Subscribe(OnStop)
Patrol()
OnStop(Agent:agent):void =
# 处理函数只做一件事:升旗
set ShouldStop = true
Patrol()<suspends>:void =
loop:
if (ShouldStop?):
break
Print("巡逻中……")
# 每圈必须让出控制权,否则处理函数永远没机会跑
Sleep(0.0)
Print("巡逻结束")
这就是你在蓝图里做同一件事的做法:一个 bool 变量 + Event Tick 里的 Branch。两个细节决定成败。if (ShouldStop?) 里的问号是在查这个 logic 的值(第 12 课的失败上下文);而循环里那句 Sleep(0.0) 不能省——第 23 课讲过,一个循环里如果没有让出控制权的点,它会死死占住不撒手,处理函数根本没机会跑,旗子永远升不起来。
方案 B(事件驱动式):把「巡逻」和「等停止信号」摆进同一个 race,让取消自动发生。
using { /Fortnite.com/Devices }
using { /Verse.org/Simulation }
using { /UnrealEngine.com/Temporary/Diagnostics }
# event(t) 的 Signal / Await 来自 /Verse.org/Concurrency
using { /Verse.org/Concurrency }
patrol_race_device := class(creative_device):
@editable
StopButton:button_device = button_device{}
StopEvent:event() = event(){}
OnBegin<override>()<suspends>:void =
StopButton.InteractedWithEvent.Subscribe(OnStop)
race:
block:
loop:
Print("巡逻中……")
Sleep(1.0)
block:
# 这一臂安静地等信号;信号一到,它获胜
StopEvent.Await()
Print("巡逻结束")
OnStop(Agent:agent):void =
StopEvent.Signal()
链路走一遍:玩家按下按钮 → OnStop 跑起来 → StopEvent.Signal() 拉响 → 第二臂的 Await() 收到信号、这条臂算跑完 → race 判它先到 → 巡逻臂被剪断 → 执行流走到 Print("巡逻结束")。注意第 24 课的取消规则在这里生效:输掉的循环臂不是在 Signal 的那一瞬间被掐断,而是跑到它下一个「能让出去的点」(那句 Sleep)才退出。
方案 B 里没有标志位、没有 break,「巡逻,直到收到停止信号」几乎就是大白话。这是蓝图作者在 Verse 里能拿到的最大结构性红利之一——蓝图那边没有 race,只能走方案 A。
二、逐项算账
| 维度 | 轮询(Tick / loop + Sleep) | 事件驱动(Subscribe / Await) |
|---|---|---|
| 每秒执行次数 | 约 30 次(服务器模拟帧节奏),不管有没有事发生 | 事情发生几次就跑几次,一整局可能只有两次 |
| 随对象数量的增长 | 线性:20 台门就是 20 条每帧空转的执行线 | 常数级:订阅是一条挂着的记录,不消耗每帧预算 |
| 响应延迟 | 最多晚一帧(约 1/30 秒) | 事件发生时立刻执行 |
| 会不会漏 | 会。只看得到「现在是什么状态」,同一帧内按下又松开就丢了 | 不会。事件记录的是「发生了什么」,每次都不落 |
| 可读性 | 意图散落在标志位、循环、处理函数三处 | 「等到 X 发生,做 Y」几乎是自解释的 |
| 清理成本 | 标志位要自己复位;循环要自己退出 | race 里输家自动取消;Await 一次性,不留记录 |
最容易被低估的是「会不会漏」那一行。性能差距还能靠机器扛过去,漏事件是正确性问题——它表现为「偶尔不生效」,是最难复现、最耗时间的一类 bug。而它的根因很简单:轮询采样的是状态,事件记录的是变化,状态采样天生看不见两次采样之间发生过什么。
三、什么时候轮询仍然是对的
把话说全:轮询不是罪,只是被滥用。下面这几类场景,逐帧循环仍然是正确答案。
- 连续量。插值、平滑移动、进度条、镜头跟随——这些东西每一帧都真的不一样,没有「事件」可等。这正是蓝图里 Timeline 存在的理由,而 Verse 没有 Timeline,只能自己写
loop+Sleep(0.0)。 - 没有事件可订阅的量。某个状态压根不广播变化时,你只能自己查。此时的最佳实践是把轮询频率降到需求的下限(
Sleep(0.25)而不是Sleep(0.0)),别默认顶满 30 次。 - 有明确终点的短期循环。三秒的开门动画、五秒的倒计时——跑完就
break,不留残线。危险的是那种「一开局就转、整局不停」的循环。
一条能替你做大部分决定的判据:问自己「这件事是离散的还是连续的」。离散(按下、进入、死亡、拾取)用事件;连续(位置、进度、剩余时间的显示)才用循环。蓝图里这条判据同样成立,只是 Verse 把「等一件事发生」写得更顺手,于是滥用 Tick 的诱惑更小。
race 方案里,StopEvent.Signal() 之后,巡逻 loop 是什么时候真正停下的?
来源
本文整理自 Epic 开发者论坛与官方文档: