处理在展开嵌套的“using”语句时抛出的“Dispose”的异常。

11

显然,在使用嵌套的using语句时,有些异常可能会丢失。考虑这个简单的控制台应用程序:

using System;

namespace ConsoleApplication
{
    public class Throwing: IDisposable
    {
        int n;

        public Throwing(int n)
        {
            this.n = n;
        }

        public void Dispose()
        {
            var e = new ApplicationException(String.Format("Throwing({0})", this.n));
            Console.WriteLine("Throw: {0}", e.Message);
            throw e;
        }
    }

    class Program
    {
        static void DoWork()
        {
            // ... 
            using (var a = new Throwing(1))
            {
                // ... 
                using (var b = new Throwing(2))
                {
                    // ... 
                    using (var c = new Throwing(3))
                    {
                        // ... 
                    }
                }
            }
        }

        static void Main(string[] args)
        {
            AppDomain.CurrentDomain.UnhandledException += (sender, e) =>
            {
                // this doesn't get called
                Console.WriteLine("UnhandledException:", e.ExceptionObject.ToString());
            };

            try
            {
                DoWork();
            }
            catch (Exception e)
            {
                // this handles Throwing(1) only
                Console.WriteLine("Handle: {0}", e.Message);
            }

            Console.ReadLine();
        }
    }
}

每个Throwing实例在被处理时会触发异常,AppDomain.CurrentDomain.UnhandledException永远不会被调用。 输出结果:
Throw: Throwing(3)
Throw: Throwing(2)
Throw: Throwing(1)
Handle: Throwing(1)
我希望至少能够记录丢失的Throwing(2)Throwing(3)如何做到这一点,而不需要为每个using使用单独的try/catch(这会破坏using的方便性)?
在现实生活中,这些对象通常是我无法控制的类的实例。它们可能会抛出异常,也可能不会,但如果它们确实这样做,我希望有一个选项来观察这些异常。
这个问题是我在查看减少嵌套using级别时遇到的。有一个简洁的答案建议聚合异常。有趣的是,这与嵌套using语句的标准行为有所不同。

[编辑] 这个问题似乎与以下问题密切相关:您是否应该实现IDisposable.Dispose()以使其永远不会引发异常?


