如何等待C#中的事件?


77

我正在创建一个包含一系列事件的类,其中一个是GameShuttingDown。触发此事件后,我需要调用事件处理程序。该事件的目的是通知用户游戏正在关闭,他们需要保存其数据。可以等待保存,而不能等待事件。因此,当处理程序被调用时,游戏将在等待的处理程序完成之前关闭。

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());
}

活动注册

// 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);

我知道事件的签名是正确的void EventName,因此使它异步基本上是生死攸关的。我的引擎大量使用事件通知第三者开发人员(和多个内部组件)事件正在引擎内发生,并让事件对事件做出反应。

有没有什么好的方法可以将事件替换为我可以使用的基于异步的东西?我不确定是否应该使用回调BeginShutdownGameEndShutdownGame与之配合使用,但这很痛苦,因为只有调用源才能传递回调,而没有任何第三者插件可以插入引擎,这就是我从事件中获得的东西。如果服务器调用game.ShutdownGame(),则引擎插件和引擎中的其他组件无法传递其回调,除非我采用某种注册方法,并保留了一系列回调。

任何关于采用哪种首选/推荐路线的建议都将不胜感激!我环顾四周,大部分情况下我所看到的是使用Begin / End方法,我认为这不会满足我想要做的事情。

编辑

我正在考虑的另一个选项是使用注册方法,该方法需要等待回调。我遍历所有回调,抓住它们的Task并等待一个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

为什么首先要使用异步事件处理程序处理关闭事件?那是您问题的核心,而且似乎是不明智和不必要的。使处理程序同步可以为事件所有者提供一种干净直接的机制,让其知道所有处理程序何时完成。如果异步处理程序的行为很关键,则应说明原因。如果您确实决定使用“注册回调”方法,请不要按照显示的方式进行操作;只需使用Task委托sig的返回类型实现事件即可。然后在调用列表上等待(当然是在调用之后)
Peter Duniho

我没有意识到您可以让您的事件委托具有Task的返回类型。这将共同解决我的问题,但是当我使用Task作为返回类型时,我得到一个编译器错误,指出其返回类型错误。使它们无效将修复它,但具有我上面提到的副作用。
Johnathon Sullinger

您在哪里遇到编译器错误?该错误表明您没有正确实施我的建议。我将用一个简单的代码示例发布答案,以说明我的意思。
彼得·杜尼奥

您介意提供您提到的回调方法的一个小例子吗?我正在使用异步关机功能,因此我可以通过引擎(和第三方插件)通知对象游戏正在关闭,并且它们需要进行清理和保存。所有的保存代码都是异步的,这就是为什么我希望关机处理异步事件。否则,在有意义的情况下,关闭将在完成保存之前完成
Johnathon Sullinger

Answers:


90

就我个人而言,我认为拥有async事件处理程序可能不是最佳的设计选择,其中至少一个原因就是您所遇到的问题。使用同步处理程序,知道它们何时完成很简单。

就是说,如果由于某种原因您必须或至少被迫坚持这种设计,则可以以await友好的方式进行。

您注册处理程序和await他们的想法很不错。但是,我建议您坚持使用现有的事件范例,因为这将使事件在代码中保持可表达性。最主要的是,您必须偏离EventHandler基于标准的委托类型,并使用返回a的委托类型,Task以便可以await使用处理程序。

这是一个简单的例子,说明我的意思:

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);
    }
}

OnShutdown()在执行标准的“获取事件委托实例的本地副本”之后,该方法首先调用所有处理程序,然后等待所有返回的内容Tasks(在调用处理程序时将它们保存到本地数组中)。

这是一个简短的控制台程序,说明了用法:

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");
    }
}

看完这个例子,我现在想知道C#是否没有办法抽象这一点。也许更改可能太复杂了,但是旧样式的void-returning事件处理程序和新的async/await功能的当前混合确实有点尴尬。上面的方法可以工作(并且很好,恕我直言),但是对场景有更好的CLR和/或语言支持(即能够等待多播委托并使C#编译器将其转换为WhenAll())会很好。。


5
对于呼叫所有订户,您可以使用LINQ:await Task.WhenAll(handler.GetInvocationList().Select(invocation => ((Func<object, EventArgs, Task>)invocation)(this, EventArgs.Empty)));
LoRdPMN 2016年

