正确处理DisposeAsync中的异常的方法

22
在切换到新的.NET Core 3的IAsynsDisposable时,我遇到了以下问题。
问题的核心:如果DisposeAsync抛出异常,则此异常会隐藏在await using块内抛出的任何异常。
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");
    }
}

如果被抛出,那么会捕获DisposeAsync异常,只有在DisposeAsync未抛出异常时才会捕获await using内部的异常。
然而,我更喜欢另一种方式:如果可能的话,从await using块中获取异常,仅当await using块成功完成时才获取DisposeAsync异常。
原因是:假设我的类D使用某些网络资源并订阅远程通知。await using内部的代码可能出现问题并导致通信渠道失败,此后尝试优雅地关闭通信的Dispose中的代码也将失败。但第一个异常给我提供了关于问题的真实信息,而第二个异常只是次要问题。
在另一种情况下,当主要部分运行完毕且处置失败时,真正的问题在于DisposeAsync内部,因此来自DisposeAsync的异常是相关的。这意味着仅抑制DisposeAsync内部所有异常可能不是一个好主意。
我知道非异步情况下也存在相同的问题:在finally中的异常会覆盖try中的异常,这就是为什么不建议在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”块的末尾,它将被跳过早期返回等情况(这正是我们想要发生的),以及异常(这也是我们想要发生的)。但这个想法看起来很有前途。 - Vlad
这是一个大忌,代码分析规则CA1065。任何你试图去做的事情都会让问题变得更糟。所以不要这样做。 - Hans Passant
@HansPassant:好的,那么你提出了什么样的解决问题的方案?将一切保持原样并在“DisposeAsync”中抑制所有异常?停止使用“await using”,并使用try/finally编写所有代码?停止使用“IAsyncDisposable”,并使用try/finally编写所有代码? - Vlad
显示剩余7条评论
4个回答

6

有一些异常需要被抛出(中断当前请求或关闭进程),还有一些异常是您的设计预计会偶尔发生并且可以处理它们(例如重试和继续)。

但是,区分这两种类型的异常取决于代码的最终调用者 - 这就是异常的全部意义,将决策留给调用者。

有时候,调用者会更加关注从原始代码块抛出的异常,有时候会更关注来自Dispose的异常。没有普遍规则来决定哪个应该优先。CLR至少在同步和非异步行为之间是一致的。

也许不幸的是,现在我们有了AggregateException来表示多个异常,它无法被改进以解决这个问题。也就是说,如果一个异常已经在飞行中,另一个异常被抛出,则它们将组合成一个AggregateException。可以修改catch机制,使其写成catch (MyException),然后捕获包含MyException类型异常的任何AggregateException。然而,这种想法引发了各种复杂问题,现在修改某些根本性的东西可能太危险了。

您可以改进您的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(如果DisposeAsync在非致命情况下不会抛出),而像UsingAsync这样的辅助程序更合适(如果DisposeAsync可能会抛出)? (当然,我需要修改UsingAsync,以便它不会盲目地捕获所有内容,而只是非致命的(并且不是Eric Lippert用法中的愚蠢异常)。) - Vlad
@Vlad 是的-正确的方法完全取决于上下文。还要注意,UsingAsync不能编写一次以使用某种全局真实分类方式来确定是否应该捕获异常类型。同样,这是根据情况而不同的决策。当Eric Lippert谈到这些类别时,它们不是关于异常类型的固有事实。每个异常类型的类别取决于您的设计。有时按设计期望IOException,有时则不是。 - Daniel Earwicker

5
也许你已经明白为什么会出现这种情况,但值得说明的是,这种行为不仅限于使用await using,与之类似的普通using块也会发生。所以,尽管我在此处说到了Dispose(),但同样适用于DisposeAsync()
正如文档的备注中所述using块只是try/finally块的语法糖。你看到的情况是因为finally块始终运行,即使出现异常也是如此。因此,如果发生异常且没有catch块,则异常被暂停,直到finally块运行,然后抛出异常。但是,如果finally中出现异常,您将永远看不到旧异常。
您可以通过以下示例查看此内容:
try {
    throw new Exception("Inside try");
} finally {
    throw new Exception("Inside finally");
}

