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

Posted

技术标签:

【中文标题】未使用 await 调用时在异步方法中捕获异常【英文标题】:Catching Exceptions in async methods when not called with await 【发布时间】:2020-04-01 20:16:26 【问题描述】:

目标:

我对我在 .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);

场景:

    按原样运行时,此代码将引发异常,但是,除非您在其上设置断点,否则您不会知道它。 Visual Studio 调试器和控制台都将给出存在问题的任何指示(除了输出屏幕中的一行注释)。

    NonAwaitedMethod 的返回类型从Task 交换为void。这将导致 Visual Studio 调试器现在中断异常。它也将在控制台中打印出来。但值得注意的是,在Main 中找到的catch 语句中NOT 捕获了异常。

    NonAwaitedMethod 的返回类型保留为void,但去掉async。还将最后一行从 await runTask; 更改为 runTask.Wait(); (这基本上会删除任何异步内容。)运行时,异常会在 Main 方法的 catch 语句中捕获。

所以,总结一下:

| Scenario   | Caught By Debugger | Caught by Catch |  
|------------|--------------------|-----------------|  
| async Task | No                 | No              |  
| async void | Yes                | No              |  
| void       | N/A                | Yes             |  

问题:

我认为因为异常是在await 完成之前引发的,所以它将同步执行直到并通过引发异常。

因此我的问题是:为什么场景 1 或 2 都没有被 catch 语句捕获?

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

【问题讨论】:

这能回答你的问题吗? Catch an exception thrown by an async void method @PavelAnikhouski - 我觉得这个问题及其答案更多地是关于在子方法中等待之后发生的事情。我的问题的核心是(据我了解),代码同步执行直到第一次等待。如果调用方法没有在等待,并且如果子方法在遇到任何等待之前发生异常,为什么它不会向上传播(就像正常的同步方法一样)? @Vaccano 你的理解是正确的。并且您的代码确实在调用者方法运行的同一线程上同步执行(在第一次等待之前)。只是当您使用async 时,编译器会使用特殊类型包装您的方法,并为您保留异常,直到您调用await,此时异常会向上传播。 附带说明,根据guidelines,异步方法应具有后缀Async。所以NonAwaitedMethod方法应该命名为NonAwaitedMethodAsyncDoStuff应该命名为DoStuffAsync。这不适用于async void 方法。它仅适用于返回可等待类型的异步方法,例如 Task 【参考方案1】:

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

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

因为您的代码有 async 关键字,它会将方法转换为异步状态机,即由特殊类型封装/包装。当任务为awaited(async void 除外)或未被观察到时,异步状态机抛出的任何异常都将被捕获并重新抛出,这可以在TaskScheduler.UnobservedTaskException 事件中捕获。

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

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

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。

【讨论】:

你说的大部分都是有道理的。我仍然对task.ExceptionContinueWith 感到困惑。我尝试从ContinueWithAction 抛出一个异常,但它仍然没有被Main 方法的catch 语句捕获。这种设置不会发生这种情况吗? (我可以在 ContinueWith 中做我需要的事情,但我仍然很好奇)。 @Vaccano 是的,因为ContinueWith 会创建另一个Task,如果你从那里抛出另一个异常,新的异常就会在返回任务中被捕获。如果您需要在Main 方法中捕获异常,正确的方法是将您的Main 方法更改为async void Mainawait NonAwaitedMethod()【参考方案2】:

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

好问题。事实上,async/await 的早期预览版确实 有这种行为。但是语言团队认为这种行为太令人困惑了。

当你有这样的代码时很容易理解:

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 同步操作如果它的 awaitable 已经完成。那么在等待从Task.Delay 返回的任务时,是否已经过了 1 毫秒?或者更现实的例子,当HttpClient 返回一个本地缓存的响应(同步)时会发生什么?更一般地说,在方法的同步部分直接抛出异常往往会导致代码根据竞争条件改变其语义。

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

关于你的场景:

    是的,异常被忽略。因为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)。 是的,async void 是一个奇怪的怪癖(和should be avoided in general)。它被放入语言中以支持异步事件处理程序,因此它具有类似于事件处理程序的语义。具体来说,任何逃避async void 方法的异常都会在方法开始时的***上下文中引发。这就是异常也适用于 UI 事件处理程序的方式。对于控制台应用程序,线程池线程会引发异常。普通的async 方法返回一个“句柄”,它代表异步操作并且可以保存异常。无法捕获来自 async void 方法的异常,因为这些方法没有“句柄”。 当然可以。在这种情况下,该方法是同步的,异常会像往常一样沿堆栈向上传播。

顺便说一句,never, ever use the Task constructor。如果要在线程池上运行代码,请使用Task.Run。如果您想要一个异步委托类型,请使用Func<Task>

【讨论】:

很高兴知道。如果在预览版中保留该行为,确实会令人困惑,并且很难编写异常处理代码。 @StephenCleary - 我回到这段代码,将new Task(()=>) 换成了Task.Run(()=>)。 (因为您的评论不使用 Task 构造函数。)当我这样做时,任务会立即启动,而不是在 DoStuff 操作中启动。事实上,它会引发错误,因为当它调用StartupDone.Start() 时。 (它说它已经完成了。)这是您的博客文章提到的使用Task 构造函数的“正确时间”之一吗? (因为我不想立即安排任务。) @Vaccano:正如我在博客中提到的,使用Task 构造函数的原因只有一个:“如果你正在做动态任务并行[你不是]并且 需要构建一个可以在任何线程上运行的任务[您不需要],并且将调度决定留给代码的另一部分 [您不需要],并且 出于某种原因不能使用Func<Task> 而不是[没有人这样做],那么(并且只有这样)你应该使用任务构造函数。”如果要延迟任务执行,请使用Func<Task> 我又回来看这个了,我似乎无法让它工作。我没有将它拖到 cmets 中,而是问了另一个问题。如果您愿意,可以在这里:***.com/questions/61488316/…【参考方案3】:

async 关键字表示编译器应该将该方法转换为异步状态机,这对于异常的处理是不可配置的。如果您希望立即抛出 NonAwaitedMethod 方法的同步部分异常,除了从方法中删除 async 关键字之外别无选择。通过将 async 部分移动到 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;
))();

【讨论】:

本地函数的好主意!我喜欢这样。

以上是关于未使用 await 调用时在异步方法中捕获异常的主要内容,如果未能解决你的问题,请参考以下文章

未捕获异步方法中引发的异常 - 为啥?

我可以在不使用 await 的情况下从异步中捕获错误吗?

优雅得捕获async/await的异常

微信小程序捕获async/await函数异常实践

捕获调用异步方法C#的异常[重复]

捕获异步 void 方法引发的异常