paint-brush
L'introduction complète à la programmation asynchrone pour le développement Unitypar@dmitrii
5,692 lectures
5,692 lectures

L'introduction complète à la programmation asynchrone pour le développement Unity

par Dmitrii Ivashchenko31m2023/03/30
Read on Terminal Reader

Trop long; Pour lire

Dans cet article, nous allons parler d'éviter de tels problèmes. Nous recommanderons des techniques de programmation asynchrone pour effectuer ces tâches dans un thread séparé, laissant ainsi le thread principal libre d'effectuer d'autres tâches. Cela contribuera à garantir un gameplay fluide et réactif et (espérons-le) des joueurs satisfaits.
featured image - L'introduction complète à la programmation asynchrone pour le développement Unity
Dmitrii Ivashchenko HackerNoon profile picture
0-item

Certaines tâches de développement de jeux ne sont pas synchrones — elles sont asynchrones. Cela signifie qu'ils ne sont pas exécutés de manière linéaire dans le code du jeu. Certaines de ces tâches asynchrones peuvent nécessiter un temps assez long, tandis que d'autres sont associées à des calculs intensifs.


Certaines des tâches asynchrones de jeu les plus courantes sont les suivantes :

  • Effectuer des requêtes réseau

  • Chargement de scènes, de ressources et d'autres ressources

  • Lire et écrire des fichiers

  • L'intelligence artificielle pour la prise de décision

  • Longues séquences d'animation

  • Traiter de grandes quantités de données

  • Trouver son chemin


Maintenant, surtout, puisque tout le code Unity s'exécute dans un thread, toute tâche comme l'une de celles mentionnées ci-dessus, si elles étaient effectuées de manière synchrone, conduirait au blocage du thread principal et, par conséquent, à la perte de trames.


Bonjour à tous, je m'appelle Dmitrii Ivashchenko et je suis le chef de l'équipe de développement de MY.GAMES. Dans cet article, nous allons parler d'éviter de tels problèmes. Nous recommanderons des techniques de programmation asynchrone pour effectuer ces tâches dans un thread séparé, laissant ainsi le thread principal libre d'effectuer d'autres tâches. Cela contribuera à garantir un gameplay fluide et réactif et (espérons-le) des joueurs satisfaits.

Coroutines

Tout d'abord, parlons des coroutines. Ils ont été introduits dans Unity en 2011, avant même que async/attend n'apparaisse dans .NET. Dans Unity, les coroutines nous permettent d'exécuter un ensemble d'instructions sur plusieurs images, au lieu de les exécuter toutes en même temps. Ils sont similaires aux threads, mais sont légers et intégrés dans la boucle de mise à jour de Unity, ce qui les rend bien adaptés au développement de jeux.


(Au fait, historiquement parlant, les coroutines ont été le premier moyen d'effectuer des opérations asynchrones dans Unity, donc la plupart des articles sur Internet en parlent.)


Pour créer une coroutine, vous devez déclarer une fonction avec le type de retour IEnumerator . Cette fonction peut contenir n'importe quelle logique que vous souhaitez que la coroutine exécute.


Pour démarrer une coroutine, vous devez appeler la méthode StartCoroutine sur une instance MonoBehaviour et passer la fonction coroutine en argument :

 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"); } }


Il existe plusieurs instructions de rendement disponibles dans Unity, telles que WaitForSeconds , WaitForEndOfFrame , WaitForFixedUpdate , WaitForSecondsRealtime , WaitUntil ainsi que quelques autres. Il est important de se rappeler que leur utilisation entraîne des allocations, elles doivent donc être réutilisées dans la mesure du possible.


Par exemple, considérez cette méthode de la documentation :

 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); } }


A chaque itération de la boucle, une nouvelle instance de new WaitForSeconds(.1f) sera créée. Au lieu de cela, nous pouvons déplacer la création en dehors de la boucle et éviter les allocations :

 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**; } }


Une autre propriété importante à noter est que yield return peut être utilisé avec toutes les méthodes Async fournies par Unity car les AsyncOperation sont des descendants de YieldInstruction :

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

