摘录
学习如何在 C# 中安全使用异步事件处理程序。了解风险并发现在你的 C# 代码中管理异步事件处理程序的最佳实践。
如果你专注于 ASP.NET Core 的 Web 开发,事件和事件处理程序可能不是最常用的语言特性……但如果你在构建 WinForms、WPF、Maui — 或任何真正带有用户界面的应用 — 几乎可以保证你会使用到这些。考虑到现在异步 await 代码的普遍性,这也意味着你可能会在某个时刻遇到 C# 中的异步事件处理程序。
异步事件处理程序有其挑战。它们本质上是两件在概念上非常匹配的事情,但当这两件事交汇时,C# 的语言特性就让我们失去了方向。在这篇文章中,我会解释为什么会有风险,这些风险是什么,以及你可以尝试做些什么来改善。
理解 C# 中的异步事件处理程序
C# 中的异步事件处理程序使你能够处理程序执行期间发生的异步事件。这些事件可以包括用户输入、网络请求或任何其他异步操作。这些方法用 async
关键词标记,表示它们包含异步操作,因此我们可以在其中 await 其他调用。当事件被触发时,异步事件处理程序被调用并与程序的其余部分并发运行。
C# 中的传统同步事件处理程序有以下语法:
void MyEventHandler(object sender, EventArgs e)
{
// TODO: 处理事件
}
但如果我们需要它异步运行,它将看起来像这样:
async void MyEventHandler(object sender, EventArgs e)
{
// TODO: 处理事件,运行我们可以 await 的异步代码
}
记住事件处理程序在 C# 中 必须 有 void 返回类型,我们这里面临着什么问题呢?
为什么 C# 中的异步事件处理程序危险?
虽然异步事件处理程序提供了极大的灵活性和响应能力的提升,它们也引入了一组潜在的危险和风险,你需要意识到。如果你之前不了解这些风险,或者你了解但不确定如何最好地处理这些问题……因为事件处理程序语法和异步 await 代码的交汇处非常尴尬。
从根本上说,问题起因于事件处理程序的 async void
声明。当我们开始使用异步 await 时,我们很早就被告知使用 async void 是一个大忌。
这是为什么呢?它消除了我们等待异步操作的可能性 — 这就是 Task 使我们能够做到的。当我们不能利用任务来等待时,我们失去了管理任务执行的能力,包括异常处理。
这意味着如果你的事件处理程序出了问题,那么你的应用程序将在其他地方出问题。而且你将无法优雅地处理它。
C# 中异步事件处理程序的主要风险
现在你了解了它们通常为什么危险,这里有一些与 C# 中异步事件处理程序相关的几个关键风险:
- 未观察到的异常:当异步事件处理程序在执行期间遇到异常时,如果错误没有得到适当处理,它可能会引起未处理的异常。这可能导致程序的意外行为甚至崩溃。适当处理异常以防止这些问题很重要。这是前一节的焦点。
- 并发问题:异步事件处理程序与程序的其他部分并发运行,考虑潜在的并发问题变得很重要。并发访问共享资源可能导致竞态条件、数据损坏或不一致状态。当我们进行异步 await 时,当然,我们正在处理并发…但现在我们只是让一个异步代码体可能不受控制地运行在外太空,我们无法与之对齐。
当然,C# 中异步风格的事件处理程序也携带着同样的潜在内存泄漏问题。我们会在这里跳过详细信息,但请记得在你不再需要注册并适当管理你的事件和事件处理程序生命期时,取消挂钩事件!
在 C# 中安全处理异步事件处理程序
如果不正确处理,C# 中的异步事件处理程序可能会引入一些潜在问题。我之前尝试过提出替代方案来处理异步 void代码以及帮助简化安全事件处理程序语法的助手。这些方法要么非常复杂,要么在简单情况下语法看起来有点怪。这一节将详细介绍你可以使用的一些通用简单策略,使这些问题不那么令人头疼——但责任仍然在你身上,来添加它们!
用 Try/Catch 包装异步事件处理程序
如果我们接受我们必须使用 async void
用于事件处理程序,我们需要解决的最大问题是捕获异常。一旦异常冒泡并达到 async void 边界,游戏就结束了。如果你尝试用 try/catch 包装事件调用,当前调用堆栈将无法正确捕获这一点。
为了降低这种风险,在你的事件处理程序的最顶层使用 try-catch
块。使其成为你的事件处理程序做的第一件事也是最后一件事,这样就没有办法让代码抛出异常而不被 catch 块包裹:
async Task MyEventHandler(object sender, EventArgs e)
{
try
{
// TODO: 执行异步代码
}
catch (Exception)
{
// 处理取消
}
}
这仍然很糟糕,因为它是手动的,但也许有人可以制作一个 Roslyn 分析器来强制这个行为?!同样,请注意,我在这里捕获 所有 异常…但我们期望怎么处理它?真的,除非我们准备让我们的应用程序崩溃或经历奇怪的行为,我们需要阻止异常从一个异步 void 方法中冒泡出来。也许是记录或遥测?但默认情况下没有很多好的选择。
对异步事件处理程序强制超时
以下是一种你可以添加到混合中用于处理异步事件处理程序的不那么完美的选项。有时,事件处理程序中的异步操作可能需要的完成时间比预期更长。回想一下,由于我们不能等待它,并且我们没有任务对象可用,我们无法轻松管理这个异步 void 调用。但如果我们可以做一些事情来防止这些以某种无限循环的形式带走资源,我们可以引入一个与一些最大允许时间一起计时的取消令牌。
请参见下面的示例,其中包括之前节中的建议:
async void MyEventHandler(object sender, EventArgs e)
{
using (var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)))
{
try
{
// 继续进行异步操作,传递取消令牌
}
catch (OperationCanceledException)
{
// 处理取消
}
catch (Exception ex)
{
// TODO: 我们应该如何处理这些?
}
}
}
在上面的代码中,我们简单地尝试将执行限制在不超过5秒钟。只要我们在这里实施的异步代码利用了取消令牌,那么它们应该能够适当处理取消。但这确实意味着 某人 仍然需要承担那个责任。
频繁问及问题:C# 中的异步事件处理程序
理解 C# 中的异步事件处理程序的重要性是什么?
当在 C# 中使用事件和事件处理程序时,理解异步事件处理程序至关重要,因为它们与异步 await 代码混合得不好。
C# 中的异步事件处理程序是什么?
C# 中的异步事件处理程序允许开发人员异步处理事件,使后台处理成为可能并避免阻塞主线程,从而提高性能和响应性。它们具有与正常事件处理程序相同的签名(void 返回类型、object 发送者和 EventArgs),但是前面有 async 关键词。
与 C# 中的异步事件处理程序相关的潜在风险是什么?
最大的风险之一是允许异常冒泡过 async void 调用栈的一部分,因为没有一个优雅的位置来捕获这样的异常。异步事件处理程序还可以引入复杂性,如竞态条件、死锁和资源泄漏。对开发人员来说,理解这些风险并实施适当的处理来防止问题是很重要的。
有哪些技术可以防止异步事件处理程序的问题?
开发人员可以使用技术,如取消令牌来优雅地取消异步操作,并实施适当的错误处理和记录。不幸的是,一旦这些方法被触发并运行,就没有好方法来同步这些方法。