Цю сторінку ще не перекладено — показано англійську версію.
Потоки, час життя і тести
Цикл належить вам, а SDK доставляє все рівно в одне місце. SDK не запускає потоків, про які вам треба знати, не дає вам блокувань і викликає ваш код з одного контексту, який ви обрали під час запуску. Викликайте нас із будь-якого потоку — ми викликаємо вас з одного.
Один контекст доставки, і цикл належить вам
Екземпляр оголошує рівно один контекст доставки — єдине місце, де виконуються всі його обробники. Ви задаєте його під час ініціалізації, і за весь час життя екземпляра він не змінюється. Подія, дельта даних, результат виклику — усе приходить туди й більше нікуди.
Такий контекст буває двох видів, і ви обираєте один під час запуску:
- Цикл крутите ви. Середовище виконання саме нічого не робить: ви забираєте доставки, що чекають, зі свого циклу. Цей варіант потрібен рушію: доставки потрапляють в ігровий потік, у тому кадрі, який ви обрали.
- Цикл крутимо ми. Середовище виконання тримає один виділений потік. Цей варіант потрібен хосту в консольному застосунку або виділеному серверу.
Жоден варіант не є запасним для іншого, і третього, з пулом потоків, немає: скільки потоків тримає середовище виконання, вашому коду ніколи не треба питати.
Контекст ніколи не передають аргументом. Жоден обробник не приймає параметра «в якому я потоці», і запитати про це теж ніде. Де виконується обробник, визначає контракт, а не дані виклику.
Запуск і зупинка — явні
Ініціалізацію ви запускаєте самі, викликом, і у відповідь отримуєте результат. Ніщо не ініціалізується ліниво під час першого використання. Лінивий запуск переніс би єдине місце, де видно вимкнений модуль, у той виклик, який випадково виявився першим, і там це виглядало б як збій саме цього виклику.
Вимкнений модуль називають під час запуску, а результат повідомляє, що з двох сталося: або вимкнено весь залежний ланцюжок, або ви працюєте з меншим набором, і тоді є список того, що недоступно. Мовчазного третього варіанта немає.
// 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, поки не вирішено (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Зупинка явна, повна й ідемпотентна: після того як вона вдалася, жоден обробник цього екземпляра більше не викликається.
Зупинка не скасовує роботу, що вже виконується
Зупинка нічого не каже про операції, які вже почалися. Кожну з них можна відстежити тим способом, який назвала сама операція. Якщо вам треба знати, чи пройшла покупка, дізнайтеся це саме так, а не зупинкою.
У кожного дескриптора один оголошений кінець
Підписка (subscription), опис відкладеної роботи, сесія — усе це дескриптори (handle), і в кожного рівно один кінець, який називає контракт. Звільнення ідемпотентне, тож звільнити двічі — не помилка.
Три наслідки, у яких легко помилитися:
- Кінець — це ніколи не фіналізатор, не деструктор і не вихід з області видимості. Покинутий дескриптор лишається відкритим. Це помилка у вашому коді, і потай ми його не прибираємо: час життя, що залежит ь від мови, у кожному біндингу був би інший.
- Звернення до дескриптора після його кінця — оголошена відмова з кодом. Не порожній результат, не невизначена поведінка й не загальна помилка про звільнений об’єкт, з якою нічого не вдієш.
- Обрив з’єднання — не кінець дескриптора. Підписка переживає відключення і після перепідключення далі отримує дані. Дескриптори закінчуються з причин, які називає контракт, і втрати мережі серед них немає.
Жоден дескриптор не живе довше за екземпляр, який його видав: щойно ви зупинили екземпляр, усі видані ним дескриптори досягли свого кінця.
Викликати з обробника можна, чекати всередині нього — ні
Звертайтеся до інтерфейсу з будь-якого свого потоку. Кожен дескриптор потокобезпечний, і це обіцянка, а не властивість нинішньої збірки. Жоден примітив синхронізації не входить до інтерфейсу.
Обробники одного екземпляра виконуються послідовно: два ніколи не працюють одночасно, а порядок у межах одного потоку даних (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, поки не вирішено (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«Скасувати» — це дві різні речі
У більшості мов це одне слово, а тут — дві операції, і різницю між ними видно:
| Що вам потрібно | Що це |
|---|---|
| перестати доставляти мені | локальна дія. Завжди вдається, навіть коли з’єднання немає. Звільнення підписки — саме це. |
| зупинити роботу | запит до платформи. Ідемпотентний, і нічого не обіцяє щодо того, чи робота відбулася. |
Помиляються зазвичай із другою. Скасування прийнятої роботи — ц е запит, який може не встигнути дійти, так само як тайм-аут, що теж не означає «не застосовано». Після будь-якого з двох видів скасування результат розпочатої неідемпотентної операції лишається доступним тим способом, який назвала сама операція.
До того ж ці дві невдачі можна розрізнити: скасування виклику, що до нас так і не дійшов, — локальна помилка, а скасування вже прийнятої нами роботи дає кінцевий стан з оголошеного набору.
Те, що ви отримуєте, — копія минулого
Значення, доставлене вашому обробнику, потім не змінюється. Ми ніколи не видаємо живого посилання на власний стан, тож ніщо з того, що ви тримаєте, не зміниться між двома рядками вашого коду.
Тому зберігати доставлене значення після обробника безпечно — але збережене — це спостереження за моментом, а не вікно в теперішнє. Дорогою до вас дельти можуть злитися, тож збережений список значень — не історія того, що відбувалося.
Наші буфери вам теж ніколи не належать. Немає «взяти в оренду й повернути», немає «зібрати, потім надіслати»: зміна оголошеного стану і є мережевою операцією.
Тестування: реалізація в пам’яті, а не мок
Є повна реалізація в пам’яті — той самий інтерфейс, той самий набір оголошених результатів, без мережі. Це окрема залежність, а не прапорець робочого середовища виконання.
- Вона не урізана. Операцію, якої вона не підтримує, вона відхиляє з оголошеним кодом, а не відповідає вигаданим успіхом.
- Час у ваших руках. Оголошені строки — час життя опису роботи (work descriptor), резервування, вікно зберігання — настають, коли ви робите крок уперед, а не тоді, коли ви чекаєте.
- Детермінізм оголошений і обмежений: порядок у межах потоку даних, оголошений режим доставки (delivery mode), керований час. Детермінізм обчислень з рухомою комою не обіцяно, тож повну симуляцію тут теж не відтворити.
Що ви встановлюєте, і мінімальна версія
Ядро — це один пакет; необов’язкові модулі — окремі пакети, у кожного з яких оголошено склад і перелік обов’язкових залежностей. Додавання пакета ніколи не змінює інтерфейс іншого: модуль монтується туди, куди вказує його оголошення, тож через те, що ви встановили поруч, ніщо ніде не з’являється і не зникає.
Якщо на необов’язковий пакет є посилання, але його не вдається завантажити, це оголошений результат ініціалізації — там само, де повідомляють про вимкнений модуль. Ніколи не заглушка, яка мовчки нічого не робить.
Кожен біндинг оголошує мінімальну версію середовища виконання, під яку його зібрано. Нижче від неї ви отримаєте відмову під час ініціалізації, а не часткову роботу: інакше надто старе середовище виконання ламається на першій можливості, якої йому бракує, — у довільному місці вашого коду і зазвичай на машині гравця, а не на вашій. Підвищення цього мінімуму — зміна, що порушує сумісність, і вона проходить той самий процес, що й будь-яка інша.
Куди далі
- Перші кроки — перша кімната від початку до кінця.
- Як побудовано SDK — чому інтерфейс один і як частини складаються в ціле.
- Під капотом — шар під цим, якщо цікаво.