Полное руководство по асинхронному программированию для Unity Development

Полное руководство по асинхронному программированию для Unity Development

31 марта 2023 г.

Некоторые задачи в разработке игр не синхронны — они асинхронны. Это означает, что они не выполняются линейно в коде игры. Для выполнения некоторых из этих асинхронных задач может потребоваться довольно много времени, а другие связаны с интенсивными вычислениями.

Некоторые из наиболее распространенных игровых асинхронных задач:

  • Выполнение сетевых запросов
  • Загрузка сцен, ресурсов и других объектов
  • Чтение и запись файлов
  • Искусственный интеллект для принятия решений
  • Длинные анимационные последовательности
  • Обработка больших объемов данных
  • Поиск пути

Теперь, что особенно важно, поскольку весь код Unity выполняется в одном потоке, любая задача, подобная одной из упомянутых выше, если бы они выполнялись синхронно, привела бы к блокировке основного потока и, следовательно, к пропуску кадров.

Всем привет, меня зовут Дмитрий Иващенко, я руководитель отдела разработки MY.GAMES. В этой статье мы поговорим о том, как избежать таких проблем. Мы рекомендуем методы асинхронного программирования для выполнения этих задач в отдельном потоке, оставляя основной поток свободным для выполнения других задач. Это поможет обеспечить плавный и отзывчивый игровой процесс и (надеюсь) довольных игроков.

Сопрограммы

Сначала поговорим о сопрограммах. Они появились в Unity в 2011 году, еще до появления async/await в .NET. В Unity сопрограммы позволяют нам выполнять набор инструкций в нескольких кадрах, а не выполнять их все сразу. Они похожи на потоки, но более легкие и интегрированы в цикл обновления Unity, что делает их хорошо подходящими для разработки игр.

(Кстати, исторически сопрограммы были первым способом выполнения асинхронных операций в Unity, поэтому большинство статей в Интернете посвящено им.)

Чтобы создать сопрограмму, вам нужно объявить функцию с типом возвращаемого значения IEnumerator. Эта функция может содержать любую логику, которую должна выполнять сопрограмма.

Чтобы запустить сопрограмму, вам нужно вызвать метод StartCoroutine для экземпляра MonoBehaviour и передать функцию сопрограммы в качестве аргумента:

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 доступно несколько инструкций выхода, например WaitForSeconds, WaitForEndOfFrame, WaitForFixedUpdate, WaitForSecondsRealtime, WaitUntil, а также некоторые другие. Важно помнить, что их использование приводит к выделению памяти, поэтому их следует использовать повторно везде, где это возможно.

Например, рассмотрим этот метод из документации:

IEnumerator Fade()
{
    Color c = renderer.material.color;
    for (float alpha = 1f; alpha >= 0; alpha -= 0.1f)
    {
        c.a = 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)
    {
        c.a = alpha;
        renderer.material.color = c;
        yield return **waitForSeconds**;
    }
}

Следует отметить еще одно важное свойство: yield return можно использовать со всеми методами Async, предоставляемыми Unity, поскольку AsyncOperation являются потомками Инструкция по доходности:

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

Некоторые возможные ловушки сопрограмм

При всем при этом у сопрограмм также есть несколько недостатков, о которых следует помнить:

* Невозможно вернуть результат длительной операции. Вам по-прежнему нужны обратные вызовы, которые будут переданы сопрограмме и вызваны, когда она будет завершена, чтобы извлечь из нее любые данные. * Сопрограмма строго привязана к MonoBehaviour, который ее запускает. Если GameObject выключен или уничтожен, сопрограмма перестает обрабатываться. * Структуру try-catch-finally нельзя использовать из-за наличия синтаксиса yield. * После yield return пройдет как минимум один кадр, прежде чем начнется выполнение следующего кода. * Выделение лямбды и самой сопрограммы

Обещания

Промисы – это шаблон для организации и повышения удобочитаемости асинхронных операций. Они стали популярными благодаря использованию во многих сторонних библиотеках JavaScript, а начиная с ES6 реализованы изначально.

При использовании промисов мы сразу же возвращаем объект из вашей асинхронной функции. Это позволяет вызывающей стороне дождаться разрешения (или ошибки) операции.

По сути, это делает так, что асинхронные методы могут возвращать значения и «действовать» как синхронные методы: вместо того, чтобы сразу возвращать окончательное значение, они дают «обещание», что они вернут значение когда-нибудь в будущем.

Существует несколько реализаций промисов для Unity:

Основной способ взаимодействия с промисом — это функции обратного вызова.

Вы можете определить функцию обратного вызова, которая будет вызываться при разрешении обещания, и другую функцию обратного вызова, которая будет вызываться, если обещание отклонено. Эти обратные вызовы получают результат асинхронной операции в качестве аргументов, которые затем можно использовать для выполнения дальнейших операций.

В соответствии с этими спецификациями от t В организациях Promises/A+ Promise может находиться в одном из трех состояний:

* Pending: начальное состояние, это означает, что асинхронная операция все еще выполняется, и результат операции еще не известен. * Выполнено (Решено): разрешенное состояние сопровождается значением, представляющим результат операции. * Отклонено: если асинхронная операция по какой-либо причине не удалась, обещание считается "отклоненным". Состояние отклонения сопровождается указанием причины сбоя.

Подробнее об обещаниях

Кроме того, промисы можно объединять в цепочки, чтобы результат одного промиса можно было использовать для определения результата другого промиса.

Например, вы можете создать промис, который извлекает некоторые данные с сервера, а затем использовать эти данные для создания другого промиса, который выполняет некоторые вычисления и другие действия:

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

Бесперспективные части обещаний

Несмотря на все удобства использования, у промисов есть и недостатки:

* Накладные расходы: создание промисов сопряжено с дополнительными накладными расходами по сравнению с использованием других методов асинхронного программирования, таких как сопрограммы. В некоторых случаях это может привести к снижению производительности. * Отладка: отладка промисов может быть более сложной, чем отладка других шаблонов асинхронного программирования. Может быть сложно отследить поток выполнения и определить источник ошибок. * Обработка исключений. Обработка исключений с промисами может быть более сложной по сравнению с другими шаблонами асинхронного программирования. Управлять ошибками и исключениями, возникающими в цепочке промисов, может быть сложно.

Асинхронные/ожидающие задачи

Функция async/await была частью C#, начиная с версии 5.0 (2012 г.), и была представлена ​​в Unity 2017 с реализацией среды выполнения .NET 4.x.

В истории .NET можно выделить следующие этапы:

  1. EAP (асинхронный шаблон на основе событий). Этот подход основан на событиях, которые запускаются после завершения операции, и обычном методе, который вызывает эту операцию.
  2. APM (модель асинхронного программирования). Этот подход основан на двух методах. Метод BeginSmth возвращает интерфейс IAsyncResult. Метод EndSmth принимает IAsyncResult; если операция не завершена во время вызова EndSmth, поток блокируется.
  3. TAP (асинхронный шаблон на основе задач): эта концепция была улучшена за счет введения async/await и типов 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
}

В этом примере выполнение будет происходить следующим образом:

  1. Сначала будет выполнен код, предшествующий вызову первой асинхронной операции (SyncMethodA).
  2. Запускается первая асинхронная операция await Task.Delay(1000), которая должна быть выполнена. При этом код, который будет вызываться после завершения асинхронной операции ("продолжение"), будет сохранен.
  3. После завершения первой асинхронной операции начнется выполнение «продолжения» — кода до следующей асинхронной операции (SyncMethodB).
  4. Вторая асинхронная операция (await Task.Delay(2000)) запущена и должна быть выполнена. При этом продолжение — код, следующий за второй асинхронной операцией (SyncMethodC), будет сохранен.
  5. После завершения второй асинхронной операции будет выполнен SyncMethodC, после чего следует выполнение и ожидание третьей асинхронной операции await Task.Delay(3000).< /li>

Это упрощенное объяснение, поскольку на самом деле 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");
});

IAsyncStateMachine

Компилятор C# преобразует вызовы async/await в конечный автомат IAsyncStateMachine, который последовательный набор действий, которые необходимо выполнить для завершения асинхронной операции.

Каждый раз, когда вы вызываете операцию ожидания, конечный автомат завершает свою работу и ожидает завершения этой операции, после чего продолжает выполнять следующую операцию. Это позволяет выполнять асинхронные операции в фоновом режиме, не блокируя основной поток, а также делает вызовы асинхронных методов более простыми и читабельными.

Таким образом, метод Example трансформируется в создание и инициализацию конечного автомата с аннотацией [AsyncStateMachine(typeof(ExampleStateMachine))], а сам конечный автомат имеет ряд состояния равны количеству вызовов await.

* Пример преобразованного метода Example

