未通过await调用时在异步方法中捕获异常

时间:2020-04-01 20:16:26

标签: c# exception .net-core async-await .net-core-3.1

目标:

.Net Core库中的异常让我感到困惑。这个问题的目的是了解为什么正在做我所看到的。

执行摘要

我认为当调用async方法时,其中的代码将同步执行,直到遇到第一次等待。如果是这种情况,那么如果在该“同步代码”中引发了异常,为什么它不传播到调用方法呢? (就像普通的同步方法一样。)

示例代码:

在.Net Core控制台应用程序中给出以下代码:

static void Main(string[] args)
{
    Console.WriteLine("Hello World!");

    try
    {
        NonAwaitedMethod();
    }
    catch (Exception e)
    {
        Console.WriteLine("Exception Caught");
    }

    Console.ReadKey();
}

public static async Task NonAwaitedMethod()
{
    Task startupDone = new Task(() => { });
    var runTask = DoStuff(() =>
    {
        startupDone.Start();
    });
    var didStartup = startupDone.Wait(1000);
    if (!didStartup)
    {
        throw new ApplicationException("Fail One");
    }

    await runTask;
}

public static async Task DoStuff(Action action)
{
    // Simulate starting up blocking
    var blocking = 100000;
    await Task.Delay(500 + blocking);
    action();
    // Do the rest of the stuff...
    await Task.Delay(3000);
}

}

场景:

  1. 按原样运行时,此代码将引发异常,但是,除非您有断点,否则您将不会知道它。 Visual Studio Debugger或控制台将给出任何问题的指示(除了“输出”屏幕中的一行注释)。

  2. NonAwaitedMethod的返回类型从Task切换为void。这将导致Visual Studio调试器现在在异常时中断。它还将在控制台中打印出来。但值得注意的是,在catch的{​​{1}}语句中捕获了 NOT

  3. Main的返回类型保留为NonAwaitedMethod,但取下void。还将最后一行从async更改为await runTask;(这实际上删除了所有异步内容。)运行时,该异常会捕获在runTask.Wait();方法的catch语句中。

所以,总结一下:

Main

问题:

我认为因为异常是在完成| Scenario | Caught By Debugger | Caught by Catch | |------------|--------------------|-----------------| | async Task | No | No | | async void | Yes | No | | void | N/A | Yes | 之前抛出的,所以它会一直同步执行,直到抛出异常为止

我的问题是:为什么方案1或2都不会被await语句抓住?

此外,为什么从catch交换为Task返回类型会导致调试器捕获异常? (即使我没有使用该返回类型。)

3 个答案:

答案 0 :(得分:5)

在等待完成之前抛出了异常,它将同步执行

这确实是正确的,但这并不意味着您可以捕获异常。

因为您的代码具有async关键字,这会将方法转换为异步状态机,即由特殊类型封装/包装。 await执行任务(除那些async void任务之外)或不观察它们(它们可以被TaskScheduler.UnobservedTaskException捕获)时,将从异步状态机引发的任何异常捕获并重新抛出。事件。

如果从async方法中删除NonAwaitedMethod关键字,则可以捕获该异常。

观察此行为的一种好方法是使用此方法:

try
{
    NonAwaitedMethod();

    // You will still see this message in your console despite exception
    // being thrown from the above method synchronously, because the method
    // has been encapsulated into an async state machine by compiler.
    Console.WriteLine("Method Called");
}
catch (Exception e)
{
    Console.WriteLine("Exception Caught");
}

因此,您的代码的编译与此类似:

try
{
    var stateMachine = new AsyncStateMachine(() =>
    {
        try
        {
            NonAwaitedMethod();
        }
        catch (Exception ex)
        {
            stateMachine.Exception = ex;
        }
    });

    // This does not throw exception
    stateMachine.Run();
}
catch (Exception e)
{
    Console.WriteLine("Exception Caught");
}

为什么从Task转换为void返回类型会导致捕获异常

