paint-brush
El manual completo de programación asíncrona para el desarrollo de Unitypor@dmitrii
5,860 lecturas
5,860 lecturas

El manual completo de programación asíncrona para el desarrollo de Unity

por Dmitrii Ivashchenko31m2023/03/30
Read on Terminal Reader

Demasiado Largo; Para Leer

En este artículo, vamos a hablar sobre cómo evitar tales problemas. Recomendaremos técnicas de programación asíncrona para realizar estas tareas en un subproceso separado, dejando así el subproceso principal libre para realizar otras tareas. Esto ayudará a garantizar una jugabilidad fluida y receptiva, y (con suerte) jugadores satisfechos.
featured image - El manual completo de programación asíncrona para el desarrollo de Unity
Dmitrii Ivashchenko HackerNoon profile picture
0-item

Algunas tareas en el desarrollo de juegos no son síncronas, son asíncronas. Esto significa que no se ejecutan linealmente dentro del código del juego. Algunas de estas tareas asincrónicas pueden requerir bastante tiempo para completarse, mientras que otras están asociadas con cálculos intensivos.


Algunas de las tareas asincrónicas de juegos más comunes son las siguientes:

  • Realización de solicitudes de red

  • Carga de escenas, recursos y otros recursos

  • Lectura y escritura de archivos.

  • Inteligencia artificial para la toma de decisiones

  • Largas secuencias de animación.

  • Procesamiento de grandes cantidades de datos

  • búsqueda de caminos


Ahora, de manera crucial, dado que todo el código de Unity se ejecuta en un hilo, cualquier tarea como una de las mencionadas anteriormente, si se realizaran de forma sincrónica, provocaría el bloqueo del hilo principal y, por lo tanto, caídas de fotogramas.


Hola a todos, mi nombre es Dmitrii Ivashchenko y soy el jefe del equipo de desarrollo de MY.GAMES. En este artículo, vamos a hablar sobre cómo evitar tales problemas. Recomendaremos técnicas de programación asincrónica para realizar estas tareas en un subproceso separado, dejando así el subproceso principal libre para realizar otras tareas. Esto ayudará a garantizar una jugabilidad fluida y receptiva, y (con suerte) jugadores satisfechos.

corrutinas

Primero, hablemos de corrutinas. Se introdujeron en Unity en 2011, incluso antes de que async/await apareciera en .NET. En Unity, las corrutinas nos permiten realizar un conjunto de instrucciones en varios marcos, en lugar de ejecutarlos todos a la vez. Son similares a los subprocesos, pero son livianos e integrados en el ciclo de actualización de Unity, lo que los hace ideales para el desarrollo de juegos.


(Por cierto, históricamente hablando, las corrutinas fueron la primera forma de realizar operaciones asincrónicas en Unity, por lo que la mayoría de los artículos en Internet tratan sobre ellas).


Para crear una rutina, debe declarar una función con el tipo de retorno IEnumerator . Esta función puede contener cualquier lógica que desee que ejecute la rutina.


Para iniciar una rutina, debe llamar al método StartCoroutine en una instancia MonoBehaviour y pasar la función de rutina como argumento:

 public class Example : MonoBehaviour { void Start() { StartCoroutine(MyCoroutine()); } IEnumerator MyCoroutine() { Debug.Log("Starting coroutine"); yield return null; Debug.Log("Executing coroutine"); yield return null; Debug.Log("Finishing coroutine"); } }


Hay varias instrucciones de rendimiento disponibles en Unity, como WaitForSeconds , WaitForEndOfFrame , WaitForFixedUpdate , WaitForSecondsRealtime , WaitUntil y algunas otras. Es importante recordar que su uso genera asignaciones, por lo que deben reutilizarse siempre que sea posible.


Por ejemplo, considere este método de la documentación:

 IEnumerator Fade() { Color c = renderer.material.color; for (float alpha = 1f; alpha >= 0; alpha -= 0.1f) { ca = alpha; renderer.material.color = c; yield return new WaitForSeconds(.1f); } }


