Перейти к основному содержимому
Как работает 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
Вызовы идут из любого потока, доставки выходят в одном контексте по одному обработчику за раз, а ответ на вызов из обработчика приходит новой доставкой

Экземпляр объявляет ровно один контекст доставки — единственное место, где выполняются все его обработчики. Контекст задаётся при инициализации и не меняется, пока экземпляр живёт. Событие (event), дельта данных, результат вызова — всё приходит туда и больше никуда.

Контекст бывает двух видов, и один из них вы выбираете при запуске:

  • Цикл крутите вы. Среда выполнения сама ничего не делает: вы забираете ожидающие доставки из своего цикла. Такой вариант нужен движку — доставки попадают в игровой поток, в том кадре, который вы выбрали.
  • Цикл крутим мы. Среда выполнения держит один выделенный поток. Такой вариант нужен хосту в консольном приложении или выделенному серверу.

Ни один из них не запасной вариант для другого, и третьего варианта с пулом потоков нет: сколько потоков держит среда выполнения, вашему коду знать не нужно.

Контекст никогда не передаётся аргументом. Ни один обработчик не принимает параметр «в каком я потоке», и спросить об этом тоже негде. Где выполняется обработчик, определяет контракт, а не данные вызова.

Запуск и остановка — явные

Инициализацию вы вызываете сами и в ответ получаете результат. Ничто не инициализируется лениво при первом обращении. При ленивом запуске единственное место, где виден выключенный модуль, переехало бы в тот вызов, который случайно оказался первым, и выглядело бы как сбой этого вызова.

Выключенный модуль называется при запуске, и результат сообщает, какой из двух случаев произошёл: либо выключена вся зависимая цепочка, либо вы работаете с урезанным набором — тогда к результату прилагается список того, что недоступно. Молчаливого третьего случая нет.

Инициализируем с явным результатом, крутим цикл сами и завершаем работу, когда закончили
// 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

Завершение работы — явное, полное и идемпотентное: после того как оно прошло успешно, ни один обработчик этого экземпляра больше не вызывается.

Завершение работы не отменяет начатые операции

Завершение ничего не говорит об операциях, которые уже начались. Чем закончилась каждая из них, можно узнать тем способом, который назвала сама операция. Если нужно понять, прошла ли покупка, спросите об этом так, а не через завершение работы.

У каждого дескриптора один объявленный конец

Подписка (subscription), описание отложенной работы, сессия — всё это дескрипторы, и у каждого ровно один конец, который называет контракт. Освобождение идемпотентно, так что освободить дескриптор дважды — не ошибка.

Три следствия, в которых легко ошибиться:

  • Конец — это никогда не финализатор, не деструктор и не выход из области видимости. Брошенный дескриптор (handle) остаётся открытым. Это ошибка в вашем коде, и втихую мы его не подбираем: время жизни, которое зависит от языка, в каждом биндинге было бы своим.
  • Обращение к дескриптору после его конца — объявленный отказ с кодом. Не пустой результат, не неопределённое поведение и не общая ошибка об освобождённом объекте, с которой ничего нельзя сделать.
  • Обрыв соединения — не конец дескриптора. Подписка переживает отключение и после переподключения продолжает получать данные. Дескрипторы заканчиваются по причинам, которые называет контракт, и потеря сети к ним не относится.

Ни один дескриптор не живёт дольше экземпляра, который его выдал: после завершения работы все выданные им дескрипторы закрыты.

Вызывать из обработчика можно, ждать внутри него — нельзя

Вызывайте SDK из любого своего потока. Каждым дескриптором можно пользоваться из любого потока, и это обещание, а не особенность сегодняшней сборки. Примитивов синхронизации в интерфейсе SDK нет.

Обработчики одного экземпляра выполняются последовательно: два никогда не работают одновременно, а порядок внутри одного потока данных (stream) сохраняется. Поэтому собственные блокировки обработчику не нужны.

Последовательно — не значит без дублей. Порядок — одно обещание, а сколько раз сообщение будет доставлено — другое, и оно объявляется на типе сообщения. При at-least-once вы увидите одно и то же сообщение дважды, а отличить повтор поможет ключ дедупликации, который всегда идёт вместе с сообщением.

