跳到主要内容
SDK 如何工作

本页尚未翻译,当前显示英文版本。

线程、生命周期与测试

循环由你掌控,SDK 只往一个地方投递。 SDK 不会启动任何需要你操心的线程,也不会交给你任何锁;它只在你启动时选定的那一个上下文中调用你的代码。你可以从任意线程调用我们;我们只从一个地方调用你。

一个投递上下文,循环由你掌控

your threads — any numberthe SDKone delivery context — handlers run one at a timegame threadworkerjob task calls, from any threaddeliveries a call from a handler
调用可以从任意线程进入;投递只在一个上下文中发出,每次一个处理器;在处理器中发起的调用,其结果作为新的投递返回

每个实例都声明恰好一个投递上下文——它的所有处理器(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, idempotent
即将推出 —— Go

Go 中基于特性的声明方式尚未确定(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.
Unity C# is the same C# API here — 在 Unity 中原样运行;投递落在主线程上,由包替你取出
// 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 down
即将推出 —— Go

Go 中基于特性的声明方式尚未确定(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.
Unity C# is the same C# API here — 在 Unity 中基于生成的类型原样运行
// 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 会在遇到第一个缺失的能力时出错,出错位置在你代码里的某个随机地方,而且通常发生在玩家的机器上,而不是你的机器上。提高最低版本属于破坏性变更,和其他破坏性变更走同样的流程。

后续阅读

  • 快速入门——从头到尾创建第一个房间。
  • SDK 的构成——为什么只有一套接口,各部分如何组合在一起。
  • 深入底层——如果你好奇,可以看看这一层之下的那一层。