Wie warte ich auf Ereignisse in C #?

77

Ich erstelle eine Klasse, die eine Reihe von Ereignissen hat, eines davon ist GameShuttingDown. Wenn dieses Ereignis ausgelöst wird, muss ich den Ereignishandler aufrufen. Der Zweck dieses Ereignisses besteht darin, Benutzer zu benachrichtigen, dass das Spiel heruntergefahren wird und sie ihre Daten speichern müssen. Die Speicherungen sind erwartbar und Ereignisse nicht. Wenn der Handler gerufen wird, wird das Spiel beendet, bevor die erwarteten Handler abgeschlossen werden können.

public event EventHandler<EventArgs> GameShuttingDown;

public virtual async Task ShutdownGame()
{
    await this.NotifyGameShuttingDown();

    await this.SaveWorlds();

    this.NotifyGameShutDown();
}

private async Task SaveWorlds()
{
    foreach (DefaultWorld world in this.Worlds)
    {
        await this.worldService.SaveWorld(world);
    }
}

protected virtual void NotifyGameShuttingDown()
{
    var handler = this.GameShuttingDown;
    if (handler == null)
    {
        return;
    }

    handler(this, new EventArgs());
}

Veranstaltungsanmeldung

// The game gets shut down before this completes because of the nature of how events work
DefaultGame.GameShuttingDown += async (sender, args) => await this.repo.Save(blah);

Ich verstehe, dass die Signatur für Ereignisse void EventNameso ist, dass es im Grunde genommen Feuer und Vergessen ist, sie asynchron zu machen. Meine Engine nutzt Eventing in hohem Maße, um Entwickler von Drittanbietern (und mehrere interne Komponenten) darüber zu informieren, dass Ereignisse in der Engine stattfinden, und sie auf diese reagieren zu lassen.

Gibt es einen guten Weg, um Eventing durch etwas Asynchrones zu ersetzen, das ich verwenden kann? Ich bin mir nicht sicher, ob ich Rückrufe verwenden BeginShutdownGameund verwenden soll EndShutdownGame, aber das ist ein Problem, da dann nur die anrufende Quelle einen Rückruf weiterleiten kann und keine Inhalte von Drittanbietern, die an die Engine angeschlossen werden, was ich bei Ereignissen bekomme . Wenn der Server anruft game.ShutdownGame(), können Engine-Plugins und andere Komponenten innerhalb der Engine ihre Rückrufe nicht weiterleiten , es sei denn, ich verkabele eine Registrierungsmethode und behalte eine Sammlung von Rückrufen.

Jeder Rat, was die bevorzugte / empfohlene Route ist, wäre sehr dankbar! Ich habe mich umgesehen und zum größten Teil habe ich gesehen, dass ich den Anfang / Ende-Ansatz verwende, von dem ich nicht glaube, dass er das befriedigt, was ich tun möchte.

Bearbeiten

Eine andere Option, die ich in Betracht ziehe, ist die Verwendung einer Registrierungsmethode, die einen erwarteten Rückruf erfordert. Ich durchlaufe alle Rückrufe, greife nach ihrer Aufgabe und warte mit einem WhenAll.

private List<Func<Task>> ShutdownCallbacks = new List<Func<Task>>();

public void RegisterShutdownCallback(Func<Task> callback)
{
    this.ShutdownCallbacks.Add(callback);
}

