paint-brush
Công cụ lập trình không đồng bộ hoàn chỉnh để phát triển Unitytừ tác giả@dmitrii
5,860 lượt đọc
5,860 lượt đọc

Công cụ lập trình không đồng bộ hoàn chỉnh để phát triển Unity

từ tác giả Dmitrii Ivashchenko31m2023/03/30
Read on Terminal Reader

dài quá đọc không nổi

Trong bài viết này, chúng ta sẽ nói về việc tránh những vấn đề như vậy. Chúng tôi sẽ đề xuất các kỹ thuật lập trình không đồng bộ để thực hiện các tác vụ này trong một luồng riêng biệt, do đó để luồng chính tự do thực hiện các tác vụ khác. Điều này sẽ giúp đảm bảo quá trình chơi mượt mà và nhạy bén, đồng thời (hy vọng) làm hài lòng các game thủ.
featured image - Công cụ lập trình không đồng bộ hoàn chỉnh để phát triển Unity
Dmitrii Ivashchenko HackerNoon profile picture
0-item

Một số tác vụ trong quá trình phát triển trò chơi không đồng bộ — chúng không đồng bộ. Điều này có nghĩa là chúng không được thực thi tuyến tính trong mã trò chơi. Một số tác vụ không đồng bộ này có thể yêu cầu thời gian khá dài để hoàn thành, trong khi những tác vụ khác liên quan đến tính toán chuyên sâu.


Một số tác vụ không đồng bộ chơi game phổ biến nhất như sau:

  • Thực hiện yêu cầu mạng

  • Đang tải cảnh, tài nguyên và các nội dung khác

  • Đọc và ghi tệp

  • Trí tuệ nhân tạo để ra quyết định

  • Chuỗi hoạt hình dài

  • Xử lý một lượng lớn dữ liệu

  • tìm đường


Bây giờ, điều quan trọng là, vì tất cả mã Unity chạy trong một luồng, nên bất kỳ tác vụ nào giống như một trong những tác vụ được đề cập ở trên, nếu chúng được thực hiện đồng bộ, sẽ dẫn đến luồng chính bị chặn và do đó, khung hình bị giảm.


Xin chào mọi người, tên tôi là Dmitrii Ivashchenko và tôi là Trưởng nhóm phát triển tại MY.GAMES. Trong bài viết này, chúng ta sẽ nói về việc tránh bất kỳ vấn đề nào như vậy. Chúng tôi sẽ đề xuất các kỹ thuật lập trình không đồng bộ để thực hiện các tác vụ này trong một luồng riêng biệt, do đó để luồng chính tự do thực hiện các tác vụ khác. Điều này sẽ giúp đảm bảo quá trình chơi mượt mà và nhạy bén, đồng thời (hy vọng) làm hài lòng các game thủ.

quân đoàn

Đầu tiên, hãy nói về coroutines. Chúng được giới thiệu trong Unity vào năm 2011, thậm chí trước khi async/await xuất hiện trong .NET. Trong Unity, các coroutine cho phép chúng ta thực hiện một tập hợp các hướng dẫn trên nhiều khung, thay vì thực hiện tất cả chúng cùng một lúc. Chúng tương tự như các luồng, nhưng nhẹ hơn và được tích hợp vào vòng cập nhật của Unity, khiến chúng rất phù hợp để phát triển trò chơi.


(Nhân tiện, về mặt lịch sử, coroutine là cách đầu tiên để thực hiện các hoạt động không đồng bộ trong Unity, vì vậy hầu hết các bài báo trên Internet đều nói về chúng.)


Để tạo một coroutine, bạn cần khai báo một hàm với kiểu trả về IEnumerator . Hàm này có thể chứa bất kỳ logic nào mà bạn muốn coroutine thực thi.


Để bắt đầu một coroutine, bạn cần gọi phương thức StartCoroutine trên một cá thể MonoBehaviour và chuyển hàm coroutine làm đối số:

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


