如何异步调用事件?

4
我正在构建一个控件,将被其他开发人员使用,在一些地方我有事件,开发人员可以订阅以执行一些潜在的长操作。
我的目标是给他们选择使用异步/等待、多线程、后台工作者等选项,如果他们选择,但仍能在事件调用完成之前完成执行。以下是一个伪代码示例(我意识到这不会编译,但希望意图清晰):
我的代码:
public event MyEventHandler MyEvent;
private void InvokeMyEvent()
{
    var args = new MyEventArgs();

    // Keep the UI responsive until this returns
    await MyEvent?.Invoke(this, args);

    // Then show the result
    MessageBox.Show(args.Result);
}

开发者/订阅者的潜在代码:

// Option 1: The operation is very quick,
// so the dev doesn't mind doing it synchronously
private void myForm_MyEvent(object sender, MyEventArgs e)
{
    // Short operation, I'll just do it right here.
    string result = DoQuickOperation();
    e.Result = result;
}

// Option 2, The operation is long,
// so the dev wants to do it on another thread and keep the UI responsive.
private void myForm_MyEvent(object sender, MyEventArgs e)
{
    myForm.ShowProgressPanel();

    // Long operation, I want to multithread this!
    Task.Run(() =>
    {
        string result = DoVeryLongOperation();
        e.Result = result;
    })
    .ContinueWith((task) =>
    {
        myForm.HideProgressPanel();
    }
}

有没有标准的方法来实现这个?我看了一下委托的BeginInvoke和EndInvoke方法,希望它们能有所帮助,但我并没有想到什么解决方案。

非常感谢任何帮助或建议!


每个订阅者是否总是会有结果? - Servy
实际上只会有一个订阅者,但如果有多个,我只关心最后一个。如果他们没有设置它,结果就是由MyEventArgs构造的任何内容。 - AnotherProgrammer
  1. 很遗憾,你要求的事情违反了惯例,无法实现。
  2. 我刚告诉你如何做到这一点,所以不要告诉我它是不可能的。你可以选择在获取第二个任务之前等待第一个“Task”完成...但是如果让异步处理程序并行运行,你就无法控制对“args.Result”进行更改的顺序。
- Ben Voigt
你要求的事情如果不违反惯例是不可能实现的。我问过是否有惯例,你建议打破惯例。但无论如何,如果没有适当的方法来做到这一点,我愿意尝试其他方法来实现我的目标。 - AnotherProgrammer
@BenVoigt 如果每个处理程序都有自己的参数副本很重要,那么您可以为每个处理程序提供它们自己的副本;然后由您决定如何处理所有结果。另一个选择是让每个处理程序的 Task 成为 Task<T>,并以此获取结果。 - Servy
显示剩余2条评论
2个回答

8

没有标准的方法来实现这一点。

当我处于这种情况时,我的个人偏好是使用返回Task的自定义委托类型。返回同步结果的处理器可以简单地返回Task.CompletedTask,它们不需要为此创建新的任务。

public delegate Task MyAsyncEventHandler(object sender, MyEventArgs e);
public event MyAsyncEventHandler MyEvent;
private async Task InvokeMyEvent()
{
    var args = new MyEventArgs();

    // Keep the UI responsive until this returns
    var myEvent = MyEvent;
    if (myEvent != null)
        await Task.WhenAll(Array.ConvertAll(
          myEvent.GetInvocationList(),
          e => ((MyAsyncEventHandler)e).Invoke(this, args)));

    // Then show the result
    MessageBox.Show(args.Result);
}

请注意将myEvent捕获到本地变量中以避免线程问题,并使用GetInvocationList()确保等待所有任务执行完毕。
请注意,在此实现中,如果您有多个事件处理程序,则它们都会一次性调度,因此可能会并行执行。处理程序需要确保它们不以线程不安全的方式访问args
根据您的用例,按顺序执行处理程序(在循环中使用await)可能更为合适。

线程安全不是访问args的问题,因为async任务之间的并发是协作切换,而不是真正的并行。但是,对于重排序和重入的安全性非常重要。 - Ben Voigt
@BenVoigt 哦,好的,我误解了你的评论,但线程安全仍然是一个问题:如果你有两个事件处理程序,并且它们都await一些其他操作(或在其实现中直接调用task.ContinueWith),它们的继续可能会在两个不同的后台线程上并行执行。 - user743382
通常情况下是这样的,我应该编辑我的评论来说明这一点。await Task.Run(...) 是另一种后台线程可能访问 args 的方法,而 OP 也在使用它。 - user743382
@hvd,针对特定的结果处理,如此处所述,您应该将任务设置为Task<T>并以这种方式获取结果。 - Servy
@Servy 如果要描述一个事件处理程序的结果,我会举BindingList<T>.AddingNew事件为例。.NET还有其他类似的例子。无论如何,在AnotherProgrammer的评论之后,这个术语已经不重要了,我也会称其为变异。 - user743382
显示剩余12条评论

4
你可能会发现我关于异步事件的博客文章很有用。
虽然没有严格的约定,但由于WinRT / Win10 API处理这个问题的方式,存在一种松散的约定:延迟(deferrals)。我有一个DeferralManager类型在这里可以提供帮助:
class MyEventArgs : IDeferralSource
{
  private IDeferralSource _deferralSource;
  public MyEventArgs(IDeferralSource deferralSource) { _deferralSource = deferralSource; }
  IDisposable GetDeferral() => _deferralSource.GetDeferral();

  ... // Other properties
}

public event MyEventHandler MyEvent;
private async Task InvokeMyEventAsync()
{
  var deferralManager = new DeferralManager();
  var args = new MyEventArgs(deferralManager.DeferralSource);

  MyEvent?.Invoke(this, args);

  await deferralManager.WaitForDeferralsAsync();

  MessageBox.Show(args.Result);
}

这种方法的好处在于同步事件处理程序与普通处理程序完全相同,同时允许异步事件处理程序。但这并不理想; 特别是,没有任何东西“强制”异步事件处理程序获取延迟 - 如果它没有获取延迟,则它将无法按预期工作。
然而,我必须提醒您,在第一次使用事件进行此类设计时需要谨慎。在.NET中,事件是观察者模式的适当实现。但你的代码没有使用Observer; 它使用的是模板方法设计模式。你遇到的设计问题是因为你试图使用事件来实现模板方法设计模式。你可以通过几种不同的方式来强制使其适合,但这并不理想。

嗨,感谢您的想法。针对您最后一段的回应,您是否建议采用其他方法?如果有帮助的话,一个具体的案例是我正在加载一个 DataTable,在我对它进行操作之前,我调用这个事件来允许订阅者修改 DataTable,或者通过 MyEventArgs 提供自己的自定义 DataTable。然后我在我的代码中使用这个 DataTable。 - AnotherProgrammer
你可以使用继承,其中 MyEvent 变成 virtual Task UpdateDataDable(DataTable x);,或者你可以使用组合,其中 MyEvent 成为 IDataTableUpdater { Task UpdateDataTable(DataTable x); } 的实例,该实例被传递到你的类型的构造函数中。个人而言,我更喜欢使用组合。 - Stephen Cleary
很遗憾,我认为这两个选项都不适用于我,因为这段代码位于一个控件上...该控件位于另一个控件上...而该控件又位于另一个控件上。为了方便起见,我在顶层控件上公开我的事件,因为子控件是有意私有的。 - AnotherProgrammer
1
我不明白这些方法为什么行不通。尤其是组合的方法 - 私有派生类型会变得笨重。 - Stephen Cleary

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