重新抛出时堆栈跟踪不正确

43

我用 "throw;" 重新抛出异常,但是堆栈跟踪不正确:

static void Main(string[] args) {
    try {
        try {
            throw new Exception("Test"); //Line 12
        }
        catch (Exception ex) {
            throw; //Line 15
        }
    }
    catch (Exception ex) {
        System.Diagnostics.Debug.Write(ex.ToString());
    }
    Console.ReadKey();
}
正确的堆栈跟踪应该是:
System.Exception: Test
   at ConsoleApplication1.Program.Main(String[] args) in Program.cs:Line 12
但我得到的是:
System.Exception: Test
   at ConsoleApplication1.Program.Main(String[] args) in Program.cs:Line 15
但是第15行是 "throw;" 的位置。我已经使用 .NET 3.5 进行了测试。

4
我认为将异常作为innerexception添加并抛出自己的异常不会更加丑陋或冗长。对我来说,这是最佳实践。 - Pabuc
2
@acid:这正是我所想的。我曾经把这个规则(使用throw)记录在石头上...现在是时候改变了。 - Ignacio Soler Garcia
@Andomar:谢谢。我也是这么想,但作为提问者,我不想说什么。 - Ignacio Soler Garcia
没有人提到这一点,但是Mono在这方面做得很正确(在Linux上进行了测试。我没有安装Mono Windows版)。 - user34537
显示剩余8条评论
12个回答

27

在同一个方法中抛出两次异常可能是一种特殊情况 - 我没能够创建出在同一个方法中不同行紧接着发生的堆栈跟踪。正如其名称所示,“堆栈跟踪”显示了异常遍历的堆栈帧,而每个方法调用只有一个堆栈帧!

如果你从另一个方法中抛出异常,则throw;不会像预期的那样移除Foo()的条目:

  static void Main(string[] args)
  {
     try
     {
        Rethrower();
     }
     catch (Exception ex)
     {
        Console.Write(ex.ToString());
     }
     Console.ReadKey();
  }

  static void Rethrower()
  {
     try
     {
        Foo();
     }
     catch (Exception ex)
     {
        throw;
     }

  }

  static void Foo()
  {
     throw new Exception("Test"); 
  }
如果您修改 Rethrower() 并将 throw; 替换为 throw ex;,则堆栈跟踪中的 Foo() 条目将消失。同样,这是预期的行为。

这是一个解释。重写函数并不总是一个选项。如果您有一个名为“CreatePerson”的WCF函数,并且多次调用像“ValidParamLength(string s, uint minlength, uint maxlength)”这样的函数,而此函数引发异常,则必须构建一个内部函数,以便“CreatePerson”函数调用“_internalCreatePerson”函数来反映这一点。如果我这样做,我会得到抛出的位置,我不知道哪些字段验证失败了。 - Floyd
一个例子: void CreatePerson(Person p) { try { 验证参数长度(p.name, 1, 50); 验证参数长度(p.vname, 1, 50); 验证参数长度(p.street, 5, 50); 验证参数长度(p.zip, 5, 5); 验证参数长度(p.location, 5, 30); 验证参数通过数据库(p.location, "tblLocations", "a_Location"); } catch (Exception ex) { System.Diagnostics.Debug.Write(ex.ToString()); throw; } } - Floyd
@Floyd:通常情况下,您需要为每个数据部分设置不同的验证规则:例如ValidateName(name)ValidateZip(zip)等。然后在ValidateZip内部调用ValidParamLength(zip, 5, 5) - Wim Coenen
2
有一篇文章解释了如何摆脱这种特殊情况:http://weblogs.asp.net/fmarguerie/archive/2008/01/02/rethrowing-exceptions-and-preserving-the-full-call-stack-trace.aspx - redtuna
1
@redtuna:很有趣,但博客文章的作者未能警告称调用框架类的内部成员可能会在任何.NET升级中出现问题。如果您的代码在没有反射权限的情况下运行,它也无法正常工作。 - Wim Coenen

26

这可以被认为是一种预期的情况。如果您指定throw ex;,修改堆栈跟踪是通常的情况,FxCop会通知您堆栈已经被修改。如果您使用throw;,则不会生成警告,但仍将修改跟踪。因此,很遗憾目前最好不要捕获异常或将其作为内部异常抛出。 我认为这应该被视为Windows影响或类似于此的东西-编辑过的。 在他的"CLR via C#" 中,Jeff Richter更详细地描述了这种情况:

以下代码将抛出相同的异常对象,它捕获并导致CLR重置其异常处理的起始点:

private void SomeMethod() {
  try { ... }
  catch (Exception e) {
    ...
    throw e; // CLR thinks this is where exception originated.
    // FxCop reports this as an error
  }
}

相比之下,如果你只使用throw关键字重新抛出异常对象,CLR不会重置堆栈的起始点。以下代码重新抛出捕获的相同异常对象,导致CLR不重置异常的起始点:

private void SomeMethod() {
  try { ... }
  catch (Exception e) {
    ...
    throw; // This has no effect on where the CLR thinks the exception
    // originated. FxCop does NOT report this as an error
  }
}

事实上,这两个代码片段之间唯一的区别是CLR认为异常被抛出的原始位置。不幸的是,当你抛出或重新抛出异常时,Windows会重置堆栈的起始点。因此,如果异常变得无法处理,向Windows错误报告的报告的堆栈位置是最后一个throw或rethrow的位置,即使CLR知道原始异常被抛出的堆栈位置。这很不幸,因为它使得调试在现场失败的应用程序更加困难。一些开发者发现这样做是不能容忍的,他们选择了不同的方法来实现他们的代码,以确保堆栈跟踪真正反映了异常最初被抛出的位置:

private void SomeMethod() {
  Boolean trySucceeds = false;
  try {
    ...
    trySucceeds = true;
  }
  finally {
    if (!trySucceeds) { /* catch code goes in here */ }
  }
}

好的,我会考虑你提到的方法。至少这是一个不同的方法。非常感谢。 - Ignacio Soler Garcia
VB.net 允许在 finally 块运行之前,以及决定是否捕获异常之前,对异常进行操作和检查异常对象。这在调试时特别有用,因为导致异常的对象状态可能仍然存在于“未处理异常”调试器陷阱触发时。太遗憾了,C# 不能做到这一点。 - supercat
@supercat:你是在说when语句吗? - Ignacio Soler Garcia
@SoMoS:是的。在"When"语句中需要谨慎使用,因为可能会出现"When"标记表示应该捕获异常但在Catch执行之前异常消失的情况(例如,如果嵌套的Finally子句引发异常并且该异常被捕获)。 - supercat
是的,我喜欢 Vb.Net 中的 when。它使 catch 处理程序变得更短。我认为这应该移植到 C#。 - Ignacio Soler Garcia
1
你在代码中写道:“这对CLR认为异常起源的位置没有影响。”实际上,如果异常是在a()方法中抛出的,并且您有多个对该方法的调用,则会知道a()中哪一行引发了异常,但是您将“不会”知道哪个调用导致了问题。这是因为堆栈跟踪将指向“throw;”行而不是调用行。我不再使用“throw;”语句,而是重新抛出一个指定原因的新异常。 - cquezel

20

这是Windows版本CLR中众所周知的限制。它使用Windows内置的异常处理(SEH)。问题在于,它是基于栈帧的,一个方法只有一个栈帧。您可以通过将内部try/catch块移动到另一个辅助方法中来轻松解决此问题,从而创建另一个栈帧。该限制的另一个后果是JIT编译器不会内联包含try语句的任何方法。


10

如何保留真实的堆栈跟踪信息?

抛出一个新的异常,并将原始异常作为内部异常包含在其中。

但那样太丑了...更长了...你还需要选择正确的异常去抛出....

你关于“丑陋”的观点是错误的,但其他两点是正确的。一个经验法则是:除非你打算对它做些什么,比如包装它、修改它、吞噬它或记录它,否则不要进行捕获。如果你决定 catch 然后再 throw,请确保你正在对其进行处理,否则就让它自然抛出。

你可能也会想放一个 catch 只是为了在 catch 中打断点,但是 Visual Studio 调试器有足够的选项使得这种做法是不必要的,可以尝试使用首次机会异常或条件断点来代替。


2
当然,我只在需要时使用catch。最让我担心的是,这在我的工作中是最佳实践(使用throw),很难改变... :\ - Ignacio Soler Garcia

7

编辑/替换

实际上,这种行为是不同的,但微妙的不同之处。至于为什么行为不同,我需要请CLR专家来解答。