Có một số hướng dẫn năng suất có sẵn trong Unity, chẳng hạn như WaitForSeconds , WaitForEndOfFrame , WaitForFixedUpdate , WaitForSecondsRealtime , WaitUntil cũng như một số hướng dẫn khác. Điều quan trọng cần nhớ là việc sử dụng chúng dẫn đến phân bổ, vì vậy chúng nên được sử dụng lại bất cứ khi nào có thể.


Ví dụ: xem xét phương pháp này từ tài liệu:

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


Với mỗi lần lặp lại vòng lặp, một phiên bản mới của new WaitForSeconds(.1f) sẽ được tạo. Thay vì điều này, chúng ta có thể di chuyển việc tạo ra bên ngoài vòng lặp và tránh phân bổ:

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


Một thuộc tính quan trọng khác cần lưu ý là yield return có thể được sử dụng với tất cả các phương thức Async do Unity cung cấp vì AsyncOperation s là hậu duệ của YieldInstruction :

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

Một số cạm bẫy có thể có của coroutines

Tất cả điều này đã được nói, coroutines cũng có một vài nhược điểm cần lưu ý:


  • Không thể trả lại kết quả của một hoạt động dài. Bạn vẫn cần các cuộc gọi lại sẽ được chuyển đến coroutine và được gọi khi nó kết thúc để trích xuất bất kỳ dữ liệu nào từ nó.
  • Một coroutine được liên kết chặt chẽ với MonoBehaviour khởi chạy nó. Nếu GameObject bị tắt hoặc bị hủy, coroutine sẽ ngừng xử lý.
  • Cấu trúc try-catch-finally không thể được sử dụng do sự hiện diện của cú pháp năng suất.
  • Ít nhất một khung sẽ vượt qua sau khi yield return trước khi mã tiếp theo bắt đầu thực thi.
  • Phân bổ lambda và chính coroutine

lời hứa

Lời hứa là một mẫu để tổ chức và làm cho các hoạt động không đồng bộ dễ đọc hơn. Chúng đã trở nên phổ biến do được sử dụng trong nhiều thư viện JavaScript của bên thứ ba và kể từ ES6, chúng đã được triển khai nguyên bản.


Khi sử dụng Lời hứa, chúng tôi ngay lập tức trả về một đối tượng từ chức năng không đồng bộ của bạn. Điều này cho phép người gọi đợi giải pháp (hoặc lỗi) của thao tác.


Về cơ bản, điều này làm cho các phương thức không đồng bộ có thể trả về các giá trị và “hành động” giống như các phương thức đồng bộ: thay vì trả về giá trị cuối cùng ngay lập tức, chúng đưa ra một “lời hứa” rằng chúng sẽ trả lại một giá trị trong tương lai.


Có một số triển khai Promise cho Unity:


Cách chính để tương tác với Promise là thông qua các hàm gọi lại .


Bạn có thể xác định hàm gọi lại sẽ được gọi khi Lời hứa được giải quyết và một hàm gọi lại khác sẽ được gọi nếu Lời hứa bị từ chối. Các cuộc gọi lại này nhận kết quả của hoạt động không đồng bộ làm đối số, sau đó có thể được sử dụng để thực hiện các hoạt động tiếp theo.


Theo các thông số kỹ thuật này từ tổ chức Promises/A+, một Promise có thể ở một trong ba trạng thái:


  • Pending : trạng thái ban đầu, điều này có nghĩa là hoạt động không đồng bộ vẫn đang được tiến hành và kết quả của hoạt động vẫn chưa được biết.
  • Fulfilled ( Resolved ): trạng thái đã giải quyết được kèm theo một giá trị đại diện cho kết quả của hoạt động.
  • Rejected : nếu hoạt động không đồng bộ không thành công vì bất kỳ lý do gì, Lời hứa được cho là "bị từ chối". Trạng thái bị từ chối kèm theo lý do thất bại.

Thông tin thêm về Lời hứa

Ngoài ra, các lời hứa có thể được xâu chuỗi lại với nhau để kết quả của một Lời hứa có thể được sử dụng để xác định kết quả của một Lời hứa khác.


