跳至内容

Tutorial de Async en Unity: ¿En qué se diferencia realmente del .Net nativo?

No voy a explicar qué es Async/Await, es similar a las corrutinas pero más limpio. Aquí asumo que tienes conocimientos de programación asíncrona moderna.

Resumamos la implementación interna de Unity para entender claramente qué está pasando. En pocas palabras, Unity hace algunos procesamientos internos para garantizar dos puntos cuando el llamador está en el hilo principal: 1. El llamador no cambiará de hilo, 2. La función asíncrona llamada no se ejecutará en otro hilo.

Objetivo del patrón Async en Unity

El patrón Async es más moderno y es el camino a seguir, pero Unity actualmente no planea reemplazar las corrutinas (IEnumerators) con Async. Su propósito principal es para operaciones como I/O, etc.

Unity Async tiene mejor rendimiento que las corrutinas, pero un menor grado de paralelismo

Es adecuado para objetos singleton/principales, como la cámara, la interfaz de usuario, métodos de I/O, etc. Para una pequeña cantidad de operaciones, Async supera en rendimiento a las corrutinas. Para código donde muchos objetos necesiten ejecutarse en paralelo, aún se deben usar corrutinas.

Sección de Rendimiento

Prefiere devolver el Awaitable empaquetado por Unity, en lugar de Task (Unity 6)

El Async de .Net utiliza Task, como se muestra a continuación:

  async Task function() {
        await Task.Delay(1000);
  }

El Task nativo de .Net crea un nuevo objeto cada vez que se devuelve, mientras que el objeto Awaitable empaquetado por Unity se comparte, por lo que la presión del GC es menor. Awaitable también tiene la ventaja de que el cambio de contexto de hilo es “gratuito” (sin costo), ver la siguiente sección.

  async Awaitable function() {
        while(true) {
            await Awaitable.NextFrameAsync();
        }
  }

Para métodos que necesitan await repetidamente, como el código de ejemplo anterior, utiliza un valor de retorno Awaitable. Por supuesto, para métodos de I/O que hacen await una sola vez, no importa.

Dado que los objetos Awaitable se comparten (se usan por otros después del await), no se pueden hacer await repetidos (como en el código siguiente), lo que causaría un deadlock.

async Awaitable function() {
        while(true) {
            var returned_awaitable = Awaitable.NextFrameAsync();
            await returned_awaitable;
            await returned_awaitable; //deadlock
        }
  }

Unity maneja internamente el contexto (cambio) de Async, diferente a .Net

El concepto principal de Async, la reanudación Resume, se refiere a cambiar de vuelta y continuar ejecutando el código después del await.

El “contexto” determina cómo se reanuda el código await. .Net utiliza el ThreadPool para cambiar entre diferentes códigos asíncronos. Por ejemplo, el siguiente código .Net:

using System;

async Task function() {
    Console.WriteLine($"task start: Tid {Environment.CurrentManagedThreadId}");
    await Task.Delay(1000);
    Console.WriteLine($"task end: Tid {Environment.CurrentManagedThreadId}");
}

Console.WriteLine($"caller start, Tid{Environment.CurrentManagedThreadId}");
await function();
Console.WriteLine($"caller end, Tid{Environment.CurrentManagedThreadId}");

El resultado de la ejecución es:

caller start, Tid12
task start: Tid 12
task end: Tid 38
caller end, Tid38

Se puede ver que tanto el llamador como el ejecutor cambiaron de hilo.

Para garantizar que los métodos llamados desde el hilo principal solo se ejecuten en el hilo principal, Unity empaqueta internamente un método de contexto llamado UnitySynchronizationContext (esto es interno, solo debemos saberlo, no necesitamos gestionarlo).

Así que no te preocupes por problemas de hilos. Ya sea que el valor de retorno sea Awaitable o Task de .Net, siempre que sea llamado desde el hilo principal, será gestionado por UnitySynchronizationContext y se garantizará su ejecución en el hilo principal.

Sin embargo, las funciones Async que devuelven Task, al reanudarse (Resume), solo pueden hacerlo en el siguiente frame, no inmediatamente. Además, Unity necesita realizar un paso extra para reemplazar el contexto de .Net, por lo que Awaitable tiene un rendimiento superior.

Por supuesto, las funciones Async llamadas desde otros hilos, que no tienen el requisito de restricción al hilo principal, seguirán utilizando el ThreadPool de .Net.

Patrones de Uso

Patrón “Dispara y Olvida” (Fire-and-Forget)

Al llamar a una función Async, puedes omitir await. El sistema ejecutará internamente esa función Async de manera continua.

async Awaitable worker() {
    await Awaitable.WaitForSecondsAsync(5);
    Debug.Log($"worker end, frame {Time.frameCount}");
}

void caller() {
    _ = worker();
    Debug.Log($"caller end, frame {Time.frameCount}");
}

Resultado:

caller end, frame 1
worker end, frame 292

caller no escribió await, pero worker se ejecutará de todos modos, simplemente no se espera a que termine.

En .Net, worker se ejecutaría continuamente en el ThreadPool. En Unity, lo ejecuta continuamente el loop del hilo principal.

Este patrón también se usa comúnmente para llamar a funciones Async desde funciones Sync (sincrónicas) ordinarias.

Nota: Este método es equivalente a async void, se ejecuta en el nivel superior (se pierde la pila de llamadas), las excepciones en el código serán invisibles, manifestándose como si el código no se hubiera ejecutado. No es que el patrón no sea usable.

Unity puede implementar fácilmente programación multihilo usando Async

Esto permite implementar fácilmente código multihilo sin necesidad de Jobs, etc. Ejecutar cualquier fragmento de código en un hilo diferente.

Si deseas cambiar al pool de hilos en segundo plano (similar a la implementación del ThreadPool de .Net), o cambiar al hilo principal, puedes especificarlo manualmente. Por ejemplo:

async Awaitable function() {
    await Awaitable.BackgroundThreadAsync();
    ...
    return null;
}

El código después de este await, hasta el final de la función, se ejecutará en el ThreadPool administrado de .Net en segundo plano.

Si el llamador está en el hilo principal, el cambio no alterará el hilo del llamador, lo cual es muy conveniente. Nota: El cambio requiere al menos 1 frame de tiempo.

Cambiar al hilo principal es await Awaitable.MainThreadAsync();, generalmente no es necesario llamarlo.

Desafortunadamente, en WebGL (wasm), no puedes usar multihilo de .Net, incluyendo cualquier método de Task, así como Awaitable.BackgroundThreadAsync().

Las funciones Async pueden definirse como void, convenientes para varios callbacks de event

Si quieres que un callback ejecute tu función Async, no necesitas envolverla en una función normal para llamar a tu función asíncrona. Puedes usar async void, similar a esto:

async void function(CallbackContext ctx) {
    try{
        ...hacer algo...
        await Awaitable.WaitForSecondsAsync(5);
    }catch (Exception e){
        Debug.LogError(e);
    }
}

void Start() {
    InputAction.performed += function;
}

Nota: async void se ejecuta en el nivel superior, por lo que las excepciones dentro de la función no serán detectadas externamente; necesitas capturarlas tú mismo con catch.

Forma asíncrona de los eventos de MonoBehaviour

Los eventos de MonoBehaviour como Start, Update, etc., pueden usarse en modo async, por ejemplo: async Awaitable Start() {}. Pero recuerda que el Start o Awake asíncrono, antes de completar su ejecución, ya será llamado Update.