Quelques pièges possibles des coroutines

Ceci étant dit, les coroutines ont également quelques inconvénients à noter :


  • Il est impossible de retourner le résultat d'une longue opération. Vous avez toujours besoin de rappels qui seront transmis à la coroutine et appelés lorsqu'elle aura terminé pour en extraire toutes les données.
  • Une coroutine est strictement liée au MonoBehaviour qui la lance. Si le GameObject est désactivé ou détruit, la coroutine cesse d'être traitée.
  • La structure try-catch-finally ne peut pas être utilisée en raison de la présence de la syntaxe yield.
  • Au moins une image passera après le yield return avant que le code suivant ne commence à s'exécuter.
  • Allocation du lambda et de la coroutine elle-même

Promesses

Les promesses sont un modèle pour organiser et rendre les opérations asynchrones plus lisibles. Ils sont devenus populaires en raison de leur utilisation dans de nombreuses bibliothèques JavaScript tierces et, depuis ES6, ont été implémentés de manière native.


Lors de l'utilisation de Promises, nous renvoyons immédiatement un objet de votre fonction asynchrone. Cela permet à l'appelant d'attendre la résolution (ou une erreur) de l'opération.


Essentiellement, cela fait en sorte que les méthodes asynchrones peuvent renvoyer des valeurs et "agir" comme des méthodes synchrones : au lieu de renvoyer immédiatement la valeur finale, elles donnent une "promesse" qu'elles renverront une valeur dans le futur.


Il existe plusieurs implémentations de Promises pour Unity :


La principale façon d'interagir avec une promesse est via les fonctions de rappel .


Vous pouvez définir une fonction de rappel qui sera appelée lorsqu'une promesse est résolue, et une autre fonction de rappel qui sera appelée si la promesse est rejetée. Ces rappels reçoivent le résultat de l'opération asynchrone sous forme d'arguments, qui peuvent ensuite être utilisés pour effectuer d'autres opérations.


Selon ces spécifications de l' organisation Promises/A+, une promesse peut être dans l'un des trois états suivants :


  • Pending : l'état initial, cela signifie que l'opération asynchrone est toujours en cours, et que le résultat de l'opération n'est pas encore connu.
  • Fulfilled ( Resolved ) : l'état résolu est accompagné d'une valeur qui représente le résultat de l'opération.
  • Rejected : si l'opération asynchrone échoue pour une raison quelconque, la Promise est dite "rejected". L'état rejeté est accompagné de la raison de l'échec.

En savoir plus sur les promesses

De plus, les promesses peuvent être enchaînées, de sorte que le résultat d'une promesse puisse être utilisé pour déterminer le résultat d'une autre promesse.


Par exemple, vous pouvez créer une promesse qui récupère certaines données d'un serveur, puis utiliser ces données pour créer une autre promesse qui effectue des calculs et d'autres actions :

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


Voici un exemple d'organisation d'une méthode qui effectue une opération asynchrone :

 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; }


Nous pourrions également envelopper des coroutines dans une 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(); }


Et bien sûr, vous pouvez organiser n'importe quelle combinaison d'ordre d'exécution des promesses en utilisant ThenAll / Promise.All et 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") ) );

Les parties « non prometteuses » des promesses

Malgré toute la commodité d'utilisation, les promesses présentent également certains inconvénients:


  • Frais généraux : la création de promesses implique des frais généraux supplémentaires par rapport à l'utilisation d'autres méthodes de programmation asynchrone, comme les coroutines. Dans certains cas, cela peut entraîner une diminution des performances.
  • Débogage : Le débogage des promesses peut être plus difficile que le débogage d'autres modèles de programmation asynchrones. Il peut être difficile de retracer le flux d'exécution et d'identifier la source des bogues.
  • Gestion des exceptions : La gestion des exceptions peut être plus complexe avec Promises par rapport à d'autres modèles de programmation asynchrone. Il peut être difficile de gérer les erreurs et les exceptions qui se produisent dans une chaîne Promise.

