paint-brush
Die vollständige Einführung in die asynchrone Programmierung für die Unity-Entwicklungvon@dmitrii
5,692 Lesungen
5,692 Lesungen

Die vollständige Einführung in die asynchrone Programmierung für die Unity-Entwicklung

von Dmitrii Ivashchenko31m2023/03/30
Read on Terminal Reader
Read this story w/o Javascript

Zu lang; Lesen

In diesem Artikel werden wir über die Vermeidung solcher Probleme sprechen. Wir empfehlen asynchrone Programmiertechniken, um diese Aufgaben in einem separaten Thread auszuführen, sodass der Hauptthread frei bleibt, um andere Aufgaben auszuführen. Dies wird dazu beitragen, ein reibungsloses und reaktionsschnelles Gameplay und (hoffentlich) zufriedene Spieler zu gewährleisten.
featured image - Die vollständige Einführung in die asynchrone Programmierung für die Unity-Entwicklung
Dmitrii Ivashchenko HackerNoon profile picture
0-item

Einige Aufgaben in der Spieleentwicklung sind nicht synchron, sondern asynchron. Dies bedeutet, dass sie nicht linear innerhalb des Spielcodes ausgeführt werden. Einige dieser asynchronen Aufgaben können recht lange dauern, während andere mit intensiven Berechnungen verbunden sind.


Einige der häufigsten asynchronen Gaming-Aufgaben sind wie folgt:

  • Durchführen von Netzwerkanfragen

  • Laden von Szenen, Ressourcen und anderen Assets

  • Dateien lesen und schreiben

  • Künstliche Intelligenz zur Entscheidungsfindung

  • Lange Animationssequenzen

  • Verarbeitung großer Datenmengen

  • Wegfindung


Da nun der gesamte Unity-Code in einem Thread ausgeführt wird, ist es von entscheidender Bedeutung, dass jede Aufgabe wie eine der oben genannten, wenn sie synchron ausgeführt würde, zur Blockierung des Hauptthreads und damit zu Frame-Drops führen würde.


Hallo zusammen, mein Name ist Dmitrii Ivashchenko und ich bin der Leiter des Entwicklungsteams bei MY.GAMES. In diesem Artikel werden wir über die Vermeidung solcher Probleme sprechen. Wir empfehlen asynchrone Programmiertechniken, um diese Aufgaben in einem separaten Thread auszuführen, sodass der Hauptthread frei bleibt, um andere Aufgaben auszuführen. Dies wird dazu beitragen, ein reibungsloses und reaktionsschnelles Gameplay und (hoffentlich) zufriedene Spieler zu gewährleisten.

Coroutinen

Lassen Sie uns zunächst über Coroutinen sprechen. Sie wurden 2011 in Unity eingeführt, noch bevor async/await in .NET erschien. In Unity ermöglichen uns Coroutinen, eine Reihe von Anweisungen über mehrere Frames hinweg auszuführen, anstatt sie alle auf einmal auszuführen. Sie ähneln Threads, sind jedoch leichtgewichtig und in die Update-Schleife von Unity integriert, wodurch sie sich gut für die Spieleentwicklung eignen.


(Historisch gesehen waren Coroutinen übrigens die erste Möglichkeit, asynchrone Operationen in Unity auszuführen, daher geht es in den meisten Artikeln im Internet um sie.)


Um eine Coroutine zu erstellen, müssen Sie eine Funktion mit dem Rückgabetyp IEnumerator deklarieren. Diese Funktion kann jede Logik enthalten, die die Coroutine ausführen soll.


Um eine Coroutine zu starten, müssen Sie die StartCoroutine Methode auf einer MonoBehaviour Instanz aufrufen und die Coroutine-Funktion als Argument übergeben:

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


In Unity sind mehrere Ertragsanweisungen verfügbar, z. B. WaitForSeconds , WaitForEndOfFrame , WaitForFixedUpdate , WaitForSecondsRealtime , WaitUntil und einige andere. Es ist wichtig zu bedenken, dass ihre Verwendung zu Zuordnungen führt und daher nach Möglichkeit wiederverwendet werden sollte.


