跳至内容

Unity Async асинхронное руководство: чем же оно отличается от нативного .Net?

Что такое Async/Await — объяснять не буду, это похоже на корутины, но проще. Здесь предполагается, что у вас есть знания о современном асинхронном программировании.

Подведу итог по внутренней реализации Unity, чтобы было понятно, что же происходит. Проще говоря, Unity внутренне выполняет некоторые обработки, гарантируя 2 пункта, когда вызывающая сторона — главный поток: 1. У вызывающей стороны не произойдет переключения потока, 2. Вызываемая асинхронная функция не будет выполняться в другом потоке.

Цель Async-паттерна в Unity

Async-паттерн более современный, это направление будущего, но Unity в настоящее время не планирует заменять им корутины (IEnumerators). Основная цель — использование для операций типа IO и подобных.

Unity Async производительнее корутин, но с меньшим параллелизмом

Подходит для использования в объектах-одиночках (singletons) или основных объектах, например, камера, интерфейс, IO-методы и т.д. При небольшом количестве Async производительность выше, чем у корутин. Для кода, где множество объектов должны работать параллельно, всё равно следует использовать корутины.

Часть о производительности

По возможности возвращайте обернутый Unity Awaitable, а не Task (Unity 6)

Async в .Net реализуется с помощью Task, как показано ниже:

  async Task function() {
        await Task.Delay(1000);
  }

Нативный .Net Task каждый раз возвращает новый объект, а обернутый Unity объект Awaitable используется совместно, поэтому нагрузка на GC меньше. Awaitable также имеет преимущество «бесплатного» (без потерь) переключения потоков, см. следующий раздел.

  async Awaitable function() {
        while(true) {
            await Awaitable.NextFrameAsync();
        }
  }

Для методов, которые требуют многократного await, как в примере выше, используйте возвращаемое значение Awaitable. Конечно, для методов с одноразовым await, таких как IO, это не имеет значения.

Поскольку объект Awaitable используется совместно (после await передается другому), его нельзя await повторно (как в коде ниже), это приведет к взаимоблокировке (deadlock).

async Awaitable function() {
        while(true) {
            var returned_awaitable = Awaitable.NextFrameAsync();
            await returned_awaitable;
            await returned_awaitable; //deadlock
        }
  }

Unity внутренне управляет контекстом Async (переключением), что отличается от .Net

Основная концепция Async, возобновление Resume, означает переключение обратно для продолжения выполнения кода после await.

«Контекст» определяет, как возобновить код после await. .Net использует пул потоков для переключения между различными асинхронными блоками кода. Например, следующий код .Net:

using System;

async Task function() {
    Console.WriteLine($"task start: Tid {Environment.CurrentManagedThreadId}");
    await Task.Delay(1000);
    Console.WriteLine($"task end: Tid {Environment.CurrentManagedThreadId}");
}

Console.WriteLine($"caller start, Tid{Environment.CurrentManagedThreadId}");
await function();
Console.WriteLine($"caller end, Tid{Environment.CurrentManagedThreadId}");

Результат выполнения:

caller start, Tid12
task start: Tid 12
task end: Tid 38
caller end, Tid38

Видно, что переключение потока произошло как у вызывающей, так и у выполняющей стороны.

А Unity, чтобы гарантировать, что методы, вызванные из главного потока, выполняются только в главном потоке, обернули свой собственный метод контекста под названием UnitySynchronizationContext (это внутренняя реализация, нам не нужно вникать, достаточно знать).

Поэтому не беспокойтесь о проблемах с потоками. Независимо от того, возвращает ли метод Awaitable или .Net Task, если вызов происходит из главного потока, он будет управляться UnitySynchronizationContext и гарантированно выполняться в главном потоке.

Однако при использовании Async-функций с возвращаемым значением Task, возобновление Resume может произойти только в следующем кадре, а не сразу, и Unity требуется выполнить дополнительный шаг по замене контекста .Net, поэтому Awaitable производительнее.

Конечно, для Async-функций, вызванных из других потоков, где нет требования ограничения главным потоком, по-прежнему будет использоваться пул потоков .Net.

Паттерны использования

Паттерн «запустил и забыл»

При вызове Async-функции можно не использовать await, система будет продолжать выполнять эту Async-функцию внутри.

async Awaitable worker() {
    await Awaitable.WaitForSecondsAsync(5);
    Debug.Log($"worker end, frame {Time.frameCount}");
}

void caller() {
    _ = worker();
    Debug.Log($"caller end, frame {Time.frameCount}");
}

Результат:

caller end, frame 1
worker end, frame 292

В caller не написан await, но worker всё равно выполнится, просто его завершения ждать не будут.

Если бы это было в .Net, то worker продолжал бы выполняться в пуле потоков. В Unity же его продолжает выполнять главный цикл (loop) в главном потоке.

Этот паттерн также часто используется для вызова Async-функций из обычных синхронных (Sync) функций.

Примечание: этот метод эквивалентен async void, выполняется на самом верхнем уровне (стек вызовов теряется), исключения в коде будут невидимы, что проявляется так, будто код не выполнился. Это не значит, что паттерн нельзя использовать.

С помощью Async в Unity можно легко реализовать многопоточное программирование

Это позволяет удобно реализовать многопоточный код без использования Job и подобного. Запускать любой фрагмент кода в другом потоке.

Если вы хотите переключиться на фоновый пул потоков (аналогично реализации пула потоков .Net) или на главный поток, можно указать это вручную. Например:

async Awaitable function() {
    await Awaitable.BackgroundThreadAsync();
    ...
    return null;
}

Код после этой строки await и до конца функции будет выполняться в фоновом управляемом пуле потоков .Net.

Если вызывающая сторона — главный поток, переключение не изменит поток вызывающей стороны, что очень удобно. Обратите внимание, что переключение занимает как минимум 1 кадр.

Переключение на главный поток — это await Awaitable.MainThreadAsync();, обычно вызывать его не нужно.

Но, к сожалению, в WebGL (wasm) вы не можете использовать многопоточность .Net, включая любые методы Task, а также Awaitable.BackgroundThreadAsync().

Async-функции можно определять как void, что удобно для различных event-обработчиков

Если вы хотите, чтобы ваш Async-функция выполнялась в качестве обработчика, не нужно оборачивать её в обычную функцию для вызова асинхронной. Можно использовать async void, примерно так:

async void function(CallbackContext ctx) {
    try{
        ...do something...
        await Awaitable.WaitForSecondsAsync(5);
    }catch (Exception e){
        Debug.LogError(e);
    }
}

void Start() {
    InputAction.performed += function;
}

Обратите внимание, что async void выполняется на самом верхнем уровне, поэтому исключения внутри функции не будут видны извне, их нужно обрабатывать самостоятельно с помощью catch.

Асинхронная форма событий MonoBehaviour

События MonoBehaviour, такие как Start, Update и другие, можно использовать в async-режиме, например: async Awaitable Start() {}. Но помните, что асинхронные Start или Awake, пока они не завершат выполнение, уже будут вызываться Update.