Как стать автором
Обновить

Комментарии 20

Отличная статья, однако с "Async void" не согласен. Есть случаи когда без него не обойтись(подписка на ивенты). Но таких случаев в действительности мало, поэтому ПОЧТИ ВСЕГДА нужно возвращать Task вместо void.

А про CreateLinkedTokenSource и вовсе не знал, спасибо!

В статье про это есть)

В C# async void методы обычно используются для сценариев «запустил и забыл». В идеале эти методы не должны быть блокирующими и, как правило, используются только в обработчиках событий или асинхронном коде верхнего уровня.

Я бы еще добавил, что асинхронные методы имеет смысл именовать как DoSomethingAsync(), тогда, даже на уровне написания не запутаешься, даже если там void.

А как добавление суффикса Async к async void методу решает описанную проблему? Это только больше вводит в заблуждение. Почему метод асинхронный, а я не могу воспользоваться ключевым словом await? Это то же самое, что взять плохой код, но при этом назвать его красиво, например, «Photon».

По поводу добавления суффикса Async к методам, которые возвращают UniTask, Task и т.д. согласен. Во всех приведённых примерах эта конвенция наименования соблюдена.

P.S. Все названия и события вымышлены. Любые совпадения с реальными продуктами случайны.

Asynс изначально везде добавляли к дублирующим методам - string Download() и Task<string> DownloadAsync(). Но сейчас нет часто нет дублирующих и тип сразу подсказывает среда разработки. А раз уникальной инфы это не несёт, то зачем оно?

В более старых версиях юнити, к примеу 2021, метод GetCancellationTokenOnDestroy() вешает на GO свой компонент AsyncDestroyTrigger. Это хорошо бы иметь ввиду.

Всё верно. А ещё GetCancellationTokenOnDestroy() под капотом вызывает TryGetComponent(). Поэтому, если планируете его использовать, лучше кэшировать CancellationToken на старте.

В более старых версиях юнитаска, может? Потому что UniTask не является частью юнити.
По теме - лучше сразу повесить AsyncDestroyTrigger на компонент, потому что TryGetComponent это еще ничего, а вот AddComponent уже побольнее будет

Статья отличная, как и первая часть.

Почему решили использовать UniTask вместо ValueTask?

Если интересует мое мнение: мне сначала нравились UniTask, но не удобно то что они не показывают стек вызова, в эксепшене ты видишь только что вызов был из PlayerLoop и юнитаску. ValueTask честно показывает стек вызова, откуда была запущена.

Если коротко, то UniTask был написан специально для Unity, тесно интегрирован с игровым циклом и учитывает его особенности. Т.е. например, ситуации, когда запустил задачу, а потом вышел из Play Mode, а задача продолжает работать, не будет.

По поводу стека вызовов. Не совсем понял.

Следующий код:

public class AwaitAllTheWayTest : MonoBehaviour
{
    [ContextMenu(nameof(StartOperation))]
    public void StartOperation()
    {
        FirstAsync(destroyCancellationToken).Forget();
    }

    private async UniTaskVoid FirstAsync(CancellationToken cancellationToken)
    {
        await SecondAsync(cancellationToken);
    }

    private async UniTask SecondAsync(CancellationToken cancellationToken)
    {
        await ThirdAsync(cancellationToken);
    }

    private async UniTask ThirdAsync(CancellationToken cancellationToken)
    {
        await UniTask.Delay(1000, cancellationToken: cancellationToken);
        throw new Exception();
    }
}

Выдаст такой стек вызовов:

Exception: Exception of type 'System.Exception' was thrown.

