为什么来自async void的异常会导致应用程序崩溃,但来自async Task的异常被吞噬了?

22

我了解一个 async Task 的异常可以通过以下方式捕获:

try { await task; }
catch { }

当一个方法被标记为 async void 时,它不能被等待,因此无法捕获异常。

但是,当异步 任务(Task)没有被等待时(就像异步 void 一样),Exception 就会被吞噬,而 void 的方法则会导致应用程序崩溃?

调用者ex();

被调用者

async void ex() { throw new Exception(); }
async Task ex() { throw new Exception(); }

3个回答

38

简而言之

这是因为不应该使用async voidasync void只用于使旧代码工作(例如WindowsForms和WPF中的事件处理程序)。

技术细节

这是因为C#编译器如何为async方法生成代码。

您应该知道,在async/await背后有一个状态机(由编译器生成的IAsyncStateMachine实现)。

当您声明一个async方法时,将为其生成一个状态机struct。 对于您的ex()方法,此状态机代码将如下所示:

void IAsyncStateMachine.MoveNext()
{
    try
    {
        throw new Exception();
    }
    catch (Exception exception)
    {
        this.state = -2;
        this.builder.SetException(exception);
    }
}

注意这个this.builder.SetException(exception);语句。对于返回Taskasync方法,它将是一个AsyncTaskMethodBuilder对象。对于一个void ex()方法,它将是一个AsyncVoidMethodBuilder

ex()方法体将被编译器替换为类似以下内容的代码:

private static Task ex()
{
    ExAsyncStateMachine exasm;
    exasm.builder = AsyncTaskMethodBuilder.Create();
    exasm.state = -1;
    exasm.builder.Start<ExAsyncStateMachine>(ref exasm);
    return exasm.builder.Task;
}

(对于 async void ex(),将没有最后的 return 行)

方法构建器的 Start<T> 方法将调用状态机的 MoveNext 方法。状态机的方法在其 catch 块中捕获异常。这个异常通常应该在 Task 对象上被观察到 - AsyncTaskMethodBuilder.SetException 方法会将异常对象存储在 Task 实例中。当我们放弃这个 Task 实例(没有 await),我们根本看不到异常,但异常本身也不再被抛出。

async void ex() 的状态机中,使用了一个 AsyncVoidMethodBuilder。它的 SetException 方法看起来不同: 因为没有 Task 来存储异常,所以必须抛出异常。然而,它的抛出方式不同于普通的 throw

AsyncMethodBuilderCore.ThrowAsync(exception, synchronizationContext);

AsyncMethodBuilderCore.ThrowAsync的内部逻辑如下:

  • 如果存在SynchronizationContext(例如,在WPF应用程序的UI线程上),则异常将在该上下文中发布
  • 否则,异常将在ThreadPool线程上排队。

在这两种情况下,异常都不会被可能设置在ex()调用周围的try-catch块捕获(除非您有一个特殊的SynchronizationContext可以执行此操作,例如参见Stephen Cleary的AsyncContext)。

原因很简单:当我们发布排队 throw操作时,我们只需从ex()方法返回,因此离开了try-catch块。然后,发布/排队的操作将被执行(在同一线程上或在不同的线程上执行)。


9

请注意底部的重要说明。

async void 方法将会导致应用程序崩溃,因为C#编译器无法将异常推入 Task 对象中。从功能层面上讲,Task 返回方法上的 async 关键字只是一种重度语法糖,它告诉编译器使用对象的各种方法以及实用程序(如 Task.FromResultTask.FromExceptionTask.FromCancelled)在术语上重写您的方法,有时还会使用 Task.Run 或从编译器的角度等效的工具。这意味着像下面这样的代码:

async Task Except()
{
    throw new Exception { };
}

被转换为大约

Task Except()
{
    return Task.FromException(new Exception { });
}

当调用返回Taskasync方法时,如果该方法抛出异常,程序不会崩溃,因为实际上没有异常被抛出;相反,一个处于“期望”状态的Task对象将被创建并返回给调用者。如前所述,使用async void修饰的方法没有Task对象可以返回,因此编译器不会尝试将该方法重写为基于Task对象的形式,而只是尝试处理获取等待调用的值。

更多上下文

即使不被等待,返回Task的方法也可能会引发异常,因为async关键字才是导致异常被忽略的原因,所以如果没有async关键字,方法中的异常将不会被忽略,例如以下情况。
Task Except() // Take note that there is no async modifier present.
{
    throw new Exception { }; // This will now throw no matter what.
    return Task.FromResult(0); // Task<T> derives from Task so this is an implicit cast.
}

等待调用实际上会抛出在返回一个带有任务的异步方法中所抛出的异常,这是因为await关键字被设计成在异步上下文中通过抛出被忽略的异常来使调试更加容易。
重要提示:
这些“重写”的处理方式在编译器中的实现和编译后的代码可能与我所暗示的不同,但在功能层面上大致相等。

1
杰出的回答,Alex。 - davidcarr

1
因为你的方法不是异步执行的。
执行将同步运行,直到“遇到”await关键字。
所以在void的情况下,应用程序会抛出异常,因为异常发生在当前执行上下文中。
对于Task,即使同步抛出异常,它也会被包装在Task中并返回给调用者。
如果在函数中使用await,则即使使用void,也应该获得所需的行为。
async void Ex()
{
    await Task.Delay(1000);
    throw new Exception();
}

如果返回值为void,应用程序将抛出异常,因为异常发生在当前执行上下文中。那么,为什么在try-catch块(调用者中)包装时没有被捕获呢? - ispiro
await Task.Delay(1000); - 不行,应用程序仍然崩溃。 - ispiro
当void出现问题导致应用程序崩溃时,应该注意。 - Fabio

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