Betrachten Sie beispielsweise diese Methode aus der Dokumentation:

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


Mit jeder Iteration der Schleife wird eine neue Instanz von new WaitForSeconds(.1f) erstellt. Stattdessen können wir die Erstellung außerhalb der Schleife verschieben und Zuweisungen vermeiden:

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


Eine weitere wichtige Eigenschaft ist, dass yield return mit allen von Unity bereitgestellten Async Methoden verwendet werden kann, da AsyncOperation s Nachkommen von YieldInstruction sind:

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

Einige mögliche Fallstricke von Coroutinen

Abgesehen davon haben Coroutinen auch einige Nachteile, die es zu beachten gilt:


  • Es ist unmöglich, das Ergebnis einer langen Operation zurückzugeben. Sie benötigen weiterhin Rückrufe, die an die Coroutine übergeben und aufgerufen werden, wenn sie fertig ist, um Daten daraus zu extrahieren.
  • Eine Coroutine ist streng an das MonoBehaviour gebunden, das sie startet. Wenn das GameObject ausgeschaltet oder zerstört wird, wird die Coroutine nicht mehr verarbeitet.
  • Die try-catch-finally Struktur kann aufgrund der Yield-Syntax nicht verwendet werden.
  • Mindestens ein Frame vergeht nach der yield return , bevor der nächste Code ausgeführt wird.
  • Zuordnung des Lambda und der Coroutine selbst

Versprechen

Versprechen sind ein Muster, um asynchrone Vorgänge zu organisieren und besser lesbar zu machen. Sie sind aufgrund ihrer Verwendung in vielen JavaScript-Bibliotheken von Drittanbietern populär geworden und werden seit ES6 nativ implementiert.


Wenn Sie Promises verwenden, geben wir sofort ein Objekt von Ihrer asynchronen Funktion zurück. Dadurch kann der Aufrufer auf die Lösung (oder einen Fehler) des Vorgangs warten.


Im Wesentlichen bedeutet dies, dass asynchrone Methoden Werte zurückgeben und sich wie synchrone Methoden „verhalten“ können: Anstatt den endgültigen Wert sofort zurückzugeben, geben sie ein „Versprechen“, dass sie irgendwann in der Zukunft einen Wert zurückgeben werden.


Es gibt mehrere Promises-Implementierungen für Unity:


Die Interaktion mit einem Promise erfolgt hauptsächlich über Callback-Funktionen .


Sie können eine Rückruffunktion definieren, die aufgerufen wird, wenn ein Versprechen aufgelöst wird, und eine andere Rückruffunktion, die aufgerufen wird, wenn das Versprechen abgelehnt wird. Diese Rückrufe erhalten das Ergebnis der asynchronen Operation als Argumente, die dann zur Ausführung weiterer Operationen verwendet werden können.


Gemäß diesen Spezifikationen der Promises/A+-Organisation kann ein Promise einen von drei Zuständen haben:


  • Pending : Der Anfangszustand bedeutet, dass der asynchrone Vorgang noch läuft und das Ergebnis des Vorgangs noch nicht bekannt ist.
  • Fulfilled ( Resolved ): Der gelöste Zustand wird von einem Wert begleitet, der das Ergebnis des Vorgangs darstellt.
  • Rejected : Wenn der asynchrone Vorgang aus irgendeinem Grund fehlschlägt, wird das Versprechen als „abgelehnt“ bezeichnet. Dem abgelehnten Status ist der Grund für das Scheitern beigefügt.

Mehr zu Versprechen

Darüber hinaus können Versprechen miteinander verkettet werden, sodass das Ergebnis eines Versprechens zur Bestimmung des Ergebnisses eines anderen Versprechens verwendet werden kann.