AwaitAllTheWay.AwaitAllTheWayTest.ThirdAsync (at Assets/Scripts/AwaitAllTheWay/AwaitAllTheWayTest.cs:29)
...
AwaitAllTheWay.AwaitAllTheWayTest.SecondAsync (at Assets/Scripts/AwaitAllTheWay/AwaitAllTheWayTest.cs:23)
...
AwaitAllTheWay.AwaitAllTheWayTest.FirstAsync (at Assets/Scripts/AwaitAllTheWay/AwaitAllTheWayTest.cs:18)
...
AwaitAllTheWay.<FirstAsync>d__1:MoveNext() (at Assets/Scripts/AwaitAllTheWay/AwaitAllTheWayTest.cs:18)
..
AwaitAllTheWay.<SecondAsync>d__2:MoveNext() (at Assets/Scripts/AwaitAllTheWay/AwaitAllTheWayTest.cs:23)
...
AwaitAllTheWay.<ThirdAsync>d__3:MoveNext() (at Assets/Scripts/AwaitAllTheWay/AwaitAllTheWayTest.cs:29)
...

у меня бывали ситуации, в которых задача продолжает работу после выхода из playmode

Без примера кода сложно сказать почему. Но такое возможно, если использовать UniTask.RunOnThreadPool или UniTask.SwitchToThreadPool. Тогда задача запустится на ThreadPool, а не на PlayerLoop.

Не пользуюсь этими плагинами, только нативный C# async/await/Task и т.д. Ошибки по 1-2 пункту есть. По крайней мере у меня это не выполняется, да и не сходится с документацией msdn.

2)
private void Awake(){
Debug.Log("Awake_start");
Method();
Debug.Log("Awake_end");}
private async void Method(){
Debug.Log("Method_start");
Task t = Task.CompletedTask;
await t;
Debug.Log("Method_end");}

Тут выведет:
Awake_start Method_start Method_end Awake_end
Если задача не завершена, т.е. случится ошидание, то выведет:
Awake_start Method_start Awake_end Method_end
Всё потому, что код до ожидания выполняется синхронно деля async методы AwakeиMethod на части, где ожидание - разделитель. Условно, GOTO в первом случае перекидывает на 2 часть Method, переключения не происходит. А в случае ожидания в Method, сразу на вторую часть Awake. Вторая же часть Method будет запущена через UnitySynchronizationContext, в порядке очереди player loop(Start,Awake,Update и прочие).

1) Всё выполняется в основном потоке, если правильно запускать.
В принципе, так же как UniTask. Task t = SomeTask(); await t; запустит всё, включая внутренности SomeTask в основном потоке. И даже после ожидания продолжится выполнение так же в основном потоке. В случае без ожидания мы просто не получим сообщений(дебаг, ошибки), но выполнение будет так же в основном потоке.
До какой-то там версии это было не так, но уже давно всё работает через UnitySynchronizationContext. Со всеми + и, к сожалению, - тоже. Потому как логи после ожидания идут от UnitySynchronizationContext, а не с начала вызова, что очень печально, ведь без UnitySynchronizationContext вполне удавалось получать логи от начала вызова.

Для запуска вне основного потока надо использовать Task.Run().
Так называемая "настоящая асинхронность" потоков будет работать только в этом случае.
Разница прежде всего в том, что вне основного потока мы не можем выполнять методы с обращением к нативному коду, к примеру, работать с Transform.SetPositions(). Но весь чисто C# код доступен.
Ну и так же берегитесь async void, который вызывает другой async void. В этом случае работа становится несколько непредсказуемой. К примеру могут сообщаться ошибки, но пропасть обычные логи, а может наоборот, ну или вообще никаких сообщений в редакторе не будет(в файлах логов будут).

As a limitation, all UniTask objects cannot await twice, as they automatically return to the pool when the await completes. Ну и продолжение с конкретикой.
Вот это надо всегда говорить. Совсем не явная штука, которая ограничивает UniTask в использовании, приводя их не более чем к аналогу корутин, промисов с некоторыми улучшениями.

Для запуска вне основного потока надо использовать Task.Run(). Так называемая "настоящая асинхронность" потоков будет работать только в этом случае.

Не совсем так. В другой поток мы можем попасть и так:

private async Task StartOperationAsync()
{
    await WaitAsync().ConfigureAwait(false);

    // Not a main thread.
}

