在AsyncDispose中处理异常的正确方法


20

在切换到新的.NET Core 3的过程中IAsynsDisposable,我偶然发现了以下问题。

问题的核心:如果DisposeAsync引发异常,则此异常隐藏await using-block 内部引发的所有异常。

class Program 
{
    static async Task Main()
    {
        try
        {
            await using (var d = new D())
            {
                throw new ArgumentException("I'm inside using");
            }
        }
        catch (Exception e)
        {
            Console.WriteLine(e.Message); // prints I'm inside dispose
        }
    }
}

class D : IAsyncDisposable
{
    public async ValueTask DisposeAsync()
    {
        await Task.Delay(1);
        throw new Exception("I'm inside dispose");
    }
}

被捕获的是AsyncDispose-exception(如果引发了),并且await using只有AsyncDispose不引发时才从内部发出异常。

但是,我还是更喜欢它:await using如果可能的话,从块中获取异常,并且DisposeAsync仅在await using块成功完成时才使用-exception 。

基本原理:想象一下,我的班级D使用一些网络资源并远程订阅了一些通知。内部的代码await using可能会出错,并导致通信通道失败,此后,Dispose中试图正常关闭通信(例如,取消订阅通知)的代码也会失败。但是第一个例外为我提供了有关该问题的真实信息,第二个例外只是次要问题。

在另一种情况下,当主要部分通过并且处置失败时,真正的问题就在内部DisposeAsync,因此相关的例外DisposeAsync。这意味着仅仅抑制内部的所有异常DisposeAsync并不是一个好主意。


我知道非异步情况也存在相同的问题:in中finally的exception覆盖in中的exception try,这就是为什么不建议使用in的原因Dispose()。但是,通过网络访问类,抑制关闭方法中的异常看起来一点也不好。


可以使用以下帮助程序解决问题:

static class AsyncTools
{
    public static async Task UsingAsync<T>(this T disposable, Func<T, Task> task)
            where T : IAsyncDisposable
    {
        bool trySucceeded = false;
        try
        {
            await task(disposable);
            trySucceeded = true;
        }
        finally
        {
            if (trySucceeded)
                await disposable.DisposeAsync();
            else // must suppress exceptions
                try { await disposable.DisposeAsync(); } catch { }
        }
    }
}

并像这样使用

await new D().UsingAsync(d =>
{
    throw new ArgumentException("I'm inside using");
});

这有点丑陋(并且禁止在using块内使用类似早日返回的功能)。

是否有一个好的,规范的解决方案,await using如果可能的话?我在互联网上的搜索甚至都没有找到讨论此问题的信息。


1
但是使用网络访问类抑制关闭方法中的异常看起来一点也不好 ” –我认为大多数网络BLC类都有一个单独的Close方法,正是出于这个原因。做同样的CloseAsync事可能是明智的:尝试很好地关闭事物并抛出失败。DisposeAsync尽力而为,却默默地失败。
canton7

@ canton7:嗯,单独使用CloseAsync意味着我需要采取额外的预防措施来使其运行。如果我只是将其放在using-block 的末尾,它将在早期返回等(这就是我们想要发生的事情)和异常(这就是我们想要发生的事情)上被跳过。但是这个想法看起来很有希望。
弗拉德

有一个原因是许多编码标准都禁止早期返回:)在涉及网络的情况下,将内容明确一点并不是IMO的坏事。Dispose一直是“事情可能出了问题:尽力改善情况,但不要让情况变得更糟”,我看不出为什么AsyncDispose会有什么不同。
canton7

@ canton7:好吧,用一种有例外的语言,每条陈述都可能是早日返回:-\
Vlad

是的,但是那将是例外。在这种情况下,DisposeAsync尽最大努力整理而不丢球是正确的事情。您所谈论的是有意提早返回,其中有意提早返回可能会错误地绕过对的调用CloseAsync:这些是许多编码标准所禁止的。
canton7

Answers:


3

有一些您要浮出水面的异常(中断当前请求,或降低流程),还有一些您的设计预期会在某些时候发生的异常,您可以对其进行处理(例如重试并继续)。

但是,区分这两种类型取决于代码的最终调用者-这是异常的全部要点,从而将决定权交给调用者。

有时,调用者会更加重视显示原始代码块中的异常,有时还会显示中的异常Dispose。没有通用的规则来决定哪个优先。CLR在同步和非异步行为之间至少是一致的(如您所指出的)。

不幸的是,现在我们不得不AggregateException代表多个异常,无法对其进行改进以解决此问题。也就是说,如果一个异常已经在发生中,并且抛出了另一个异常,则将它们合并为一个AggregateExceptioncatch可以修改该机制,以便在编写catch (MyException)时可以捕获任何AggregateException包含type异常的内容MyException。但是,这种想法还带来了其他各种复杂情况,现在修改如此基本的内容可能太冒险了。

