为什么不会抛出此异常?

8

有时我会使用一组任务,为了确保它们都被等待,我采用以下方法:

public async Task ReleaseAsync(params Task[] TaskArray)
{
  var tasks = new HashSet<Task>(TaskArray);
  while (tasks.Any()) tasks.Remove(await Task.WhenAny(tasks));
}

然后像这样调用它:
await ReleaseAsync(task1, task2, task3);
//or
await ReleaseAsync(tasks.ToArray());

然而,最近我注意到了一些奇怪的行为,并试图查看ReleaseAsync方法是否存在问题。我设法将其缩小到这个简单的演示中,如果您包括System.Threading.Tasks,则可以在linqpad中运行。稍加修改后,它也可以在控制台应用程序或asp.net mvc控制器中运行。

async void Main()
{
 Task[] TaskArray = new Task[]{run()};
 var tasks = new HashSet<Task>(TaskArray);
 while (tasks.Any<Task>()) tasks.Remove(await Task.WhenAny(tasks));
}

public async Task<int> run()
{
 return await Task.Run(() => {
  Console.WriteLine("started");
  throw new Exception("broke");
  Console.WriteLine("complete");
  return 5;
 });
}

我不理解的是为什么异常从未出现在任何地方。我本来以为如果等待异常的任务,它就会抛出异常。通过将while循环替换为简单的for each,我确认了这一点:
foreach( var task in TaskArray )
{
  await task;//this will throw the exception properly
}

我的问题是,为什么这个例子没有正确地抛出异常(它从未在任何地方显示)。


可能是如何处理Task.Factory.StartNew异常?的重复问题。 - David L
不使用 Task.WhenAll 的原因是什么? - Paulo Morgado
@PauloMorgado - 是的,这些资源是托管的,并且我希望在它们可用时释放它们,而不是等待它们全部完成然后再释放。 - Travis J
这个资源释放与你发布的代码无关,对吧?而托管资源会“自我释放”。 - Paulo Morgado
@PauloMorgado - 发布版不在代码中,是吧。至于托管资源的注释,抱歉打错了,应该是“非托管”,因为它的生命周期由using块决定,并继承IDisposable。 - Travis J
3个回答

11

TL;DR: run()抛出异常,但你在等待WhenAny(),它本身不会抛出异常。


WhenAny的MSDN文档说明:

返回的任务将在任何一个提供的任务完成时完成。返回的任务总是以RanToCompletion状态结束,并设置其结果为第一个完成的任务。即使第一个完成的任务处于CanceledFaulted状态,也是如此。

实际上发生的情况是,由WhenAny返回的任务只吞噬了出错的任务。它只关心任务是否完成,而不关心它是否成功完成。当你等待该任务时,它只是在没有错误的情况下完成,因为内部任务已失败,而你等待的那个任务没有失败。


10

一个未被awaited或者没有使用其Wait()Result()方法的Task,默认情况下会吞掉异常。可以通过在Task被GC'd后使运行进程崩溃来修改此行为,使其回到.NET 4.0中原本的方式。你可以按照以下步骤在你的app.config中进行设置:

<configuration> 
    <runtime> 
        <ThrowUnobservedTaskExceptions enabled="true"/> 
    </runtime> 
</configuration>

这是微软并行编程团队在博客文章中的一句引用:

熟悉.NET 4中任务的人都知道TPL有“未被观察到”的异常的概念。这是TPL中两个竞争设计目标之间的妥协:支持从异步操作向使用其完成/输出的代码调用未处理的异常的传递,并遵循未由应用程序代码处理的异常的标准.NET异常升级策略。自.NET 2.0以来,在新创建的线程、线程池工作项等中未处理的异常都会导致默认的异常升级行为,即进程崩溃。通常情况下,这是期望的,因为异常表示出现了问题,而崩溃有助于开发人员立即识别应用程序已进入不可靠状态。理想情况下,任务应该遵循同样的行为。然而,任务用于表示稍后与代码连接的异步操作,如果这些异步操作发生异常,则应将这些异常传递到运行和消耗异步操作结果的连接代码所在的位置。这本质上意味着TPL需要支持这些异常并将它们保存下来,直到可以在连接代码访问任务时重新抛出它们。由于这会防止默认的升级策略,因此.NET 4引入了“未被观察到”的异常概念来补充“未处理”的异常概念。一个“未被观察到”的异常是指将异常存储到任务中,但从未以任何方式被连接代码查看的异常。有许多观察异常的方法,包括Wait()操作等待任务、访问任务的Result、查看任务的Exception属性等等。如果代码从不观察任务的异常,那么当任务消失时,TaskScheduler.UnobservedTaskException将被触发,为应用程序提供一次“观察”异常的机会。如果异常仍然未被观察到,那么异常升级策略就会启用,异常在终结器线程上未被处理。


我相信Yuval的回答解释了你所看到的行为。此外,异常应该可以在运行()返回的任务对象中访问。 - Naylor

4

从评论中:

这些 [任务] 与受管资源绑定,我希望在它们变得可用时立即释放它们,而不是等待所有任务完成后再释放。

使用帮助程序 async void 方法可能为您提供所需的行为,可同时从列表中删除已完成的任务并立即引发未观察到的异常:

public static class TaskExt
{
    public static async void Observe<TResult>(Task<TResult> task)
    {
        await task;
    }

    public static async Task<TResult> WithObservation(Task<TResult> task)
    {
        try
        {
            return await task;
        }
        catch (Exception ex)
        {
            // Handle ex
            // ...

            // Or, observe and re-throw
            task.Observe(); // do this if you want to throw immediately

            throw;
        }
    }
}

那么你的代码可能是这样的(未经测试):
async void Main()
{
    Task[] TaskArray = new Task[] { run().WithObservation() };
    var tasks = new HashSet<Task>(TaskArray);
    while (tasks.Any<Task>()) tasks.Remove(await Task.WhenAny(tasks));
}
.Observe()会立即使用SynchronizationContext.Post(如果调用线程有同步上下文)或使用ThreadPool.QueueUserWorkItem(否则),将任务的异常“out-of-band”重新抛出。您可以使用AppDomain.CurrentDomain.UnhandledException处理这种“out-of-band”异常。更多细节请参见:TAP全局异常处理程序

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