1
我不认为有可能修改您的Dispose,使其不会抛出异常,是吗?我确实明白为什么会发生这种情况,而且我不会称它们为“丢失”(我认为这类似于在catch块中捕获异常,然后抛出新异常)。编辑:也许我应该问一下,您是否必须使用AppDomain.CurrentDomain.UnhandledException处理程序来捕获它们,或者其他机制也可以接受? - Chris Sinclair
1
因此,对其进行反汇编显示,leave.s被放置在由using语句生成的try块的末尾。文档说明此操作符“无条件”转移控件到在其执行finally块之后指定的标签。因此,似乎异常确实“丢失”了...因为在执行完finally块后,“无条件”将控制权转移到finally块之后的标签处。(控制权被转移到finally块之后)。 - Simon Whitehead
1
@Noseratio:UnhandledException无法捕获它们,因为它们并没有未处理。相反,它们被更间接的方式吞噬了。 - Chris Sinclair
2
@Noseratio:你愿意使用托管的一次性包装器,如LoggedDisposable来包装你的一次性用法吗?你可以像这样使用它:using(var loggedDisposable = new LoggedDisposable(() => new Throwing(1)) { var myThrowing = loggedDisposable.WrappedDisposable; ... } 在其中,你的LoggedDisposable.Dispose方法中有一个try/catch日志块,它包装了一个WrappedDisposable.Dispose调用。 - Chris Sinclair
1
我认为,当它处理 c 时,会抛出一个异常。这导致它离开了 b 的 using 块,从而调用 b.Dispose。这又引发了一个新的异常,导致它离开了 a 的 using 块,从而调用 a.Dispose,并抛出自己的新异常... - Chris Sinclair
显示剩余6条评论
3个回答

23
这里有一个代码分析器警告。CA1065,“不要在意外的位置引发异常”。Dispose()方法在此列表中。在框架设计指南第9.4.1章节中也有强烈的警告:

避免在Dispose(bool)内部抛出异常,除非出现严重情况,其中包含的进程已被破坏(泄漏,不一致的共享状态等)。

这是因为using语句在finally块内调用Dispose()。在finally块被调用时,如果堆栈正在因异常而展开,则引发的异常可能会产生不良影响,它将替换活动异常。这正是您在此处看到的情况。
复制代码:
class Program {
    static void Main(string[] args) {
        try {
            try {
                throw new Exception("You won't see this");
            }
            finally {
                throw new Exception("You'll see this");
            }
        }
        catch (Exception ex) {
            Console.WriteLine(ex.Message);
        }
        Console.ReadLine();
    }
}

1
我来澄清一下:我可能甚至不知道第三方库的Dispose抛出了异常(或者我的同事在代码中抛出了异常),因为它被像这样吞掉了(除非我在调试器中启用了一次性异常)。我不喜欢这个;我宁愿使用像@ChrisSinclair在上面的评论中提到的LoggedDisposable包装器,或者从这里中聚合这些异常的DisposableList - noseratio - open to work
7
嗯,不,你总是会得到一个异常。虽然它不是你希望得到的那个,但这并不能消除程序中发生了两件严重的错误这一事实。避免偏袒,烂东西加上烂东西还是烂东西。 - Hans Passant
1
@HansPassant:有些异常代表着事情“严重”出错了。其他异常则代表着预期失败的事情。如果在Dispose中发生了严重错误(例如,尝试写入文件失败),那通常比守卫块中发生的任何事情都更重要,但该异常中包含的信息通常会更少。正确的做法是抛出一个DisposeFailureException,其中封装了所有其他异常,但不幸的是这样做相当困难。 - supercat
@HansPassant,您已经提供了一个很好的解释为什么会发生这种情况,但是您能详细说明如何解决问题本身吗?除了使用代码分析器之外,聚合异常是否有意义,例如这样 - noseratio - open to work
利用异常信息来修复错误。反复执行此过程,直到程序稳定。 - Hans Passant
显示剩余8条评论

3
也许有一些辅助函数可以让你编写类似于using的代码:
 void UsingAndLog<T>(Func<T> creator, Action<T> action) where T:IDisposabe
 {  
      T item = creator();
      try 
      {
         action(item);
      }
      finally
      { 
          try { item.Dispose();}
          catch(Exception ex)
          {
             // Log/pick which one to throw.
          } 
      }      
 }

 UsingAndLog(() => new FileStream(...), item => 
 {
     //code that you'd write inside using 
     item.Write(...);
 });

请注意,我可能不会选择这种方法,而是让Dispose中的异常覆盖普通using中代码抛出的异常。如果库从Dispose中抛出异常,尽管强烈建议不要这样做,那么很有可能它不是唯一的问题,需要重新考虑使用该库的实用性。

@KrisVandermotten:我的意思是“如果应用程序没有调用Close,则抛出异常。”至于“文件无论如何都会损坏”,这并不一定。通常,如果数据采集和记录程序在获取数据时抛出异常,则未提供给记录代码的数据将不可用,但记录代码应尝试完成写出所有可用的内容。至于Dispose在“正常”执行时是否会抛出异常,这个想法是,如果只有在存在待处理异常时才会在没有调用Close的情况下调用Dispose,那么Dispose可以解决... - supercat
@supercat 通过实现 finalizer 可以轻松地设置闹钟。但请注意,这将显著影响垃圾回收。 - Kris Vandermotten
@supercat,使用终结器,您可以知道是否存在需要调查的问题。即使它不能为您进行调查,这听起来对我来说非常有帮助。话虽如此,我开始对您对Dispose应该做什么的想法产生一些怀疑。请查看http://blogs.msdn.com/b/kimhamil/archive/2008/03/15/the-often-non-difference-between-close-and-dispose.aspx以获取更多信息。 - Kris Vandermotten
@KrisVandermotten:从根本上讲,如果一个对象封装了一个文件或流,并且缓冲数据进行写操作,那么就不可能避免该对象接受数据(而不抛出异常),但稍后无法实际写入数据的情况。 如果已经写入文件的代码调用Close,并且Close发现无法写入数据,则让Close抛出异常几乎肯定是指示问题的最佳方式,因为它无法满足其后置条件。 因此,在调用Dispose之前应用程序调用Close是一种很好的模式。 - supercat
@supercat - 注意在using块结束之前(或显式调用Dispose之前)使用Close(或Flush)需要2-3行注释,解释为什么在该特定情况下它如此重要,因为它违反了using的默认模式。请注意,我并不争论这是好还是坏的模式-只是你的建议在代码中看起来会很奇怪(这可能导致其他人通过删除这些调用来“修复代码”)。 - Alexei Levenkov
显示剩余7条评论

3
您所注意到的是Dispose和using的设计中存在的一个根本问题,目前还没有很好的解决方案。在我看来,最好的设计是有一个版本的Dispose,它接收任何可能挂起的异常作为参数(如果没有挂起的异常,则为null),并且如果需要抛出自己的异常,则可以记录或封装该异常。否则,如果您同时控制可能在using内部引发异常的代码以及Dispose内部的代码,那么您可以使用某种外部数据通道来让Dispose了解内部异常,但这种方式不太好。
很遗憾,没有适当的语言支持与finally块相关联的代码(无论是显式还是隐式地通过using)知道关联的try是否完成正确,如果没有完成,则出了什么问题。认为Dispose应该默默地失败是非常危险和错误的。如果一个对象封装了一个打开写入文件的文件,并且Dispose关闭了文件(一种常见模式),而数据无法写入,则使Dispose调用正常返回会使调用代码相信数据已正确写入,从而可能导致覆盖唯一的好备份。此外,如果文件应该显式关闭,并且在没有关闭文件的情况下调用Dispose应该被视为错误,则意味着如果受保护的块否则将正常完成,则Dispose应该抛出异常,但是如果受保护的块未能调用Close,因为首先出现了异常,则使Dispose抛出异常将是没有帮助的。
如果性能不是关键问题,您可以在VB.NET中编写一个包装器方法,该方法将接受两个委托(类型为Action和Action),在try块内调用第一个委托,然后在finally块中使用发生在try块中的异常(如果有)调用第二个委托。如果包装器方法是用VB.NET编写的,则可以在不必捕获和重新抛出它的情况下发现并报告发生的异常。还有其他可能的模式。大多数包装器的用法都涉及到闭包,这很棘手,但是包装器至少可以实现正确的语义。
另一种替代包装器设计可以避免闭包,但需要客户端正确使用,并且提供很少的保护以防止不正确的使用,其用法如下:
var dispRes = new DisposeResult();
... 
try
{
  .. the following could be in some nested routine which took dispRes as a parameter
  using (dispWrap = new DisposeWrap(dispRes, ... other disposable resources)
  {
    ...
  }
}
catch (...)
{
}
finally
{
}
if (dispRes.Exception != null)
  ... handle cleanup failures here

这种方法的问题在于,没有办法确保任何人都会评估 dispRes.Exception。可以使用终结器来记录 dispRes 被抛弃且未被检查的情况,但无法区分出现这种情况是因为异常使得代码跳出 if 测试之外,还是程序员简单地忘记了检查。

PS--当 IDisposable 对象用于包装锁或其他范围时,对象不变量可能暂时无效,但预计在代码离开范围之前将被恢复时,Dispose 真正需要知道是否发生异常的另一种情况。如果发生异常,代码通常不应期望解决异常,但仍应基于它采取行动,使锁既不保持也不释放,而是使其无效,以便任何当前或将来尝试获取它的尝试都会引发异常。如果没有将来获取锁或其他资源的尝试,则使其无效不应破坏系统操作。如果该资源对程序的某个部分至关重要,则使其无效将导致该程序部分死亡,同时最小化它对其他任何内容造成的损害。我所知道的真正实现这种情况并具有良好语义的唯一方法是使用棘手的闭包。否则,唯一的选择是要求明确的 invalidte/validate 调用,并希望在资源无效的代码部分中任何返回语句都在调用 validate 之前。

我认为,这是处理异常最灵活的方式,可能在Dispose中抛出,当它作为using模式的一部分自动调用时。 - noseratio - open to work
1
@Noseratio:我对这种方法最大的抱怨是编程语言没有提供任何帮助来确保即使在try块内执行了return语句,也会调用检查清理失败的代码。 try/finally将导致在这种情况下执行该代码,但如果发生异常(这是想要的),也会执行,但如果有返回,则不会执行其他构造。 - supercat

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