Sie können beispielsweise ein Promise erstellen, das einige Daten von einem Server abruft, und diese Daten dann verwenden, um ein weiteres Promise zu erstellen, das einige Berechnungen und andere Aktionen durchführt:

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


Hier ist ein Beispiel für die Organisation einer Methode, die einen asynchronen Vorgang ausführt:

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


Wir könnten Coroutinen auch in ein Promise einschließen:

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


Und natürlich können Sie mit ThenAll / Promise.All und ThenRace / Promise.Race jede beliebige Kombination der Versprechen-Ausführungsreihenfolge organisieren:

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

Die „vielversprechenden“ Teile von Versprechen

Trotz aller Benutzerfreundlichkeit haben Versprechen auch einige Nachteile:


  • Overhead : Das Erstellen von Promises ist im Vergleich zur Verwendung anderer Methoden der asynchronen Programmierung, wie etwa Coroutinen, mit zusätzlichem Overhead verbunden. In manchen Fällen kann dies zu einer verminderten Leistung führen.
  • Debuggen : Das Debuggen von Promises kann schwieriger sein als das Debuggen anderer asynchroner Programmiermuster. Es kann schwierig sein, den Ausführungsfluss zu verfolgen und die Fehlerquelle zu identifizieren.
  • Ausnahmebehandlung : Die Ausnahmebehandlung kann bei Promises im Vergleich zu anderen asynchronen Programmiermustern komplexer sein. Es kann schwierig sein, Fehler und Ausnahmen zu verwalten, die innerhalb einer Promise-Kette auftreten.

Asynchrone/wartende Aufgaben

Die Async/Await-Funktion ist seit Version 5.0 (2012) Teil von C# und wurde in Unity 2017 mit der Implementierung der .NET 4.x-Laufzeit eingeführt.


In der Geschichte von .NET lassen sich folgende Phasen unterscheiden:


  1. EAP (Event-based Asynchronous Pattern): Dieser Ansatz basiert auf Ereignissen, die nach Abschluss einer Operation ausgelöst werden, und einer regulären Methode, die diese Operation aufruft.
  2. APM (Asynchronous Programming Model): Dieser Ansatz basiert auf zwei Methoden. Die BeginSmth Methode gibt die IAsyncResult Schnittstelle zurück. Die EndSmth -Methode akzeptiert IAsyncResult ; Wenn der Vorgang zum Zeitpunkt des EndSmth Aufrufs nicht abgeschlossen ist, wird der Thread blockiert.
  3. TAP (Task-based Asynchronous Pattern): Dieses Konzept wurde durch die Einführung von async/await und den Typen Task und Task<TResult> verbessert.


Die vorherigen Ansätze wurden aufgrund des Erfolgs des letzten Ansatzes obsolet.

Um eine asynchrone Methode zu erstellen, muss die Methode mit dem Schlüsselwort async gekennzeichnet sein, ein „ await enthalten und der Rückgabewert muss Task , Task<T> oder void sein (nicht empfohlen).

 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 }


In diesem Beispiel erfolgt die Ausführung wie folgt:


  1. Zunächst wird der Code vor dem Aufruf des ersten asynchronen Vorgangs ( SyncMethodA ) ausgeführt.
  2. Der erste asynchrone Vorgang await Task.Delay(1000) wird gestartet und wird voraussichtlich ausgeführt. In der Zwischenzeit wird der Code gespeichert, der aufgerufen werden soll, wenn der asynchrone Vorgang abgeschlossen ist (die „Fortsetzung“).
  3. Nachdem der erste asynchrone Vorgang abgeschlossen ist, beginnt die „Fortsetzung“ – der Code, bis der nächste asynchrone Vorgang ( SyncMethodB ) ausgeführt wird.
  4. Der zweite asynchrone Vorgang ( await Task.Delay(2000) ) wird gestartet und wird voraussichtlich ausgeführt. Gleichzeitig bleibt die Fortsetzung – der Code nach der zweiten asynchronen Operation ( SyncMethodC ) – erhalten.
  5. Nach Abschluss des zweiten asynchronen Vorgangs wird SyncMethodC ausgeführt, gefolgt von der Ausführung und dem Warten auf den dritten asynchronen await Task.Delay(3000) .