Ví dụ: bạn có thể tạo một Lời hứa tìm nạp một số dữ liệu từ máy chủ, sau đó sử dụng dữ liệu đó để tạo một Lời hứa khác thực hiện một số tính toán và các hành động khác:

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


Đây là một ví dụ về cách tổ chức một phương thức thực hiện thao tác không đồng bộ:

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


Chúng ta cũng có thể bọc các coroutine trong một 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(); }


Và tất nhiên, bạn có thể tổ chức bất kỳ sự kết hợp nào của thứ tự thực hiện lời hứa bằng cách sử dụng ThenAll / Promise.AllThenRace / 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") ) );

Những phần “không hứa hẹn” của lời hứa

Mặc dù có tất cả sự tiện lợi khi sử dụng, nhưng những lời hứa cũng có một số nhược điểm:


  • Chi phí chung : Tạo Lời hứa liên quan đến chi phí bổ sung so với việc sử dụng các phương pháp lập trình không đồng bộ khác, như coroutines. Trong một số trường hợp, điều này có thể dẫn đến giảm hiệu suất.
  • Gỡ lỗi : Gỡ lỗi Lời hứa có thể khó hơn gỡ lỗi các mẫu lập trình không đồng bộ khác. Có thể khó theo dõi luồng thực thi và xác định nguồn gốc của lỗi.
  • Xử lý ngoại lệ : Xử lý ngoại lệ có thể phức tạp hơn với Lời hứa so với các mẫu lập trình không đồng bộ khác. Có thể khó quản lý các lỗi và ngoại lệ xảy ra trong chuỗi Promise.

Tác vụ không đồng bộ/đang chờ

Tính năng async/await đã là một phần của C# kể từ phiên bản 5.0 (2012) và được giới thiệu trong Unity 2017 với việc triển khai thời gian chạy .NET 4.x.


Trong lịch sử của .NET, có thể phân biệt các giai đoạn sau:


  1. EAP (Mẫu không đồng bộ dựa trên sự kiện): Cách tiếp cận này dựa trên các sự kiện được kích hoạt khi hoàn thành một thao tác và một phương thức thông thường gọi thao tác này.
  2. APM (Mô hình lập trình không đồng bộ): Cách tiếp cận này dựa trên hai phương pháp. Phương thức BeginSmth trả về giao diện IAsyncResult . Phương thức EndSmth lấy IAsyncResult ; nếu hoạt động không được hoàn thành tại thời điểm cuộc gọi EndSmth , luồng sẽ bị chặn.
  3. TAP (Mẫu không đồng bộ dựa trên tác vụ): Khái niệm này đã được cải thiện bằng cách giới thiệu async/await và các loại TaskTask<TResult> .


Các phương pháp trước đó đã trở nên lỗi thời do sự thành công của phương pháp cuối cùng.

Để tạo một phương thức không đồng bộ, phương thức đó phải được đánh dấu bằng từ khóa async , chứa một await bên trong và giá trị trả về phải là Task , Task<T> hoặc void (không được khuyến nghị).

 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 }


Trong ví dụ này, việc thực thi sẽ diễn ra như sau:


  1. Đầu tiên, mã trước cuộc gọi đến hoạt động không đồng bộ đầu tiên ( SyncMethodA ) sẽ được thực thi.
  2. Hoạt động không đồng bộ đầu tiên await Task.Delay(1000) được khởi chạy và dự kiến sẽ được thực thi. Trong khi đó, mã được gọi khi hoạt động không đồng bộ hoàn tất ("tiếp tục") sẽ được lưu.
  3. Sau khi hoàn thành thao tác không đồng bộ đầu tiên, "sự tiếp tục" — mã cho đến khi thao tác không đồng bộ tiếp theo ( SyncMethodB ) sẽ bắt đầu thực thi.
  4. Hoạt động không đồng bộ thứ hai ( await Task.Delay(2000) ) được khởi chạy và dự kiến sẽ được thực thi. Đồng thời, phần tiếp theo — mã sau thao tác không đồng bộ thứ hai ( SyncMethodC ) sẽ được giữ nguyên.
  5. Sau khi hoàn thành hoạt động không đồng bộ thứ hai, SyncMethodC sẽ được thực thi, tiếp theo là thực thi và chờ hoạt động không đồng bộ thứ ba await Task.Delay(3000) .


