在C#中,try、throw和catch块会对性能产生多大的影响?

15

首先,免责声明:我在其他语言方面有经验,但仍在学习C#的细微差别。

接下来讲问题... 我正在查看一些代码,它使用try/catch块的方式让我感到担忧。当调用解析例程时,程序员没有返回错误代码,而是使用了以下逻辑:

catch (TclException e) {
  throw new TclRuntimeError("unexpected TclException: " + e.Message,e);
}  

这个错误被调用者捕获并抛出相同的错误...
...由调用者捕获,抛出相同的错误...
...以此类推,直到回溯约6层。

我是否正确地认为所有这些catch/throw块都会导致性能问题,还是这是在C#下合理的实现?


1
这里显示的 catch 块确实向异常对象添加了额外信息,这可能证明它的存在是有道理的。但如果 catch 块是无用的,请将其删除。 - Qwertie
8个回答

16

无论使用何种语言,这都是一种糟糕的设计。

异常被设计成在你可以处理它们的层次上捕获。仅仅为了再次抛出异常是完全没有意义的浪费时间(这还会导致丢失有关原始错误位置的宝贵信息)。

显然,编写该代码的人曾经使用错误代码,然后转而使用异常,但实际上并不理解它们的工作原理。如果某个级别没有catch,那么异常会自动“冒泡”到栈上方。

此外,请注意,异常仅用于非常规情况,即永远不应该发生的事情。它们不应该用于正常有效性检查的替代(例如,不要捕获除以零的异常;先检查除数是否为零)。


在问题提供的示例中,原始异常被传递为innerException参数到TclRuntimeError构造函数中,因此没有丢失任何原始信息。 - yfeldblum
这是正确的。然而,实际的异常被埋藏在抛出给用户的异常中的第六层深处。 - Christian Klauser
2
通常应该只在抽象层次上包装异常,并且即使这样做,也仅当您将提供有关错误的附加信息时才这样做。例如,在包装第三方组件时,可以包装任何奇怪的异常(IndexOutOfBounds->KeyNotFound)。 - Guvante
异常并不是用来处理永远不会发生的事情,而是用来处理罕见错误条件的。尽管它们比正常控制流要慢,但每秒钟只有几个异常不会造成性能问题(除非你在调试器中运行)。 - Qwertie

15

2
你通过那个链接帮我省去了不少和同事争辩的时间 :) 如果你不想麻烦查看链接,这里是文章中的一个直接引用:“寻找并设计避免异常处理的代码可以带来相当不错的性能提升。请记住,这与try/catch块无关:只有在实际引发异常时才会产生成本。你可以使用任意多个try/catch块。滥用异常将导致性能下降。例如,你应该远离像是为控制流程而使用异常的做法。” - kayleeFrye_onDeck
1
@kayleeFrye_onDeck 是的,你可以尽可能多地使用try和catch而不会出现任何问题。唯一的问题是如果在程序的常规流程中使用了throw场景。 - Avram

12

抛出异常(而不是捕获)是昂贵的。

除非你要做一些有用的事情(例如将其转换为更有用的异常、处理错误),否则不要放置一个 catch 块。

仅重新抛出异常(不带参数的 throw 语句),甚至更糟的是,抛出与刚刚捕获的相同的对象,肯定是错误的做法。

编辑:为避免歧义:

重新抛出:

catch (SomeException) {
  throw;
}

从先前的异常对象创建异常,其中所有运行时提供的状态(特别是堆栈跟踪)都被覆盖:

catch (SomeException e) {
  throw e;
}

后一种情况是一种毫无意义的丢弃异常信息的方式。而在 catch 块中抛出异常之前没有任何内容更是没有意义的。这可能会更糟:

catch (SomeException e) {
  throw new SomeException(e.Message);
}

这会丢失几乎所有有用的状态信息,包括最初抛出的内容。