Con cada iteración del bucle, se creará una nueva instancia de new WaitForSeconds(.1f) . En lugar de esto, podemos mover la creación fuera del bucle y evitar asignaciones:

 IEnumerator Fade() { Color c = renderer.material.color; **var waitForSeconds = new WaitForSeconds(0.2f);** for (float alpha = 1f; alpha >= 0; alpha -= 0.1f) { ca = alpha; renderer.material.color = c; yield return **waitForSeconds**; } }


Otra propiedad importante a tener en cuenta es que yield return se puede usar con todos los métodos Async proporcionados por Unity porque AsyncOperation s son descendientes de YieldInstruction :

 yield return SceneManager.LoadSceneAsync("path/to/scene.unity");

Algunas posibles trampas de las corrutinas

Habiendo dicho todo esto, las corrutinas también tienen algunos inconvenientes a tener en cuenta:


  • Es imposible devolver el resultado de una operación larga. Todavía necesita devoluciones de llamada que se pasarán a la corrutina y se llamarán cuando termine para extraer cualquier dato de ella.
  • Una rutina está estrictamente ligada al MonoBehaviour que la inicia. Si el GameObject se apaga o se destruye, la rutina deja de procesarse.
  • La estructura try-catch-finally no se puede utilizar debido a la presencia de la sintaxis de rendimiento.
  • Al menos un cuadro pasará después de la yield return antes de que comience a ejecutarse el siguiente código.
  • Asignación de la lambda y la corrutina en sí.

promesas

Las promesas son un patrón para organizar y hacer que las operaciones asincrónicas sean más legibles. Se han vuelto populares debido a su uso en muchas bibliotecas de JavaScript de terceros y, desde ES6, se han implementado de forma nativa.


Al usar Promises, devolvemos inmediatamente un objeto de su función asíncrona. Esto permite que la persona que llama espere la resolución (o un error) de la operación.


Esencialmente, esto hace que los métodos asíncronos puedan devolver valores y "actuar" como métodos síncronos: en lugar de devolver el valor final de inmediato, dan una "promesa" de que devolverán un valor en algún momento en el futuro.


Hay varias implementaciones de Promises para Unity:


La forma principal de interactuar con una promesa es a través de funciones de devolución de llamada .


Puede definir una función de devolución de llamada que se llamará cuando se resuelva una Promesa y otra función de devolución de llamada que se llamará si se rechaza la Promesa. Estas devoluciones de llamada reciben el resultado de la operación asincrónica como argumentos, que luego se pueden usar para realizar más operaciones.


De acuerdo con estas especificaciones de la organización Promises/A+, una promesa puede estar en uno de tres estados:


  • Pending : el estado inicial, esto significa que la operación asíncrona aún está en curso y aún no se conoce el resultado de la operación.
  • Fulfilled ( Resolved ): el estado resuelto va acompañado de un valor que representa el resultado de la operación.
  • Rejected : si la operación asíncrona falla por algún motivo, se dice que la Promesa está "rechazada". El estado rechazado va acompañado del motivo del fallo.

Más sobre promesas

Además, las promesas se pueden encadenar, de modo que el resultado de una Promesa se pueda usar para determinar el resultado de otra Promesa.


Por ejemplo, puede crear una Promesa que obtenga algunos datos de un servidor y luego usar esos datos para crear otra Promesa que realice algunos cálculos y otras acciones:

 var promise = MakeRequest("https://some.api") .Then(response => Parse(response)) .Then(result => OnRequestSuccess(result)) .Then(() => PlaySomeAnimation()) .Catch(exception => OnRequestFailed(exception));