public async Task Shutdown()
{
    var callbackTasks = new List<Task>();
    foreach(var callback in this.ShutdownCallbacks)
    {
        callbackTasks.Add(callback());
    }

    await Task.WhenAll(callbackTasks);
}
Johnathon Sullinger
quelle
Mein Problem mit dem Callback-Ansatz wären potenzielle Speicherlecks. Das Aufheben der Registrierung von Objekten setzt voraus, dass sich die Handler selbst bereinigen, anstatt dass die Spiel-Engine sich nur darum kümmert, wie ich es möchte
Johnathon Sullinger
Warum behandeln Sie ein Shutdown- Ereignis überhaupt mit asynchronen Ereignishandlern? Das ist der Kern Ihres Problems und es scheint unklug + unnötig. Das Synchronisieren der Handler bietet dem Ereignisbesitzer einen sauberen, direkten Mechanismus, um zu wissen, wann alle Handler abgeschlossen sind. Wenn das Verhalten des asynchronen Handlers kritisch ist, sollten Sie erläutern, warum dies so ist. Wenn Sie sich für einen "Rückruf registrieren" -Ansatz entscheiden, tun Sie dies nicht so, wie Sie es zeigen. Implementieren Sie einfach Ihre Veranstaltung mit dem TaskRückgabetyp für die Delegatensignatur. und dann auf der
Aufrufliste
Ich wusste nicht, dass Sie Ihren Event-Delegierten einen Rückgabetyp für eine Aufgabe geben können. Das würde mein Problem insgesamt lösen, aber wenn ich eine Aufgabe als Rückgabetyp verwende, wird ein Compilerfehler angezeigt, der besagt, dass es sich um den falschen Rückgabetyp handelt. Wenn man sie ungültig macht, wird das Problem behoben, aber es hat den oben erwähnten Nebeneffekt.
Johnathon Sullinger
Sie erhalten einen Compilerfehler wo? Der Fehler deutet darauf hin, dass Sie meinen Vorschlag falsch umgesetzt haben. Ich werde eine Antwort mit einem einfachen Codebeispiel veröffentlichen, das veranschaulicht, was ich meine.
Peter Duniho
Würde es Ihnen etwas ausmachen, ein kleines Beispiel für den von Ihnen erwähnten Rückrufansatz zu liefern? Ich verwende das asynchrone Herunterfahren, damit ich Objekte über die Engine (und Plugins von Drittanbietern) benachrichtigen kann, dass das Spiel heruntergefahren wird und sie bereinigt und gespeichert werden müssen. Der gesamte Speichercode ist asynchron, weshalb das Herunterfahren Ereignisse asynchron behandeln soll. Andernfalls wird das Herunterfahren beendet, bevor das Speichern abgeschlossen ist, wenn dies sinnvoll ist
Johnathon Sullinger

Antworten:

90

Persönlich denke ich, dass asyncEvent-Handler möglicherweise nicht die beste Wahl für das Design sind, nicht zuletzt, weil der Grund genau das Problem ist, das Sie haben. Bei synchronen Handlern ist es trivial zu wissen, wann sie abgeschlossen sind.

Das heißt, wenn Sie aus irgendeinem Grund gezwungen sind oder zumindest stark gezwungen sind, an diesem Design awaitfestzuhalten , können Sie dies auf eine freundliche Art und Weise tun .

Ihre Idee, Handler und awaitsie zu registrieren , ist gut. Ich würde jedoch vorschlagen, am bestehenden Ereignisparadigma festzuhalten, da dadurch die Ausdruckskraft von Ereignissen in Ihrem Code erhalten bleibt. Die Hauptsache ist, dass Sie vom standardbasierten EventHandlerDelegatentyp abweichen und einen Delegatentyp verwenden müssen, der a zurückgibt, Taskdamit Sie awaitdie Handler können.

Hier ist ein einfaches Beispiel, das zeigt, was ich meine:

class A
{
    public event Func<object, EventArgs, Task> Shutdown;

    public async Task OnShutdown()
    {
        Func<object, EventArgs, Task> handler = Shutdown;

        if (handler == null)
        {
            return;
        }

        Delegate[] invocationList = handler.GetInvocationList();
        Task[] handlerTasks = new Task[invocationList.Length];

        for (int i = 0; i < invocationList.Length; i++)
        {
            handlerTasks[i] = ((Func<object, EventArgs, Task>)invocationList[i])(this, EventArgs.Empty);
        }

        await Task.WhenAll(handlerTasks);
    }
}

Das OnShutdown()Verfahren, nach dem Standard zu tun „erhält lokale Kopie der Veranstaltung Delegatinstanz“, zuerst ruft die Handler alle, und wartet dann auf alle zurück Tasks(eine lokale Array als die Handler aufgerufen wird gespeichert haben).

Hier ist ein kurzes Konsolenprogramm, das die Verwendung veranschaulicht:

class Program
{
    static void Main(string[] args)
    {
        A a = new A();

        a.Shutdown += Handler1;
        a.Shutdown += Handler2;
        a.Shutdown += Handler3;

        a.OnShutdown().Wait();
    }

    static async Task Handler1(object sender, EventArgs e)
    {
        Console.WriteLine("Starting shutdown handler #1");
        await Task.Delay(1000);
        Console.WriteLine("Done with shutdown handler #1");
    }

    static async Task Handler2(object sender, EventArgs e)
    {
        Console.WriteLine("Starting shutdown handler #2");
        await Task.Delay(5000);
        Console.WriteLine("Done with shutdown handler #2");
    }