Dies ist eine vereinfachte Erklärung, da async/await tatsächlich syntaktischer Zucker ist, der das bequeme Aufrufen asynchroner Methoden und das Warten auf deren Abschluss ermöglicht.


Sie können auch jede beliebige Kombination von Ausführungsaufträgen mithilfe von WhenAll und WhenAny organisieren:

 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

Der C# -Compiler wandelt asynchrone/await-Aufrufe in eine IAsyncStateMachine Zustandsmaschine um, bei der es sich um einen sequentiellen Satz von Aktionen handelt, die ausgeführt werden müssen, um den asynchronen Vorgang abzuschließen.


Jedes Mal, wenn Sie eine Warteoperation aufrufen, schließt die Zustandsmaschine ihre Arbeit ab und wartet auf den Abschluss dieser Operation. Anschließend führt sie die nächste Operation weiter aus. Dies ermöglicht die Ausführung asynchroner Vorgänge im Hintergrund, ohne den Hauptthread zu blockieren, und macht außerdem asynchrone Methodenaufrufe einfacher und lesbarer.


Somit wird die Example in die Erstellung und Initialisierung eines Zustandsautomaten mit der Annotation [AsyncStateMachine(typeof(ExampleStateMachine))] umgewandelt, und der Zustandsautomat selbst verfügt über eine Anzahl von Zuständen, die der Anzahl der Warteaufrufe entspricht.


  • Beispiel der transformierten Methode 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; }


  • Beispiel einer generierten Zustandsmaschine 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) { /*...*/ } }

SynchronizationContext

Im AwaitUnsafeOnCompleted Aufruf wird der aktuelle Synchronisationskontext SynchronizationContext abgerufen. SynchronizationContext ist ein Konzept in C#, das zur Darstellung eines Kontexts verwendet wird, der die Ausführung einer Reihe asynchroner Vorgänge steuert. Es wird verwendet, um die Ausführung von Code über mehrere Threads hinweg zu koordinieren und sicherzustellen, dass Code in einer bestimmten Reihenfolge ausgeführt wird. Der Hauptzweck von SynchronizationContext besteht darin, eine Möglichkeit zur Steuerung der Planung und Ausführung asynchroner Vorgänge in einer Multithread-Umgebung bereitzustellen.


In verschiedenen Umgebungen verfügt der SynchronizationContext über unterschiedliche Implementierungen. In .NET gibt es beispielsweise:


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


Unity verfügt außerdem über einen eigenen Synchronisierungskontext, UnitySynchronizationContext , der es uns ermöglicht, asynchrone Vorgänge mit Bindung an die PlayerLoop-API zu verwenden. Das folgende Codebeispiel zeigt, wie man mit Task.Yield() ein Objekt in jedem Frame dreht:

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


Ein weiteres Beispiel für die Verwendung von async/await in Unity zum Stellen einer Netzwerkanfrage:

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


Dank UnitySynchronizationContext können wir UnityEngine Methoden (z. B. Debug.Log() ) direkt nach Abschluss eines asynchronen Vorgangs sicher verwenden, da die Ausführung dieses Codes im Haupt-Unity-Thread fortgesetzt wird.

TaskCompletionSource<T>

Mit dieser Klasse können Sie ein Task Objekt verwalten. Es wurde erstellt, um alte asynchrone Methoden an TAP anzupassen, ist aber auch sehr nützlich, wenn wir eine Task um einen lang andauernden Vorgang wickeln möchten, der bei einem bestimmten Ereignis auftritt.


Im folgenden Beispiel wird das Task Objekt in taskCompletionSource 3 Sekunden nach dem Start abgeschlossen und wir erhalten das Ergebnis in der Update Methode:

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

Stornierungstoken

