如何“等待”引发EventHandler事件

86
有时事件模式被用于在MVVM应用程序中引发事件,由一个子视图模型向其父视图模型发送消息以松耦合的方式通信,如下所示。 父视图模型:
searchWidgetViewModel.SearchRequest += (s,e) => 
{
    SearchOrders(searchWidgitViewModel.SearchCriteria);
};

搜索小部件 ViewModel

public event EventHandler SearchRequest;

SearchCommand = new RelayCommand(() => {

    IsSearching = true;
    if (SearchRequest != null) 
    {
        SearchRequest(this, EventArgs.Empty);
    }
    IsSearching = false;
});

我在重构我的.NET4.5应用程序时,尽可能多地使用asyncawait。然而,以下内容无法正常工作(我真的没有期望它会正常工作)。


 await SearchRequest(this, EventArgs.Empty);

这个框架肯定会这样做来调用事件处理程序,比如这个,但我不确定它是如何实现的?

private async void button1_Click(object sender, RoutedEventArgs e)
{
   textBlock1.Text = "Click Started";
   await DoWork();
   textBlock2.Text = "Click Finished";
}

我在获取与异步引发事件有关的所有信息时,发现都比较陈旧,但是我找不到框架中支持此功能的内容。

我该如何await事件的调用,同时仍保留在UI线程上。


你的意思是“不工作”是什么意思? - Peter Ritchie
1
它无法编译。您不能等待返回 void 的内容,而这正是它返回的内容。 - Simon_Weaver
你真的需要等到所有处理程序完成吗?难道你不能只是启动它们并让它们的“异步”部分在不等待它们的情况下完成吗? - svick
1
微软现在在Microsoft.VisualStudio.Threading包中提供了一个AsyncEventHandler - Shahin Dohan
13个回答

53

编辑: 这对于多个订阅者来说效果不好,所以除非你只有一个订阅者,否则我不建议使用这种方法。


感觉有点巧妙 - 但我从未找到过更好的方法:

声明一个委托。这与EventHandler相同,但返回一个任务而不是void。

public delegate Task AsyncEventHandler(object sender, EventArgs e);
你可以运行以下代码,只要父元素声明的处理程序正确使用asyncawait,则此代码将异步运行:
if (SearchRequest != null) 
{
    Debug.WriteLine("Starting...");
    await SearchRequest(this, EventArgs.Empty);
    Debug.WriteLine("Completed");
}

示例处理程序:

 // declare handler for search request
 myViewModel.SearchRequest += async (s, e) =>
 {                    
     await SearchOrders();
 };

注意:我从未尝试过同时有多个订阅者,也不确定这将如何工作 - 因此,如果您需要多个订阅者,请务必仔细测试。


8
想法不错,但不幸的是 await 在任何处理程序完成时都会完成。也就是说,它不会等待所有任务完成。 - Laith
3
@Laith - 我已经仔细检查过了,对我来说似乎是有效的(我在等待调用之前和之后放置了调试WriteLine命令,并且完成的消息直到搜索完成后才出现)。你的事件处理程序是否使用了async(添加到答案中),并且是否使用多个订阅者(未经测试)? - Simon_Weaver
2
是的,我在谈论多个订阅者。如果您多次使用 +=,则完成的第一个 Task 将结束 await 语句。其他任务仍然继续,但不会被等待。 - Laith
6
这篇关于事件返回值的答案建议使用await来等待最后一个处理程序返回的Task。在该答案的评论中,讨论了可以使用Delegate.GetInvocationList()手动逐个执行处理程序,并将它们的返回值包装在Task.WhenAll()中。 - binki
2
适用于单个订阅者,非常好用。谢谢! - str8ball
显示剩余5条评论

40

基于Simon_Weaver的回答,我创建了一个助手类,可以处理多个订阅者,并且具有类似于c#事件的语法。

public class AsyncEvent<TEventArgs> where TEventArgs : EventArgs
{
    private readonly List<Func<object, TEventArgs, Task>> invocationList;
    private readonly object locker;

    private AsyncEvent()
    {
        invocationList = new List<Func<object, TEventArgs, Task>>();
        locker = new object();
    }

    public static AsyncEvent<TEventArgs> operator +(
        AsyncEvent<TEventArgs> e, Func<object, TEventArgs, Task> callback)
    {
        if (callback == null) throw new NullReferenceException("callback is null");

        //Note: Thread safety issue- if two threads register to the same event (on the first time, i.e when it is null)
        //they could get a different instance, so whoever was first will be overridden.
        //A solution for that would be to switch to a public constructor and use it, but then we'll 'lose' the similar syntax to c# events             
        if (e == null) e = new AsyncEvent<TEventArgs>();

        lock (e.locker)
        {
            e.invocationList.Add(callback);
        }
        return e;
    }