    static async Task Handler3(object sender, EventArgs e)
    {
        Console.WriteLine("Starting shutdown handler #3");
        await Task.Delay(2000);
        Console.WriteLine("Done with shutdown handler #3");
    }
}

Nachdem ich dieses Beispiel durchgesehen habe, frage ich mich jetzt, ob es für C # keine Möglichkeit gegeben hätte, dies ein wenig zu abstrahieren. Vielleicht wäre eine Änderung zu kompliziert gewesen, aber die aktuelle Mischung aus den voidEreignishandlern im alten Stil und der neuen async/ awaitFunktion scheint etwas umständlich. Das Obige funktioniert (und funktioniert meiner Meinung nach gut), aber es wäre schön gewesen, eine bessere CLR- und / oder Sprachunterstützung für das Szenario zu haben (dh in der Lage zu sein, auf einen Multicast-Delegaten zu warten und den C # -Compiler dazu zu bringen, dies in einen Aufruf zu verwandeln WhenAll()). .

Peter Duniho
quelle
5
Um alle Teilnehmer await Task.WhenAll(handler.GetInvocationList().Select(invocation => ((Func<object, EventArgs, Task>)invocation)(this, EventArgs.Empty)));
anzurufen
4
FWIW, ich habe AsyncEvent zusammengestellt , das im Wesentlichen das tut, was Peter vorschlägt. Dies ist eine Notlösung, bis Microsoft die richtige Unterstützung implementiert.
Tagc
handlerTasks = Array.ConvertAll(invocationList, invocation => ((Func<object, EventArgs, Task>)invocation)(this, EventArgs.Empty)));
Ben Voigt
6

Peters Beispiel ist großartig, ich habe es nur ein wenig mit LINQ und Erweiterungen vereinfacht:

public static class AsynchronousEventExtensions
{
    public static Task Raise<TSource, TEventArgs>(this Func<TSource, TEventArgs, Task> handlers, TSource source, TEventArgs args)
        where TEventArgs : EventArgs
    {
        if (handlers != null)
        {
            return Task.WhenAll(handlers.GetInvocationList()
                .OfType<Func<TSource, TEventArgs, Task>>()
                .Select(h => h(source, args)));
        }

        return Task.CompletedTask;
    }
}

Es kann eine gute Idee sein, eine Zeitüberschreitung hinzuzufügen. Um das Ereignis auszulösen, rufen Sie die Raise-Erweiterung auf:

public event Func<A, EventArgs, Task> Shutdown;

private async Task SomeMethod()
{
    ...

    await Shutdown.Raise(this, EventArgs.Empty);

    ...
}

Sie müssen sich jedoch bewusst sein, dass diese Implementierung im Gegensatz zu synchronen Ereignissen Handler gleichzeitig aufruft. Es kann ein Problem sein, wenn Handler streng nacheinander ausgeführt werden müssen, was sie häufig tun, z. B. hängt ein nächster Handler von den Ergebnissen des vorherigen ab:

someInstance.Shutdown += OnShutdown1;
someInstance.Shutdown += OnShutdown2;

...

private async Task OnShutdown1(SomeClass source, MyEventArgs args)
{
    if (!args.IsProcessed)
    {
        // An operation
        await Task.Delay(123);
        args.IsProcessed = true;
    }
}

private async Task OnShutdown2(SomeClass source, MyEventArgs args)
{
    // OnShutdown2 will start execution the moment OnShutdown1 hits await
    // and will proceed to the operation, which is not the desired behavior.
    // Or it can be just a concurrent DB query using the same connection
    // which can result in an exception thrown base on the provider
    // and connection string options
    if (!args.IsProcessed)
    {
        // An operation
        await Task.Delay(123);
        args.IsProcessed = true;
    }
}

Sie sollten die Erweiterungsmethode so ändern, dass Handler nacheinander aufgerufen werden:

public static class AsynchronousEventExtensions
{
    public static async Task Raise<TSource, TEventArgs>(this Func<TSource, TEventArgs, Task> handlers, TSource source, TEventArgs args)
        where TEventArgs : EventArgs
    {
        if (handlers != null)
        {
            foreach (Func<TSource, TEventArgs, Task> handler in handlers.GetInvocationList())
            {
                await handler(source, args);
            }
        }
    }
}
Kosta_Arnorsky
quelle
Gute Antwort. Erweiterungsmethoden machen es sicherlich sauberer. Als ich das ursprünglich gemacht habe, hatte ich ein paar Pasta-Kopien.
Johnathon Sullinger
2
internal static class EventExtensions
{
    public static void InvokeAsync<TEventArgs>(this EventHandler<TEventArgs> @event, object sender,
        TEventArgs args, AsyncCallback ar, object userObject = null)
        where TEventArgs : class
    {
        var listeners = @event.GetInvocationList();
        foreach (var t in listeners)
        {
            var handler = (EventHandler<TEventArgs>) t;
            handler.BeginInvoke(sender, args, ar, userObject);
        }
    }
}

Beispiel:

    public event EventHandler<CodeGenEventArgs> CodeGenClick;

        private void CodeGenClickAsync(CodeGenEventArgs args)
    {
        CodeGenClick.InvokeAsync(this, args, ar =>
        {
            InvokeUI(() =>
            {
                if (args.Code.IsNotNullOrEmpty())
                {
                    var oldValue = (string) gv.GetRowCellValue(gv.FocusedRowHandle, nameof(License.Code));
                    if (oldValue != args.Code)
                        gv.SetRowCellValue(gv.FocusedRowHandle, nameof(License.Code), args.Code);
                }
            });
        });
    }

Hinweis: Dies ist asynchron, sodass der Ereignishandler möglicherweise den UI-Thread gefährdet. Der Event-Handler (Abonnent) sollte keine UI-Arbeit leisten. Sonst würde es nicht viel Sinn machen.

  1. Erklären Sie Ihre Veranstaltung bei Ihrem Veranstalter:

    öffentliche Veranstaltung EventHandler DoSomething;

  2. Rufen Sie das Ereignis Ihres Providers auf:

    DoSomething.InvokeAsync (new MyEventArgs (), this, ar => {Rückruf wird aufgerufen, wenn der Vorgang abgeschlossen ist (Benutzeroberfläche bei Bedarf hier synchronisieren!)}, Null);

  3. Abonnieren Sie die Veranstaltung wie gewohnt vom Client

Martin.Martinsson
quelle
Wie funktioniert das? Werden Sie nicht für jeden Handler zurückgerufen?
Johnny 5.
siehe obiges Beispiel!
Martin.Martinsson
2
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;

namespace Example
{
    // delegate as alternative standard EventHandler
    public delegate Task AsyncEventHandler<TEventArgs>(object sender, TEventArgs e, CancellationToken token);


    public class ExampleObject
    {
        // use as regular event field
        public event AsyncEventHandler<EventArgs> AsyncEvent;

        // invoke using the extension method
        public async Task InvokeEventAsync(CancellationToken token) {
            await this.AsyncEvent.InvokeAsync(this, EventArgs.Empty, token);
        }

        // subscribe (add a listener) with regular syntax
        public static async Task UsageAsync() {
            var item = new ExampleObject();
            item.AsyncEvent += (sender, e, token) => Task.CompletedTask;
            await item.InvokeEventAsync(CancellationToken.None);
        }
    }


    public static class AsynEventHandlerExtensions
    {
        // invoke a async event (with null-checking)
        public static async Task InvokeAsync<TEventArgs>(this AsyncEventHandler<TEventArgs> handler, object sender, TEventArgs args, CancellationToken token) {
            var delegates = handler?.GetInvocationList();
            if (delegates?.Length > 0) {
                var tasks = delegates
                    .Cast<AsyncEventHandler<TEventArgs>>()
                    .Select(e => e.Invoke(sender, args, token));
                await Task.WhenAll(tasks);
            }
        }
    }
}
Den
quelle
1

Es ist wahr, Ereignisse sind von Natur aus nicht zu erwarten, sodass Sie sie umgehen müssen.

Eine Lösung, die ich in der Vergangenheit verwendet habe, ist die Verwendung eines Semaphors , um darauf zu warten, dass alle darin enthaltenen Einträge freigegeben werden. In meiner Situation hatte ich nur ein abonniertes Ereignis, sodass ich es fest codieren new SemaphoreSlim(0, 1)konnte. In Ihrem Fall möchten Sie jedoch möglicherweise den Getter / Setter für Ihr Ereignis überschreiben und einen Zähler für die Anzahl der Abonnenten behalten, damit Sie die maximale Anzahl dynamisch festlegen können gleichzeitige Threads.

