游戏开发中的一些任务不是同步的——它们是异步的。这意味着它们不会在游戏代码中线性执行。其中一些异步任务可能需要相当长的时间才能完成,而其他任务则与密集计算相关联。
执行网络请求
加载场景、资源和其他资产
读写文件
用于决策的人工智能
长动画序列
处理大量数据
寻找路径
现在,至关重要的是,由于所有 Unity 代码都在一个线程中运行,因此任何类似上述任务的任务如果同步执行,都会导致主线程被阻塞,从而导致帧丢失。
大家好,我是 Dmitrii Ivashchenko,我是 MY.GAMES 开发团队的负责人。在本文中,我们将讨论避免任何此类问题。我们将推荐使用异步编程技术在单独的线程中执行这些任务,从而让主线程可以自由执行其他任务。这将有助于确保流畅且反应灵敏的游戏玩法,并(希望)让游戏玩家满意。
首先,让我们谈谈协程。它们于 2011 年在 Unity 中引入,甚至在 .NET 中出现 async / await 之前。在 Unity 中,协程允许我们在多个帧上执行一组指令,而不是一次执行所有指令。它们类似于线程,但轻量级并集成到 Unity 的更新循环中,使它们非常适合游戏开发。
(顺带一提,从历史上看,协程是Unity中最早进行异步操作的方式,所以网上大部分文章都是关于协程的。)
要创建协程,您需要声明一个返回类型为IEnumerator
函数。此函数可以包含您希望协程执行的任何逻辑。
要启动协程,您需要在MonoBehaviour
实例上调用StartCoroutine
方法并将协程函数作为参数传递:
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"); } }
Unity 中有几个可用的 yield 指令,例如WaitForSeconds
、 WaitForEndOfFrame
、 WaitForFixedUpdate
、 WaitForSecondsRealtime
、 WaitUntil
以及其他一些指令。重要的是要记住,使用它们会导致分配,因此应尽可能重用它们。
例如,考虑文档中的这个方法:
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); } }
随着循环的每次迭代,将创建new WaitForSeconds(.1f)
的新实例。取而代之的是,我们可以将创建移到循环之外并避免分配:
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**; } }
另一个需要注意的重要属性是yield return
可以与 Unity 提供的所有Async
方法一起使用,因为AsyncOperation
是YieldInstruction
的后代:
yield return SceneManager.LoadSceneAsync("path/to/scene.unity");
综上所述,协程也有一些缺点需要注意:
MonoBehaviour
严格相关。如果GameObject
被关闭或销毁,协程将停止处理。try-catch-finally
结构。yield return
之后,在下一个代码开始执行之前,至少会经过一帧。Promises是一种用于组织异步操作并使异步操作更具可读性的模式。由于它们在许多第三方 JavaScript 库中的使用而变得流行,并且自 ES6 以来,它们已被原生实现。
使用 Promises 时,我们会立即从您的异步函数返回一个对象。这允许调用者等待操作的解决(或错误)。
从本质上讲,这使得异步方法可以返回值并像同步方法一样“行为”:它们不是立即返回最终值,而是“承诺”它们将在未来某个时间返回一个值。
Unity 有几种 Promises 实现:
与 Promise 交互的主要方式是通过回调函数。
您可以定义一个回调函数,当 Promise 被解析时将被调用,以及另一个回调函数将在 Promise 被拒绝时被调用。这些回调接收异步操作的结果作为参数,然后可用于执行进一步的操作。
根据 Promises/A+ 组织的这些规范,Promise 可以处于以下三种状态之一:
Pending
:初始状态,这意味着异步操作仍在进行中,操作的结果尚不可知。Fulfilled
( Resolved
):已解决的状态伴随着一个代表操作结果的值。Rejected
:如果异步操作因任何原因失败,则称 Promise 被“拒绝”。拒绝状态伴随着失败的原因。此外,promise 可以链接在一起,这样一个 Promise 的结果可以用来确定另一个 Promise 的结果。
例如,您可以创建一个从服务器获取一些数据的 Promise,然后使用该数据创建另一个 Promise 来执行一些计算和其他操作:
var promise = MakeRequest("https://some.api") .Then(response => Parse(response)) .Then(result => OnRequestSuccess(result)) .Then(() => PlaySomeAnimation()) .Catch(exception => OnRequestFailed(exception));
下面是如何组织执行异步操作的方法的示例:
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; }
我们还可以将协程包装在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(); }
当然,您可以使用ThenAll
/ Promise.All
和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") ) );
尽管使用起来很方便,但 promises 也有一些缺点:
自 5.0 (2012) 版以来,async/await 功能一直是 C# 的一部分,并且随着 .NET 4.x 运行时的实现在 Unity 2017 中引入。
在 .NET 的发展历程中,可以分为以下几个阶段:
BeginSmth
方法返回IAsyncResult
接口。 EndSmth
方法采用IAsyncResult
;如果在调用EndSmth
时操作尚未完成,则线程将被阻塞。Task
和Task<TResult>
改进了这个概念。
由于最后一种方法的成功,以前的方法已经过时了。
要创建一个异步方法,该方法必须用关键字async
标记,内部包含一个await
,返回值必须是Task
、 Task<T>
或void
(不推荐)。
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 }
在此示例中,执行将如下进行:
SyncMethodA
) 之前的代码。await Task.Delay(1000)
已启动并预计将被执行。同时,将保存异步操作完成(“延续”)时要调用的代码。SyncMethodB
)的代码将开始执行。await Task.Delay(2000)
) 已启动并预计将被执行。同时,continuation — 第二个异步操作 ( SyncMethodC
) 之后的代码将被保留。SyncMethodC
,接着执行等待第三次异步操作await Task.Delay(3000)
。
这是一个简化的解释,因为实际上 async/await 是语法糖,可以方便地调用异步方法并等待它们完成。
您还可以使用WhenAll
和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"); });
C#编译器将 async/await 调用转换为IAsyncStateMachine
状态机,这是完成异步操作必须执行的一组顺序操作。
每次调用 await 操作时,状态机完成其工作并等待该操作完成,然后继续执行下一个操作。这样可以在不阻塞主线程的情况下在后台执行异步操作,还可以使异步方法调用更简单、更易读。
因此, Example
方法被转换为使用注解[AsyncStateMachine(typeof(ExampleStateMachine))]
创建和初始化状态机,并且状态机本身具有与 await 调用次数相等的状态数。
转换方法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; }
生成的状态机示例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) { /*...*/ } }
在AwaitUnsafeOnCompleted
调用中,将获取当前同步上下文SynchronizationContext
。 SynchronizationContext是C#中的一个概念,用来表示控制一组异步操作执行的上下文。它用于协调跨多个线程的代码执行,并确保代码按特定顺序执行。 SynchronizationContext 的主要目的是提供一种在多线程环境中控制异步操作的调度和执行的方法。
在不同的环境中, SynchronizationContext
有不同的实现。例如,在 .NET 中,有:
System.Windows.Threading.DispatcherSynchronizationContext
System.Windows.Forms.WindowsFormsSynchronizationContext
System.Threading.WinRTSynchronizationContext
System.Web.AspNetSynchronizationContext
Unity也有自己的同步上下文UnitySynchronizationContext
,它使我们能够通过绑定到 PlayerLoop API 使用异步操作。以下代码示例显示了如何使用Task.Yield()
在每个帧中旋转对象:
private async void Start() { while (true) { transform.Rotate(0, Time.deltaTime * 50, 0); await Task.Yield(); } }
另一个在 Unity 中使用 async/await 进行网络请求的例子:
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; } } }
感谢UnitySynchronizationContext
,我们可以在异步操作完成后立即安全地使用UnityEngine
方法(例如Debug.Log()
),因为此代码的执行将在主 Unity 线程中继续。
此类允许您管理Task
对象。它的创建是为了使旧的异步方法适应 TAP,但当我们想要围绕某个事件发生的一些长时间运行的操作包装一个Task
时,它也非常有用。
在下面的示例中, taskCompletionSource
中的Task
对象将在开始后 3 秒后完成,我们将在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); } }
C# 中使用取消令牌来表示应取消任务或操作。令牌被传递给任务或操作,任务或操作中的代码可以定期检查令牌以确定是否应停止任务或操作。这允许干净而优雅地取消任务或操作,而不是突然终止它。
取消令牌通常用于用户可以取消长时间运行的任务,或者不再需要该任务的情况,例如用户界面中的取消按钮。
整体模式类似于TaskCompletionSource
的使用。首先,创建一个CancellationTokenSource
,然后将其Token
传递给异步操作:
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(); } }
取消操作时,将抛出OperationCanceledException
并将Task.IsCanceled
属性设置为true
。
重要的是要注意Task
对象由 .NET 运行时管理,而不是由 Unity 管理,如果执行任务的对象被销毁(或者如果游戏在编辑器中退出播放模式),任务将继续运行,因为 Unity 已没有办法取消它。
您始终需要将await Task
与相应的CancellationToken
一起使用。这导致了一些代码冗余,在 Unity 2022.2 中出现了MonoBehaviour
级别和整个Application
级别的内置令牌。
让我们看看前面的例子在使用MonoBehaviour
对象的destroyCancellationToken
时是如何变化的:
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); } } }
我们不再需要手动创建CancellationTokenSource
并在OnDestroy
方法中完成任务。对于与特定MonoBehaviour
无关的任务,我们可以使用UnityEngine.Application.exitCancellationToken
。这将在退出播放模式(在编辑器中)或退出应用程序时终止任务。
尽管 .NET Tasks 使用方便且功能强大,但在 Unity 中使用它们时存在明显的缺点:
Task
对象过于繁琐,造成多次分配。Task
与 Unity 线程(单线程)不匹配。
UniTask库在不使用线程或SynchronizationContext
情况下绕过了这些限制。它通过使用基于UniTask<T>
结构的类型实现了没有分配。
UniTask 需要 .NET 4.x 脚本运行时版本,Unity 2018.4.13f1 是官方支持的最低版本。
您还可以使用扩展方法将所有AsyncOperations
转换为UnitTask
:
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 } } }
在此示例中, LoadAsset
方法使用Resources.LoadAsync
异步加载资产。然后使用AsUniTask
方法将LoadAsync
返回的AsyncOperation
转换为可以等待的UniTask
。
和以前一样,您可以使用UniTask.WhenAll
和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 } }
在 UniTask 中,还有一个SynchronizationContext
的实现,称为UniTaskSynchronizationContext
,可以用来替代UnitySynchronizationContext
以获得更好的性能。
在 Unity 2023.1 的第一个 alpha 版本中,引入了Awaitable
类。 Awaitable 协程是异步/等待兼容的类似任务的类型,旨在在 Unity 中运行。与 .NET 任务不同,它们由引擎而不是运行时管理。
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"); // ... }
它们可以被等待并用作异步方法的返回类型。与System.Threading.Tasks
相比,它们不那么复杂,但采用基于 Unity 特定假设的性能增强捷径。
以下是与 .NET Tasks 相比的主要区别:
Awaitable
对象只能等待一次;它不能被多个异步函数等待。Awaiter.GetResults()
在完成之前不会阻塞。在操作完成之前调用它是未定义的行为。ExecutionContext
。出于安全原因,.NET 任务在等待时捕获执行上下文,以便跨异步调用传播模拟上下文。SynchronizationContext
。协程延续从引发完成的代码同步执行。在大多数情况下,这将来自 Unity 主框架。ObjectPool
已得到改进,以避免在异步状态机生成的典型获取/释放序列中进行Stack<T>
边界检查。
要获得长时间操作的结果,您可以使用Awaitable<T>
类型。您可以使用AwaitableCompletionSource
和AwaitableCompletionSource<T>
管理Awaitable
的完成,类似于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); } }
有时需要执行大量计算,这可能会导致游戏卡顿。为此,最好使用 Awaitable 方法: BackgroundThreadAsync()
和MainThreadAsync()
。它们允许您退出主线程并返回主线程。
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); }
这样,Awaitables 消除了使用 .NET Tasks 的缺点,还允许等待 PlayerLoop 事件和 AsyncOperations。
我们可以看到,随着Unity的发展,组织异步操作的工具越来越多:
统一 | 协程 | 承诺 | .NET 任务 | 单任务 | 内置取消令牌 | 等待API |
---|---|---|---|---|---|---|
5.6 | ✅ | ✅ | | | | |
2017.1 | ✅ | ✅ | ✅ | | | |
2018.4 | ✅ | ✅ | ✅ | ✅ | | |
2022.2 | ✅ | ✅ | ✅ | ✅ | ✅ | |
2023.1 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
我们已经考虑了 Unity 中异步编程的所有主要方式。根据您的任务的复杂性和您使用的 Unity 版本,您可以使用范围广泛的技术,从协程和承诺到任务和等待,以确保您的游戏流畅无缝地进行游戏。感谢阅读,我们期待您的下一部杰作。