Tâches asynchrones/en attente

La fonctionnalité async/wait fait partie de C# depuis la version 5.0 (2012) et a été introduite dans Unity 2017 avec l'implémentation du runtime .NET 4.x.


Dans l'histoire de .NET, on distingue les étapes suivantes :


  1. EAP (Event-based Asynchronous Pattern) : cette approche est basée sur des événements déclenchés à la fin d'une opération et sur une méthode régulière qui appelle cette opération.
  2. APM (Asynchronous Programming Model) : Cette approche repose sur deux méthodes. La méthode BeginSmth renvoie l'interface IAsyncResult . La méthode EndSmth prend IAsyncResult ; si l'opération n'est pas terminée au moment de l'appel EndSmth , le thread est bloqué.
  3. TAP (Task-based Asynchronous Pattern) : Ce concept a été amélioré par l'introduction de async/wait et des types Task et Task<TResult> .


Les approches précédentes sont devenues obsolètes en raison du succès de la dernière approche.

Pour créer une méthode asynchrone, la méthode doit être marquée avec le mot-clé async , contenir un await à l'intérieur et la valeur de retour doit être Task , Task<T> ou void (non recommandé).

 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 }


Dans cet exemple, l'exécution se déroulera comme ceci :


  1. Dans un premier temps, le code précédant l'appel à la première opération asynchrone ( SyncMethodA ) sera exécuté.
  2. La première opération asynchrone await Task.Delay(1000) est lancée et devrait être exécutée. Pendant ce temps, le code à appeler lorsque l'opération asynchrone est terminée (la « suite ») sera enregistré.
  3. Une fois la première opération asynchrone terminée, la "continuation" - le code jusqu'à la prochaine opération asynchrone ( SyncMethodB ) commencera à s'exécuter.
  4. La deuxième opération asynchrone ( await Task.Delay(2000) ) est lancée et devrait être exécutée. En même temps, la continuation — le code suivant la deuxième opération asynchrone ( SyncMethodC ) sera préservée.
  5. Après l'achèvement de la deuxième opération asynchrone, SyncMethodC sera exécuté, suivi de l'exécution et de l'attente de la troisième opération asynchrone await Task.Delay(3000) .


Il s'agit d'une explication simplifiée, car en fait async/wait est du sucre syntaxique permettant d'appeler facilement des méthodes asynchrones et d'attendre leur achèvement.


Vous pouvez également organiser n'importe quelle combinaison d'ordres d'exécution en utilisant WhenAll et 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"); });

IAsyncStateMachineIAsyncStateMachine

Le compilateur C# transforme les appels async/wait en une machine d'état IAsyncStateMachine , qui est un ensemble séquentiel d'actions qui doivent être effectuées pour terminer l'opération asynchrone.


Chaque fois que vous appelez une opération d'attente, la machine d'état termine son travail et attend la fin de cette opération, après quoi elle continue à exécuter l'opération suivante. Cela permet d'effectuer des opérations asynchrones en arrière-plan sans bloquer le thread principal, et rend également les appels de méthode asynchrones plus simples et plus lisibles.


Ainsi, la méthode Example est transformée en création et initialisation d'une machine d'état avec l'annotation [AsyncStateMachine(typeof(ExampleStateMachine))] , et la machine d'état elle-même a un nombre d'états égal au nombre d'appels en attente.


  • Exemple de la méthode transformée 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; }


  • Exemple de machine d'état générée 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) { /*...*/ } }

SynchronizationContextSynchronizationContext

Dans l'appel AwaitUnsafeOnCompleted , le contexte de synchronisation actuel SynchronizationContext sera obtenu. SynchronizationContext est un concept en C# utilisé pour représenter un contexte qui contrôle l'exécution d'un ensemble d'opérations asynchrones. Il est utilisé pour coordonner l'exécution du code sur plusieurs threads et pour garantir que le code est exécuté dans un ordre spécifique. L'objectif principal de SynchronizationContext est de fournir un moyen de contrôler la planification et l'exécution d'opérations asynchrones dans un environnement multithread.