In C# wird ein Abbruchtoken verwendet, um zu signalisieren, dass eine Aufgabe oder ein Vorgang abgebrochen werden soll. Das Token wird an die Aufgabe oder Operation übergeben, und der Code innerhalb der Aufgabe oder Operation kann das Token regelmäßig überprüfen, um festzustellen, ob die Aufgabe oder Operation gestoppt werden sollte. Dies ermöglicht einen sauberen und ordnungsgemäßen Abbruch einer Aufgabe oder eines Vorgangs, anstatt ihn einfach abrupt abzubrechen.


Abbruchtokens werden häufig in Situationen verwendet, in denen eine lang laufende Aufgabe vom Benutzer abgebrochen werden kann oder wenn die Aufgabe nicht mehr benötigt wird, z. B. eine Schaltfläche zum Abbrechen in einer Benutzeroberfläche.


Das Gesamtmuster ähnelt der Verwendung von TaskCompletionSource . Zuerst wird eine CancellationTokenSource erstellt, dann wird ihr Token an die asynchrone Operation übergeben:

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


Wenn der Vorgang abgebrochen wird, wird eine OperationCanceledException ausgelöst und die Eigenschaft Task.IsCanceled wird auf true gesetzt.

Neue asynchrone Funktionen in Unity 2022.2

Es ist wichtig zu beachten, dass Task von der .NET-Laufzeitumgebung und nicht von Unity verwaltet werden. Wenn das Objekt, das die Aufgabe ausführt, zerstört wird (oder wenn das Spiel den Spielmodus im Editor verlässt), wird die Aufgabe weiterhin wie von Unity ausgeführt Es gibt keine Möglichkeit, es abzubrechen.


Sie müssen await Task immer mit dem entsprechenden CancellationToken begleiten. Dies führt zu einer gewissen Redundanz des Codes, und in Unity 2022.2 wurden integrierte Token auf der MonoBehaviour Ebene und der gesamten Application angezeigt.


Sehen wir uns an, wie sich das vorherige Beispiel ändert, wenn das destroyCancellationToken des MonoBehaviour Objekts verwendet wird:

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


Wir müssen nicht mehr manuell eine CancellationTokenSource erstellen und die Aufgabe in der OnDestroy Methode abschließen. Für Aufgaben, die nicht mit einem bestimmten MonoBehaviour verknüpft sind, können wir UnityEngine.Application.exitCancellationToken verwenden. Dadurch wird die Aufgabe beim Verlassen des Wiedergabemodus (im Editor) oder beim Beenden der Anwendung beendet.

UniTask

Trotz der Benutzerfreundlichkeit und der von .NET Tasks bereitgestellten Funktionen weisen sie bei der Verwendung in Unity erhebliche Nachteile auf:


  • Task sind zu umständlich und verursachen viele Zuordnungen.
  • Task ist nicht dem Unity-Threading (Single-Thread) zugeordnet.


Die UniTask- Bibliothek umgeht diese Einschränkungen, ohne Threads oder SynchronizationContext zu verwenden. Das Fehlen von Zuordnungen wird durch die Verwendung des strukturbasierten Typs UniTask<T> erreicht.


UniTask erfordert die .NET 4.x-Skriptlaufzeitversion, wobei Unity 2018.4.13f1 die offiziell niedrigste unterstützte Version ist.


Sie können auch alle AsyncOperations mit Erweiterungsmethoden in UnitTask konvertieren:

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


In diesem Beispiel verwendet die LoadAsset Methode Resources.LoadAsync , um ein Asset asynchron zu laden. Die AsUniTask Methode wird dann verwendet, um die von LoadAsync zurückgegebene AsyncOperation in eine UniTask umzuwandeln, die abgewartet werden kann.


Wie zuvor können Sie mit UniTask.WhenAll und UniTask.WhenAny jede beliebige Kombination der Ausführungsreihenfolge organisieren:

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


