async void – 如何驯服异步噩梦





5.00/5 (14投票s)
async void 的问题及解决方案
你是一名中级 dotnet 程序员,你大部分时间都知道如何使用 Tasks。你将 async
和 await
散布在你的代码中,一切都按预期工作。你一次又一次地听说,你总是希望你的异步方法的返回类型是 Task
(或 Task<T>
),而 async void
本质上是万恶之源。没问题。
有一天,你尝试使用 myObject.SomeEvent += SomeEventHandler
语法连接一个 事件处理程序,你的事件处理程序需要等待一些异步代码。你采取了所有正确的步骤,并修改了你的方法签名,添加了漂亮的 async Task
,替换了 void
。但突然间,你收到了一个关于你的事件处理程序不兼容的编译错误。
你感到被困住了。你很害怕。然后,你做了不可言喻的事情……
你将事件处理程序的方法签名改为 async void
,突然间,所有的编译问题都消失了。你听到了所有你敬仰的传奇 dotnet 程序员的声音在你脑海中回响:“你在做什么?!你不能犯下如此滔天的编码罪行!”。但为时已晚。你已经屈服于 async void
的力量,你所有的问题似乎都消失了。
直到有一天,一切都崩溃了。那时你就在网上搜索,找到了这篇文章。
欢迎,朋友。
一部配套视频!
async void 到底有什么问题?
让我们从基础开始。以下是你在 C# 代码中使用 async void
的几个危险之处:
async void
方法中抛出的异常不能像async Task
方法中那样被捕获。当async void
方法中抛出异常时,它会在同步上下文中引发,这可能导致应用程序崩溃。- 由于
async void
方法无法被等待,它们可能导致混乱且难以调试的代码。例如,如果一个async void
方法被多次调用,它可能导致该方法的多个实例同时运行,这可能导致竞态条件和其他意外行为。
我敢打赌你是因为第一点才来到这里的。你的应用程序正在经历奇怪的崩溃,你很难诊断问题并获取跟踪。我们无法(传统上)将对 async void
方法的调用包装在 try
/catch
块中,并让它们按预期工作。
我想用一个简单的代码片段来演示这一点,你可以在 你的浏览器中实际尝试。让我们讨论下面的代码(顺便说一下,它使用了 .NET 7.0 的顶级语句功能,所以如果你不习惯看到没有 static void main
的 C# 程序……不要感到震惊!)
using System;
using System.Threading;
using System.Threading.Tasks;
Console.WriteLine("Start");
try
{
// NOTE: uncomment the single line for each one of the scenarios below
// one at a time to try it out!
// Scenario 1: we can await an async Task which allows us to catch exceptions
//await AsyncTask();
// Scenario 2: we cannot await an async void and as a result,
// we cannot catch the exception
//AsyncVoid();
// Scenario 3: we purposefully wrap the async void in a Task
// (which we can await), but it still blows up
//await Task.Run(AsyncVoid);
}
catch (Exception ex)
{
Console.WriteLine("Look! We caught the exception here!");
Console.WriteLine(ex);
}
Console.WriteLine("End");
async void AsyncVoid()
{
Console.WriteLine("Entering async void method");
await AsyncTask();
// Pretend there's some super critical code right here
// ...
Console.WriteLine("Leaving async void method.");
}
async Task AsyncTask()
{
Console.WriteLine("Entering async Task method");
Console.WriteLine("About to throw...");
throw new Exception("The expected exception");
}
如上面的代码所示,我们有三个示例场景可供查看。说真的,跳转到链接并每次取消注释其中一行来尝试它们。
场景 1
在场景 1 中,我们看到了我们忠实的老朋友 async Task
。当程序运行时,这个任务会抛出异常,由于我们正在等待一个 async Task
,包装的 try
/catch
块能够像我们预期那样捕获异常。
场景 2
在场景 2 中,这可能看起来像是一些让你来到这里的代码。可怕的 async void
。当你运行它时,你会注意到我们打印了即将抛出异常的信息……但是我们从未看到我们正在离开 async void
方法的指示!事实上,我们只看到指示程序已结束的行。这有多诡异?
场景 3
在场景 3 中,这可能看起来是你尝试解决 async void
困境的尝试。当然,如果我们将 async void
包装在一个 Task
中,我们可以等待它,然后我们就会回到安全状态……对吗?对吗?!不。事实上,在 .NET Fiddle 中,你实际上会看到它打印“Unhandled exception
”。这不是我们代码中有的东西,而是在程序完成 *之后* 发生的更可怕的事情。
到底真正的问题是什么?
从根本上说,无论你如何尝试修改代码,如果你有一个事件处理程序,它的返回类型必须是 void
才能使用 +
运算符连接到 C# 事件。
就是这样。
这意味着即使你尝试重构所有代码以避免这种情况,你也只是通过逃避问题来“解决”问题。是的,虽然我同意如果可能的话,你应该尝试编写不会让你陷入糟糕模式的代码,但有时好奇心会占上风。
我们是否仍然可以将事件处理程序设置为 async void
(以便我们可以在其中等待事物)?是的,当然!我们即将深入探讨的方法的额外好处是它不仅限于事件处理程序。你可以将此模式应用于你拥有的任何旧 async void
方法。
我推荐它吗?绝对不。我经常发现自己设计带有事件的对象,并且我尝试将我的使用限制在这种确切的场景中。你知道,所有 C# 大师都说“好吧,如果你 *必须* 使用 async void
,那么……确保它是用于按钮或其他东西上的事件处理程序。”
所以让我们看看如何让未来的体验不那么痛苦。
从噩梦中醒来
你已经足够耐心了,所以让我们直接深入。如果你想在浏览器中跟着一个工作演示,请查看此链接。
这是演示代码,我们稍后会介绍
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Runtime.ExceptionServices;
var someEventRaisingObject = new SomeEventRaisingObject();
// notice the async void. BEHOLD!!
someEventRaisingObject.TheEvent += async (s, e) =>
{
Console.WriteLine("Entering the event handler...");
await TaskThatIsReallyImportantAsync();
Console.WriteLine("Exiting the event handler...");
};
try
{
// forcefully fire our event!
await someEventRaisingObject.RaiseEventAsync();
}
catch (Exception ex)
{
Console.WriteLine($"Caught an exception in our handler: {ex.Message}");
}
// just block so we can wait for async operations to complete
Console.WriteLine("Press Enter to exit");
Console.ReadLine();
async Task TaskThatIsReallyImportantAsync()
{
// switch between these two lines (comment one or the other out)
// to play with the behavior
throw new Exception("This is an expected exception");
//await Task.Run(() => Console.WriteLine
//("Look at us writing to the console from a task!"));
}
class SomeEventRaisingObject
{
// you could in theory have your own event args here
public event EventHandler<EventArgs> TheEvent;
public Task RaiseEventAsync()
{
// the old way (if you toggle this way with the exception throwing,
// it will not hit our handler!)
//TheEvent?.Invoke(this, EventArgs.Empty);
//return Task.CompletedTask;
// the new way (if you toggle this way with the exception throwing,
// it WILL hit our handler!)
return InvokeAsync(TheEvent, true, true, this, EventArgs.Empty);
}
private async Task InvokeAsync(
MulticastDelegate theDelegate,
bool forceOrdering,
bool stopOnFirstError,
params object[] args)
{
if (theDelegate is null)
{
return;
}
var taskCompletionSource = new TaskCompletionSource<bool>();
// This is used to try and ensure we do not try and set more
// information on the TaskCompletionSource after it is complete
// due to some out-of-ordering issues.
bool taskCompletionSourceCompleted = false;
var delegates = theDelegate.GetInvocationList();
var countOfDelegates = delegates.Length;
// Keep track of exceptions along the way and a separate collection
// for exceptions, we have assigned to the TCS
var assignedExceptions = new List<Exception>();
var trackedExceptions = new ConcurrentQueue<Exception>();
foreach (var @delegate in theDelegate.GetInvocationList())
{
var async = @delegate.Method
.GetCustomAttributes(typeof(AsyncStateMachineAttribute), false)
.Any();
bool waitFlag = false;
var completed = new Action(() =>
{
if (Interlocked.Decrement(ref countOfDelegates) == 0)
{
lock (taskCompletionSource)
{
if (taskCompletionSourceCompleted)
{
return;
}
assignedExceptions.AddRange(trackedExceptions);
if (!trackedExceptions.Any())
{
taskCompletionSource.SetResult(true);
}
else if (trackedExceptions.Count == 1)
{
taskCompletionSource.SetException
(assignedExceptions[0]);
}
else
{
taskCompletionSource.SetException
(new AggregateException(assignedExceptions));
}
taskCompletionSourceCompleted = true;
}
}
waitFlag = true;
});
var failed = new Action<Exception>(e =>
{
trackedExceptions.Enqueue(e);
});
if (async)
{
var context = new EventHandlerSynchronizationContext
(completed, failed);
SynchronizationContext.SetSynchronizationContext(context);
}
try
{
@delegate.DynamicInvoke(args);
}
catch (TargetParameterCountException e)
{
throw;
}
catch (TargetInvocationException e) when (e.InnerException != null)
{
// When exception occured inside Delegate.Invoke method,
// all exceptions are wrapped in TargetInvocationException.
failed(e.InnerException);
}
catch (Exception e)
{
failed(e);
}
if (!async)
{
completed();
}
while (forceOrdering && !waitFlag)
{
await Task.Yield();
}
if (stopOnFirstError && trackedExceptions.Any() &&
!taskCompletionSourceCompleted)
{
lock (taskCompletionSource)
{
if (!taskCompletionSourceCompleted &&
!assignedExceptions.Any())
{
assignedExceptions.AddRange(trackedExceptions);
if (trackedExceptions.Count == 1)
{
taskCompletionSource.SetException
(assignedExceptions[0]);
}
else
{
taskCompletionSource.SetException
(new AggregateException(assignedExceptions));
}
taskCompletionSourceCompleted = true;
}
}
break;
}
}
await taskCompletionSource.Task;
}
private class EventHandlerSynchronizationContext : SynchronizationContext
{
private readonly Action _completed;
private readonly Action<Exception> _failed;
public EventHandlerSynchronizationContext(
Action completed,
Action<Exception> failed)
{
_completed = completed;
_failed = failed;
}
public override SynchronizationContext CreateCopy()
{
return new EventHandlerSynchronizationContext(
_completed,
_failed);
}
public override void Post(SendOrPostCallback d, object state)
{
if (state is ExceptionDispatchInfo edi)
{
_failed(edi.SourceException);
}
else
{
base.Post(d, state);
}
}
public override void Send(SendOrPostCallback d, object state)
{
if (state is ExceptionDispatchInfo edi)
{
_failed(edi.SourceException);
}
else
{
base.Send(d, state);
}
}
public override void OperationCompleted() => _completed();
}
}
理解场景
在此代码片段中,我们可以处理两类事物:
- 切换
TaskThatIsReallyImportantAsync
的功能。你可以安全地打印到控制台,也可以让它抛出异常,以便你可以尝试好的路径和坏的路径。这本身并不是本文的重点,但它允许你尝试不同的情况。 - 切换
RaiseEventAsync
的行为,这将向你展示async void
相对于我们解决方案的不太好的行为!将此与上一个选项结合起来,看看我们如何提高捕获异常的能力。
工作原理
这个解决方案我很大程度上借鉴了 Oleg Karasik。事实上,他的原始文章 非常出色地解释了他当时是如何构建算法功能的。所以我完全归功于他,因为没有他的帖子,我永远不会把这里展示的东西组合起来。
关于其工作原理的主要要点是:
- 我们可以使用自定义同步上下文,专门用于处理事件,它接受自定义完成/失败回调。
- 我们可以使用反射来检查
AsyncStateMachineAttribute
以查看我们的处理程序是否真正标记为async
。 GetInvocationList
允许我们获取注册到事件的整个处理程序链。DynamicInvoke
允许我们调用委托,而编译器不会抱怨类型。
当你将上述内容结合起来时,我们还可以分层添加其他一些功能:
forceOrdering
:此布尔标志可以强制每个处理程序按照它们注册的顺序运行,如果禁用,处理程序可以彼此异步运行。stopOnFirstError
:如果其中一个处理程序引发异常,此布尔标志可以阻止代码触发后续事件处理程序。
最后,我们能够在一个 TaskCompletionSource
实例上设置信息,指示完成或正在跟踪的异常。
很酷的东西!你贡献了什么?
好问题!除了试图提高人们对我认为非常酷的这段代码的认识之外,我还做了自己的调整。虽然我真的很喜欢原始代码提供的功能,但这并不是我希望在自己的代码库中访问它的方式。
多播委托
当我开始玩这段代码时,我意识到我想要支持的东西本质上都是一种叫做 MulticastDelegate
的类型。这意味着 EventHandler
、EventHandler<T>
,甚至 Action
、Action<T>
、Action<T1, T2>
等都技术上受此方法支持。
我想在库支持中提供这个,这样如果出现事件处理程序之外的情况,我就会有一个解决方案可以使用。这种情况何时发生?其实不常发生。但是,如果我现在遇到需要具有 async void
实现的 Action
的情况,我就可以安然入睡了。
扩展方法
从技术角度来看,这并不那么令人印象深刻。然而,从生活质量的角度来看,我想构建一个库,其中包含对它们的用例非常明显的扩展方法。将这些进一步分解以将 EventHandler<T>
支持与 Action
支持分开意味着我可以拥有更清晰的 API。例如,每个 EventHandler<T>
都必须有一个发送者和一个 EventArgs
实例。然而,Action
不需要两者,因此应该有一个与之对齐的 API。同样,Action
上的类型参数越多,我应该允许的参数就越多。
聚合异常
当我们要处理一堆可能抛出异常的处理程序时,我们可能会遇到类似的情况,即我们没有跟踪所有发生故障的事情。对我们大多数人来说,这无论如何都是主要动机。但是如果你超级不幸,那不仅仅是一个处理程序出了问题……而是多个。
如果我们有多个异常,跟踪异常允许我们抛出 AggregateException
。甚至有测试表明这种行为按预期被覆盖!
让我们看一个例子!
好的,最后一个例子使用我的变体。你可以通过查看此链接查看下面的代码。
using System;
using System.Threading;
using System.Threading.Tasks;
var invoker = new GenericEventHandlerInvoker();
invoker.Event += async (s, e) =>
{
await Task.Run(() => Console.WriteLine("Printing 1 from a task."));
throw new InvalidOperationException("expected 1!");
};
invoker.Event += async (s, e) =>
{
await Task.Run(() => Console.WriteLine("Printing 2 from a task."));
throw new InvalidOperationException("expected 2!");
};
Console.WriteLine("Starting...");
try
{
await invoker.InvokeAsync(
ordered: false,
stopOnFirstError: false);
}
catch (Exception ex)
{
Console.WriteLine("We caught the exception!");
Console.WriteLine(ex);
}
Console.WriteLine("Done!");
class GenericEventHandlerInvoker
{
public event EventHandler<EventArgs> Event;
public async Task InvokeAsync(
bool ordered,
bool stopOnFirstError)
{
// here is the extension method syntax we can see from NexusLabs.Framework
await Event
.InvokeAsync(
this,
EventArgs.Empty,
ordered,
stopOnFirstError)
.ConfigureAwait(false);
}
}
与之前的示例一样,我们有一个对象,它将为我们引发事件,我们可以订阅。但是在这个示例中,我们将注册两个 async void
方法。每个方法都会打印到控制台,然后抛出异常。我们将事件本身的调用包装在一个 try catch
中,这样我们就可以证明我们期望的行为,即我们必须能够捕获 async void
中的异常。
如果你按原样运行此代码,你会注意到控制台上会打印出两行。你可以通过反转 stopOnFirstError
的值来更改此设置。你还会注意到,通过不停止在第一个错误上,我们实际上抛出了一个包含我们两个异常的 AggregateException
。
最后,如果你查看文件末尾,你会看到我已经将所有这些都封装在一个名为 InvokeAsync
的扩展方法中,你基本上可以像调用普通事件一样调用它。如果你更喜欢简洁,还有其他形式,它们直接在方法名称中表达排序,而不是将其作为参数传递。
结论
希望本文能将你从 async void
的噩梦中解救出来。我已将此代码上传到 GitHub 上的一个仓库,需要明确的是,我的目的不是让你去下载和使用我的包/仓库。如果你愿意,那很好,但我创建这个库是为了在我的 C# 项目中节省时间。如果有一群愤怒的人经常因为 async
/await
问题追着我,那可能会降低我的生活质量。
我希望这篇文章能提醒大家,当我们听到“不可能完成
”或“永远不要做 X
”或“总是做 Y
”时,我们不应该总是停下来而不去进一步理解。当有约束时,我们才能想出最具创意的解决方案!