4
FWIW,我把AsyncEvent放在一起,基本上完成了Peter的建议。在Microsoft实施适当的支持之前,这是一个权宜之计。
Tagc

handlerTasks = Array.ConvertAll(invocationList, invocation => ((Func<object, EventArgs, Task>)invocation)(this, EventArgs.Empty)));
Ben Voigt

6

Peter的例子很好,我使用LINQ和扩展对其进行了一些简化:

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;
    }
}

添加超时可能是一个好主意。引发事件呼叫“提高”扩展:

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

private async Task SomeMethod()
{
    ...

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

    ...
}

但是您必须意识到,与同步偶数不同,此实现并发调用处理程序。如果必须严格连续地执行处理程序,这可能是一个问题,例如,下一个处理程序取决于上一个处理程序的结果:

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;
    }
}

您最好将扩展方法更改为连续调用处理程序:

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);
            }
        }
    }
}

好答案。扩展方法肯定会使它更干净。当我最初这样做时,我最终得到了一堆复制粘贴。
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);
        }
    }
}

例:

    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);
                }
            });
        });
    }

注意:这是异步的,因此事件处理程序可能会损害UI线程。事件处理程序(订阅者)不应执行任何UI工作。否则,这没有多大意义。

  1. 在事件提供者中声明您的事件:

    公共事件EventHandler DoSomething;

  2. 调用事件您的提供者:

    DoSomething.InvokeAsync(new MyEventArgs(),此,ar => {完成时调用的回调(此处需要同步UI!)},空);

  3. 像往常一样由客户订阅活动


这是如何运作的?您是否会为每个处理程序回调调用?
约翰尼

见上面的例子!
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);
            }
        }
    }
}

1

的确,事件本质上是无法等待的,因此您必须解决它。

我过去使用一种解决方案是使用信号量等待释放其中的所有条目。在我的情况下,我只有一个订阅的事件,因此我可以将其硬编码为,new SemaphoreSlim(0, 1)但是在您的情况下,您可能希望覆盖事件的getter / setter并保留有多少订阅者的计数器,以便您可以动态设置同时线程。

然后,您向每个订阅者传递一个信号量条目,然后让他们做自己的事情,直到SemaphoreSlim.CurrentCount == amountOfSubscribers(又名:所有位置都已释放)。

这实际上将阻塞您的程序,直到所有事件订阅者都已完成。

您可能还需要考虑为GameShutDownFinished订户提供一个事件,当他们完成游戏结束任务时必须调用该事件。结合SemaphoreSlim.Release(int)重载,您现在可以清除所有信号量条目,并仅用于Semaphore.Wait()阻塞线程。现在,您不必等待所有条目是否都已清除,而不必等到释放一个位置(但是只有一刻可以一次释放所有位置)。


我是否将信号灯条目通过事件arg类传递给处理程序?如果是这样,该Shutdown方法将取决于在处理程序中释放的信号量,还是应该将回调作为事件arg提供?
Johnathon Sullinger

1
尽管这可以工作,但是每个处理程序代码都需要代码的关键部分来更新信号量,如果任何单个处理程序中都缺少更新代码,则整个前提将失败。如果消费者仍然必须进行更改,我认为我们应该寻求非基于事件的解决方案。
TIA

考虑使用基于回调的方法RegisterShutdownCallback(Func<Task> callback),侦听器将调用该方法来注册等待的回调。然后,当Shutdown被调用时,我遍历所有已注册的回调。虽然感觉不像事件那样好,但是它是一种可能的解决方案
Johnathon Sullinger

1

我知道操作人员专门询问有关为此使用异步和任务的问题,但这是一种替代方法,这意味着处理程序不需要返回值。该代码基于Peter Duniho的示例。首先是等效的A类(压缩一点以适应):

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); });
    }
}

一个简单的控制台应用程序可以显示其用途...

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); }
}

我希望这对某人有用。


1

如果您需要等待标准的.net事件处理程序,则不能这样做,因为它是 void

但是您可以创建一个异步事件系统来处理:

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));
    }
}

现在您可以声明您的异步事件:

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);.
    }
}

并使用以下命令调用异步事件:

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

通用版本:

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));
    }
}
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.