Как приручить «асинхронную пустоту» в C#

(Это было изначально размещено в моем блоге как полная статья)

Вы программист среднего уровня в дотнете и в основном разбираетесь в использовании Задач. Вы запускаете асинхронность и ожидание в своем коде, и все работает так, как и ожидалось. Вы снова и снова слышали, что вы всегда хотите, чтобы возвращаемые типы ваших асинхронных методов были Task (или Task‹T›), и что async void, по сути, является корнем всех зол. Нет пота.

Однажды вы собираетесь подключить обработчик событий, используя синтаксис myObject.SomeEvent += SomeEventHandler, и ваш обработчик событий должен ожидать некоторый асинхронный код. Вы делаете все правильные шаги и меняете сигнатуру метода, чтобы добавить эту красивую асинхронную задачу, заменив void. Но вдруг вы получаете ошибку компиляции о том, что ваш обработчик событий несовместим.

Вы чувствуете себя в ловушке. Вы боитесь. И тогда вы делаете немыслимое…

Вы меняете сигнатуру метода для обработчика событий на async void, и вдруг все ваши проблемы с компиляцией исчезают прямо на ваших глазах. Вы слышите голоса всех легендарных программистов dotnet, на которых вы равняетесь: «Что вы делаете?! Вы не можете совершить такое гнусное преступление с кодированием!». Но уже слишком поздно. Вы поддались силе async void, и все ваши проблемы исчезли.

То есть, пока однажды все не рухнет. И вот тогда вы сделали поиск в Интернете и нашли эту статью.

Добро пожаловать друг.

Что на самом деле не так с async void?

Давайте начнем с основ. Вот несколько опасностей использования async void в коде C#:

  • Исключения, возникающие в методе «асинхронная пустота», не могут быть перехвачены так же, как в методе «асинхронная задача». Когда в методе «async void» генерируется исключение, оно возникает в контексте синхронизации, что может привести к сбою приложения.
  • Поскольку методы «async void» нельзя ожидать, они могут привести к запутанному и трудно отлаживаемому коду. Например, если метод «async void» вызывается несколько раз, это может привести к одновременному запуску нескольких экземпляров метода, что может вызвать состояние гонки и другое неожиданное поведение.

Готов поспорить, что вы здесь из-за первого пункта. Ваше приложение испытывает странные сбои, и вы проводите чертовски много времени, диагностируя проблему и получая следы. Мы не можем (традиционно) оборачивать вызовы асинхронных методов void в блоки try/catch и фактически заставить их работать так, как мы ожидаем.