Đây là một lời giải thích đơn giản hóa, vì trên thực tế, async/await là cú pháp đường để cho phép gọi các phương thức không đồng bộ một cách thuận tiện và chờ chúng hoàn thành.


Bạn cũng có thể tổ chức bất kỳ tổ hợp lệnh thực thi nào bằng cách sử dụng WhenAllWhenAny :

 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

Trình biên dịch C# chuyển đổi các cuộc gọi không đồng bộ/chờ đợi thành một máy trạng thái IAsyncStateMachine , đây là một tập hợp các hành động tuần tự phải được thực hiện để hoàn thành thao tác không đồng bộ.


Mỗi khi bạn gọi một hoạt động đang chờ, máy trạng thái sẽ hoàn thành công việc của nó và chờ hoàn thành hoạt động đó, sau đó nó tiếp tục thực hiện hoạt động tiếp theo. Điều này cho phép các hoạt động không đồng bộ được thực hiện trong nền mà không chặn luồng chính và cũng làm cho các lệnh gọi phương thức không đồng bộ trở nên đơn giản và dễ đọc hơn.


Do đó, phương thức Example được chuyển thành tạo và khởi tạo một máy trạng thái với chú thích [AsyncStateMachine(typeof(ExampleStateMachine))] và bản thân máy trạng thái có một số trạng thái bằng với số lần gọi chờ.


  • Ví dụ về phương thức được biến đổi 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; }


  • Ví dụ về máy trạng thái được tạo 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) { /*...*/ } }

Đồng bộ hóaBối cảnh

Trong lệnh gọi AwaitUnsafeOnCompleted , bối cảnh đồng SynchronizationContext hóa hiện tại sẽ được lấy. SynchronizationContext là một khái niệm trong C# được sử dụng để biểu diễn ngữ cảnh kiểm soát việc thực hiện một tập hợp các hoạt động không đồng bộ. Nó được sử dụng để điều phối việc thực thi mã trên nhiều luồng và để đảm bảo rằng mã được thực thi theo một thứ tự cụ thể. Mục đích chính của SynchronizationContext là cung cấp một cách để kiểm soát việc lên lịch và thực hiện các hoạt động không đồng bộ trong môi trường đa luồng.


Trong các môi trường khác nhau, SynchronizationContext có các triển khai khác nhau. Ví dụ, trong .NET, có:


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


Unity cũng có bối cảnh đồng bộ hóa riêng, UnitySynchronizationContext , cho phép chúng tôi sử dụng các hoạt động không đồng bộ với liên kết với API PlayerLoop. Ví dụ mã sau đây cho thấy cách xoay một đối tượng trong mỗi khung bằng cách sử dụng Task.Yield() :

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


Một ví dụ khác về việc sử dụng async/await trong Unity để thực hiện yêu cầu mạng:

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


Nhờ UnitySynchronizationContext , chúng ta có thể sử dụng các phương thức UnityEngine một cách an toàn (chẳng hạn như Debug.Log() ) ngay sau khi hoàn thành thao tác không đồng bộ, vì việc thực thi mã này sẽ tiếp tục trong chuỗi Unity chính.

TaskCompletitionSource<T>

Lớp này cho phép bạn quản lý một đối tượng Task . Nó được tạo ra để điều chỉnh các phương thức không đồng bộ cũ thành TAP, nhưng nó cũng rất hữu ích khi chúng ta muốn bao bọc một Task xung quanh một số hoạt động chạy dài dựa trên một số sự kiện.


Trong ví dụ sau, đối tượng Task bên trong taskCompletionSource sẽ hoàn thành sau 3 giây kể từ khi bắt đầu và chúng ta sẽ nhận được kết quả của nó trong phương thức 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); } }