In UniTask gibt es eine weitere Implementierung von SynchronizationContext namens UniTaskSynchronizationContext , die zur Erzielung einer besseren Leistung als Ersatz für UnitySynchronizationContext verwendet werden kann.

Erwartete API

In der ersten Alpha-Version von Unity 2023.1 wurde die Awaitable Klasse eingeführt. Awaitable-Coroutinen sind asynchrone/await-kompatible aufgabenähnliche Typen, die für die Ausführung in Unity entwickelt wurden. Im Gegensatz zu .NET-Aufgaben werden sie von der Engine und nicht von der Laufzeit verwaltet.

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


Sie können erwartet und als Rückgabetyp einer asynchronen Methode verwendet werden. Im Vergleich zu System.Threading.Tasks sind sie weniger ausgefeilt, nutzen aber leistungssteigernde Abkürzungen, die auf Unity-spezifischen Annahmen basieren.


Hier sind die Hauptunterschiede im Vergleich zu .NET-Aufgaben:


  • Auf das Awaitable Objekt kann nur einmal gewartet werden; Es kann nicht von mehreren asynchronen Funktionen erwartet werden.
  • Awaiter.GetResults() wird bis zum Abschluss nicht blockiert. Der Aufruf vor Abschluss des Vorgangs ist ein undefiniertes Verhalten.
  • Erfassen Sie niemals einen ExecutionContext . Aus Sicherheitsgründen erfassen .NET-Aufgaben beim Warten Ausführungskontexte, um Identitätswechselkontexte über asynchrone Aufrufe hinweg weiterzugeben.
  • Erfassen Sie niemals SynchronizationContext . Coroutine-Fortsetzungen werden synchron vom Code ausgeführt, der die Vervollständigung auslöst. In den meisten Fällen erfolgt dies über den Unity-Hauptrechner.
  • Awaitables sind gepoolte Objekte, um übermäßige Zuweisungen zu verhindern. Dabei handelt es sich um Referenztypen, sodass sie über verschiedene Stapel hinweg referenziert, effizient kopiert usw. werden können. Der ObjectPool wurde verbessert, um Stack<T> -Grenzprüfungen in typischen Get/Release-Sequenzen zu vermeiden, die von asynchronen Zustandsmaschinen generiert werden.


Um das Ergebnis eines längeren Vorgangs zu erhalten, können Sie den Typ Awaitable<T> verwenden. Sie können den Abschluss eines Awaitable mithilfe von AwaitableCompletionSource und AwaitableCompletionSource<T> verwalten , ähnlich wie bei 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); } }


Manchmal sind umfangreiche Berechnungen erforderlich, die zum Einfrieren des Spiels führen können. Hierfür ist es besser, die Awaitable-Methoden zu verwenden: BackgroundThreadAsync() und MainThreadAsync() . Sie ermöglichen es Ihnen, den Hauptthread zu verlassen und zu ihm zurückzukehren.

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


Auf diese Weise beseitigen Awaitables die Nachteile der Verwendung von .NET-Aufgaben und ermöglichen außerdem das Warten auf PlayerLoop-Ereignisse und AsyncOperations.

Abschluss

Wie wir sehen können, gibt es mit der Entwicklung von Unity immer mehr Tools zum Organisieren asynchroner Vorgänge:

Einheit

Coroutinen

Versprechen

.NET-Aufgaben

UniTask

Integrierte Stornierungstoken

Erwartete API

5.6





2017.1




2018.4



2022.2


2023.1


Wir haben alle wichtigen Möglichkeiten der asynchronen Programmierung in Unity betrachtet. Abhängig von der Komplexität Ihrer Aufgabe und der von Ihnen verwendeten Unity-Version können Sie eine breite Palette von Technologien von Coroutines und Promises bis hin zu Tasks und Awaitables nutzen, um ein reibungsloses und nahtloses Gameplay in Ihren Spielen zu gewährleisten. Vielen Dank fürs Lesen und wir freuen uns auf Ihre nächsten Meisterwerke.