无论是在 finally 中调用 Dispose() 还是调用 DisposeAsync(),行为都是相同的。
我的第一反应是:不要在 Dispose() 中抛出异常。但是在查看了一些 Microsoft 自己的代码后,我认为这取决于具体情况。
以他们对 FileStream 的实现为例。例如同步的Dispose()方法和DisposeAsync()都可能会抛出异常。同步的Dispose()故意忽略了部分异常,但不是全部。
但我认为关键是要考虑你的类的性质。例如,在 FileStream 中,Dispose() 将缓冲区刷新到文件系统。这是非常重要的任务,你需要知道是否失败。你不能只是无视它。
然而,在其他类型的对象中,当你调用 Dispose() 时,你真的没有再使用这个对象的必要了。调用 Dispose() 真的只意味着 "这个对象对我来说已经没用了"。也许它会清理一些已分配的内存,但是失败并不会以任何方式影响你的应用程序的操作。在这种情况下,你可能决定在 Dispose() 中忽略异常。
但无论如何,如果你想区分是在 using 块内发生的异常还是来自 Dispose() 的异常,那么你需要在你的 using 块内外都使用一个 try/catch 块:
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
顺便说一下,https://source.dot.net (.NET Core) / https://referencesource.microsoft.com (.NET Framework) 比 GitHub 更易于浏览。 - canton7
谢谢您的答复!我知道真正的原因是什么了(我在问题中提到了try/finally和同步情况)。现在关于您的建议。using块内部的catch不会有所帮助,因为通常异常处理是在using块本身远离的地方进行的,因此在using内部处理它通常不太实用。关于不使用using- 那真的比所提出的解决方法更好吗? - Vlad
2
@canton7 太棒了!我知道 https://referencesource.microsoft.com,但不知道有一个针对 .NET Core 的等效物。谢谢! - Gabriel Luci
@Vlad,“更好”是只有你自己才能回答的问题。我知道如果我在阅读别人的代码时,我会更喜欢看到try/catch/finally块,因为这样可以立即清楚地知道它正在做什么,而不必去阅读AsyncUsing正在做什么。您还可以选择提前返回。您的AwaitUsing也将增加额外的CPU成本。虽然很小,但确实存在。 - Gabriel Luci
2
@PauloMorgado 这意味着Dispose()不应该因为被调用多次而抛出异常。微软自己的实现可能会抛出异常,这是有充分理由的,正如我在这个答案中所展示的。然而,我同意如果可能的话应该避免抛出异常,因为没有人会通常期望它会抛出异常。 - Gabriel Luci
显示剩余4条评论

3

使用using语句等效于异常处理代码(try...finally...Dispose())的语法糖。

如果你的异常处理代码抛出了异常,那么问题就已经变得非常严重了。

无论其他事情如何,都已经不重要了。错误的异常处理代码会隐藏所有可能的异常,无论哪种方式。必须修复异常处理代码,这是绝对优先的。没有这个,你永远不会获得足够的调试数据来解决实际问题。我经常看到这个做错。它几乎和裸指针一样容易出错。因此,有两篇与我链接的主题文章,可能会帮助您解决任何潜在的设计误解:

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

对于致命的、愚蠢的和恼人的例外情况,解决方案相同。

必须尽力避免外部异常。我们仍然使用日志文件而不是日志数据库来记录异常的原因是:数据库操作很容易遇到外部问题。在日志文件的情况下,如果您保持文件句柄打开整个运行时,我不介意。

如果你必须关闭连接,请不要太担心另一端。像UDP一样处理它:“我会发送信息,但我不在乎对方是否收到。” Dispose是关于在客户端/你正在工作的一侧清理资源。

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


那么你的建议实际上归结为在连接关闭时抑制异常,对吧? - Vlad
1
@Vlad 外部的?当然。Dispose/Finalizer 用于自动清理它们自己的一面。如果由于异常而关闭 Connection 实例,则很有可能是因为您已经没有可以使用的连接了。在处理前一个“没有连接”异常时,得到“无连接”异常有什么意义呢? 您只需发送一个“Yo,我正在关闭此连接”,在这里忽略所有外部异常或即使它接近目标也可以。据我所知,Dispose 的默认实现已经做到了这一点。 - Christopher
如果由于异常拆除了我的连接而导致我降落在“Dispose”中,那么异常确实无关紧要。但是,如果我以通常的方式降落在“Dispose”中,并且在“using”块中没有异常,那该怎么办?在这种情况下,抑制异常是一件好事吗?我对此表示怀疑。 - Vlad
@Vlad:“但是如果我以通常的方式在Dispose中着陆,没有在using块中使用异常呢?”那么您不再需要连接,它断开也不再与您相关。我再次查看了SQL Connections代码中的Dispose()。SQLConnection是Dispose的典范示例,特别是在using中。|无论您如何(返回、异常、获取)或为什么从using块中传递出去-您不再需要该连接。它必须可靠地被处理。并且按设计,Dispose可以随时调用。 - Christopher
在一些情况下,我确实不需要异常,例如当我关闭连接时。 (尽管有人可能会争辩说,采用这种方法会使软件注定始终处于失败模式中。)但是请想象一下,dispose()并非关闭连接,而仅是取消订阅远程事件。 因此,在取消订阅后,我仍然需要连接。 但是,如果由于取消订阅而导致连接断开,则抑制异常开始变得危险。 - Vlad
显示剩余7条评论

1
你可以尝试使用AggregateException并修改代码,类似于这样:

您可以尝试使用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://learn.microsoft.com/ru-ru/dotnet/api/system.aggregateexception?view=netframework-4.8

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


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