编辑:AlexD的答案似乎表明这是有意设计的。

在同一个方法中抛出异常并捕获它会使情况变得有些混乱,因此让我们从另一个方法中抛出异常:

class Program
{
    static void Main(string[] args)
    {
        try
        {
            Throw();
        }
        catch (Exception ex)
        {
            throw ex;
        }
    }

    public static void Throw()
    {
        int a = 0;
        int b = 10 / a;
    }
}

如果使用throw;,则调用堆栈如下(行号已替换为代码):
at Throw():line (int b = 10 / a;)
at Main():line (throw;) // This has been modified

如果使用throw ex;,调用堆栈如下:
at Main():line (throw ex;)

如果异常未被捕获,调用堆栈如下:
at Throw():line (int b = 10 / a;)
at Main():line (Throw())

在.NET 4 / VS 2010中进行了测试


3
这是错误的,当你使用throw时,它没有原始的堆栈跟踪。你只能看到抛出异常的那一行代码,无法在堆栈跟踪中找到int a=10/0这一行的引用。 - Ignacio Soler Garcia
@SoMoS - 看起来与异常抛出的位置有关,请查看我的更新。 - Richard Szalay
嗯,我认为你错了。我有相同结果的两种情况。你能用演示检查一下吗? - Ignacio Soler Garcia
看起来你是对的,但它并没有“重新生成”堆栈跟踪;它是修改了堆栈跟踪中“Main()”行的行号,使其成为重新抛出点。区别在于“Throw()”的调用仍然在堆栈跟踪中“Main()”之上。如果你调用throw ex,那么“Main()”将成为堆栈跟踪的顶部。 - Richard Szalay
你的解释之所以值得赞赏,是因为它能够将 OP 观察到的行为过滤到一个非常具体的场景中,在这个场景中,只有当出错的行在同一个方法中时,才会出现 OP 所提到的行为。然而,以 OP 提到的确切示例为例,OP 描述的行为似乎确实是针对那个非常特定的场景的一个 bug。 - Jagmag
1
@InSane - 其实,我认为场景是一样的,只是当异常从被调用的方法抛出时更为明显。AlexD的回答似乎表明,帧修改是rethrow MSIL指令设计的结果。 - Richard Szalay

5

这里有一个重复的问题

据我所知,throw;被编译成'rethrow' MSIL指令,并修改了堆栈跟踪的最后一帧。

我原本以为它会保留原始的堆栈跟踪,并添加它重新抛出的行,但显然每个方法调用只能有一个堆栈帧

结论:避免使用throw;并在重新抛出时包装您的异常-这不是丑陋的,而是最佳实践。


5
您可以使用以下方法保留堆栈跟踪:
ExceptionDispatchInfo.Capture(ex);

以下是代码示例:

    static void CallAndThrow()
    {
        throw new ApplicationException("Test app ex", new Exception("Test inner ex"));
    }

    static void Main(string[] args)
    {
        try
        {
            try
            {
                try
                {
                    CallAndThrow();
                }
                catch (Exception ex)
                {
                    var dispatchException = ExceptionDispatchInfo.Capture(ex);

                    // rollback tran, etc

                    dispatchException.Throw();
                }
            }
            catch (Exception ex)
            {
                var dispatchException = ExceptionDispatchInfo.Capture(ex);

                // other rollbacks

                dispatchException.Throw();
            }
        }
        catch (Exception ex)
        {
            Console.WriteLine(ex.Message);
            Console.WriteLine(ex.InnerException.Message);
            Console.WriteLine(ex.StackTrace);
        }

        Console.ReadLine();
    }

输出结果将类似于:
测试应用程序异常
测试内部异常
   在 D:\Projects\TestApp\TestApp\Program.cs 的 TestApp.Program.CallAndThrow() 中的第 19 行
   在 D:\Projects\TestApp\TestApp\Program.cs 的 TestApp.Program.Main(String[] args) 中的第 30 行
--- 前一个引发异常的位置的堆栈跟踪结尾 ---
   在 System.Runtime.ExceptionServices.ExceptionDispatchInfo.Throw()
   在 D:\Projects\TestApp\TestApp\Program.cs 的 TestApp.Program.Main(String[] args) 中的第 38 行
