异步方法未使用await调用时如何捕获异常

6

目标:

我在我的 .Net Core 库中的异常处理行为感到困惑。这个问题的目标是理解 为什么 出现我所看到的情况。

执行摘要:

我曾认为当调用一个async方法时,其中的代码会同步执行,直到第一个await被执行。如果是这样,那么如果在“同步代码”期间抛出异常,为什么不像正常的同步方法一样将其传播到调用方法?

示例代码:

给定以下代码在 .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调试器或控制台不会给出任何指示,除了在输出屏幕中的一行注释。

  2. NonAwaitedMethod的返回类型从Task更改为void。这将导致Visual Studio调试器现在在异常处中断。它也将打印在控制台中。但值得注意的是,异常在Main中找到的catch语句中没有被捕获。

  3. 保持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返回类型会导致调试器捕获异常?(即使我没使用那个返回类型。)


1
这个回答解决了你的问题吗?捕获由异步void方法抛出的异常 - Pavel Anikhouski
@PavelAnikhouski - 我觉得这个问题及其答案更多地涉及到在子方法中的await之后发生的事情。我的问题的核心是(据我所理解),代码在第一个await之前是同步执行的。如果调用方法没有等待,如果子方法在遇到任何await之前就抛出异常,为什么它不会像正常的同步方法一样向上传播? - Vaccano
@Vaccano,您的理解是正确的。而且,在第一个await之前,您的代码确实在调用方法运行的同一线程上同步执行。只是当您使用async时,编译器会将您的方法包装成一个特殊类型,并将异常保留给您,直到您调用await,此时异常会向上传播。 - weichch
顺便提一下,根据指南,异步方法应该有后缀Async。因此,NonAwaitedMethod方法应该被命名为NonAwaitedMethodAsync,而DoStuff应该被命名为DoStuffAsync。这不适用于async void方法。它仅适用于返回可等待类型(如Task)的异步方法。 - Theodor Zoulias
3个回答

9

在执行await之前抛出异常,这会导致它同步执行

虽然这个说法基本正确,但并不意味着你可以捕获异常。

因为你的代码有 async 关键字,这将该方法转换为一个异步状态机,即被特殊类型封装/包装。从异步状态机抛出的任何异常都将在任务被 await(除了那些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,那么异常将从任意线程池线程重新抛出。从线程池线程抛出的任何未处理异常都会导致应用程序崩溃,所以调试器(或者可能是JIT调试器)正在监视这种异常。
如果您想要“点火并忘记”但正确处理异常,可以使用ContinueWith为任务创建一个继续操作:
NonAwaitedMethod()
    .ContinueWith(task => task.Exception, TaskContinuationOptions.OnlyOnFaulted);

请注意,您必须访问task.Exception属性以使异常被观察到,否则,任务调度程序仍将收到UnobservedTaskException事件。
或者,如果需要在Main中捕获和处理异常,则正确的方法是使用async Main methods

你说的大部分都有道理。我仍然对task.ExceptionContinueWith这一部分感到困惑。我尝试从ContinueWithAction中抛出异常,但它仍然没有被Main方法的catch语句捕获。在这种设置下,这种情况不会发生吗?(我能够在ContinueWith内部完成所需的操作,但我仍然很好奇)。 - Vaccano
@Vaccano 是的,因为ContinueWith会创建另一个 Task,如果你在那里抛出另一个异常,新异常将在返回任务中被捕获。如果您需要在您的Main方法中捕获异常,正确的方法是将您的Main方法更改为async void Main,并await NonAwaitedMethod() - weichch

6
如果在“同步代码”期间抛出异常,为什么它不会向上传播到调用方法呢?(正常的同步方法会这样做。)这是一个好问题。事实上,早期版本的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将表现为同步。因此,当从Task.Delay返回任务并进行等待时,是否已经过去1毫秒?或者以更现实的例子,当HttpClient返回本地缓存的响应(同步)时会发生什么?更普遍地说,在方法的同步部分直接抛出异常往往会导致代码根据竞争条件而改变其语义。
因此,决定单方面改变所有async方法的工作方式,使所有抛出的异常都放置在返回的任务上。一个不错的副作用是,这使它们的语义与枚举器块保持一致;如果您有一个使用yield return的方法,任何异常都不会在调用该方法时看到,而是在枚举器被实现时才能看到。
关于您的情景:
  1. 是的,异常被忽略了。因为Main中的代码通过忽略任务来进行“fire and forget”。而“fire and forget”意味着“我不关心异常”。如果你关心异常,那么就不要使用“fire and forget”,而是在某个时候await任务。任务是async方法向其调用者报告完成情况的方式,而执行await是调用代码检索任务结果(并观察异常)的方式
  2. 是的,async void是一个奇怪的怪癖(通常应该避免使用)。它被加入语言以支持异步事件处理程序,因此它具有类似于事件处理程序的语义。具体而言,任何从async void方法中逃逸的异常都会在方法开始时的顶级上下文中引发。这也是UI事件处理程序的异常工作方式。对于控制台应用程序,异常会在线程池线程上引发。普通的async方法返回代表异步操作的“句柄”,可以保存异常。async void方法的异常无法被捕获,因为这些方法没有“句柄”。
  3. 当然了。在这种情况下,该方法是同步的,异常会像正常情况一样向上传递。

顺便说一下,永远不要使用Task构造函数。如果您想在线程池上运行代码,请使用Task.Run。如果您想要一个异步委托类型,请使用Func<Task>


很好知道。如果行为与预览版保持一致,那么确实会令人困惑,并且难以编写异常处理代码。 - weichch
@StephenCleary - 我回到了这个代码,用 Task.Run(()=>{}) 替换了 new Task(()=>{})。 (因为你的评论告诉我不使用 Task 构造函数。) 当我这样做时,任务立即启动,而不是在 DoStuff 动作中启动。 实际上,它会抛出一个错误,因为当调用 StartupDone.Start() 时(它说已经完成),这将是你的博客文章所提到的“正确时间”之一来使用 Task 构造函数吗? (因为我不希望任务立即安排。) - Vaccano
@Vaccano:正如我在博客中所指出的,只有一个原因可以使用Task构造函数:“如果您正在进行动态任务并行处理[您不是] 并且需要构造一个可以在任何线程上运行的任务[您不需要],并且将该调度决策留给代码的另一部分[您不需要],并且由于某种原因不能使用Func<Task> [没有人这样做],那么(仅在这种情况下)您应该使用任务构造函数。”如果您想延迟任务执行,请使用Func<Task> - Stephen Cleary
我又回来看这个了,但似乎无法使其工作。与其在评论中拖延,我提出了另一个问题。如果你感兴趣,可以在这里查看:https://stackoverflow.com/questions/61488316/switch-new-task-for-functask - Vaccano

1
async 关键字表示编译器应将方法转换为异步状态机,关于异常处理方面不可配置。如果您希望 NonAwaitedMethod 方法的同步部分异常立即抛出,则除了从该方法中删除 async 关键字外,没有其他选择。您可以通过将异步部分移动到异步 本地函数 中来实现两全其美。
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;
}))();

1
好主意,使用本地函数!我喜欢这个。 - Vaccano

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