Anschließend übergeben Sie jedem Abonnenten einen Semaphoreintrag und lassen ihn bis dahin sein Ding machen SemaphoreSlim.CurrentCount == amountOfSubscribers (auch bekannt als: alle Plätze wurden freigegeben).

Dies würde Ihr Programm im Wesentlichen blockieren, bis alle Event-Abonnenten fertig sind.

Möglicherweise möchten Sie auch eine Veranstaltung à la GameShutDownFinishedfür Ihre Abonnenten bereitstellen , die sie anrufen müssen, wenn sie mit der Aufgabe am Ende des Spiels fertig sind. In Kombination mit der SemaphoreSlim.Release(int)Überladung können Sie jetzt alle Semaphoreinträge löschen und einfach Semaphore.Wait()zum Blockieren des Threads verwenden. Anstatt zu überprüfen, ob alle Einträge gelöscht wurden oder nicht, warten Sie jetzt, bis ein Platz freigegeben wurde (es sollte jedoch nur einen Moment geben, in dem alle Plätze gleichzeitig freigegeben werden).

Jeroen Vannevel
quelle
Würde ich den Semaphoreintrag über eine Event-Arg-Klasse an die Handler übergeben? Wenn ja, Shutdownhängt die Methode davon ab, welches Semaphor in den Handlern freigegeben wird, oder sollte ich einen Rückruf als Ereignisargument bereitstellen?
Johnathon Sullinger
1
Während dies funktioniert, benötigt jeder Handlercode einen kritischen Teil des Codes zum Aktualisieren des Semaphors, und die gesamte Prämisse schlägt fehl, wenn der Aktualisierungscode in einem einzelnen Handler fehlt. Wenn sich der Verbraucher trotzdem ändern muss, sollten wir uns für eine nicht ereignisbasierte Lösung entscheiden.
Tia
Betrachtet man einen Callback-basierten Ansatz mit einem RegisterShutdownCallback(Func<Task> callback), den Listener aufrufen, um einen erwarteten Rückruf zu registrieren. Wenn Shutdowndann aufgerufen wird, durchlaufe ich alle registrierten Rückrufe. Es fühlt sich nicht so gut an wie Eventing, aber es ist eine mögliche Lösung
Johnathon Sullinger
1

Ich weiß, dass die Operation speziell nach der Verwendung von Async und Aufgaben gefragt hat, aber hier ist eine Alternative, die bedeutet, dass die Handler keinen Wert zurückgeben müssen. Der Code basiert auf dem Beispiel von Peter Duniho. Zuerst die äquivalente Klasse A (etwas passend zusammengedrückt): -

class A
{
    public delegate void ShutdownEventHandler(EventArgs e);
    public event ShutdownEventHandler ShutdownEvent;
    public void OnShutdownEvent(EventArgs e)
    {
        ShutdownEventHandler handler = ShutdownEvent;
        if (handler == null) { return; }
        Delegate[] invocationList = handler.GetInvocationList();
        Parallel.ForEach<Delegate>(invocationList, 
            (hndler) => { ((ShutdownEventHandler)hndler)(e); });
    }
}

Eine einfache Konsolenanwendung, um ihre Verwendung zu zeigen ...

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

...

class Program
{
    static void Main(string[] args)
    {
        A a = new A();
        a.ShutdownEvent += Handler1;
        a.ShutdownEvent += Handler2;
        a.ShutdownEvent += Handler3;
        a.OnShutdownEvent(new EventArgs());
        Console.WriteLine("Handlers should all be done now.");
        Console.ReadKey();
    }
    static void handlerCore( int id, int offset, int num )
    {
        Console.WriteLine("Starting shutdown handler #{0}", id);
        int step = 200;
        Thread.Sleep(offset);
        for( int i = 0; i < num; i += step)
        {
            Thread.Sleep(step);
            Console.WriteLine("...Handler #{0} working - {1}/{2}", id, i, num);
        }
        Console.WriteLine("Done with shutdown handler #{0}", id);
    }
    static void Handler1(EventArgs e) { handlerCore(1, 7, 5000); }
    static void Handler2(EventArgs e) { handlerCore(2, 5, 3000); }
    static void Handler3(EventArgs e) { handlerCore(3, 3, 1000); }
}

Ich hoffe, dass dies für jemanden nützlich ist.

Jetbadger
quelle
1

