在切换到新的.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