Mã thông báo hủy

Mã hủy bỏ được sử dụng trong C# để báo hiệu rằng một tác vụ hoặc hoạt động sẽ bị hủy bỏ. Mã thông báo được chuyển đến nhiệm vụ hoặc hoạt động và mã trong nhiệm vụ hoặc hoạt động có thể kiểm tra mã thông báo định kỳ để xác định xem có nên dừng nhiệm vụ hoặc hoạt động hay không. Điều này cho phép hủy bỏ một nhiệm vụ hoặc hoạt động một cách gọn gàng và nhẹ nhàng, thay vì chỉ giết chết nó một cách đột ngột.


Mã thông báo hủy thường được sử dụng trong các tình huống mà người dùng có thể hủy tác vụ chạy dài hoặc nếu tác vụ không còn cần thiết nữa, chẳng hạn như nút hủy trong giao diện người dùng.


Mẫu tổng thể tương tự như việc sử dụng TaskCompletionSource . Đầu tiên, một CancellationTokenSource được tạo, sau đó Token của nó được chuyển sang hoạt động không đồng bộ:

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


Khi thao tác bị hủy, một OperationCanceledException sẽ được ném ra và thuộc tính Task.IsCanceled sẽ được đặt thành true .

Các tính năng không đồng bộ mới trong Unity 2022.2

Điều quan trọng cần lưu ý là các đối tượng Task được quản lý bởi thời gian chạy .NET chứ không phải bởi Unity và nếu đối tượng thực thi tác vụ bị hủy (hoặc nếu trò chơi thoát khỏi chế độ chơi trong trình chỉnh sửa), thì tác vụ sẽ tiếp tục chạy như Unity có không có cách nào để hủy bỏ nó.


Bạn luôn cần đi kèm với await Task với CancellationToken tương ứng. Điều này dẫn đến một số mã dự phòng và trong Unity 2022.2, các mã thông báo tích hợp ở cấp MonoBehaviour và toàn bộ cấp Application đã xuất hiện.


Hãy xem ví dụ trước thay đổi như thế nào khi sử dụng destroyCancellationToken của đối tượng 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); } } }


Chúng tôi không còn cần phải tạo thủ công CancellationTokenSource và hoàn thành tác vụ trong phương thức OnDestroy . Đối với các tác vụ không được liên kết với một MonoBehaviour cụ thể, chúng ta có thể sử dụng UnityEngine.Application.exitCancellationToken . Thao tác này sẽ chấm dứt tác vụ khi thoát Chế độ phát (trong Trình chỉnh sửa) hoặc khi thoát ứng dụng.

UniTask

Bất chấp sự tiện lợi khi sử dụng và các khả năng do .NET Tasks cung cấp, chúng có những hạn chế đáng kể khi được sử dụng trong Unity:


  • Các đối tượng Task quá cồng kềnh và gây ra nhiều phân bổ.
  • Task không khớp với luồng Unity (luồng đơn).


Thư viện UniTask bỏ qua những hạn chế này mà không cần sử dụng các luồng hoặc SynchronizationContext . Nó đạt được sự vắng mặt của phân bổ bằng cách sử dụng loại dựa trên cấu trúc UniTask<T> .


UniTask yêu cầu phiên bản thời gian chạy tập lệnh .NET 4.x, với Unity 2018.4.13f1 là phiên bản chính thức được hỗ trợ thấp nhất.


Ngoài ra, bạn có thể chuyển đổi tất cả AsyncOperations thành UnitTask bằng các phương thức mở rộng:

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


Trong ví dụ này, phương thức LoadAsset sử dụng Resources.LoadAsync để tải nội dung không đồng bộ. Sau đó, phương thức AsUniTask được sử dụng để chuyển đổi AsyncOperation do LoadAsync trả về thành UniTask , có thể chờ đợi.


Như trước đây, bạn có thể tổ chức bất kỳ sự kết hợp thứ tự thực thi nào bằng cách sử dụng UniTask.WhenAllUniTask.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 } }