如果方法返回Task,则任务捕获异常。

如果方法是void,则从任意线程池线程重新抛出异常。从线程池线程中抛出的任何未处理的异常都将导致应用程序崩溃,因此调试器(或JIT调试器)很有可能正在监视此类异常。

如果您想解雇但忘记处理异常,则可以使用ContinueWith创建任务的延续:

NonAwaitedMethod()
    .ContinueWith(task => task.Exception, TaskContinuationOptions.OnlyOnFaulted);

请注意,您必须访问task.Exception属性才能观察到异常,否则,任务计划程序仍将收到UnobservedTaskException事件。

或者如果需要在Main中捕获和处理异常,则正确的方法是使用async Main methods

答案 1 :(得分:4)

如果在该“同步代码”中抛出异常,为什么它不传播到调用方法? (就像通常的同步方法一样。)

好问题。实际上,async / await的早期预览版本 did 具有该行为。但是语言团队认为行为太混乱了。

当您拥有这样的代码时,很容易理解:

if (test)
  throw new Exception();
await Task.Delay(TaskSpan.FromSeconds(5));

但是这样的代码呢?

await Task.Delay(1);
if (test)
  throw new Exception();
await Task.Delay(TaskSpan.FromSeconds(5));

请记住,await 同步执行,如果它的等待已完成。那么,从Task.Delay返回的任务等待了1毫秒了吗?还是举一个更现实的例子,当HttpClient返回本地缓存的响应(同步)时会发生什么?更普遍地讲,在方法的同步部分中直接抛出异常会导致代码根据竞争条件改变其语义。

因此,决定单方面更改所有async方法的工作方式,以便将抛出的 all 异常置于返回的任务上。作为一个很好的副作用,这使它们的语义与枚举器块保持一致。如果您有一个使用yield return的方法,则在枚举器实现之前,不会看到任何异常,而不是在调用该方法时才会出现。

关于您的方案:

  1. 是的,该异常将被忽略。因为Main中的代码通过忽略任务而“失火”。 “解雇后忘记”的意思是“我不在乎例外”。如果您愿意关心异常,请不要使用“即兴即忘”;而是await在某个时候完成任务。 The task is how async methods report their completion to their callers, and doing an await is how calling code retrieves the results of the task (and observe exceptions)
  2. 是的,async void是一个奇怪的怪癖(和should be avoided in general)。它被放入支持异步事件处理程序的语言中,因此它具有与事件处理程序类似的语义。具体来说,在方法开始时最新的顶级上下文中会引发任何逃避async void方法的异常。这也是异常也可用于UI事件处理程序的方式。对于控制台应用程序,线程池线程会引发异常。普通的async方法返回一个“句柄”,该句柄表示异步操作并可以容纳异常。 async void方法的异常无法捕获,因为这些方法没有“句柄”。
  3. 当然可以。在这种情况下,该方法是同步的,并且异常像往常一样在堆栈中传播。

在旁注never, ever use the Task constructor。如果要在线程池上运行代码,请使用Task.Run。如果要使用异步委托类型,请使用Func<Task>

答案 2 :(得分:1)

async关键字指示编译器应将方法转换为异步状态机,这对于异常处理而言是不可配置的。如果要立即抛出NonAwaitedMethod方法的sync-part-excepts,除了从该方法中删除async关键字外,没有其他选择。通过将异步部分移到异步local function中,您可以两全其美:

public static Task NonAwaitedMethod()
{
    Task startupDone = new Task(() => { });
    var runTask = DoStuff(() =>
    {
        startupDone.Start();
    });
    var didStartup = startupDone.Wait(1000);
    if (!didStartup)
    {
        throw new ApplicationException("Fail One");
    }

    return ImlpAsync(); async Task ImlpAsync()
    {
        await runTask;
    };
}

除了使用命名函数外,还可以使用匿名函数:

return ((Func<Task>)(async () =>
{
    await runTask;
}))();