在长时间的调试会话中,应用程序的功能有时会神秘地停止工作,这可能会让人感到困惑。处理异步事件处理器有多种不同的解决方案,无论是避免使用async void,还是接受async void,本文将探索另一种选择,即Task EventHandler。
本文最初是基于这样的观点撰写的:这种解决方案感觉接近无懈可击,但存在一些重要的限制。文章稍后将讨论这些限制,并且认为探索这个领域(包括优点和缺点)仍然很有价值。
首先,来探讨问题的根源。在C#中,正常的EventHandler具有void返回类型,这在需要连接一个标记为async的EventHandler时就成为了问题,因为希望等待内部调用的Task。
为什么这是一个问题?因为async void破坏了异常正常冒泡的能力,当无法追踪异常流向何处时,这可能会造成调试噩梦。根本的复杂性在于EventHandler的签名具有void返回类型,这破坏了异常控制:
void TheObject_TheEvent(object sender, EventArgs e);
但如果能够绕过这个问题呢?
Task EventHandlers可能有效!即将深入探讨的特定解决方案,其使用案例比之前提到的一些解决方案更为有限。然而,仍然认为,当能够控制添加到类中的事件,并且理解限制时,这是一个非常可行的解决方案。也就是说,如果正在创建一个类并定义自己的事件,希望调用者能够订阅,这个解决方案可能适合。将在后面讨论另一个重要的缺点,并且像所有事情一样,认为在做出决策之前,理解利弊是很重要的。
基于上一节所说的,可以尝试解决这里的问题,即EventHandler的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 {
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 EventHandler在早期执行的处理程序抛出异常,而有一个后续的处理程序时会崩溃。如果颠倒顺序,抛出异常的Task EventHandler位于调用的末尾,将得到在上一节中展示的行为。鉴于这种行为对于事件订阅者来说可能感觉非常不一致,这让陷入了困境。
虽然不一定建议这样做,但认为根据用例,可能会考虑以下情景。如果设计只要求对象生命周期内有一个处理程序,可以添加自定义的添加/移除事件语法。也就是说,在添加重载期间,可以检查是否为null,并且只在这种情况下允许注册。
另一种可能的替代方案是探索以下内容:
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);
}
};
}
remove { _ExplicitAsyncEvent -= value; }
}
在上面的代码中,实际上使用了这篇文章中的技巧,将处理程序包装在try/catch中。通过这种方式分层try/catch会创建其他复杂性,关于打算如何处理异常,以及取消挂钩事件也会变得更加复杂。