Trong UniTask, có một triển khai SynchronizationContext khác gọi là UniTaskSynchronizationContext có thể được sử dụng để thay thế UnitySynchronizationContext để có hiệu suất tốt hơn.

API có thể chờ đợi

Trong phiên bản alpha đầu tiên của Unity 2023.1, lớp Awaitable đã được giới thiệu. Các Coroutine có thể chờ đợi là các loại giống như Tác vụ không đồng bộ/tương thích với chờ đợi được thiết kế để chạy trong Unity. Không giống như .NET Tasks, chúng được quản lý bởi engine chứ không phải 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"); // ... }


Chúng có thể được chờ đợi và được sử dụng làm kiểu trả về của phương thức không đồng bộ. So với System.Threading.Tasks , chúng ít phức tạp hơn nhưng có các phím tắt nâng cao hiệu suất dựa trên các giả định dành riêng cho Unity.


Dưới đây là những điểm khác biệt chính so với .NET Tasks:


  • Đối tượng Awaitable chỉ có thể được chờ đợi một lần; nó không thể được chờ đợi bởi nhiều chức năng không đồng bộ.
  • Awaiter.GetResults() sẽ không chặn cho đến khi hoàn thành. Gọi nó trước khi hoạt động kết thúc là hành vi không xác định.
  • Không bao giờ nắm bắt một ExecutionContext . Vì lý do bảo mật, Nhiệm vụ .NET nắm bắt ngữ cảnh thực thi khi đang chờ để truyền bá ngữ cảnh mạo danh qua các cuộc gọi không đồng bộ.
  • Không bao giờ nắm bắt SynchronizationContext . Các phần tiếp theo của Coroutine được thực thi đồng bộ từ mã làm tăng phần hoàn thành. Trong hầu hết các trường hợp, đây sẽ là từ khung chính của Unity.
  • Awaitables là các đối tượng gộp lại để ngăn phân bổ quá mức. Đây là các loại tham chiếu, vì vậy chúng có thể được tham chiếu qua các ngăn xếp khác nhau, được sao chép một cách hiệu quả, v.v. ObjectPool đã được cải thiện để tránh kiểm tra giới hạn Stack<T> trong các trình tự nhận/phát hành thông thường được tạo bởi các máy trạng thái không đồng bộ.


Để có được kết quả của một thao tác dài, bạn có thể sử dụng loại Awaitable<T> . Bạn có thể quản lý việc hoàn thành Awaitable bằng cách sử dụng AwaitableCompletionSourceAwaitableCompletionSource<T> , tương tự như 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); } }


Đôi khi, cần phải thực hiện các phép tính lớn có thể dẫn đến đóng băng trò chơi. Đối với điều này, tốt hơn là sử dụng các phương thức Có thể chờ đợi: BackgroundThreadAsync()MainThreadAsync() . Chúng cho phép bạn thoát khỏi luồng chính và quay lại luồng đó.

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


Bằng cách này, Awaitables loại bỏ những nhược điểm của việc sử dụng .NET Tasks và cũng cho phép chờ đợi các sự kiện PlayerLoop và AsyncOperations.

Phần kết luận

Như chúng ta có thể thấy, với sự phát triển của Unity, ngày càng có nhiều công cụ để tổ chức các hoạt động không đồng bộ:

Đoàn kết

quân đoàn

lời hứa

Nhiệm vụ .NET

UniTask

Mã thông báo hủy tích hợp

API có thể chờ đợi

5.6





2017.1




2018.4



2022.2


2023.1


Chúng tôi đã xem xét tất cả các cách lập trình không đồng bộ chính trong Unity. Tùy thuộc vào mức độ phức tạp của nhiệm vụ và phiên bản Unity bạn đang sử dụng, bạn có thể sử dụng nhiều loại công nghệ từ Coroutines và Promise cho đến Nhiệm vụ và Awaitables, để đảm bảo quá trình chơi mượt mà và liền mạch trong trò chơi của bạn. Cảm ơn bạn đã đọc, và chúng tôi chờ đợi những kiệt tác tiếp theo của bạn.