```csharp [AsyncStateMachine (typeof (ExampleStateMachine))] public /async/ Пример задачи() { // Создаем новый экземпляр класса ExampleStateMachine 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

```csharp [Создано компилятором] частный запечатанный класс ExampleStateMachine : IAsyncStateMachine { общедоступное текущее состояние; публичный AsyncTaskMethodBuilder таскMethodBuilder; частный 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 есть:

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

Unity также имеет собственный контекст синхронизации, UnitySynchronizationContext, который позволяет нам использовать асинхронные операции с привязкой к API PlayerLoop. В следующем примере кода показано, как повернуть объект в каждом кадре с помощью Task.Yield():

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

Еще один пример использования async/await в Unity для выполнения сетевого запроса:

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 вокруг какой-то длительной операции, которая выполняется при каком-то событии.

В следующем примере объект Task внутри taskCompletionSource завершится через 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);
    }
}

Токен отмены

Токен Cancellation Token используется в 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.

Новые асинхронные функции в Unity 2022.2

Важно отметить, что объектами Task управляет среда выполнения .NET, а не Unity, и если объект, выполняющий задачу, уничтожен (или если игра выходит из режима воспроизведения в редакторе). , задача будет продолжать выполняться, так как Unity не сможет ее отменить.

Вам всегда нужно сопровождать await Task соответствующим CancellationToken. Это приводит к некоторой избыточности кода, и в Unity 2022.2 появились встроенные токены на уровне MonoBehaviour и всего уровня Application.

Давайте посмотрим, как изменится предыдущий пример при использовании destroyCancellationToken объекта 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);
        }
    }
}

Нам больше не нужно вручную создавать CancellationTokenSource и выполнять задачу в методе OnDestroy. Для задач, не связанных с определенным MonoBehaviour, мы можем использовать UnityEngine.Application.exitCancellationToken. Это завершит задачу при выходе из режима воспроизведения (в редакторе) или при выходе из приложения.

Единая задача

Несмотря на удобство использования и возможности, предоставляемые .NET Tasks, они имеют существенные недостатки при использовании в Unity:

* Объекты 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 используется для преобразования AsyncOperation, возвращаемого LoadAsync, в 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 для повышения производительности.

Ожидаемый API

В первой альфа-версии Unity 2023.1 был представлен класс Awaitable. Awaitable Coroutines — это совместимые с async/await типы задач, разработанные для работы в 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:

* Объект Awaitable можно ожидать только один раз; его нельзя ожидать несколькими асинхронными функциями. * Awaiter.GetResults() не будет блокироваться до завершения. Вызов его до завершения операции является неопределенным поведением. * Никогда не захватывайте ExecutionContext. Из соображений безопасности задачи .NET фиксируют контексты выполнения при ожидании, чтобы распространять контексты олицетворения между асинхронными вызовами. * Никогда не захватывайте SynchronizationContext. Продолжения сопрограммы выполняются синхронно из кода, вызывающего завершение. В большинстве случаев это будет основной фрейм Unity. * Awaitables — это объединенные в пул объекты для предотвращения чрезмерного распределения. Это ссылочные типы, поэтому на них можно ссылаться в разных стеках, эффективно копировать и т. д. ObjectPool был улучшен, чтобы избежать проверок границ Stack<T> в типичных последовательностях получения/освобождения, генерируемых асинхронными конечными автоматами.

Для получения результата длительной операции можно использовать тип Awaitable<T>. Вы можете управлять выполнением Awaitable с помощью AwaitableCompletionSource и AwaitableCompletionSource<T>, аналогично 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, а также позволяет ожидать событий PlayerLoop и AsyncOperations.

Заключение

Как мы видим, с развитием Unity инструментов для организации асинхронных операций становится все больше:

| единство | Корутины | Обещания | Задачи .NET | ЮниТаск | Встроенные токены отмены | Ожидаемый API | |----|----|----|----|----|----|----| | 5.6 | ✅ | ✅ | | | | | | 2017.1 | ✅ | ✅ | ✅ | | | | | 2018.4 | ✅ | ✅ | ✅ | ✅ | | | | 2022.2 | ✅ | ✅ | ✅ | ✅ | ✅ | | | 2023.1 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |

Мы рассмотрели все основные способы асинхронного программирования в Unity. В зависимости от сложности вашей задачи и версии Unity, которую вы используете, вы можете использовать широкий спектр технологий от Coroutines и Promises до Tasks и Awaitables, чтобы обеспечить плавный и плавный игровой процесс в ваших играх. Спасибо за чтение, и мы ждем ваших следующих шедевров.


Оригинал