处理DisposeSync中异常的正确方法



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

这有点难看(并且不允许使用块内的早期返回)。

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

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

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

有时调用方会将更大的优先级放在从原始代码块中呈现异常上,有时还会将异常放在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特有的。这也会发生在普通的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()。但在审查了微软自己的一些代码后,我认为这取决于情况。

例如,看看他们对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
}
}

使用是有效的异常处理代码(try…finally…Dispose()的语法糖)。

如果您的异常处理代码正在抛出异常,则某些东西会严重损坏

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

  • 异常的分类以及您应该捕获的异常
  • 分类无法涵盖的一般良好做法

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

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

即使付出巨大代价,也必须避免外来例外。我们仍然使用日志文件而不是日志数据库来记录异常是有原因的——DB操作很容易遇到外源性问题。日志文件就是一种情况,我甚至不介意你是否将文件句柄保持为打开整个运行时。

如果你必须关闭一个连接,不要太担心另一端。像UDP一样处理:"我会发送信息,但我不在乎对方是否收到。"处理是为了清理你正在处理的客户端上的资源。

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

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

相关内容

  • 没有找到相关文章

最新更新