--- 前一个引发异常的位置的堆栈跟踪结尾 ---
   在 System.Runtime.ExceptionServices.ExceptionDispatchInfo.Throw()
   在 D:\Projects\TestApp\TestApp\Program.cs 的 TestApp.Program.Main(String[] args) 中的第 47 行

ExceptionDispatchInfo 仅在 .NET Framework 4 及以上版本中可用。 - jirkamat
上次我检查的时候是.NET 4.5版本 - https://learn.microsoft.com/en-us/dotnet/api/system.runtime.exceptionservices.exceptiondispatchinfo.capture?view=netframework-4.5 - aderesh

3

好的,.NET Framework 中似乎存在一个 bug,如果你在同一个方法中抛出异常并重新抛出它,原始行号会丢失(它将成为该方法的最后一行)。

幸运的是,一位名叫 Fabrice MARGUERIE 的聪明人发现了解决方案来解决这个 bug。下面是我的版本,您可以在这个 .NET Fiddle上测试。

private static void RethrowExceptionButPreserveStackTrace(Exception exception)
{
    System.Reflection.MethodInfo preserveStackTrace = typeof(Exception).GetMethod("InternalPreserveStackTrace",
      System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic);
    preserveStackTrace.Invoke(exception, null);
      throw exception;
}

现在像平常一样捕获异常,但是不要使用throw语句,只需调用这个方法,原始的行号就会被保留下来!


对我来说不起作用。除了将此方法添加到堆栈中,我没有看到堆栈跟踪中的任何显着差异。异常的原始行号仍然未在堆栈跟踪中显示。 - NickG

2

不确定这是否是设计上的问题,但我认为它一直都是这样的。

如果原始的throw new Exception在一个单独的方法中,则throw的结果应该包含原始方法名称和行号,以及在主方法中重新抛出异常的行号。

如果使用throw ex,则结果将仅为在主方法中重新抛出异常的行号。

换句话说,throw ex丢失了全部堆栈跟踪,而throw保留了堆栈跟踪历史记录(即低级方法的详细信息)。但如果您的异常是由与重抛相同的方法生成的,则可能会丢失一些信息。

请注意。如果您编写一个非常简单和小的测试程序,框架有时可以优化代码并将方法更改为内联代码,这意味着结果可能与“真实”程序不同。


无论如何,重抛异常的行不幸仍然存在,而不是调用函数时写入堆栈跟踪的行。 - Floyd

1

想要正确的行号吗?只需在每个方法中使用一个try/catch。在系统中,嗯...仅在UI层中,而不是逻辑或数据访问中,这非常烦人,因为如果您需要数据库事务,那么它们不应该在UI层中,并且您将无法获得正确的行号,但是如果您不需要它们,请勿在catch中重新抛出异常...

5分钟示例代码:

菜单文件->新建项目,放置三个按钮,并在每个按钮中调用以下代码:

private void button1_Click(object sender, EventArgs e)
{
    try
    {
        Class1.testWithoutTC();
    }
    catch (Exception ex)
    {
        MessageBox.Show(ex.Message + Environment.NewLine + ex.StackTrace + Environment.NewLine + Environment.NewLine + "In. Ex.: " + ex.InnerException);
    }
}

private void button2_Click(object sender, EventArgs e)
{
    try
    {
        Class1.testWithTC1();
    }
    catch (Exception ex)
    {
            MessageBox.Show(ex.Message + Environment.NewLine + ex.StackTrace + Environment.NewLine + Environment.NewLine + "In. Ex.: " + ex.InnerException);
    }
}

private void button3_Click(object sender, EventArgs e)
{
    try
    {
        Class1.testWithTC2();
    }
    catch (Exception ex)
    {
        MessageBox.Show(ex.Message + Environment.NewLine + ex.StackTrace + Environment.NewLine + Environment.NewLine + "In. Ex.: " + ex.InnerException);
    }
}

现在,创建一个新的类:

class Class1
{
    public int a;
    public static void testWithoutTC()
    {
        Class1 obj = null;
        obj.a = 1;
    }
    public static void testWithTC1()
    {
        try
        {
            Class1 obj = null;
            obj.a = 1;
        }
        catch
        {
            throw;
        }
    }
    public static void testWithTC2()
    {
        try
        {
            Class1 obj = null;
            obj.a = 1;
        }
        catch (Exception ex)
        {
            throw ex;
        }
    }
}

运行...第一个按钮很漂亮!


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