Начать операцию изнутри обработчика можно, и взаимной блокировки это не вызовет. Но её результат никогда не придёт в тот же обработчик: он вернётся отдельной доставкой в том же контексте.

Никогда не блокируйте контекст доставки

Внутри обработчика не ждите сеть, чужую блокировку или синхронный результат собственного вызова.

У этого запрета есть симптом. Если обработчик держит контекст дольше объявленного бюджета, результатом будет либо объявленная деградация доставки, либо объявленный отказ. Молчаливого замедления, которое вы обнаружите уже в сессии игрока, не бывает.

Запускаем работу из обработчика и сразу выходим — результат придёт отдельной доставкой
// 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

«Отмена» — это две разные вещи

В большинстве языков это одно слово, а здесь — две операции, и разница между ними заметна:

Что вам нужноЧто это
перестать доставлять мнелокальная операция. Всегда проходит успешно, даже без соединения. Освобождение подписки — это как раз она.
остановить работузапрос к платформе. Идемпотентен и ничего не обещает о том, была ли работа выполнена.

Ошибаются обычно со второй. Отмена принятой работы — это запрос, который может не успеть дойти, в точности как тайм-аут, который тоже не означает «не применилось». После любой из двух отмен результат начатой неидемпотентной операции по-прежнему можно узнать тем способом, который назвала сама операция.

Сбои у них тоже различаются. Отмена вызова, который до нас так и не дошёл, — локальный сбой. Отмена работы, которую мы уже приняли, даёт конечное состояние из объявленного набора.

Вы получаете копию прошлого

Значение, доставленное в обработчик, потом не меняется. Мы никогда не отдаём живую ссылку на своё внутреннее состояние, поэтому между двумя строками вашего кода ничего из того, что вы держите, не изменится.

Значит, доставленное значение можно спокойно хранить и после выхода из обработчика. Но сохранили вы наблюдение за моментом, а не окно в настоящее. По дороге к вам дельты могут объединяться, так что сохранённый список значений — не история того, что произошло.

Наши буферы вам тоже никогда не принадлежат. Никаких «взять и вернуть», никаких «собрать, потом отправить»: изменили объявленное состояние — значит, уже отправили по сети.

Тестирование: реализация в памяти, а не мок

Есть полная реализация в памяти — тот же интерфейс, тот же набор объявленных результатов, только без сети. Это отдельная зависимость, а не флаг в рабочей среде выполнения.

  • Она не урезана. Операцию, которую она не поддерживает, она отклоняет с объявленным кодом, а не отвечает выдуманным успехом.
  • Время в ваших руках. Объявленные сроки — время жизни описания работы, резервирование, окно хранения — наступают, когда вы делаете шаг вперёд, а не после ожидания.
  • Детерминизм объявлен и ограничен: порядок внутри потока данных, объявленный режим доставки, управляемое время. Детерминизм вычислений с плавающей точкой не обещается, поэтому полную симуляцию здесь тоже не воспроизвести.

Что вы устанавливаете и минимальная версия

Ядро — это один пакет, а необязательные модули — отдельные пакеты, и у каждого объявлены состав и список обязательных зависимостей. Новый пакет никогда не меняет интерфейс другого: модуль монтируется туда, куда указывает его объявление (declaration), поэтому из-за того, что вы установили рядом, в других местах ничего не появляется и не исчезает.

Если на необязательный пакет есть ссылка, но загрузить его не удалось, это объявленный результат инициализации — там же, где сообщается о выключенном модуле. Заглушки, которая молча ничего не делает, не будет.

Каждый биндинг объявляет минимальную версию среды выполнения, под которую он собран. На версии ниже вы получите отказ при инициализации, а не частичную работу. Иначе слишком старая среда сломается на первой же возможности, которой в ней нет, — в случайном месте вашего кода и обычно на машине игрока (player), а не на вашей. Повышение минимальной версии — ломающее изменение, и оно проходит тот же процесс, что и любое другое.

Что дальше