private async Task WaitAsync()
{
    // Still the main thread.

    await Task.Yield();
}

Если говорим про UniTask, то можем так:

private async UniTask StartOperationAsync()
{
    await UniTask.SwitchToThreadPool();

    // Not a main thread.
    print(Thread.CurrentThread.ManagedThreadId);

    await UniTask.SwitchToMainThread();
}

As a limitation, all UniTask objects cannot await twice, as they automatically return to the pool when the await completes.

Если ознакомиться со статьёй внимательнее, а именно с разделом Concurrency, то можно там найти следующее:

Обратите внимание, что поле _longServerRequestTask у нас типа AsyncLazy<Data>, а не UniTask<Data>. Всё потому, что UniTask не позволяет выполнить await задачи более одного раза, как и ValueTask. Связано это с тем, что UniTask – это переиспользуемая структура, и после await объект возвращается в пул. Повторный вызов await выбросит исключение, так как объект уже может использоваться в другом месте.

Там же приведен пример, как одну и туже задачу мы await'им сколь угодно раз, используя AsyncLazy.

await WaitAsync().ConfigureAwait(false);
На деле способов много, все даже не упомню. Разница в том, что Task.Run сразу запускает задачу через менеджер, а не после ожидания. Это является правильным способом именно для запуска с распределением задач по потокам.
_longServerRequestTask
Но это же просто обход ограничения по применению. А если нужно множество запросов? Выходит прямо таки одноразовый сетевой сервис. А если его сделать многоразовым, то придётся генерить классы. А если ожидать данные с одного запроса нужно из нескольких мест и мы не контролируем момент получения результата и запроса?
Т.е. обычное ожидание
Task<Player> player = Request.Send(); c нуждой в ожидании из нескольких мест(к примеру, нескольким объектам нужно имя пользователя), а тем более из других задач, с не ясным временем когда понадобится player, то как делать? Вместо обычного await player придётся городить весьма не мало кода, теряя, собственно ожидание. Либо городить фабрику ожидающую и отдающую результат. Причём, накладные расходы будут значительно выше.

Спасибо за Ваши комментарии. Благодаря им моя следующая статья про детальный разбор отличий UniTask от Task будет куда богаче, так как становится понятнее, в чём заключается основное недопонимание работы с async/await и с UniTask в частности.

Касательно вопросов.

А если нужно множество запросов?

Оставляете начальную реализацию NetworkService без изменений.

А если ожидать данные с одного запроса нужно из нескольких мест и мы не контролируем момент получения результата и запроса?

Меняете реализацию NetworkService на предложенный вариант с AsyncLazy.

придётся генерить классы.

Реализация через Task и реализация через AsyncLazy будет генерировать одинаковое кол-во классов.

Такое ощущение, что Вам лично не приятен UniTask, но я же не заставляю Вас его использовать. Используйте Task. Но статья от этого актуальности не теряет, ведь основной упор сделан на работу с async/await, а что для этого использовать – решать Вам.

P.S. Если про неприязнь к UniTask я не прав, вместо догадок предлагаю открыть IDE и попробовать реализовать все Ваши идеи. И если останутся вопросы, то приходите с конкретными примерами. Но перед этим перечитайте ещё раз статью внимательнее. В ней есть ответы на все Ваши вопросы.

Мне так же интересно услышать об опыте других людей) Я ранее так же использовал первые версии UniTask или аналоги(не помню), ещё до того как async/await появился в языке в Unity. И тогда же опробовал в небольшим коммерческом проекте и уже тогда это было на много лучше других решений, хотя и очень узко применимо.
Сейчас единственной проблемой Task .Net в Unity считаю отсутствие стека при вызове от UnitySyncronizationContext. Её так и не получилось решить. Но и в этих статьях нашёл парочку особенностей, что когда-нибудь упростит мне код) Всё остальное, что я тут видел или о чём слышал, я, пожалуй, уже пробовал.