Aquí hay un ejemplo de cómo organizar un método que realiza una operación asincrónica:

 public IPromise<string> MakeRequest(string url) { // Create a new promise object var promise = new Promise<string>(); // Create a new web client using var client = new WebClient(); // Add a handler for the DownloadStringCompleted event client.DownloadStringCompleted += (sender, eventArgs) => { // If an error occurred, reject the promise if (eventArgs.Error != null) { promise.Reject(eventArgs.Error); } // Otherwise, resolve the promise with the result else { promise.Resolve(eventArgs.Result); } }; // Start the download asynchronously client.DownloadStringAsync(new Uri(url), null); // Return the promise return promise; }


También podríamos envolver las corrutinas en una Promise :

 void Start() { // Load the scene and then show the intro animation LoadScene("path/to/scene.unity") .Then(() => ShowIntroAnimation()) .Then( ... ); } // Load a scene and return a promise Promise LoadScene(string sceneName) { // Create a new promise var promise = new Promise(); // Start a coroutine to load the scene StartCoroutine(LoadSceneRoutine(promise, sceneName)); // Return the promise return promise; } IEnumerator LoadSceneRoutine(Promise promise, string sceneName) { // Load the scene asynchronously yield return SceneManager.LoadSceneAsync(sceneName); // Resolve the promise once the scene is loaded promise.Resolve(); }


Y, por supuesto, puede organizar cualquier combinación de orden de ejecución de la promesa usando ThenAll / Promise.All y ThenRace / Promise.Race :

 // Execute the following two promises in sequence Promise.Sequence( () => Promise.All( // Execute the two promises in parallel RunAnimation("Foo"), PlaySound("Bar") ), () => Promise.Race( // Execute the two promises in a race RunAnimation("One"), PlaySound("Two") ) );

Las partes "poco prometedoras" de las promesas

A pesar de toda la comodidad de uso, las promesas también tienen algunos inconvenientes:


  • Gastos generales : la creación de promesas implica gastos generales adicionales en comparación con el uso de otros métodos de programación asíncrona, como las corrutinas. En algunos casos, esto puede conducir a una disminución del rendimiento.
  • Depuración : la depuración de promesas puede ser más difícil que la depuración de otros patrones de programación asincrónica. Puede ser difícil rastrear el flujo de ejecución e identificar el origen de los errores.
  • Manejo de excepciones : el manejo de excepciones puede ser más complejo con Promises en comparación con otros patrones de programación asíncrona. Puede ser difícil administrar errores y excepciones que ocurren dentro de una cadena Promise.

Tareas asíncronas/en espera

La característica async/await ha sido parte de C# desde la versión 5.0 (2012) y se introdujo en Unity 2017 con la implementación del tiempo de ejecución .NET 4.x.


En la historia de .NET se pueden distinguir las siguientes etapas:


  1. EAP (Patrón asíncrono basado en eventos): este enfoque se basa en eventos que se desencadenan al finalizar una operación y un método regular que invoca esta operación.
  2. APM (modelo de programación asíncrona): este enfoque se basa en dos métodos. El método BeginSmth devuelve la interfaz IAsyncResult . El método EndSmth toma IAsyncResult ; si la operación no se completa en el momento de la llamada EndSmth , el subproceso se bloquea.
  3. TAP (patrón asíncrono basado en tareas): este concepto se mejoró con la introducción de async/await y los tipos Task y Task<TResult> .


Los enfoques anteriores quedaron obsoletos debido al éxito del último enfoque.

Para crear un método asíncrono, el método debe estar marcado con la palabra clave async , contener una await dentro y el valor de retorno debe ser Task , Task<T> o void (no recomendado).

 public async Task Example() { SyncMethodA(); await Task.Delay(1000); // the first async operation SyncMethodB(); await Task.Delay(2000); // the second async operation SyncMethodC(); await Task.Delay(3000); // the third async operation }


En este ejemplo, la ejecución tendrá lugar así:


  1. Primero, se ejecutará el código que precede a la llamada a la primera operación asíncrona ( SyncMethodA ).
  2. Se inicia la primera operación asíncrona await Task.Delay(1000) y se espera que se ejecute. Mientras tanto, se guardará el código que se llamará cuando se complete la operación asincrónica (la "continuación").
  3. Una vez completada la primera operación asincrónica, la "continuación": el código hasta la siguiente operación asincrónica ( SyncMethodB ) comenzará a ejecutarse.
  4. La segunda operación asíncrona ( await Task.Delay(2000) ) se inicia y se espera que se ejecute. Al mismo tiempo, se conservará la continuación: el código que sigue a la segunda operación asíncrona ( SyncMethodC ).
  5. Después de completar la segunda operación asincrónica, se ejecutará SyncMethodC , seguido de la ejecución y esperando la tercera operación asincrónica await Task.Delay(3000) .


Esta es una explicación simplificada, ya que, de hecho, async/await es azúcar sintáctico para permitir la llamada conveniente de métodos asincrónicos y esperar a que se completen.


También puede organizar cualquier combinación de órdenes de ejecución usando WhenAll y WhenAny :

 var allTasks = Task.WhenAll( Task.Run(() => { /* ... */ }), Task.Run(() => { /* ... */ }), Task.Run(() => { /* ... */ }) ); allTasks.ContinueWith(t => { Console.WriteLine("All the tasks are completed"); }); var anyTask = Task.WhenAny( Task.Run(() => { /* ... */ }), Task.Run(() => { /* ... */ }), Task.Run(() => { /* ... */ }) ); anyTask.ContinueWith(t => { Console.WriteLine("One of tasks is completed"); });

IAsyncStateMachine

El compilador de C# transforma las llamadas async/await en una máquina de estado IAsyncStateMachine , que es un conjunto secuencial de acciones que se deben realizar para completar la operación asincrónica.


Cada vez que llama a una operación de espera, la máquina de estado completa su trabajo y espera la finalización de esa operación, después de lo cual continúa ejecutando la siguiente operación. Esto permite que las operaciones asincrónicas se realicen en segundo plano sin bloquear el subproceso principal y también hace que las llamadas a métodos asincrónicos sean más simples y legibles.


Por lo tanto, el método Example se transforma en la creación e inicialización de una máquina de estado con la anotación [AsyncStateMachine(typeof(ExampleStateMachine))] , y la propia máquina de estado tiene una cantidad de estados igual a la cantidad de llamadas en espera.


  • Ejemplo del método transformado Example

     [AsyncStateMachine(typeof(ExampleStateMachine))] public /*async*/ Task Example() { // Create a new instance of the ExampleStateMachine class ExampleStateMachine stateMachine = new ExampleStateMachine(); // Create a new AsyncTaskMethodBuilder and assign it to the taskMethodBuilder property of the stateMachine instance stateMachine.taskMethodBuilder = AsyncTaskMethodBuilder.Create(); // Set the currentState property of the stateMachine instance to -1 stateMachine.currentState = -1; // Start the stateMachine instance stateMachine.taskMethodBuilder.Start(ref stateMachine); // Return the Task property of the taskMethodBuilder return stateMachine.taskMethodBuilder.Task; }


  • Ejemplo de una máquina de estado generada ExampleStateMachine

     [CompilerGenerated] private sealed class ExampleStateMachine : IAsyncStateMachine { public int currentState; public AsyncTaskMethodBuilder taskMethodBuilder; private TaskAwaiter taskAwaiter; public int paramInt; private int localInt; void IAsyncStateMachine.MoveNext() { int num = currentState; try { TaskAwaiter awaiter3; TaskAwaiter awaiter2; TaskAwaiter awaiter; switch (num) { default: localInt = paramInt; // Call the first synchronous method SyncMethodA(); // Create a task awaiter for a delay of 1000 milliseconds awaiter3 = Task.Delay(1000).GetAwaiter(); // If the task is not completed, set the current state to 0 and store the awaiter if (!awaiter3.IsCompleted) { currentState = 0; taskAwaiter = awaiter3; // Store the current state machine ExampleStateMachine stateMachine = this; // Await the task and pass the state machine taskMethodBuilder.AwaitUnsafeOnCompleted(ref awaiter3, ref stateMachine); return; } // If the task is completed, jump to the label after the first await goto Il_AfterFirstAwait; case 0: // Retrieve the awaiter from the taskAwaiter field awaiter3 = taskAwaiter; // Reset the taskAwaiter field taskAwaiter = default(TaskAwaiter); currentState = -1; // Jump to the label after the first await goto Il_AfterFirstAwait; case 1: // Retrieve the awaiter from the taskAwaiter field awaiter2 = taskAwaiter; // Reset the taskAwaiter field taskAwaiter = default(TaskAwaiter); currentState = -1; // Jump to the label after the second await goto Il_AfterSecondAwait; case 2: // Retrieve the awaiter from the taskAwaiter field awaiter = taskAwaiter; // Reset the taskAwaiter field taskAwaiter = default(TaskAwaiter); currentState = -1; break; Il_AfterFirstAwait: awaiter3.GetResult(); // Call the second synchronous method SyncMethodB(); // Create a task awaiter for a delay of 2000 milliseconds awaiter2 = Task.Delay(2000).GetAwaiter(); // If the task is not completed, set the current state to 1 and store the awaiter if (!awaiter2.IsCompleted) { currentState = 1; taskAwaiter = awaiter2; // Store the current state machine ExampleStateMachine stateMachine = this; // Await the task and pass the state machine taskMethodBuilder.AwaitUnsafeOnCompleted(ref awaiter2, ref stateMachine); return; } // If the task is completed, jump to the label after the second await goto Il_AfterSecondAwait; Il_AfterSecondAwait: // Get the result of the second awaiter awaiter2.GetResult(); // Call the SyncMethodC SyncMethodC(); // Create a new awaiter with a delay of 3000 milliseconds awaiter = Task.Delay(3000).GetAwaiter(); // If the awaiter is not completed, set the current state to 2 and store the awaiter if (!awaiter.IsCompleted) { currentState = 2; taskAwaiter = awaiter; // Set the stateMachine to this ExampleStateMachine stateMachine = this; // Await the task and pass the state machine taskMethodBuilder.AwaitUnsafeOnCompleted(ref awaiter, ref stateMachine); return; } break; } // Get the result of the awaiter awaiter.GetResult(); } catch (Exception exception) { currentState = -2; taskMethodBuilder.SetException(exception); return; } currentState = -2; taskMethodBuilder.SetResult(); } void IAsyncStateMachine.SetStateMachine(IAsyncStateMachine stateMachine) { /*...*/ } }

SynchronizationContextContexto de sincronización

En la llamada AwaitUnsafeOnCompleted , se obtendrá el contexto de sincronización actual SynchronizationContext . SynchronizationContext es un concepto en C# que se usa para representar un contexto que controla la ejecución de un conjunto de operaciones asincrónicas. Se utiliza para coordinar la ejecución de código en varios subprocesos y para garantizar que el código se ejecute en un orden específico. El objetivo principal de SynchronizationContext es proporcionar una forma de controlar la programación y ejecución de operaciones asincrónicas en un entorno de subprocesos múltiples.


En diferentes entornos, SynchronizationContext tiene diferentes implementaciones. Por ejemplo, en .NET, hay:


  • WPF : System.Windows.Threading.DispatcherSynchronizationContext
  • WinForms : System.Windows.Forms.WindowsFormsSynchronizationContext
  • WinRT : System.Threading.WinRTSynchronizationContext
  • ASP.NET : System.Web.AspNetSynchronizationContext


Unity también tiene su propio contexto de sincronización, UnitySynchronizationContext , que nos permite usar operaciones asincrónicas con enlace a la API de PlayerLoop. El siguiente ejemplo de código muestra cómo rotar un objeto en cada cuadro usando Task.Yield() :

 private async void Start() { while (true) { transform.Rotate(0, Time.deltaTime * 50, 0); await Task.Yield(); } }


Otro ejemplo del uso de async/await en Unity para realizar una solicitud de red:

 using UnityEngine; using System.Net.Http; using System.Threading.Tasks; public class NetworkRequestExample : MonoBehaviour { private async void Start() { string response = await GetDataFromAPI(); Debug.Log("Response from API: " + response); } private async Task<string> GetDataFromAPI() { using (var client = new HttpClient()) { var response = await client.GetStringAsync("https://api.example.com/data"); return response; } } }


Gracias a UnitySynchronizationContext , podemos usar de manera segura los métodos UnityEngine (como Debug.Log() ) justo después de que se haya completado una operación asincrónica, ya que la ejecución de este código continuará en el subproceso principal de Unity.

TaskCompletitionSource<T>

Esta clase le permite administrar un objeto Task . Fue creado para adaptar métodos asincrónicos antiguos a TAP, pero también es muy útil cuando queremos envolver una Task en una operación de ejecución prolongada que ocurre en algún evento.


En el siguiente ejemplo, el objeto Task dentro de taskCompletionSource se completará después de 3 segundos desde el inicio y obtendremos su resultado en el método Update :

 using System.Threading.Tasks; using UnityEngine; public class Example : MonoBehaviour { private TaskCompletionSource<int> taskCompletionSource; private void Start() { // Create a new TaskCompletionSource taskCompletionSource = new TaskCompletionSource<int>(); // Start a coroutine to wait 3 seconds // and then set the result of the TaskCompletionSource StartCoroutine(WaitAndComplete()); } private IEnumerator WaitAndComplete() { yield return new WaitForSeconds(3); // Set the result of the TaskCompletionSource taskCompletionSource.SetResult(10); } private async void Update() { // Await the result of the TaskCompletionSource int result = await taskCompletionSource.Task; // Log the result to the console Debug.Log("Result: " + result); } }

Ficha de cancelación

Un token de cancelación se usa en C# para indicar que se debe cancelar una tarea u operación. El token se pasa a la tarea u operación, y el código dentro de la tarea u operación puede verificar el token periódicamente para determinar si la tarea u operación debe detenerse. Esto permite una cancelación limpia y elegante de una tarea u operación, en lugar de simplemente eliminarla abruptamente.


Los tokens de cancelación se usan comúnmente en situaciones en las que el usuario puede cancelar una tarea de larga duración, o si la tarea ya no es necesaria, como un botón de cancelación en una interfaz de usuario.


El patrón general se asemeja al uso de TaskCompletionSource . Primero, se crea un CancellationTokenSource , luego su Token se pasa a la operación asincrónica:

 public class ExampleMonoBehaviour : MonoBehaviour { private CancellationTokenSource _cancellationTokenSource; private async void Start() { // Create a new CancellationTokenSource _cancellationTokenSource = new CancellationTokenSource(); // Get the token from the CancellationTokenSource CancellationToken token = _cancellationTokenSource.Token; try { // Start a new Task and pass in the token await Task.Run(() => DoSomething(token), token); } catch (OperationCanceledException) { Debug.Log("Task was cancelled"); } } private void DoSomething(CancellationToken token) { for (int i = 0; i < 100; i++) { // Check if the token has been cancelled if (token.IsCancellationRequested) { // Return if the token has been cancelled return; } Debug.Log("Doing something..."); // Sleep for 1 second Thread.Sleep(1000); } } private void OnDestroy() { // Cancel the token when the object is destroyed _cancellationTokenSource.Cancel(); } }


Cuando se cancela la operación, se generará una OperationCanceledException y la propiedad Task.IsCanceled se establecerá en true .

Nuevas funciones asíncronas en Unity 2022.2

Es importante tener en cuenta que los objetos Task son administrados por el tiempo de ejecución de .NET, no por Unity, y si el objeto que ejecuta la tarea se destruye (o si el juego sale del modo de juego en el editor), la tarea continuará ejecutándose como lo ha hecho Unity. no hay manera de cancelarlo.


Siempre debe acompañar await Task con el CancellationToken correspondiente. Esto conduce a cierta redundancia de código, y en Unity 2022.2 aparecieron tokens integrados en el nivel MonoBehaviour y en todo el nivel Application .


Veamos cómo cambia el ejemplo anterior al usar el destroyCancellationToken del objeto MonoBehaviour :

 using System.Threading; using System.Threading.Tasks; using UnityEngine; public class ExampleMonoBehaviour : MonoBehaviour { private async void Start() { // Get the cancellation token from the MonoBehaviour CancellationToken token = this.destroyCancellationToken; try { // Start a new Task and pass in the token await Task.Run(() => DoSomething(token), token); } catch (OperationCanceledException) { Debug.Log("Task was cancelled"); } } private void DoSomething(CancellationToken token) { for (int i = 0; i < 100; i++) { // Check if the token has been cancelled if (token.IsCancellationRequested) { // Return if the token has been cancelled return; } Debug.Log("Doing something..."); // Sleep for 1 second Thread.Sleep(1000); } } }


Ya no necesitamos crear manualmente un CancellationTokenSource y completar la tarea en el método OnDestroy . Para tareas no asociadas con un MonoBehaviour en particular, podemos usar UnityEngine.Application.exitCancellationToken . Esto finalizará la tarea al salir del Modo de reproducción (en el Editor) o al salir de la aplicación.

UniTarea

A pesar de la comodidad de uso y las capacidades proporcionadas por .NET Tasks, tienen importantes inconvenientes cuando se usan en Unity:


  • Los objetos Task son demasiado engorrosos y provocan muchas asignaciones.
  • Task no coincide con el subproceso de Unity (subproceso único).


La biblioteca UniTask pasa por alto estas restricciones sin usar subprocesos o SynchronizationContext . Logra la ausencia de asignaciones mediante el uso del tipo basado en estructura UniTask<T> .


UniTask requiere la versión de tiempo de ejecución de secuencias de comandos .NET 4.x, siendo Unity 2018.4.13f1 la versión compatible más baja oficial.


También puede convertir todas las AsyncOperations a UnitTask con métodos de extensión:

 using UnityEngine; using UniTask; public class AssetLoader : MonoBehaviour { public async void LoadAsset(string assetName) { var loadRequest = Resources.LoadAsync<GameObject>(assetName); await loadRequest.AsUniTask(); var asset = loadRequest.asset as GameObject; if (asset != null) { // Do something with the loaded asset } } }


En este ejemplo, el método LoadAsset usa Resources.LoadAsync para cargar un activo de forma asíncrona. Luego, el método AsUniTask se usa para convertir la AsyncOperation devuelta por LoadAsync en una UniTask , que se puede esperar.


Como antes, puede organizar cualquier combinación de orden de ejecución utilizando UniTask.WhenAll y UniTask.WhenAny :

 using System.Threading; using Cysharp.Threading.Tasks; using UnityEngine; public class Example : MonoBehaviour { private async void Start() { // Start two Tasks and wait for both to complete await UniTask.WhenAll(Task1(), Task2()); // Start two Tasks and wait for one to complete await UniTask.WhenAny(Task1(), Task2()); } private async UniTask Task1() { // Do something } private async UniTask Task2() { // Do something } }


En UniTask, hay otra implementación de SynchronizationContext llamada UniTaskSynchronizationContext que se puede usar para reemplazar UnitySynchronizationContext para un mejor rendimiento.

API disponible

En la primera versión alfa de Unity 2023.1, se introdujo la clase Awaitable . Las corrutinas en espera son tipos similares a tareas compatibles con async/await diseñados para ejecutarse en Unity. A diferencia de las tareas .NET, son administradas por el motor, no por el tiempo de ejecución.

 private async Awaitable DoSomethingAsync() { // awaiting built-in events await Awaitable.EndOfFrameAsync(); await Awaitable.WaitForSecondsAsync(); // awaiting .NET Tasks await Task.Delay(2000, destroyCancellationToken); await Task.Yield(); // awaiting AsyncOperations await SceneManager.LoadSceneAsync("path/to/scene.unity"); // ... }


Se pueden esperar y usar como tipo de retorno de un método asíncrono. En comparación con System.Threading.Tasks , son menos sofisticados pero toman atajos para mejorar el rendimiento basados en suposiciones específicas de Unity.


Estas son las principales diferencias en comparación con las tareas .NET:


  • El objeto Awaitable solo se puede esperar una vez; no puede ser esperado por múltiples funciones asíncronas.
  • Awaiter.GetResults() no se bloqueará hasta que se complete. Llamarlo antes de que finalice la operación es un comportamiento indefinido.
  • Nunca capture un ExecutionContext . Por razones de seguridad, las tareas de .NET capturan contextos de ejecución cuando están en espera para propagar contextos de suplantación a través de llamadas asincrónicas.
  • Nunca capture SynchronizationContext . Las continuaciones de Coroutine se ejecutan sincrónicamente desde el código que genera la finalización. En la mayoría de los casos, será desde el marco principal de Unity.
  • Awaitables son objetos agrupados para evitar asignaciones excesivas. Estos son tipos de referencia, por lo que se puede hacer referencia a ellos en diferentes pilas, copiarlos de manera eficiente, etc. ObjectPool se ha mejorado para evitar las comprobaciones de límites Stack<T> en secuencias típicas de obtención/liberación generadas por máquinas de estado asíncronas.


Para obtener el resultado de una operación larga, puede usar el tipo Awaitable<T> . Puede administrar la finalización de un Awaitable usando AwaitableCompletionSource y AwaitableCompletionSource<T> , similar a TaskCompletitionSource :

 using UnityEngine; using Cysharp.Threading.Tasks; public class ExampleBehaviour : MonoBehaviour { private AwaitableCompletionSource<bool> _completionSource; private async void Start() { // Create a new AwaitableCompletionSource _completionSource = new AwaitableCompletionSource<bool>(); // Start a coroutine to wait 3 seconds // and then set the result of the AwaitableCompletionSource StartCoroutine(WaitAndComplete()); // Await the result of the AwaitableCompletionSource bool result = await _completionSource.Awaitable; // Log the result to the console Debug.Log("Result: " + result); } private IEnumerator WaitAndComplete() { yield return new WaitForSeconds(3); // Set the result of the AwaitableCompletionSource _completionSource.SetResult(true); } }


A veces es necesario realizar cálculos masivos que pueden llevar a que el juego se congele. Para esto, es mejor usar los métodos Awaitable: BackgroundThreadAsync() y MainThreadAsync() . Le permiten salir del hilo principal y volver a él.

 private async Awaitable DoCalculationsAsync() { // Awaiting execution on a ThreadPool background thread. await Awaitable.BackgroundThreadAsync(); var result = PerformSomeHeavyCalculations(); // Awaiting execution on the Unity main thread. await Awaitable.MainThreadAsync(); // Using the result in main thread Debug.Log(result); }


De esta manera, Awaitables elimina los inconvenientes de usar tareas .NET y también permite esperar eventos de PlayerLoop y AsyncOperations.

Conclusión

Como podemos ver, con el desarrollo de Unity, existen cada vez más herramientas para organizar operaciones asíncronas:

Unidad

corrutinas

promesas

Tareas .NET

UniTarea

Tokens de cancelación incorporados

API disponible

5.6





2017.1




2018.4



2022.2


2023.1


Hemos considerado todas las formas principales de programación asíncrona en Unity. Según la complejidad de su tarea y la versión de Unity que esté usando, puede usar una amplia variedad de tecnologías, desde Coroutines y Promises hasta Tasks y Awaitables, para garantizar una jugabilidad fluida y sin problemas en sus juegos. Gracias por leer, y esperamos sus próximas obras maestras.