Esta página aún no está traducida: se muestra la versión en inglés.
Hilos, ciclo de vida y pruebas
El bucle es tuyo, y el SDK entrega en un único lugar. El SDK no inicia ningún hilo que tengas que conocer, no te pasa ningún lock y llama a tu código desde un único contexto que elegiste al arrancar. Llámanos desde el hilo que quieras; nosotros te llamamos desde uno solo.
Un solo contexto de entrega, y el bucle es tuyo
Una instancia declara exactamente un contexto de entrega: el único lugar donde se ejecutan todos sus handlers. Se fija al inicializarla y no cambia en toda la vida de la instancia. Un evento, un delta de datos, el resultado de una llamada: todo llega ahí y a ningún otro lugar.
Tiene dos formas, y eliges una al arrancar:
- Lo bombeas tú. El runtime no hace nada por su cuenta; tú vacías las entregas pendientes desde tu propio bucle. Es la forma que necesita un motor: las entregas caen en el hilo del juego, en el frame que tú elijas.
- Lo manejamos nosotros. El runtime mantiene un hilo de ejecución dedicado. Es la forma que necesita una aplicación de consola que hace de host o un servidor dedicado.
Ninguna es un plan B de la otra, y no existe una tercera opción con un pool de hilos: cuántos hilos tiene el runtime nunca es algo que tu código deba preguntarse.
El contexto nunca es un argumento. Ningún handler recibe un parámetro de “en qué hilo estoy”, y no hay nada que consultar. Dónde se ejecuta tu handler es una propiedad del contrato, no un dato de la llamada.
Arrancar y detener son acciones explícitas
La inicialización es una llamada que haces tú, y responde con un resultado. Nada se inicializa de forma diferida en el primer uso. Un arranque diferido llevaría el único lugar donde se ve un módulo deshabilitado a la llamada que por casualidad fuera la primera, y ahí parecería que esa llamada falló.
Un módulo deshabilitado se nombra al arrancar, y el resultado dice cuál de dos cosas pasó: toda la cadena que depende de él está apagada, o sigues funcionando con menos y recibes la lista de lo que no está disponible. No hay un tercer caso silencioso.
// 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, idempotentTodavía no está decidido cómo declarar con atributos en Go (O-1). Los ejemplos llegarán cuando se elija la forma de escribirlo.
// 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, idempotentEl apagado es explícito, completo e idempotente: una vez que termina bien, ningún handler de esa instancia vuelve a llamarse.
Apagar no cancela el trabajo en curso
El apagado no dice nada sobre las operaciones ya iniciadas. Cada una sigue siendo consultable por el medio que definió esa operación; si necesitas saber si una compra se completó, pregúntalo por ese medio, no apagando.
Cada handle tiene un único final declarado
Una suscripción, un descriptor de trabajo diferido, una sesión: cada uno es un handle, y cada uno tiene exactamente un final, el que nombra el contrato. Liberar es idempotente, así que liberar dos veces no es un error.
Tres consecuencias con las que es fácil equivocarse:
- El final nunca es un finalizador, un destructor ni un ámbito. Un handle que dejas tirado sigue abierto. Eso es un bug de tu código, no algo que recuperamos en silencio, porque un ciclo de vida que dependiera del lenguaje sería distinto en cada binding.
- Usar un handle después de su final es un rechazo declarado, con un código. No un resultado vacío, ni un comportamiento indefinido, ni un error genérico de objeto liberado que no te da nada con qué actuar.
- Una conexión caída no es el final de un handle. Una suscripción sobrevive a una desconexión y sigue recibiendo después de reconectarse. Los handles terminan por las razones que nombra el contrato, y perder la red no es una de ellas.
Ningún handle vive más que la instancia que lo emitió: una vez que apagas, todos los handles que te dio llegan a su final.
Llamar desde un handler está bien; esperar dentro de uno, no
Llama a la interfaz desde cualquiera de tus hilos. Todos los handles se pueden usar desde cualquier hilo, y eso es una promesa, no una propiedad de la compilación actual. Ninguna primitiva de sincronización forma parte de la interfaz.
Los handlers de una instancia están serializados: nunca se ejecutan dos al mismo tiempo, y el orden dentro de un flujo (stream) se conserva. Por eso un handler no necesita locks propios.
Serializado no significa deduplicado. El orden es una promesa; cuántas veces se entrega un mensaje es otra, y se declara en el tipo de mensaje. Con la entrega al menos una vez vas a ver el mismo mensaje dos veces, y la clave de deduplicación que siempre viaja con él es lo que te permite notarlo.
Iniciar una operación desde dentro de un handler es válido y no puede provocar un deadlock. Eso sí, su resultado nunca llega dentro de ese mismo handler: vuelve como una entrega aparte en el mismo contexto.
Nunca bloquees el contexto de entrega
Dentro de un handler, no esperes a la red, ni al lock de otro, ni de forma síncrona a tu propia llamada.
La prohibición tiene un síntoma: un handler que retiene el contexto más allá de su presupuesto declarado produce una degradación declarada de la entrega o un rechazo declarado. Lo que nunca produce es una ralentización silenciosa que termines descubriendo en la sesión de un jugador (player).
// 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 downTodavía no está decidido cómo declarar con atributos en Go (O-1). Los ejemplos llegarán cuando se elija la forma de escribirlo.
// 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“Cancelar” son dos cosas distintas
En la mayoría de los lenguajes es una sola palabra; aquí son dos operaciones, y la diferencia se puede observar:
| Lo que quieres | Qué es |
|---|---|
| que dejen de entregarme | local. Siempre funciona, incluso sin conexión. Liberar una suscripción es exactamente esto. |
| que se detenga el trabajo | una solicitud a la plataforma. Es idempotente y no promete nada sobre si el trabajo se hizo o no. |
La segunda es la que suele entenderse mal. Cancelar un trabajo aceptado es una solicitud que puede no llegar a tiempo, igual que un timeout, que tampoco significa “no se aplicó”. Después de cualquiera de las dos cancelaciones, el resultado de una operación no idempotente ya iniciada sigue siendo consultable por el medio que definió esa operación.
Y los dos fallos se distinguen: cancelar una llamada que nunca nos llegó es un fallo local; cancelar un trabajo que ya habíamos aceptado te da un estado terminal de un conjunto declarado.
Lo que recibes es una copia del pasado
Un valor entregado a tu handler no cambia después. Nunca entregamos una referencia viva a nuestro propio estado, así que nada de lo que tienes muta entre dos líneas de tu código.
Por eso es seguro conservar un valor entregado más allá del handler, pero lo que guardaste es la observación de un momento, no una ventana al presente. Los deltas pueden fusionarse en el camino, así que una lista de valores guardados no es un historial de lo que pasó.
Tampoco eres dueño de nuestros buffers. No hay que pedirlos prestados y devolverlos, ni armar algo y después enviarlo: cambiar el estado declarado ya es la operación de red.
Pruebas: una implementación en memoria, no un mock
Hay una implementación completa en memoria: la misma interfaz, el mismo conjunto de resultados declarados, sin red. Es una dependencia aparte, no un flag del runtime de producción.
- No es parcial. Una operación que no soporta se rechaza con un código declarado; nunca responde con un éxito inventado.
- El tiempo es tuyo. Los plazos declarados — la vida de un descriptor de trabajo, una reserva, una ventana de retención — se alcanzan avanzando un paso, no esperando con sleep.
- El determinismo está declarado y acotado: el orden dentro de un flujo, el modo de entrega declarado, el tiempo controlable. El determinismo de punto flotante no se promete, así que aquí tampoco se reproduce una simulación completa.
Qué instalas y la versión mínima
El núcleo es una unidad; los módulos opcionales son unidades separadas, cada una con una composición declarada y una lista declarada de dependencias obligatorias. Agregar una unidad nunca cambia la interfaz de otra: un módulo se monta donde dice su declaración, así que nada aparece ni desaparece en otro lado por lo que instalaste al lado.
Si una unidad opcional está referenciada pero no puede cargarse, eso es un resultado declarado de la inicialización, en el mismo lugar donde se informa un módulo deshabilitado. Nunca un stub que en silencio no hace nada.
Cada binding declara la versión mínima de runtime con la que está construido. Por debajo de ella recibes un rechazo en la inicialización, no un funcionamiento parcial: si no, un runtime demasiado antiguo falla en la primera capacidad que le falta, que está en algún lugar arbitrario de tu código y, por lo general, en la máquina de un jugador y no en la tuya. Subir ese mínimo es un cambio incompatible y pasa por el mismo proceso que cualquier otro.
Adónde seguir
- Primeros pasos — la primera sala (room), de principio a fin.
- Cómo está construido el SDK — por qué hay una sola interfaz y cómo encajan las piezas.
- Bajo el capó — la capa que está debajo de esta, si te da curiosidad.