做一些有用的事情是否包括在重新抛出错误之前记录日志,或者最好在其他地方(例如在应用程序错误处理中)处理? - Nick
重新抛出异常并不会带来太大的开销,因为这并不涉及创建异常对象(涉及到堆栈的回退)。 - Christian Klauser
但在每个级别,他们不是在展开堆栈吗? - DevinB
在这种情况下,它们是抛出新异常,因此需要使用。如果他们只是使用“throw;”,那么就不会在每个级别上解开堆栈。在记录日志时,应该使用它。 - configurator
当“throw”传递一个异常实例时,它会向其添加新信息。程序员错误地假设“throw”可以区分新的异常实例和已使用的异常实例。由于没有区别,重新抛出的唯一方法是根本不传递异常实例。 - Triynko
显示剩余2条评论

2

本身并不可怕,但需要注意嵌套的层数。

可以接受的使用情况如下:

我是一个较低级别的组件,可能会遇到多种不同的错误,但我的消费者只关心特定类型的异常。因此,我可以这样做:

catch(IOException ex)
{
    throw new PlatformException("some additional context", ex);
}

现在这个功能允许用户做到以下几点:
try
{
    component.TryThing();
}
catch(PlatformException ex)
{
   // handle error
}

是的,我知道有些人会说消费者应该捕获IOException,但这取决于使用代码的抽象程度。如果实现正在保存某些内容到磁盘上,并且消费者没有充分的理由认为他们的操作将触及磁盘,那么在这种情况下,在消费者代码中放置此异常处理就毫无意义。
通常,我们使用此模式时要避免在业务逻辑代码中放置“捕获所有”异常处理程序,因为我们想找出所有可能的异常类型,因为它们可能导致需要调查的更根本的问题。如果我们不捕获异常,它会向上冒泡,触及“顶部”的处理程序并应该停止应用程序继续运行。这意味着客户端将报告该异常,并且您将有机会进行调查。当您尝试构建强大的软件时,这非常重要。您需要找到所有这些错误情况并编写特定的代码来处理它们。
不太美观的是嵌套这些异常过多,这是您应该使用此代码解决的问题。
正如另一位发帖者所述,异常用于合理的异常行为,但不要走得太远。基本上,代码应表达“正常”操作,异常应处理您可能遇到的潜在问题。
在性能方面,异常很好,如果您使用调试器对嵌入式设备进行性能测试,则会得到可怕的结果,但是在没有调试器的情况下发布时,它们实际上非常快。
当讨论异常性能时,人们经常忽略的主要事项是,在错误情况下,一切都会变慢,因为用户遇到了问题。当网络关闭并且用户无法保存其工作时,我们真的关心速度吗?我非常怀疑在几毫秒内更快地将错误报告返回给用户是否会有所不同。
讨论异常时要记住的主要准则是:异常不应在正常应用程序流程中发生(正常意味着没有错误)。其他所有内容都源于该语句。
在您提供的确切示例中,我不确定。在我的看来,从一个听起来很普通的tcl异常中包装另一个听起来很普通的tcl异常并没有真正获得任何好处。如果有什么区别,我建议追踪代码的原始创建者并了解他的想法是否有任何特定的逻辑。不过,可能只需删除catch即可。

通常情况下,您确实关心性能 - 在服务器上,如果您仅有1%的时间抛出异常,但每秒处理100个调用,超过100个用户 - 那就是每秒100个异常!这有时可能会对性能造成严重影响。 - gbjbaanb

2
一般来说,在.NET中抛出异常是很昂贵的。只有try/catch/finally块并没有问题。所以,是的,现有代码在性能上很差,因为当它确实抛出异常时,它会抛出5-6个膨胀的异常,而不会比让原始异常自然地向上冒5-6个堆栈帧更有价值。
更糟糕的是,从设计的角度来看,现有代码真的非常糟糕。异常处理的主要好处之一(与返回错误代码相比)是您不需要在调用栈的每个位置都检查异常/返回代码。你只需要在你真正想要处理它们的少数几个地方捕捉它们。忽略异常(不像忽略返回代码)并不忽略或隐藏问题。这意味着它将在调用栈较高的位置处理。

显然这一切都需要被编写。但作为第一步,是否可以进行简单的改进,例如将catch (TclException e){throw e;}更改为catch (TclException e){throw;},以便允许异常向堆栈上传递? - Noah
1
是的,但这比那更容易。只需删除整个catch(TclException e)块即可。如果您不捕获异常,它将自行冒泡到调用堆栈中。 - C. Dragon 76