您可以改进UsingAsync以支持早日返回值:

public static async Task<R> UsingAsync<T, R>(this T disposable, Func<T, Task<R>> task)
        where T : IAsyncDisposable
{
    bool trySucceeded = false;
    R result;
    try
    {
        result = await task(disposable);
        trySucceeded = true;
    }
    finally
    {
        if (trySucceeded)
            await disposable.DisposeAsync();
        else // must suppress exceptions
            try { await disposable.DisposeAsync(); } catch { }
    }
    return result;
}

所以我理解是正确的:您的想法是,在某些情况下,仅await using可以使用standard (在非致命情况下,DisposeAsync不会抛出该错误),而像helper这样的UsingAsync方法更合适(如果DisposeAsync可能会抛出) ?(当然,我需要进行修改,UsingAsync以确保它不会盲目地捕获所有内容,而只是非致命性的(并且在Eric Lippert的用法中不冒犯)。)
Vlad19,19年

@Vlad是的-正确的方法完全取决于上下文。还要注意,根据是否应该捕获异常,无法一次编写UsingAsync来使用某些全局真实的异常类型分类。同样,这是要根据情况做出不同决定的决定。当埃里克·利珀特(Eric Lippert)谈到这些类别时,它们并不是关于异常类型的内在事实。每个异常类型的类别取决于您的设计。有时设计会期望IOException,有时则不会。
Daniel Earwicker

4

也许您已经了解了为什么会发生这种情况,但是值得详细说明。这种行为并非特定于await using。也会发生一个普通的using块。因此,尽管我Dispose()在这里说,这同样适用DisposeAsync()

一个using块仅仅是一个语法糖try/ finally块,作为文档的备注节说。您看到的是因为该finally始终运行,即使发生异常也是如此。因此,如果发生异常,并且没有任何catch块,则将该异常搁置,直到该finally块运行,然后引发该异常。但是,如果中发生异常finally,您将永远看不到旧的异常。

您可以通过以下示例看到此内容:

try {
    throw new Exception("Inside try");
} finally {
    throw new Exception("Inside finally");
}

不要紧,是否Dispose()还是DisposeAsync()被称为内finally。行为是相同的。

我的第一个念头是:不要扔进去Dispose()。但是,在回顾了一些微软自己的代码之后,我认为这取决于。

看看它们对的实现FileStream。既是同步Dispose()方法,又DisposeAsync()实际上可以引发异常。同步Dispose()确实故意忽略某些异常,但不是全部。

但是我认为重要的是要考虑班级的性质。在一个FileStream,例如,Dispose()将刷新缓冲器到文件系统。这是一项非常重要的任务,您需要知道该操作是否失败。您不能只是忽略这一点。

但是,在其他类型的对象中,当您调用时Dispose(),您实际上不再使用该对象。调用Dispose()实际上只是意味着“该对象对我已经死了”。也许它会清理一些分配的内存,但是失败不会以任何方式影响应用程序的运行。在这种情况下,您可能决定忽略内的异常Dispose()

但无论如何,如果您想区分内的异常using或来自的异常Dispose(),则在块内外都需要一个try/ 块:catchusing

try {
    await using (var d = new D())
    {
        try
        {
            throw new ArgumentException("I'm inside using");
        }
        catch (Exception e)
        {
            Console.WriteLine(e.Message); // prints I'm inside using
        }
    }
} catch (Exception e) {
    Console.WriteLine(e.Message); // prints I'm inside dispose
}

否则您将无法使用using。写出一个try/ catch/ finally阻止自己,在那里你赶上任何异常finally

var d = new D();
try
{
    throw new ArgumentException("I'm inside try");
}
catch (Exception e)
{
    Console.WriteLine(e.Message); // prints I'm inside try
}
finally
{
    try
    {
        if (D != null) await D.DisposeAsync();
    }
    catch (Exception e)
    {
        Console.WriteLine(e.Message); // prints I'm inside dispose
    }
}

3
顺便说一句,source.dot.net(.NET核心)/ referencesource.microsoft.com(.NET框架)是一个更容易浏览比GitHub的
canton7

谢谢您的回答!我知道真正的原因是什么(我在问题中提到了try / final和同期案例)。现在介绍您的建议。一个catch 内部using块不会帮助,因为通常的异常处理是从远的地方做了using块本身,所以内部处理它using通常不是很可行。关于不使用using-真的比建议的解决方法好吗?
弗拉德

2
@ canton7太棒了!我知道referencesource.microsoft.com,但不知道.NET Core的等效项。谢谢!
加布里埃尔·卢西(Jabric Luci)

