65.9K
CodeProject 正在变化。 阅读更多。
Home

async void – 如何驯服异步噩梦

starIconstarIconstarIconstarIconstarIcon

5.00/5 (14投票s)

2023年1月30日

CPOL

10分钟阅读

viewsIcon

22166

async void 的问题及解决方案

你是一名中级 dotnet 程序员,你大部分时间都知道如何使用 Tasks。你将 asyncawait 散布在你的代码中,一切都按预期工作。你一次又一次地听说,你总是希望你的异步方法的返回类型是 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 mainC# 程序……不要感到震惊!)

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 的类型。这意味着 EventHandlerEventHandler<T>,甚至 ActionAction<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”时,我们不应该总是停下来而不去进一步理解。当有约束时,我们才能想出最具创意的解决方案!

© . All rights reserved.