Wenn Sie auf einen Standard-.net-Ereignishandler warten müssen, können Sie dies nicht tun, da dies der Fall ist void.

Sie können jedoch ein asynchrones Ereignissystem erstellen, um dies zu handhaben:

public delegate Task AsyncEventHandler(AsyncEventArgs e);

public class AsyncEventArgs : System.EventArgs
{
    public bool Handled { get; set; }
}

public class AsyncEvent
{
    private string name;
    private List<AsyncEventHandler> handlers;
    private Action<string, Exception> errorHandler;

    public AsyncEvent(string name, Action<string, Exception> errorHandler)
    {
        this.name = name;
        this.handlers = new List<AsyncEventHandler>();
        this.errorHandler = errorHandler;
    }

    public void Register(AsyncEventHandler handler)
    {
        if (handler == null)
            throw new ArgumentNullException(nameof(handler));

        lock (this.handlers)
            this.handlers.Add(handler);
    }

    public void Unregister(AsyncEventHandler handler)
    {
        if (handler == null)
            throw new ArgumentNullException(nameof(handler));

        lock (this.handlers)
            this.handlers.Remove(handler);
    }

    public IReadOnlyList<AsyncEventHandler> Handlers
    {
        get
        {
            var temp = default(AsyncEventHandler[]);

            lock (this.handlers)
                temp = this.handlers.ToArray();

            return temp.ToList().AsReadOnly();
        }
    }

    public async Task InvokeAsync()
    {
        var ev = new AsyncEventArgs();
        var exceptions = new List<Exception>();

        foreach (var handler in this.Handlers)
        {
            try
            {
                await handler(ev).ConfigureAwait(false);

                if (ev.Handled)
                    break;
            }
            catch(Exception ex)
            {
                exceptions.Add(ex);
            }
        }

        if (exceptions.Any())
            this.errorHandler?.Invoke(this.name, new AggregateException(exceptions));
    }
}

Und Sie können jetzt Ihre asynchronen Ereignisse deklarieren:

public class MyGame
{
    private AsyncEvent _gameShuttingDown;

    public event AsyncEventHandler GameShuttingDown
    {
        add => this._gameShuttingDown.Register(value);
        remove => this._gameShuttingDown.Unregister(value);
    }

    void ErrorHandler(string name, Exception ex)
    {
         // handle event error.
    }

    public MyGame()
    {
        this._gameShuttingDown = new AsyncEvent("GAME_SHUTTING_DOWN", this.ErrorHandler);.
    }
}

Rufen Sie Ihr asynchrones Ereignis mit folgenden Funktionen auf:

internal async Task NotifyGameShuttingDownAsync()
{
    await this._gameShuttingDown.InvokeAsync().ConfigureAwait(false);
}

Generische Version:

public delegate Task AsyncEventHandler<in T>(T e) where T : AsyncEventArgs;

public class AsyncEvent<T> where T : AsyncEventArgs
{
    private string name;
    private List<AsyncEventHandler<T>> handlers;
    private Action<string, Exception> errorHandler;

    public AsyncEvent(string name, Action<string, Exception> errorHandler)
    {
        this.name = name;
        this.handlers = new List<AsyncEventHandler<T>>();
        this.errorHandler = errorHandler;
    }

    public void Register(AsyncEventHandler<T> handler)
    {
        if (handler == null)
            throw new ArgumentNullException(nameof(handler));

        lock (this.handlers)
            this.handlers.Add(handler);
    }

    public void Unregister(AsyncEventHandler<T> handler)
    {
        if (handler == null)
            throw new ArgumentNullException(nameof(handler));

        lock (this.handlers)
            this.handlers.Remove(handler);
    }

    public IReadOnlyList<AsyncEventHandler<T>> Handlers
    {
        get
        {
            var temp = default(AsyncEventHandler<T>[]);

            lock (this.handlers)
                temp = this.handlers.ToArray();

            return temp.ToList().AsReadOnly();
        }
    }

    public async Task InvokeAsync(T ev)
    {
        var exceptions = new List<Exception>();

        foreach (var handler in this.Handlers)
        {
            try
            {
                await handler(ev).ConfigureAwait(false);

                if (ev.Handled)
                    break;
            }
            catch (Exception ex)
            {
                exceptions.Add(ex);
            }
        }

        if (exceptions.Any())
            this.errorHandler?.Invoke(this.name, new AggregateException(exceptions));
    }
}
FRNathan13
quelle