Как да укротите „async void“ в C#

(Това първоначално беше публикувано в моя блог като пълна статия)

Вие сте „дотнет“ „програмист“ на средно ниво и най-вече се ориентирате в Tasks. Разпръсквате async и чакате през кода си и всичко работи точно както се очаква. Отново и отново сте чували, че винаги искате връщаните типове на вашите асинхронни методи да бъдат Task (или Task‹T›) и че async void по същество е коренът на цялото зло. Без затруднение.

Един ден отивате да свържете манипулатор на събития с помощта на синтаксиса myObject.SomeEvent += SomeEventHandler и вашият манипулатор на събития трябва да изчака някакъв асинхронен код. Предприемате всички правилни стъпки и променяте сигнатурата на метода си, за да получите тази красива асинхронна задача, добавена, замествайки void. Но изведнъж получавате грешка при компилиране, че вашият манипулатор на събития не е съвместим.

Чувствате се в капан. Вие сте уплашени. И тогава правиш неописуемото...

Променяте сигнатурата на вашия метод за вашия манипулатор на събития на async void и изведнъж всичките ви проблеми с компилацията изчезват точно пред очите ви. Чувате гласовете на всички легендарни дотнет програмисти, на които гледате, да отекват в ума ви: „Какво правиш?! Не можете да извършите такова отвратително кодиращо престъпление!”. Но вече е твърде късно. Вие се поддадохте на силата на async void и всичките ви проблеми изглежда изчезнаха.

Това е, докато един ден всичко се разпадне. И тогава потърсихте в интернет и намерихте тази статия.

Добре дошъл приятел.

Какво всъщност не е наред с async void?

Нека започнем тук с основите. Ето някои от опасностите от използването на async void във вашия C# код:

  • Изключенията, хвърлени в метод „async void“, не могат да бъдат уловени по същия начин, както могат в метод „async Task“. Когато бъде хвърлено изключение в метод „async void“, то ще бъде повдигнато в контекста на синхронизация, което може да доведе до срив на приложението.
  • Тъй като методите „async void“ не могат да бъдат изчаквани, те могат да доведат до объркващ и труден за отстраняване на грешки код. Например, ако метод „async void“ се извика многократно, това може да доведе до едновременно изпълнение на множество екземпляри на метода, което може да причини условия на състезание и друго неочаквано поведение.

Бих се обзаложил, че сте тук заради първата точка. Приложението ви претърпява странни сривове и вие отделяте много време за диагностициране на проблема и получаване на следи. Не можем (традиционно) да обгърнем извиквания към async void методи в блокове try/catch и всъщност да ги накараме да работят както бихме очаквали.

Исках да демонстрирам това с прост код, който можете да буквално изпробвате във вашия браузър. Нека обсъдим кода по-долу (който, между другото, използва функцията за отчети от най-високо ниво на .NET 7.0, така че ако не сте свикнали да виждате C# програма без статичен 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 това може да изглежда като код, който ви е довел тук. Страховитата асинхронна празнота. Когато стартирате този, ще забележите, че отпечатваме информация, че сме на път да хвърлим изключението... Но след това никога не виждаме индикацията, че напускаме метода async void! Всъщност ние просто виждаме реда, показващ, че програмата е приключила. Колко призрачно е това?

Сценарий 3

В сценарий 3 това може да изглежда като опит, който сте опитали да разрешите проблемите си с async void. Разбира се, ако увием асинхронната празнота в задача, можем да изчакаме това и след това ще се върнем към безопасността... нали? нали?! Не. Всъщност в .NET Fiddle ще видите, че той отпечатва „Необработено изключение“. Това не е нещо, което имаме в нашия код, това е нещо по-страшно, което се задейства *СЛЕД*, когато нашата програма е завършена.

Какво всъщност всъщност е проблемът?

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

Месечен цикъл.

Това означава, че дори ако се опитате да преработите целия си код, за да избегнете това, вие само „поправяте“ проблема, като бягате от него. И да, въпреки че съм съгласен, ако е възможно, трябва да се опитате да напишете код, който не ви въвлича в скапани модели, понякога любопитството побеждава.

Има ли начин все още да имаме нашите манипулатори на събития като async void (така че да можем да чакаме нещата вътре в тях)? Да разбира се! И допълнителният бонус на метода, в който ще се потопим, е, че той не е ограничен само до манипулатори на събития. Можете да използвате този модел за всеки стар метод на async 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“, не трябва винаги да спираме, без да се опитваме да разберем по-нататък. Когато имаме ограничения, това е моментът, когато измисляме най-креативните решения!