本页尚未翻译,当前显示英文版本。
线程、生命周期与测试
循环由你掌控,SDK 只往一个地方投递。 SDK 不会启动任何需要你操心的线程,也不会交给你任何锁;它只在你启动时选定的那一个上下文中调用你的代码。你可以从任意线程调用我们;我们只从一个地方调用你。
一个投递上下文,循环由你掌控
每个实例都声明恰好一个投递上下文——它的所有处理器(handler)都在这一个地方运行。投递上下文在初始化时确定,在实例的整个生命周期内保持不变。事件、数据增量、调用的结果,都只到达这里,不会去别处。
投递上下文有两种形态,你在启动时选择其一:
- 由你驱动。 runtime 自己什么都不做;你在自己的循环里取出(drain)待处理的投递。引擎需要的就是这种形态——投递落在游戏线程上,落在你选定的那一帧里。
- 由我们掌控。 runtime 持有一个专用的执行线程。控制台宿主程序或专用服务器需要的是这种形态。
两者谁也不是谁的退路,也没有涉及线程池的第三种选项:runtime 持有多少线程,从来不是你的代码需要关心的问题。
上下文从不作为参数传递。 没有哪个处理器接收“我在哪个线程上”这样的参数,也没有什么可查询的。处理器在哪里运行,是契约的属性,而不是调用携带的数据。
启动和停止都是显式的
初始化是你主动发起的一次调用,它返回一个结果。没有任何东西会在首次使用时延迟初始化。如果延迟启动,唯一能看到模块已禁用的地方就会挪到碰巧排在第一个的调用里,看起来像是那次调用失败了。
已禁用的模块在启动时就会点名,结果会说明发生的是两种情况中的哪一种:整条依赖链都已关闭;或者你在功能缩减的状态下 运行,并附带不可用项的列表。不存在悄无声息的第三种情况。
// the outcome names a disabled module and what it took with it — it is not an exception
options.Delivery = DeliveryContext.Pumped(out IPump pump); // or DeliveryContext.Owned()
InitializationOutcome outcome = await PlayServRuntime.Initialize(options);
foreach (var gap in outcome.Unavailable) Log(gap);
void OnFrame() => pump.Drain(); // your loop, your frame
await runtime.DisposeAsync(); // explicit, idempotent// the outcome names a disabled module and what it took with it — it is not a thrown error
const outcome = await PlayServ.runtime.initialize({
delivery: PlayServ.delivery.pumped(), // or .owned()
});
outcome.unavailable.forEach(log);
const onFrame = () => outcome.pump.drain(); // your loop, your frame
await runtime.close(); // explicit, idempotent# the outcome names a disabled module and what it took with it — it is not an exception
outcome = await playserv.runtime.initialize(
delivery=playserv.delivery.pumped(), # or .owned()
)
for gap in outcome.unavailable:
log(gap)
def on_frame():
outcome.pump.drain() # your loop, your frame
await runtime.close() # explicit, idempotentGo 中基于特性的声明方式尚未确定(O-1),确定写法后会补上示例。
// deliveries land on the game thread; a gap is a named outcome, not an exception
FPlayServClient::Connect(Options,
TPSOnResult<FPlayServClient*>::CreateLambda([](const TPSResult<FPlayServClient*>& Result)
{
if (!Result.HasValue()) { return; }
FPlayServClient* Client = Result.Value();
for (const FPSGap& Gap : Client->Unavailable())
{
UE_LOG(LogPlayServ, Warning, TEXT("%s"), *Gap.Text);
}
}));
// no pump call: the plugin drains on the game thread for you
Client->Shutdown(); // explicit, idempotent — and not a cancel
// co_await support ships later: a built-in SDK coroutine wrapper that respects Unreal's GC and threading.// the outcome names a disabled module and what it took with it — it is not an exception
options.Delivery = DeliveryContext.Pumped(out IPump pump); // or DeliveryContext.Owned()
InitializationOutcome outcome = await PlayServRuntime.Initialize(options);
foreach (var gap in outcome.Unavailable) Log(gap);
void OnFrame() => pump.Drain(); // your loop, your frame
await runtime.DisposeAsync(); // explicit, idempotent关闭是显式、彻底且幂等的:关闭成功后,SDK 不会再调用该实例的任何处理器。
关闭不会取消进行中的工作
关闭不涉及已经开始的操作。每个操作的结果仍可以通过该操作指定的方式查到;如果你想知道一次购买是否成功,就用那种方式去查,而不是靠关闭来判断。
每个句柄都有一个声明好的终点
订阅、延迟工作描述符、会话:每一个都是句柄(handle),每一个都恰好有一个由契约指定的终点。释放是幂等的,所以释放两次不算错误。
由此得出三个容易出错的结论:
- 终点从来不是终结器、析构函数或作用域。 随手丢掉的句柄会一直保持打开。这是你代码里的 bug,我们不会悄悄回收——因为如果生命周期取决于语言,每种绑定(binding)的生命周期就各不相同。
- 在句柄终结后继续使用它,会得到声明好的拒绝,并附带错误码。不是空结果,不是未定义行为,也不是那种笼统的“对象已释放”错误——那种错误没有任何可据以处理的信息。
- 连接断开不等于句柄终结。 订阅(subscription)能挺过断线,重连后继续接收。句柄只会因契约指定的原因终结,断网不在其中。
句柄的寿命不会超过签发它的实例:一旦关闭实例,它给你的每个句柄都随之终结。
可以在处理器中调用,但不能在其中等待
你可以从自己的任意线程调用接口。每个句柄都支持任意线程访问,这是一项承诺,而不是当前 build 碰巧具备的属性。接口中不包含任何同步原语。
同一实例的处理器是串行执行的:两个处理器绝不会同时运行,同一个流(stream)内的顺序也会保持。所以处理器本身不需要加锁。
串行不等于去重。 顺序是一项承诺;一条消息投递几次是另一项承诺,在消息类型上声明。在“至少一次”模式下,你会看到同一条消息出现两次,而始终随消息一起传递的去重键,就是你分辨它们的依据。
在处理器内部发起操作是合法的,也不会死锁。不过它的结果永远不会在同一个处理器内到达——而是作为同一上下文中的另一次投递返回。
绝不要阻塞投递上下文
在处理器内部,不要等待网络,不要等待别人的锁,也不要同步等待你自己的调用。
违反这条禁令会有明确的表现:处理器占用上下文超出声明的预算时,结果要么是声明好的投递降级,要么是声明好的拒绝。绝不会出现悄无声息的变慢,等你在某个玩家的会话里才发现。
// legal: start and return. The outcome is a later delivery, not a value here.
sub = await room.Events.Subscribe<CrateOpened>(async e => {
await player.Inventory.Grant(e.Loot); // started, not awaited-to-completion inside the context
}); // ...the grant's outcome arrives on its own
await sub.DisposeAsync(); // stop receiving — local, works with the network down// legal: start and return. The outcome is a later delivery, not a value here.
const sub = await room.events.subscribe(CrateOpened, async (e) => {
await player.inventory.grant(e.loot);
});
await sub.close(); // stop receiving — local, works with the network down# legal: start and return. The outcome is a later delivery, not a value here.
sub = await room.events.subscribe(CrateOpened, lambda e: player.inventory.grant(e.loot))
await sub.close() # stop receiving — local, works with the network downGo 中基于特性的声明方式尚未确定(O-1),确定写法后会补上示例。
// subscribing is local and immediate; the outcome is a later delivery, on the game thread
TPSSubscription LootWatch = Room->Subscribe->CrateOpened(
[this](const FCrateOpened& Opened) { GrantLoot(Opened.Loot); });
LootWatch.Unsubscribe(); // stop receiving — local, works with the network down
// co_await support ships later: a built-in SDK coroutine wrapper that respects Unreal's GC and threading.// legal: start and return. The outcome is a later delivery, not a value here.
sub = await room.Events.Subscribe<CrateOpened>(async e => {
await player.Inventory.Grant(e.Loot); // started, not awaited-to-completion inside the context
}); // ...the grant's outcome arrives on its own
await sub.DisposeAsync(); // stop receiving — local, works with the network down“取消”是两件不同的事
在大多数语言里它是一个词,在这里却是两种操作,而且区别看得见:
| 你想要 | 含义 |
|---|---|
| 别再给我投递 | 本地操作。总会成功,连接断开时也一样。释放订阅就属于这一种。 |
| 停止这项工作 | 向平台发出的请求。幂等,但不保证工作是否已经执行。 |
容易出错的是第二种。取消已受理的工作是一个请求,未必能及时送达——这和超时完全一样,超时也不代表“没有生效”。无论哪种取消,已开始的非幂等操作,其结果仍可以通过该操作指定的方式查到。
两种失败也可以区分:取消一个从未到达我们的调用,是本地失败;取消我们已受理的工作,会得到声明集合中的某个终态。
你收到的是过去的副本
投递给处理器的值之后不会再变。我们从不交出指向自身状态的实时引用,所以你持有的任何东西,都不会在你两行代码之间发生变化。
因此,在处理器之外保留投递来的值是安全的——但你保留的只是某一时刻的观测,而不是通往当下的窗口。增量在送达途中可能会合并,所以你保存的一串值并不是事件的历史。
你也从不拥有我们的缓冲区。没有“借出再归还”,也没有“先组装再发送”:修改声明的状态,本身就是网络操作。
测试:内存实现,而不是 mock
SDK 提供完整的内存实现——接口相同,声明的结果集合相同,只是没有网络。它是一个需要单独依赖的组件,而不是生产 runtime 上的一个开关。
- 它不是残缺的。 不支持的操作会以声明好的错误码拒绝,绝不会凭空返回成功。
- 时间由你控制。 声明的截止时间——工作描述符的生命周期、预留、保留窗口——靠推进一步来到达,而不是靠 sleep。
- 确定性是声明好的,并且有边界:流内的顺序、声明的投递模式、可控的时间。浮点确定性不在承诺之列,所以完整的模拟在这里同样无法重放。
安装什么,以及最低版本
核心是一个单元;可选模块是各自独立的单元,每个都声明了组成和必需依赖列表。添加一个单元,绝不会改变其他单元的接口——模块挂载在它的声明所指定的位置,所以不会因为你在旁边装了什么,别处就多出或少了东西。
如果引用了某个可选单元却无法加载, 这属于初始化声明的结果之一——和报告已禁用模块的是同一个地方。绝不会是一个悄悄什么都不做的桩。
每种绑定都声明了它所基于的最低 runtime 版本。低于这个版本,初始化时就会拒绝,而不是部分可用:否则,过旧的 runtime 会在遇到第一个缺失的能力时出错,出错位置在你代码里的某个随机地方,而且通常发生在玩家的机器上,而不是你的机器上。提高最低版本属于破坏性变更,和其他破坏性变更走同样的流程。