处理 DisposeAsync 异常的正确方法

Proper way to deal with exceptions in DisposeAsync

在切换到新的 .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");
    }
}

被捕获的是 DisposeAsync 抛出的异常,只有当 DisposeAsync 没有抛出时才从 await using 内部捕获异常。

然而,我更喜欢相反的方式:如果可能,从 await using 块中获取异常,并且仅当 await using 块成功完成时才获取 DisposeAsync 异常。

基本原理:假设我的 class D 使用一些网络资源并远程订阅一些通知。 await using 中的代码可能会做错事并导致通信通道失败,之后 Dispose 中试图正常关闭通信(例如,取消订阅通知)的代码也会失败。但是第一个异常给了我关于问题的真实信息,第二个只是一个次要问题。

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


我知道非异步情况也有同样的问题:finally中的异常会覆盖try中的异常,所以不建议在Dispose()中抛出异常。但是通过网络访问 classes 在关闭方法中抑制异常看起来一点都不好。


使用以下助手可以解决此问题:

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 块中使用早期 returns 之类的东西)。

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

也许您已经明白为什么会发生这种情况,但值得详细说明。此行为并非特定于 await using。普通的 using 块也会发生这种情况。所以虽然我在这里说 Dispose(),但它也适用于 DisposeAsync()

using 块只是 try/finally 块的语法糖,正如 remarks section of the documentation 所说。你看到的是因为 finallyalways 运行,即使在异常之后也是如此。因此,如果发生异常,并且没有 catch 块,异常将被搁置,直到 finally 块运行,然后抛出异常。但是如果在 finally 中发生异常,您将永远不会看到旧的异常。

你可以通过这个例子看到这一点:

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

finally 中调用 Dispose()DisposeAsync() 并不重要。行为相同。

我的第一个想法是:不要投入Dispose()。但在查看了一些微软自己的代码后,我认为这取决于。

例如,看看他们对 FileStream 的实现。两者都是有意同步的Dispose() method, and DisposeAsync() can actually throw exceptions. The synchronous Dispose() does ignore some exceptions,但不是全部。

但我认为考虑您的 class 的性质很重要。例如,在 FileStream 中,Dispose() 会将缓冲区刷新到文件系统。这是一项非常重要的任务,你需要知道它是否失败了。你不能忽视它。

然而,在其他类型的对象中,当您调用 Dispose() 时,您真的不再需要该对象了。调用 Dispose() 实际上就意味着 "this object is dead to me"。也许它会清理一些已分配的内存,但失败不会以任何方式影响应用程序的运行。在这种情况下,您可能会决定忽略 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
    }
}

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

如果您的异常处理代码正在抛出异常,那肯定是有问题了。

无论发生什么让你进入那里,都不再重要了。错误的异常处理代码将以一种或另一种方式隐藏所有可能的异常。异常处理代码必须是固定的,具有绝对的优先级。否则,您将永远无法获得足够的调试数据来解决真正的问题。我经常看到它做错了。它和处理裸指针一样容易出错。通常,有两篇关于主题 I link 的文章可能会帮助您解决任何潜在的设计错误概念:

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

对于 Fatal、Boneheaded 和 Vexing,解决方案是相同的。

外生异常,即使代价高昂也必须避免。我们仍然使用 logfiles 而不是 logdatabases 来记录异常是有原因的——数据库操作只是容易 运行成外生问题。日志文件是一种情况,如果您在整个运行时保持文件句柄打开,我什至不介意。

如果你必须关闭一个连接,不要太担心另一端。像 UDP 一样处理它:"I will send the information, but I do not care if the other side get's it." 处理是关于清理您正在处理的客户端 side/side 上的资源。

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

有些异常是您想要显示的(中断当前请求,或停止进程),有些异常是您的设计预期有时会发生并且您可以处理它们(例如重试并继续)。

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

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

也许不幸的是,现在我们有 AggregateException 来表示多个异常,它不能被改造来解决这个问题。即,如果一个异常已经在运行中,并且抛出另一个异常,它们将组合成一个 AggregateExceptioncatch 机制可以修改,这样如果你写 catch (MyException) 那么它会捕获任何包含类型 MyException 异常的 AggregateException。不过,这个想法还有其他各种并发症,现在修改如此基础的东西可能风险太大。

您可以改进 UsingAsync 以支持早期 return 值:

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;
}

您可以尝试使用 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