1

Try / Catch / Throw 是比较慢的 - 更好的实现方式是在捕获异常之前先检查其值,但如果你绝对无法继续执行,最好的方法是只在必要时抛出和捕获异常。否则,检查和记录日志会更有效率。


1
try-block本身几乎没有额外开销,而是异常情况(抛出/捕获)会拖慢系统速度。 - Christian Klauser
正确 - 他的例子中,使用try/catch会更慢,因为它们会导致抛出异常,而这可能可以通过检查然后执行其他操作来避免不断地抛出异常。 - Fry

0

如果每个堆栈层只是以相同的信息和相同的类型重新抛出异常,而没有添加任何新内容,那么这完全是荒谬的。

如果这种情况发生在独立开发的库之间的边界上,那就可以理解了。有时库的作者想要控制从他们的库中逃逸的异常,以便他们以后可以更改实现,而不必弄清楚如何模拟先前版本的异常抛出行为。

在任何情况下捕获并重新抛出都是一个不好的主意,除非有非常好的理由。这是因为一旦找到一个catch块,所有在throw和catch之间的finally块都会被执行。只有当异常可以恢复时才应该发生这种情况。在这种情况下,这是可以接受的,因为特定类型被捕获,所以代码的作者(希望如此)知道他们可以安全地撤消对特定异常类型的任何内部状态更改。

因此,这些try/catch块在设计时可能会产生成本-它们使程序变得混乱。但在运行时,只有在抛出异常时才会产生显着的成本,因为将异常传输到堆栈上变得更加复杂。


-1

例外处理很慢,请尽量不要使用。

请看我这里给出的答案。

基本上,CLR团队的Chris Brumme说它们是作为SEH异常实现的,所以当它们被抛出时会受到很大的打击,并且必须承受它们通过操作系统堆栈冒泡所产生的惩罚。这是一篇优秀文章,深入探讨了抛出异常时发生的事情。例如:


当然,异常最大的成本是在实际抛出异常时。我会在博客的结尾回到这个话题。
性能。当您实际抛出和捕获异常时,异常会直接产生成本。它们可能还与在方法入口处推送处理程序相关的间接成本有关。它们通常会通过限制代码生成机会而具有隐匿的成本。
然而,异常在长期性能方面存在严重的问题,这必须考虑进你的决策中。考虑一下当抛出异常时会发生什么:解释编译器发出的元数据以引导我们的堆栈展开来获取堆栈跟踪信息;沿着堆栈遍历调用每个处理程序两次;对不匹配的SEH、C++和托管异常进行补偿;分配托管异常实例并运行其构造函数,最可能的情况是查找各种错误消息的资源;可能通过操作系统内核,通常会出现硬件异常;通知任何附加的调试器、分析器、向量化异常处理程序和其他有兴趣的实体。这与从你的函数调用中返回-1相去甚远。 异常本质上是非本地的,如果现代架构存在明显的并持久的趋势,那就是为了实现良好的性能,必须保持本地化。

有些人会声称异常不是问题,没有性能问题,通常是好事。这些人得到了很多的支持,但他们是错的。我曾经看到微软员工做出同样的声明(通常具有营销部门通常保留的技术知识),但从内部人士的角度来看,要谨慎使用。

古老的格言“异常只应用于特殊情况”对C#和其他任何语言都是正确的。


“尽量不要使用它们”?这是对Brumme建议的严重歪曲。 - Daniel Earwicker
不,这是我的建议,并且是一种概括,让人们思考他们正在做什么。Chris建议使用它们,但非常节制:“…通过确保错误情况极为罕见” - gbjbaanb
那么......您建议检查值而不是随意使用异常,因此您的建议更像是“尽量不要创建异常”,而不是“尽量不要使用异常”? - Fry
尽量不要使用异常,确切地说,我并没有说“不要使用异常处理”。 - gbjbaanb
我会重新表述“异常很慢,请尽量避免使用它们。”为“滥用异常的代码很糟糕,不要这样做。不要将异常用于正常程序流程。对于真正的异常情况,谁会在意速度?” - Daniel Daranas

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