Dans différents environnements, SynchronizationContext a différentes implémentations. Par exemple, dans .NET, il y a :


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


Unity a également son propre contexte de synchronisation, UnitySynchronizationContext , qui nous permet d'utiliser des opérations asynchrones avec une liaison à l'API PlayerLoop. L'exemple de code suivant montre comment faire pivoter un objet dans chaque image à l'aide de Task.Yield() :

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


Un autre exemple d'utilisation de async/wait dans Unity pour effectuer une requête réseau :

 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; } } }


Grâce à UnitySynchronizationContext , nous pouvons utiliser en toute sécurité les méthodes UnityEngine (telles que Debug.Log() ) juste après la fin d'une opération asynchrone, car l'exécution de ce code se poursuivra dans le thread principal Unity.

TaskCompletitionSource<T>

Cette classe permet de gérer un objet Task . Il a été créé pour adapter les anciennes méthodes asynchrones à TAP, mais il est également très utile lorsque nous voulons envelopper une Task autour d'une opération de longue durée qui sur un événement.


Dans l'exemple suivant, l'objet Task à l'intérieur taskCompletionSource se terminera 3 secondes après le début, et nous obtiendrons son résultat dans la méthode 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); } }

Jeton d'annulation

Un jeton d'annulation est utilisé en C# pour signaler qu'une tâche ou une opération doit être annulée. Le jeton est transmis à la tâche ou à l'opération, et le code de la tâche ou de l'opération peut vérifier périodiquement le jeton pour déterminer si la tâche ou l'opération doit être arrêtée. Cela permet une annulation propre et gracieuse d'une tâche ou d'une opération, au lieu de simplement la tuer brusquement.


Les jetons d'annulation sont couramment utilisés dans les situations où une tâche de longue durée peut être annulée par l'utilisateur, ou si la tâche n'est plus nécessaire, comme un bouton d'annulation dans une interface utilisateur.


Le modèle global ressemble à l'utilisation de TaskCompletionSource . Tout d'abord, un CancellationTokenSource est créé, puis son Token est passé à l'opération asynchrone :

 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(); } }


Lorsque l'opération est annulée, une OperationCanceledException est levée et la propriété Task.IsCanceled est définie sur true .

Nouvelles fonctionnalités asynchrones dans Unity 2022.2

Il est important de noter que les objets Task sont gérés par le runtime .NET, pas par Unity, et si l'objet exécutant la tâche est détruit (ou si le jeu quitte le mode de jeu dans l'éditeur), la tâche continuera à s'exécuter comme Unity a aucun moyen de l'annuler.


Vous devez toujours accompagner await Task avec le CancellationToken correspondant. Cela conduit à une certaine redondance de code, et dans Unity 2022.2, des jetons intégrés au niveau MonoBehaviour et l'ensemble du niveau Application sont apparus.


Voyons comment l'exemple précédent change lors de l'utilisation du destroyCancellationToken de l'objet 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); } } }


Nous n'avons plus besoin de créer manuellement un CancellationTokenSource et de terminer la tâche dans la méthode OnDestroy . Pour les tâches non associées à un MonoBehaviour particulier, nous pouvons utiliser UnityEngine.Application.exitCancellationToken . Cela mettra fin à la tâche lors de la sortie du mode de lecture (dans l'éditeur) ou lors de la fermeture de l'application.

UniTâche

Malgré la commodité d'utilisation et les fonctionnalités fournies par les tâches .NET, elles présentent des inconvénients importants lorsqu'elles sont utilisées dans Unity :


  • Les objets Task sont trop encombrants et provoquent de nombreuses allocations.
  • Task ne correspond pas au thread Unity (thread unique).


La bibliothèque UniTask contourne ces restrictions sans utiliser de threads ou SynchronizationContext . Il réalise l'absence d'allocations en utilisant le type basé sur la structure UniTask<T> .


