Что вы не знали об обработчиках событий задач
22 февраля 2023 г.(Ознакомьтесь с оригинальная статья на моем сайте)
Как программисты на C#, мы все были обожжены асинхронными обработчиками событий.
И если вы еще этого не сделали, то, надеюсь, эта статья поможет вам лучше ориентироваться в длительных сеансах отладки, когда функциональность таинственным образом перестает работать в вашем приложении.
Хотя существует несколько различных решений для работы с асинхронными обработчиками событий, либо избегая использования async void или даже с помощью включая async void, в этой статье мы рассмотрим еще один вариант — Task EventHandlers.
Отказ от ответственности: эта статья изначально была написана с учетом того, что это решение кажется почти надежным, но есть важные ограничения. Эти ограничения рассматриваются далее в статье, и я считаю, что было бы полезно изучить это пространство (как положительные, так и отрицательные).
Дополнительное видео!
Нажмите здесь, чтобы посмотреть видео к этой статье!
Источник проблемы
Обычные обработчики событий в C# имеют тип возвращаемого значения void в своей подписи. Это не проблема, пока мы не захотим подключить EventHandler, который помечен как асинхронный, потому что мы хотели бы ожидать некоторого Task, который вызывается внутри.
И почему это проблема? Поскольку async void нарушает возможность правильного всплытия исключений и может вызвать кошмар отладки, когда вы не можете отследить, куда передаются ваши исключения.
По сути, у нас есть сложность только потому, что сигнатура EventHandler имеет возвращаемый тип void, а это нарушает управление исключениями:
void TheObject_TheEvent(object sender, EventArgs e);
Но что, если бы мы могли обойти это?
Обработчики событий задач могут работать!
Следует признать, что конкретное решение, в которое мы собираемся погрузиться, имеет более ограниченный вариант использования, чем некоторые другие решения, о которых я упоминал ранее.
Тем не менее, я по-прежнему считаю, что это очень жизнеспособное решение, когда у вас есть контроль над событиями, которые вы добавляете в свои классы, и мы понимаем ограничения.
То есть, если вы создаете класс и определяете свои собственные события, на которые вы хотите, чтобы вызывающие абоненты могли подписываться, это решение может вам подойти.
Позже мы рассмотрим еще один недостаток, который важно понять, и, как и во всем, я думаю, что важно понимать все за и против, прежде чем принимать решения.
Основываясь на том, что было сказано в предыдущем разделе, проблема, которую мы можем попытаться здесь решить, — это возвращаемый тип void в обработчиках событий. Когда мы создаем наши собственные события, мы обычно объявляем их, используя существующие сигнатуры делегатов:
public event EventHandler<SomeEventArgs> MyEvent;
Но опять же, эта подпись EventHandler имеет возвращаемый тип void. А что, если мы создадим собственный?
public delegate Task AsyncEventHandler<TArgs>(object sender, TArgs args)
where TArgs : EventArgs
Вы можете увидеть пример класса, использующего этот здесь, на GitHub. или ниже:
public sealed class AsyncEventRaisingObject
{
// you could in theory have your own event args here
public event AsyncEventHandler<EventArgs> ExplicitAsyncEvent;
public async Task RaiseAsync(EventArgs e)
{
await ExplicitAsyncEvent?.Invoke(this, e);
}
}
Давайте посмотрим пример
Теперь давайте рассмотрим пример приложения, в котором объединены созданный нами делегат и класс, который мы определили выше. Вы также можете найти этот код на GitHub< /сильный>:
Console.WriteLine("Starting the code example...");
var asyncEventRaisingObject= new AsyncEventRaisingObject();
asyncEventRaisingObject.ExplicitAsyncEvent += async (s, e) =>
{
Console.WriteLine("Starting the event handler...");
await TaskThatThrowsAsync();
Console.WriteLine("Event handler completed.");
};
try
{
Console.WriteLine("Raising our async event...");
await asyncEventRaisingObject.RaiseAsync(EventArgs.Empty);
}
catch (Exception ex)
{
Console.WriteLine($"Our exception handler caught: {ex}");
}
Console.WriteLine("Completed the code example.");
async Task TaskThatThrowsAsync()
{
Console.WriteLine("Starting task that throws async...");
throw new InvalidOperationException("This is our exception");
};
Приведенный выше код настроит нас с Task EventHandler, который в конечном итоге выдаст исключение из-за Task, которого он ожидает. Учитывая, что мы определили нашу сигнатуру события как Task, а не как void, это позволило нам иметь Task EventHandler. И результат мы можем видеть ниже:
Уловка
В этой реализации есть одна большая вонючая загвоздка, которая, к сожалению, во многих моих случаях использования делает ее нарушающей условия сделки. Однако в зависимости от того, чего вы пытаетесь достичь, могут существовать творческие обходные пути.
События и EventHandler не работают * совсем * так же, как обратный вызов. Синтаксис +/-, который мы получаем от них, позволяет нам добавлять и удалять обработчики по существу в список вызовов.
Обработчики событий Task ломаются, когда исключение вызывается в ранее выполненном обработчике, и у нас есть следующий обработчик.
Если мы поменяем порядок и обработчик событий Task, который выдает исключение в конце вызова, мы получим поведение, продемонстрированное в предыдущем разделе. Учитывая, что такое поведение может показаться крайне непоследовательным подписчику ваших событий, это ставит нас в затруднительное положение.
Хотя я не обязательно предлагаю это, я думаю, что в зависимости от вашего варианта использования вы можете рассмотреть следующий сценарий. Вы можете добавить собственный синтаксис добавления/удаления событий, если ваш дизайн требует только одного обработчика на время существования объекта.
То есть во время перегрузки добавления вы можете проверить, не является ли оно нулевым, и разрешить регистрацию только в этом случае.
В качестве альтернативы можно изучить что-то вроде следующего:
public event AsyncEventHandler<EventArgs> ExplicitAsyncEvent
{
add
{
_explicitAsyncEvent += async (s, e) =>
{
try
{
await value(s, e);
}
catch (Exception ex)
{
// TODO: do something with this exception?
await Task.FromException(ex);
}
};
}
// FIXME: this needs some thought because the
// anonymous method signature we used for try/catch
// support breaks this
remove { _ExplicitAsyncEvent -= value; }
}
В приведенном выше коде мы на самом деле используем трюк из эта статья, в которой мы оборачиваем наши обработчики в try/catch. Такое наслоение в try/catch создает дополнительную сложность для того, что вы собираетесь делать с исключением, а отключение событий также усложняется.
Обработчики событий задач… Стоит ли это делать?
В заключение в этой статье мы рассмотрели альтернативный подход, при котором вы можете полностью избежать использования async void. Преимущество этого решения в том, что вы контролируете класс, определяющий события, на которые нужно подписаться, и понимаете проблемы с несколькими обработчиками.
Это решение не будет работать, если вы пытаетесь решить проблему обработки исключений для асинхронных обработчиков событий для классов, в которых уже определены эти события. Для этого вы можете проверить эту статью или эта статья.
Если бы я оказался в ситуации, когда я хотел бы использовать событие вместо обратного вызова и мог гарантировать одного подписчика, это могло бы быть приемлемым вариантом.
Однако, учитывая, что многие системы, которые я разрабатываю, вероятно, захотят поддерживать N подписчиков, обработчики событий задач могут оказаться непригодными для большей части того, что я пишу.
(Прочитайте оригинальную статью о моем сайт)
Оригинал