Что вы не знали об обработчиках событий задач

Что вы не знали об обработчиках событий задач

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 подписчиков, обработчики событий задач могут оказаться непригодными для большей части того, что я пишу.

(Прочитайте оригинальную статью о моем сайт)


Оригинал