    public static AsyncEvent<TEventArgs> operator -(
        AsyncEvent<TEventArgs> e, Func<object, TEventArgs, Task> callback)
    {
        if (callback == null) throw new NullReferenceException("callback is null");
        if (e == null) return null;

        lock (e.locker)
        {
            e.invocationList.Remove(callback);
        }
        return e;
    }

    public async Task InvokeAsync(object sender, TEventArgs eventArgs)
    {
        List<Func<object, TEventArgs, Task>> tmpInvocationList;
        lock (locker)
        {
            tmpInvocationList = new List<Func<object, TEventArgs, Task>>(invocationList);
        }

        foreach (var callback in tmpInvocationList)
        {
            //Assuming we want a serial invocation, for a parallel invocation we can use Task.WhenAll instead
            await callback(sender, eventArgs);
        }
    }
}

要使用它,您需要在类中声明它,例如:

public AsyncEvent<EventArgs> SearchRequest;

要订阅事件处理程序,您将使用熟悉的语法(与Simon_Weaver答案中相同):

myViewModel.SearchRequest += async (s, e) =>
{                    
   await SearchOrders();
};

要调用该事件,请使用与 c# 事件相同的模式(仅使用 InvokeAsync):

var eventTmp = SearchRequest;
if (eventTmp != null)
{
   await eventTmp.InvokeAsync(sender, eventArgs);
}

如果使用 C# 6,就可以使用 null 条件运算符,写成这样:

await (SearchRequest?.InvokeAsync(sender, eventArgs) ?? Task.CompletedTask);

非常智能 - bboyle1234
await SearchRequest?.InvokeAsync(...) -- 不幸的是,这样做行不通。我们不能等待空值。 - tsul
2
你说得对,当我写这段代码时,C# 6还没有发布,我不确定语义会如何工作:不过你应该可以写成 await (SearchRequest?.InvokeAsync(..) ?? Task.CompletedTask),但我不确定它是否更方便。 - tzachs
非常好的解决方案,我会选择强制使用公共构造函数,以防线程安全问题成为问题。 - Shikyo
它可以是 "public AsyncEvent<EventArgs> SearchRequest { get; }" 吗?因为我想让它成为接口的一部分,而字段在那里不受支持(属性是)。但是如果没有 setter,它就无法工作。 - ed22
2
@ed22,“+”运算符被视为setter,因此您需要在属性上拥有一个公共setter,然后它就可以工作了。请参见示例:https://dotnetfiddle.net/NdWeLq。如果您不想要一个公共的setter,您可以用普通方法(即Subscribe和Unsubscribe)替换“+”和“-”运算符。 - tzachs

33

事件并不完全与asyncawait相匹配,您已经发现了这一点。

UI处理async事件的方式与您尝试做的不同。 UI提供一个SynchronizationContext给它的async事件, 使它们能够在UI线程上恢复。 它从不“await”它们。

最佳解决方案(我个人认为)

我认为最好的选择是构建自己的async友好型发布/订阅系统,使用AsyncCountdownEvent来知道所有处理程序何时完成。

较小的解决方案#1

async void 方法在开始和结束时会通知它们的 SynchronizationContext(通过增加/减少异步操作计数)。所有 UI SynchronizationContext 都会忽略这些通知,但是您可以构建一个包装器来跟踪它并在计数为零时返回。

这里有一个示例,使用我的 AsyncEx 库 中的 AsyncContext

SearchCommand = new RelayCommand(() => {
  IsSearching = true;
  if (SearchRequest != null) 
  {
    AsyncContext.Run(() => SearchRequest(this, EventArgs.Empty));
  }
  IsSearching = false;
});

在这个例子中,UI线程在Run期间没有正在处理消息。较小的解决方案 #2是,您可以基于一个嵌套的Dispatcher框架创建自己的SynchronizationContext,当异步操作计数达到零时,该框架会弹出自身。但是,这样会引入重入问题;WPF故意省略了DoEvents

您的AsyncEx库链接已失效。 - WhatsThePoint

12

直接回答这个问题:我认为EventHandler不允许实现足够的通信来使调用者等待。你可能可以通过自定义同步上下文来进行一些技巧,但是如果您关心等待处理程序,则最好将处理程序能够将其Task返回给调用者。通过将此作为委托签名的一部分,更清楚地表明委托将被await

我建议使用Delgate.GetInvocationList()方法,该方法在Ariel的回答中描述tzachs的回答中的想法相结合。定义自己的AsyncEventHandler<TEventArgs>委托,它返回一个Task。然后使用扩展方法隐藏正确调用它的复杂性。如果您想要执行一堆异步事件处理程序并等待它们的结果,我认为这种模式很有意义。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;

public delegate Task AsyncEventHandler<TEventArgs>(
    object sender,
    TEventArgs e)
    where TEventArgs : EventArgs;