UniTask nécessite la version d'exécution de script .NET 4.x, Unity 2018.4.13f1 étant la version officielle la plus faible prise en charge.


Vous pouvez également convertir toutes les AsyncOperations en UnitTask avec des méthodes d'extension :

 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 } } }


Dans cet exemple, la méthode LoadAsset utilise Resources.LoadAsync pour charger une ressource de manière asynchrone. La méthode AsUniTask est ensuite utilisée pour convertir l' AsyncOperation renvoyée par LoadAsync en une UniTask , qui peut être attendue.


Comme auparavant, vous pouvez organiser n'importe quelle combinaison d'ordre d'exécution en utilisant UniTask.WhenAll et 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 } }


Dans UniTask, il existe une autre implémentation de SynchronizationContext appelée UniTaskSynchronizationContext qui peut être utilisée pour remplacer UnitySynchronizationContext pour de meilleures performances.

API en attente

Dans la première version alpha de Unity 2023.1, la classe Awaitable a été introduite. Les coroutines en attente sont des types de type tâche compatibles async/wait conçus pour s'exécuter dans Unity. Contrairement aux tâches .NET, elles sont gérées par le moteur, et non par le runtime.

 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"); // ... }


Ils peuvent être attendus et utilisés comme type de retour d'une méthode asynchrone. Par rapport à System.Threading.Tasks , ils sont moins sophistiqués mais utilisent des raccourcis améliorant les performances basés sur des hypothèses spécifiques à Unity.


Voici les principales différences par rapport aux tâches .NET :


  • L'objet Awaitable ne peut être attendu qu'une seule fois ; il ne peut pas être attendu par plusieurs fonctions asynchrones.
  • Awaiter.GetResults() ne bloquera pas jusqu'à la fin. L'appeler avant la fin de l'opération est un comportement indéfini.
  • Ne capturez jamais un ExecutionContext . Pour des raisons de sécurité, les tâches .NET capturent les contextes d'exécution en attente afin de propager les contextes d'emprunt d'identité sur les appels asynchrones.
  • Ne capturez jamais SynchronizationContext . Les continuations de coroutine sont exécutées de manière synchrone à partir du code qui déclenche la complétion. Dans la plupart des cas, cela proviendra du cadre principal de Unity.
  • Les éléments en attente sont des objets regroupés pour éviter des allocations excessives. Ce sont des types de référence, ils peuvent donc être référencés sur différentes piles, copiés efficacement, etc. L' ObjectPool a été amélioré pour éviter les vérifications des limites Stack<T> dans les séquences get/release typiques générées par les machines à états asynchrones.


Pour obtenir le résultat d'une opération longue, vous pouvez utiliser le type Awaitable<T> . Vous pouvez gérer l'achèvement d'un Awaitable en utilisant AwaitableCompletionSource et AwaitableCompletionSource<T> , similaire à 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); } }


Parfois, il est nécessaire d'effectuer des calculs massifs qui peuvent conduire à des blocages de jeu. Pour cela, il est préférable d'utiliser les méthodes Awaitable : BackgroundThreadAsync() et MainThreadAsync() . Ils vous permettent de quitter le thread principal et d'y revenir.

 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 cette façon, Awaitables élimine les inconvénients de l'utilisation des tâches .NET et permet également d'attendre les événements PlayerLoop et AsyncOperations.

Conclusion

Comme on peut le voir, avec le développement de Unity, il existe de plus en plus d'outils pour organiser les opérations asynchrones :

Unité

Coroutines

Promesses

Tâches .NET

UniTâche

Jetons d'annulation intégrés

API en attente

5.6





2017.1




2018.4



2022.2


2023.1


Nous avons examiné tous les principaux moyens de programmation asynchrone dans Unity. En fonction de la complexité de votre tâche et de la version d'Unity que vous utilisez, vous pouvez utiliser un large éventail de technologies allant des coroutines et promesses aux tâches et attentes, pour assurer un gameplay fluide et transparent dans vos jeux. Merci d'avoir lu, et nous attendons vos prochains chefs-d'œuvre.