Я хотел продемонстрировать это с помощью простого фрагмента кода, который вы можете буквально попробовать в своем браузере. Давайте обсудим приведенный ниже код (который, кстати, использует функцию операторов верхнего уровня .NET 7.0, поэтому, если вы не привыкли видеть программу на C# без static void main… не удивляйтесь!) :

using System;
using System.Threading;
using System.Threading.Tasks;

Console.WriteLine("Start");
try
{
	// NOTE: uncomment the single line for each one of the scenarios below one at a time to try it out!
	
	// Scenario 1: we can await an async Task which allows us to catch exceptions
	//await AsyncTask();
	
	// Scenario 2: we cannot await an async void and as a result we cannot catch the exception
	//AsyncVoid();
	
	// Scenario 3: we purposefully wrap the async void in a Task (which we can await), but it still blows up
	//await Task.Run(AsyncVoid);
}
catch (Exception ex)
{
	Console.WriteLine("Look! We caught the exception here!");
	Console.WriteLine(ex);
}
Console.WriteLine("End");
	
async void AsyncVoid()
{
	Console.WriteLine("Entering async void method");
	await AsyncTask();
	
	// Pretend there's some super critical code right here
	// ...
	
	Console.WriteLine("Leaving async void method.");
}

async Task AsyncTask()
{
	Console.WriteLine("Entering async Task method");
	Console.WriteLine("About to throw...");
	throw new Exception("The expected exception");
}

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

Сценарий 1

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

Сценарий 2

В сценарии 2 это может показаться кодом, который привел вас сюда. Страшная асинхронная пустота. Когда вы запустите этот, вы заметите, что мы печатаем информацию о том, что собираемся сгенерировать исключение… Но тогда мы никогда не видим указания на то, что мы покидаем асинхронный метод void! На самом деле мы просто видим строку, указывающую на завершение программы. Насколько это жутко?

Сценарий 3

В сценарии 3 это может выглядеть как попытка решить проблемы с асинхронной пустотой. Конечно, если мы обернем асинхронную пустоту в задачу, мы можем дождаться этого, а затем вернемся к безопасности… верно? Верно?! Нет. На самом деле, в .NET Fiddle вы увидите, что он печатает «Необработанное исключение». Это не то, что есть в нашем коде, это что-то более страшное, срабатывающее *ПОСЛЕ* нашей программы.

В чем на самом деле проблема?

По сути, независимо от того, как вы пытаетесь обработать код, если у вас есть обработчик событий, тип возвращаемого значения должен быть void, чтобы подключиться к событию C# с помощью оператора +.

Период.

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

Есть ли способ, которым мы могли бы по-прежнему использовать наши обработчики событий как async void (чтобы мы могли ожидать что-то внутри них)? Да, конечно! Дополнительным преимуществом метода, который мы собираемся рассмотреть, является то, что он не ограничивается только обработчиками событий. Вы можете использовать этот шаблон для любого старого асинхронного метода void, который у вас есть.

Рекомендую ли я это? Точно нет. Я часто проектирую объекты с событиями на них и стараюсь ограничить свое использование именно этим сценарием. Вы знаете, тот самый, о котором говорят все гуру C#: «Хорошо, если вы ДОЛЖНЫ использовать async void, тогда… Убедитесь, что это обработчик событий на кнопке или что-то в этом роде».

Итак, давайте посмотрим, как мы можем сделать этот опыт менее болезненным в будущем.

Пробуждение от кошмара

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

Вот демонстрационный код, который мы рассмотрим сразу после:

using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Runtime.ExceptionServices;

var someEventRaisingObject = new SomeEventRaisingObject();

// notice the async void. BEHOLD!!
someEventRaisingObject.TheEvent += async (s, e) =>
{
	Console.WriteLine("Entering the event handler...");
	await TaskThatIsReallyImportantAsync();
	Console.WriteLine("Exiting the event handler...");
};

try
{
	// forcefully fire our event!
	await someEventRaisingObject.RaiseEventAsync();
}
catch (Exception ex)
{
	Console.WriteLine($"Caught an exception in our handler: {ex.Message}");
}

// just block so we can wait for async operations to complete
Console.WriteLine("Press Enter to exit");
Console.ReadLine();

async Task TaskThatIsReallyImportantAsync()
{
	// switch between these two lines (comment one or the other out) to play with the behavior
	throw new Exception("This is an expected exception");
	//await Task.Run(() => Console.WriteLine("Look at us writing to the console from a task!"));
}
		
class SomeEventRaisingObject
{
	// you could in theory have your own event args here
	public event EventHandler<EventArgs> TheEvent;
	
	public Task RaiseEventAsync()
	{
		// the old way (if you toggle this way with the exception throwing, it will not hit our handler!)
		//TheEvent?.Invoke(this, EventArgs.Empty);
		//return Task.CompletedTask;
		
		// the new way (if you toggle this way with the exception throwing, it WILL hit our handler!)
		return InvokeAsync(TheEvent, true, true, this, EventArgs.Empty);
	}
	
	internal static async Task InvokeAsync(
            MulticastDelegate multicastDelegate,
            bool forceOrdering,
            bool stopOnFirstError,
            params object[] args)
        {
            if (multicastDelegate is null)
            {
                return;
            }

            var tcs = new TaskCompletionSource<bool>();
            var delegates = multicastDelegate.GetInvocationList();
            var count = delegates.Length;

            // keep track of exceptions along the way and a separate collection
            // for exceptions we have assigned to the TCS
            var assignedExceptions = new List<Exception>();
            var trackedExceptions = new ConcurrentQueue<Exception>();

            foreach (var @delegate in multicastDelegate.GetInvocationList())
            {
                var async = @delegate.Method
                    .GetCustomAttributes(typeof(AsyncStateMachineAttribute), false)
                    .Any();

                bool waitFlag = false;
                var completed = new Action(() =>
                {
                    if (Interlocked.Decrement(ref count) == 0)
                    {
                        lock (tcs)
                        {
                            assignedExceptions.AddRange(trackedExceptions);
                            if (!trackedExceptions.Any())
                            {
                                tcs.SetResult(true);
                            }
                            else if (trackedExceptions.Count == 1)
                            {
                                tcs.SetException(assignedExceptions[0]);
                            }
                            else
                            {
                                tcs.SetException(new AggregateException(assignedExceptions));
                            }
                        }
                    }

                    waitFlag = true;
                });
                var failed = new Action<Exception>(e =>
                {
                    trackedExceptions.Enqueue(e);
                });

                if (async)
                {
                    var context = new EventHandlerSynchronizationContext(completed, failed);
                    SynchronizationContext.SetSynchronizationContext(context);
                }

                try
                {
                    @delegate.DynamicInvoke(args);
                }
                catch (TargetParameterCountException e)
                {
                    throw;
                }
                catch (TargetInvocationException e) when (e.InnerException != null)
                {
                    // When exception occured inside Delegate.Invoke method all exceptions are wrapped in
                    // TargetInvocationException.
                    failed(e.InnerException);
                }
                catch (Exception e)
                {
                    failed(e);
                }

                if (!async)
                {
                    completed();
                }

                while (forceOrdering && !waitFlag)
                {
                    await Task.Yield();
                }

                if (stopOnFirstError && trackedExceptions.Any())
                {
                    lock (tcs)
                    {
                        if (!assignedExceptions.Any())
                        {
                            assignedExceptions.AddRange(trackedExceptions);
                            if (trackedExceptions.Count == 1)
                            {
                                tcs.SetException(assignedExceptions[0]);
                            }
                            else
                            {
                                tcs.SetException(new AggregateException(assignedExceptions));
                            }
                        }
                    }

                    break;
                }
            }

            await tcs.Task;
        }

        private class EventHandlerSynchronizationContext : SynchronizationContext
        {
            private readonly Action _completed;
            private readonly Action<Exception> _failed;

            public EventHandlerSynchronizationContext(
                Action completed,
                Action<Exception> failed)
            {
                _completed = completed;
                _failed = failed;
            }

            public override SynchronizationContext CreateCopy()
            {
                return new EventHandlerSynchronizationContext(
                    _completed,
                    _failed);
            }

            public override void Post(SendOrPostCallback d, object state)
            {
                if (state is ExceptionDispatchInfo edi)
                {
                    _failed(edi.SourceException);
                }
                else
                {
                    base.Post(d, state);
                }
            }

            public override void Send(SendOrPostCallback d, object state)
            {
                if (state is ExceptionDispatchInfo edi)
                {
                    _failed(edi.SourceException);
                }
                else
                {
                    base.Send(d, state);
                }
            }

            public override void OperationCompleted() => _completed();
        }
}

Понимание сценариев

В этом фрагменте кода мы можем поиграть с двумя классами вещей:

  • Переключение между тем, что делает TaskThatIsReallyImportantAsync. Вы можете либо безопасно печатать на консоли, либо вызвать исключение, чтобы вы могли поэкспериментировать со счастливым или плохим путем. Это само по себе не является предметом статьи, но позволяет вам попробовать различные ситуации.
  • Переключите поведение RaiseEventAsync, что покажет вам не очень хорошее поведение async void по сравнению с нашим решением! Смешайте это с предыдущим вариантом, чтобы увидеть, как мы можем улучшить нашу способность перехватывать исключения.

Как это работает

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

Основные выводы относительно того, как это работает, таковы:

  • Мы можем использовать настраиваемый контекст синхронизации, особенно для обработки событий, которые принимают настраиваемый обратный вызов завершено/сбой.
  • Мы можем использовать отражение, чтобы проверить AsyncStateMachineAttribute, чтобы увидеть, действительно ли наш обработчик помечен как асинхронный или нет.
  • GetInvocationList позволяет нам зарегистрировать всю цепочку обработанных событий.
  • DynamicInvoke позволяет нам вызывать делегат без криков компилятора о типах.

Когда вы объедините вышеперечисленное, мы также можем добавить несколько других функций:

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

В конце концов, мы можем установить информацию об экземпляре TaskCompletionSource, которая указывает на завершение или отслеживаемое исключение.

Что дальше?

Если вы хотите увидеть, как я расширил часть этого кода для своих собственных проектов, пожалуйста, продолжите чтение полной исходной статьи. Я надеюсь, что эта статья послужит напоминанием людям о том, что, когда мы слышим Это невозможно, или Никогда не делай X, или Всегда делай Y, мы не должны всегда останавливаться на достигнутом, не пытаясь понять дальше. Когда у нас есть ограничения, мы придумываем самые креативные решения!