Оставляете начальную реализацию и Меняете реализацию сразу?
Нужно же всё в одном. Множество запросов, множество задач, которые хотят использовать эти данные, а их использование зависит от других задач. И всё это так, чтобы не сильно париться об очерёдности. Концептуально другое использование.

Я не вижу смысла в использовании UniTask повсеместно из-за его ограничений. Это "оптимизация" за счёт функционала. Преимущества по сравнению с TaskValue пока не ясны, ещё посмотрю. В том коде, что я видел тут, async/await выступает почти как отдельный инструмент, очень ограничено в плане возможностей. Я весь код, зависящий от внешней среды или времени выполнения, оборачиваю в Task-и, а логика в целом остаётся никак не ограничена и не содержит огромное кол-во сопроводительного кода. Она остаётся очень легкой для понимания. И это даёт что-то вроде синергетического эффекта, где очень просто можно получить что надо, без обёрток и извращений как с _longServerRequestTask

Concurrency? Мне об этом вообще почти не надо думать -- достаточно чисто умозрительно заключить, что не получился Уроборос. И если так, то как ни сделаю, всё равно получится рабочий код. В этом и есть прелесть async/await. Это не просто замена корутинам или промисам. Это то, что позволяет избавится от кучи кода и проблем синхронизации. Просто забыть о мозголомных вычислениях, и ухищрениях с примитивами. Так ещё при этом получить хорошую производительность, возможность простого разделения логики и UI, или, к примеру, предикшены поставить без дополнительных ухищрений.

Ничего против UniTask для работы с готовыми эмиттерами не имею - их всё равно не в Unity, а значит альтернативу надо или делать самому или брать что подойдёт. Что есть все основные функции юнити, а может и вообще всё - это хорошо. Если нужно их использовать в проекте, и особенно в нагруженной части - вообще отлично) Но для более широкого использования нужен полный функционал.

PS: Ну и генерится всё же будут объекты, а не классы)

Нужно же всё в одном. Множество запросов, множество задач, которые хотят использовать эти данные, а их использование зависит от других задач. ... Концептуально другое использование.

Можете привести рабочий пример с кодом? Не совсем понимаю, какое концептуально другое использование async/await тут может быть.

Я не вижу смысла в использовании UniTask повсеместно из-за его ограничений.

Про какие ограничения речь? Приведите пример с Task и покажите, почему то же самое невозможно реализовать с UniTask.

Язык полный. С ним можно всё реализовать из чего угодно. Да, можно сделать костыль, как в 3 пункте. И с генератором(фабрикой), что будет генерить объекты при повторном вызове, таким образом обойти ограничение и на множественные вызовы. Весь код сведётся к ~ такому:

private Task<object> _requestResult;
private Task<object> MakeRequestAsync()
{
return _requestResult == null ? _client.LongServerRequestAsync() : _requestResult;
}
Кстати, памяти он выделит ~ столько же. Переиспользования тут не будет - будут создаваться отдельные UniTask структуры или иногда будут происходить ошибки

Сами ограничения такие же как у ValueTask. Их уже повторял я, и сам автор плагина, и в документации msdn есть. Вот даже статья когда нужно применять ValueTask(UniTask).

Рабочий пример не дам - он большой. Но можете сами дописать этот пример упрощённый для стартовой инициализации и загрузки. Методы - инициализация сервисов, сцен, загрузка контента, сетевые запросы, включая авторизацию, группы и прочие данные из разных мест, да и вообще всё, что нужно для старта игры.
Task<SomeObj> task1 ; // и так до, скажем, N = 10
void LoadAllServices(){
task1 = InternalMethod1(); // И так все N }
async Task<SomeObj> InternalMethod1(){
await Task.Yield();
return await Method1(task2, task3);
// MethodN с передачей случайного набора задач, но не приводящие к циклической зависимости
// конечно, внутри метода эти задачи ожидаются}
PS: тут мне подсказали, что Task-и всё ещё не дружат с WebGL. UniTask работает. ValueTask не понятно.

Зарегистрируйтесь на Хабре, чтобы оставить комментарий

Публикации

Истории