public static class AsyncEventHandlerExtensions
{
    public static IEnumerable<AsyncEventHandler<TEventArgs>> GetHandlers<TEventArgs>(
        this AsyncEventHandler<TEventArgs> handler)
        where TEventArgs : EventArgs
        => handler.GetInvocationList().Cast<AsyncEventHandler<TEventArgs>>();

    public static Task InvokeAllAsync<TEventArgs>(
        this AsyncEventHandler<TEventArgs> handler,
        object sender,
        TEventArgs e)
        where TEventArgs : EventArgs
        => Task.WhenAll(
            handler.GetHandlers()
            .Select(handleAsync => handleAsync(sender, e)));
}

这使您可以创建一个普通的 .net 风格的 event。只需像平常一样订阅它即可。
public event AsyncEventHandler<EventArgs> SomethingHappened;

public void SubscribeToMyOwnEventsForNoReason()
{
    SomethingHappened += async (sender, e) =>
    {
        SomethingSynchronous();
        // Safe to touch e here.
        await SomethingAsynchronousAsync();
        // No longer safe to touch e here (please understand
        // SynchronizationContext well before trying fancy things).
        SomeContinuation();
    };
}

然后只需记住使用扩展方法来调用事件,而不是直接调用它们。如果您想在调用中获得更多控制权,则可以使用GetHandlers()扩展。对于等待所有处理程序完成的常见情况,只需使用方便的包装器InvokeAllAsync()即可。在许多模式中,事件要么不会产生任何调用者感兴趣的内容,要么通过修改传递的EventArgs向调用者通信。(请注意,如果您可以假设具有调度程序样式序列化的同步上下文,则您的事件处理程序可以在其同步块内安全地改变EventArgs,因为继续将被编组到分派线程上。例如,在WinForms或WPF的UI线程中调用和await事件时,这将自动发生。否则,如果您的任何突变发生在线程池上运行的连续体中,则可能必须在突变EventArgs时使用锁定)。
public async Task Run(string[] args)
{
    if (SomethingHappened != null)
        await SomethingHappened.InvokeAllAsync(this, EventArgs.Empty);
}

这使得调用事件看起来更像正常的事件调用,但你必须使用 .InvokeAllAsync()。当然,你仍然需要处理事件的常规问题,例如需要保护事件调用以避免出现没有订阅者的事件引发 NullArgumentException
请注意,我使用 await SomethingHappened?.InvokeAllAsync(this, EventArgs.Empty),因为awaitnull上会出错。如果你愿意,可以使用以下调用模式,但可以争论括号很丑陋,而if风格通常更好,出于各种原因:
await (SomethingHappened?.InvokeAllAsync(this, EventArgs.Empty) ?? Task.CompletedTask);

易于实现,易于使用。太棒了! - pschill
非常感谢您提供这个优雅的解决方案。我正在我的MongoDB库中使用它在这里,如果有人想看一个例子。 - Dĵ ΝιΓΞΗΛψΚ

8
你可以使用由Microsoft提供的Microsoft.VisualStudio.Threading包中的AsyncEventHandler委托, 据我了解,此包在Visual Studio中使用。
private AsyncEventHandler _asyncEventHandler;

_asyncEventHandler += DoStuffAsync;

Debug.WriteLine("Async invoke incoming!");
await _asyncEventHandler.InvokeAsync(this, EventArgs.Empty);
Debug.WriteLine("Done.");

private async Task DoStuffAsync(object sender, EventArgs args)
{
    await Task.Delay(1000);
    Debug.WriteLine("hello from async event handler");
    await Task.Delay(1000);
}

输出结果:
异步调用已到达!
来自异步事件处理程序的问候
完成。


这绝对是最好的解决方案! - Matias Masso

5

我知道这是一个老问题,但我的最佳解决方案是使用TaskCompletionSource

看代码:

var tcs = new TaskCompletionSource<object>();
service.loginCreateCompleted += (object sender, EventArgs e) =>
{
    tcs.TrySetResult(e.Result);
};
await tcs.Task;

如果您只需要等待事件的第一个回调,那么这是很好的。在等待之后,最好将事件处理程序移除。 - David Lechner

4
如果您正在使用自定义事件处理程序,您可能需要查看DeferredEvents,因为它可以让您触发和等待事件的处理程序,如下所示:
await MyEvent.InvokeAsync(sender, DeferredEventArgs.Empty);

事件处理程序会做这样的事情:
public async void OnMyEvent(object sender, DeferredEventArgs e)
{
    var deferral = e.GetDeferral();

    await DoSomethingAsync();

    deferral.Complete();
}

另外,您也可以像这样使用using模式:

public async void OnMyEvent(object sender, DeferredEventArgs e)
{
    using (e.GetDeferral())
    {
        await DoSomethingAsync();
    }
}