@Vlad“更好”是只有您能回答的问题。我知道我是否在阅读别人的代码,所以我宁愿看到try/ catch/ finally块,因为这样可以立即清楚地知道正在做什么,而不必去阅读AsyncUsing正在做什么。您还可以选择提前退货。您的服务器也将增加CPU成本AwaitUsing。它会很小,但是就在那里。
加布里埃尔·卢西(Jabric Luci)

2
@PauloMorgado这只是意味着Dispose()不应抛出该异常,因为它被多次调用了。正如我在此答案中所示,Microsoft自己的实现可以抛出异常,这是有充分的理由的。但是,我同意您应尽可能避免使用它,因为通常没有人会期望它会抛出。
加布里埃尔·卢西(Jabric Luci)

4

using是有效的异常处理代码(try ... finally ... Dispose()的语法糖)。

如果您的异常处理代码引发了Exceptions,则某些事情将被彻底废除。

任何其他事情甚至使您无法进入那里,也都不再重要。错误的异常处理代码将以一种或另一种方式隐藏所有可能的异常。异常处理代码必须是固定的,具有绝对优先级。否则,您将永远得不到足够的调试数据来解决实际问题。我经常看到它做错了。它就像处理裸露的指针一样容易出错。因此,经常有两篇关于主题I的文章,它们可能会帮助您解决任何潜在的设计误解:

根据异常分类,如果您的异常处理/ Dipose代码抛出异常,则需要执行以下操作:

对于“致命”,“骨头”和“激怒”而言,解决方案是相同的。

即使代价高昂,也必须避免外来异常。我们仍然使用日志文件而不是日志数据库来记录异常是有原因的-DB Opeartions只是容易遇到外源问题的方法。日志文件就是一种情况,我什至不介意是否保持文件句柄打开整个运行时。

如果您要关闭连接,则不必担心另一端。像UDP一样处理它:“我将发送信息,但是我不在乎对方是否得到了它。” 处置是关于清理正在处理的客户端/客户端上的资源。

我可以尝试通知他们。但是清理服务器/ FS端的东西吗?这就是他们的超时和他们的异常处理负责。


因此,您的建议实际上可以归结为抑制连接关闭时的异常,对吗?
弗拉德

@Vlad外生的?当然。Dipose / Finalizer在那里可以自己清洗。由于异常,关闭Conneciton实例的机率很可能是因为您仍然无法与它们建立有效的连接。在处理先前的“无连接”异常时获得“无连接”异常有什么意义呢?您发送一个“哟,我正在关闭此连接”,您将忽略所有外部异常,即使它接近目标也是如此。Afaik Dispose的默认实现已经做到了。
Christopher

@Vlad:我想起了,一堆东西你永远都不会抛出异常(Coruse Fatal除外)。类型初始化器在列表上。Dispose也是其中之一:“为确保始终正确地清理资源,Dispose方法应可多次调用而不会引发异常。” docs.microsoft.com/en-us/dotnet/standard/garbage-collection/…–
Christopher,

@Vlad致命异常的机会?我们不得不冒险冒险,并且绝不应该对它们进行“呼叫处置”之外的处理。并且不应该真正对这些做任何事情。实际上,它们在任何文档中都没有提及。| 骨头异常?始终修复它们。| 令人讨厌的异常是吞咽/处理的主要候选对象,例如TryParse()| 外生的?还应始终轻描淡写。通常,您还想告诉用户有关它们并进行记录。但是否则,它们不值得杀死您的过程。
Christopher

@Vlad我查了SqlConnection.Dispose()。甚至不关心将有关连接结束的任何信息发送到服务器。不是有什么事情还是发生了作为的结果NativeMethods.UnmapViewOfFile();NativeMethods.CloseHandle()。但是那些是从外部导入的。没有检查任何返回值或任何其他可用于获得正确的.NET异常的东西,无论这两个对象可能遇到什么情况。所以我强烈假设,SqlConnection.Dispose(bool)根本不在乎。| 实际上,关闭告诉服务器。在调用之前进行处置。
Christopher

1

您可以尝试使用AggregateException并修改您的代码,如下所示:

class Program 
{
    static async Task Main()
    {
        try
        {
            await using (var d = new D())
            {
                throw new ArgumentException("I'm inside using");
            }
        }
        catch (AggregateException ex)
        {
            ex.Handle(inner =>
            {
                if (inner is Exception)
                {
                    Console.WriteLine(e.Message);
                }
            });
        }
        catch (Exception e)
        {
            Console.WriteLine(e.Message);
        }
    }
}

class D : IAsyncDisposable
{
    public async ValueTask DisposeAsync()
    {
        await Task.Delay(1);
        throw new Exception("I'm inside dispose");
    }
}

https://docs.microsoft.com/ru-ru/dotnet/api/system.aggregateexception?view=netframework-4.8

https://docs.microsoft.com/ru-ru/dotnet/standard/parallel-programming/exception-handling-task-parallel-library

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.