您可以在此处了解有关延迟事件的详细信息


有趣的方法,与其他解决方案不同。我个人仍然更喜欢处理程序返回Task并通过调用GetInvocationList()来提取它。对于您在此SO答案中的使用示例,我认为最好使用using (e.GetDeferral()) {}模式变体,因为它更加健壮。 - binki

2
由于委托(事件也是委托)实现了异步编程模型(APM),因此您可以使用TaskFactory.FromAsync方法。(另请参阅任务和异步编程模型(APM)。)
public event EventHandler SearchRequest;

public async Task SearchCommandAsync()
{
    IsSearching = true;
    if (SearchRequest != null)
    {
        await Task.Factory.FromAsync(SearchRequest.BeginInvoke, SearchRequest.EndInvoke, this, EventArgs.Empty, null);
    }
    IsSearching = false;
}

然而,上述代码将在线程池线程上调用事件,即不会捕获当前的同步上下文。如果这是一个问题,您可以按以下方式修改它:
public event EventHandler SearchRequest;

private delegate void OnSearchRequestDelegate(SynchronizationContext context);

private void OnSearchRequest(SynchronizationContext context)
{
    context.Send(state => SearchRequest(this, EventArgs.Empty), null);
}

public async Task SearchCommandAsync()
{
    IsSearching = true;
    if (SearchRequest != null)
    {
        var search = new OnSearchRequestDelegate(OnSearchRequest);
        await Task.Factory.FromAsync(search.BeginInvoke, search.EndInvoke, SynchronizationContext.Current, null);
    }
    IsSearching = false;
}

2

我不太清楚你所说的“如何在保持UI线程的情况下等待事件调用”的意思。您希望事件处理程序在UI线程上执行吗?如果是这样,那么您可以像这样做:

var h = SomeEvent;
if (h != null)
{
    await Task.Factory.StartNew(() => h(this, EventArgs.Empty),
        Task.Factory.CancellationToken,
        Task.Factory.CreationOptions,
        TaskScheduler.FromCurrentSynchronizationContext());
}

该方法将处理程序的调用包装在一个Task对象中,以便您可以使用await,因为您不能在void方法中使用await - 这就是您编译错误的来源。

但是,我不确定您希望从中获得什么好处。

我认为这里存在基本的设计问题。在单击事件上启动一些后台工作很好,并且您可以实现支持await的内容。但是,这对UI的使用有什么影响呢?例如,如果您有一个触发需要2秒钟的操作的Click处理程序,您是否希望用户能够在操作挂起时点击该按钮?取消和超时是附加的复杂性。我认为在这里需要更多地了解可用性方面的问题。


在上面的示例中,我在调用周围放置了“IsSearching=true”和“IsSearching=false”。这些是MVVM属性,需要在UI线程上执行,可能会使“搜索”按钮变灰。最终我的问题是,WPF(Dispatcher?)可以异步地调用void事件处理程序,就像上面的“button1_Click”事件一样,我想知道他们是如何做到这一点的。 - Simon_Weaver
@Simon_Weaver 嗯,你不能 await 返回 void 的方法,你只能 await 返回 Task<T>Task 的方法。所以,如果你想异步地调用事件处理程序,你必须将其包装在一个 Task 对象中,就像我在我的回答中所示。 - Peter Ritchie
那么,你认为当WPF调用我声明为异步的事件处理程序时,它就是这样做的吗? - Simon_Weaver
async修饰符只是告诉编译器生成一个异步状态机来管理方法内部遇到的任何await关键字(更多是管理await之后的代码行)。它在外部查看方法时不会有任何作用。也就是说,它不能使方法可被await使用。await仅依赖于方法返回一个Task变量。因此,WPF可能只是同步调用您的方法。要使用await异步调用它,需要执行我在答案中详细说明的操作:将其包装在Task对象中。 - Peter Ritchie
你可以编写一个返回 Task 的事件处理程序,但是没有其他东西会直接异步调用它。 - Peter Ritchie
@Simon_Weaver 我知道这是一段时间以前的事情了,但实际上 await 编译成了 Task.GetAwaiter().OnComplete(()=> /* your follow up code */)。如果没有方法被等待,异步方法将同步完成。如果您的 UI 调用异步方法,只有等待部分会在单独的线程上执行,因此您不需要在代码中添加 Dispatcher.Invoke() 的内容。 - LuckyLikey

1
public static class FileProcessEventHandlerExtensions
{
    public static Task InvokeAsync(this FileProcessEventHandler handler, object sender, FileProcessStatusEventArgs args)
     => Task.WhenAll(handler.GetInvocationList()
                            .Cast<FileProcessEventHandler>()
                            .Select(h => h(sender, args))
                            .ToArray());
}

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接