实时环境中异常仍然不可取吗?

45
几年前我被教导,在实时应用程序中,例如嵌入式系统或(非Linux)内核开发中,C++异常是不可取的。(也许那时候是在gcc-2.95之前学到的)。但我也知道,异常处理变得更好了。
那么,在实时应用程序的背景下,C++异常的实践情况如何呢?
  • 完全不需要吗?
  • 甚至可以通过编译器开关关闭吗?
  • 还是需要非常小心地使用?
  • 或者现在处理得很好,可以几乎自由地使用,只需注意一些事项?
  • C++11对此有什么改变吗?
更新:异常处理是否真的需要启用RTTI(如一个回答者所建议的)?是否涉及动态转换或类似的操作?

7
@Chris,C++在这些环境下有哪些问题?我正在使用C++来开发嵌入式系统,效果很好。 - BЈовић
2
@RedX:在实时环境下,C++是完全可以胜任的(除非实时要求真的非常极端),只要你小心处理时间关键部分所做的事情(就像在任何语言中一样)。真正“幕后发生”的事情只有构造函数、析构函数和运算符重载,只要不对性能关键类做任何奇怪的事情,就可以轻松掌控它们。 - Mike Seymour
2
通过异常处理进行错误处理意味着无法证明代码覆盖率。内核(而不仅仅是嵌入式或实时)开发需要代码放置 - C++的隐式生成代码结构无法显式放置。内核开发再次出现硬件异常绝不能抛出的情况,因此在硬件异常上实现软件异常是行不通的。嵌入式开发还有C++内存模型不方便的内存条件。 - Chris Becke
1
@chris:我也认为许多C++特性可以并且应该用于嵌入式系统。但是需要额外小心一些事项,例如使new符合你的要求,小心异常处理(可能需要),等等。 - towi
2
@Lundin:这有点偏题了,我不会花钱去发现为什么MISRA认为需要将C++限制为子集,或者那个子集是什么。但我确实不同意你所谓的遵守编码标准和在混乱中工作之间的选择。编码指南可以很有用(例如,“优先使用RAII而不是手动资源管理”,而不是“把这个大括号放在这里,而不是那里”),但它们不能替代对语言和问题领域的理解以及生产干净、可维护代码的愿望。对我来说,这些是专业人士的标志。 - Mike Seymour
显示剩余13条评论
7个回答

24

异常现在被很好地处理了,而且用于实现它们的策略使它们实际上比测试返回代码更快,因为它们的成本(以速度衡量)几乎为零,只要您不抛出任何异常。

但是它们确实会花费:在代码大小方面。异常通常与RTTI一起使用,不幸的是,RTTI与其他C++功能不同,在整个项目中启用或禁用,一旦启用,它将为任何具有虚拟方法的类生成补充代码,从而违反“你不为你不使用的付费”思维方式。

此外,它确实需要补充代码来处理。

因此,异常的成本应该根据代码增长而不是速度测量。

编辑

来自@Space_C0wb0y博客文章提供了一个小概述,并介绍了两种广泛使用的实现异常的方法——跳转和零成本。正如名称所示,现在好的编译器使用零成本机制。

维基百科关于异常处理的文章讨论了所使用的两种机制。零成本机制是表驱动机制。

编辑

来自@Vlad Lazarenko,他的博客我在上面引用过,可能会因为存在抛出异常而阻止编译器将代码内联和优化到寄存器中。


2
我知道两种典型的方式来为潜在的异常做“设置”(大致如此):我觉得,一种是需要空间,另一种是需要运行时的时间。即使没有发生异常。 - towi
1
@VJo:你错了 :) 这是旧的做法,但现在编译器使用另一种策略,使异常传播变慢,但在没有抛出异常的情况下不会引入开销。我将不要脸地借用@Space_C0wb0y的链接添加一些参考资料。 - Matthieu M.
1
@Matthieu 不可能没有至少最小的开销。检查实际发生的情况的唯一方法是将示例编译为汇编代码。 - BЈовић
2
@VJo:表驱动方法基于程序计数器(http://en.wikipedia.org/wiki/Program_counter),虽然它在技术上是一种开销,但无论如何都已经支付了。当异常被抛出时,计数器的值会在表中查找相应的处理程序。因此,在运行时您不需要设置任何东西,但表确实会占用空间(尽管是只读的,并在编译期间预计算)。 - Matthieu M.
1
@VJo:这篇文章https://db.usenix.org/events/wiess2000/full_papers/dinechin/dinechin.pdf在2.2节详细介绍了表驱动方法的内部工作原理,然后总结了其缺点。不过我还没有读完剩下的部分 :) - Matthieu M.
显示剩余5条评论

11

回答更新部分:

异常处理是否确实需要启用RTTI?

在某些方面,异常处理实际上需要比RTTI和动态转换更强大的功能。考虑以下代码:

try {
    some_function_in_another_TU();
} catch (const int &i) {
} catch (const std::logic_error &e) {}

因此,当另一个TU中的函数抛出异常时,它将在堆栈中查找(即立即检查所有级别还是在堆栈展开期间逐个检查每个级别,这取决于实现)与被抛出对象匹配的catch子句。

为了执行此匹配,它可能不需要存储每个对象类型的RTTI方面,因为抛出异常的类型是throw表达式的静态类型。但是它确实需要以instanceof方式在运行时比较类型,并且需要在运行时执行此操作,因为some_function_in_another_TU可以从任何地方调用,并带有堆栈上任意类型的catch。与dynamic_cast不同,它需要对没有虚成员函数的类型以及对于那些不是类类型的类型执行此运行时instanceof检查。最后一部分并不增加难度,因为非类类型没有层次结构,所以只需要类型相等,但仍然需要可以在运行时进行比较的类型标识符。

因此,如果启用异常,则需要RTTI的比较类型的部分,例如dynamic_cast的类型比较,但覆盖更多类型。您不一定需要存储用于在每个类的vtable中执行此比较的数据的RTTI部分,在那里可以从对象访问该数据 - 该数据可以只在每个throw表达式和每个catch子句的点上进行编码。但我怀疑这不是一个重要的节省,因为typeid对象并不是非常庞大,它们包含一个通常在符号表中需要的名称,以及一些实现定义的数据来描述类型层次结构。因此,到那时可能最好拥有整个RTTI。


谢谢,这是一个非常深入的解释。我会仔细考虑。虽然我需要复习一下dynamic_cast不需要RTTI等内容,但我会让它定案并加以梳理:typeid()的作用,dynamic_cast的作用,以及vtable中存储了什么,何时以及如何进行静态类型匹配,以及是否需要这些内容来处理异常。 - towi
“为了执行此匹配,可能不需要存储每个对象类型的RTTI方面。” 换句话说,您不需要使用 typeid(object),但需要使用 typeid(type) - curiousguy

8
异常处理的问题不在于速度(取决于实现方式,速度可能相差很大),而是它们的实际作用。
在实时世界中,当您对操作有时间限制时,您需要确切地知道您的代码在做什么。异常提供了捷径,但可能会影响代码的总运行时间(例如,异常处理程序可能无法符合实时限制,或由于异常您可能根本不返回查询响应)。
如果您所说的“实时”是指“嵌入式”,那么代码大小就成为一个问题。嵌入式代码可能并不一定是实时的,但它可能存在大小约束(通常如此)。
此外,嵌入式系统通常被设计成无限事件循环运行。异常可能会将您带到该循环之外,并且还可能破坏您的存储器和数据(因为堆栈展开)- 这再次取决于您对其的使用方式以及编译器的实际实现方式。
因此最好保险起见,不要使用异常。如果您可以承受偶尔的系统故障,如果您正在运行可以轻松重启的单独任务中,如果您并不是真正的实时,而只是被迫假装是 - 那么您可能可以尝试使用异常。如果您正在编写心脏起搏器的软件 - 我更喜欢检查返回代码。

6
我不同意“异常可能会损坏您的内存和数据”的说法。有人可以使用异常或不使用异常编写正确的代码——这是不同的风格。因此,我认为“宁愿安全也不要后悔”不是我要寻找的答案。但是,在代码大小方面有很好的观点。谢谢。 - towi
如果您担心时间问题,那么异常不就是另一条需要测试的执行路径吗?当然,相比于测试返回代码的替代方案,了解C++异常下发生的神秘事件可能更加困难。 - Craig McQueen
4
如果"Exception may take you somewhere out of that loop, and also corrupt your memory and data (because of the stack unwinding)",那么显然你没有正确使用异常。你有充分的论据吗? - curiousguy
1
我也不同意“异常可能会破坏您的内存和数据”的说法。如果您可以在出现错误时终止程序,那么在性能至关重要时,这就是您应该采取的措施。如果您无法承受这种情况(例如因为您正在编写一个库),那么您有两个选择:返回错误代码或抛出异常。在这里,错误代码方法更容易因代码中检查错误代码的错误而导致数据损坏。 - Kristian Spangsege

5

目前并非所有实时环境都支持C++异常处理,这种方法不被普遍接受。

以视频游戏为例(每帧有16.6毫秒的软时间限制),主要编译器实现C++异常处理的方式会使程序运行变慢,增加代码大小,无论是否真正抛出异常。由于游戏主机的性能和内存都至关重要,这是一个致命缺陷:例如,PS3的SPU单元只有256KB的内存用于代码和数据!

此外,抛出异常仍然相当缓慢(如果你不相信,请测量一下),并且可能导致堆分配,这在没有多余微秒的情况下也是不可取的。

唯一的例外是当异常可能在应用程序运行期间仅抛出一次时——不是每帧一次,而是确实只有一次。在这种情况下,结构化异常处理是一种可接受的方式,可以捕获操作系统中的稳定性数据,并在游戏崩溃时将其传递回开发人员。


1
在任何情况下,每帧(或在其他领域中类似频率的)抛出异常都是不好的。 - Andriy Tylychko
@Andy T:确实如此,但我见过开发人员在已发布的产品中仍然这样做。由于性能不佳,该产品失败了,他们的工作室也倒闭了。 - Crashworks
抛出异常仍然非常慢(如果你不相信,请进行测量),并且可能导致堆分配,这在你没有多余微秒的情况下也是不可取的。为什么要抛出异常? - curiousguy
C++异常在未被抛出时没有任何开销,实现使用表驱动的异常。 - Bonita Montero

3

当抛出异常时,异常机制的实现通常非常缓慢,否则使用它们的成本几乎为零。在我看来,如果你正确使用它们,异常非常有用。

在实时应用程序中,只有当出现问题并且程序必须停止和修复问题(可能需要等待用户交互)时才应该抛出异常。在这种情况下,修复问题需要更长的时间。

异常提供了报告错误的隐藏路径。它们使代码更短,更易读,因此更易于维护。


慢?据我所知,它们比不太可能的测试更快,只要它们不被抛弃,成本几乎为零。 - Matthieu M.
2
请查看这篇博客,它提供了关于异常处理的权衡取舍的良好解释,并解释了在某些情况下,异常甚至可以使代码更快。 - Björn Pollex
@Matthieu @Space 当抛出异常时会变慢。使用异常实现不会减缓执行速度,只是会稍微慢一点(为了提供try/catch上下文),但是当没有抛出异常时,使用if的替代方案会更慢。 - BЈовић
我同意,抛出异常时比“if”语句慢得多,实际上相差十倍之多。但是,现在使用零成本机制,只要没有抛出异常,就不再需要上下文设置,它是免费的(就像啤酒一样)。 - Matthieu M.

1
典型的C++异常处理实现仍然不理想,可能会导致整个语言实现在一些资源极其有限的嵌入式目标中几乎无法使用,即使用户代码没有明确使用这些功能。最近的WG21文件将此称为“零开销原则违规”,详见N4049N4234。在这种环境下,无论应用程序是否实时,异常处理都无法按预期工作(消耗合理的系统资源)。
然而,在嵌入式环境中应该有能够承受这些开销的实时应用程序,例如手持设备中的视频播放器。
异常处理应始终谨慎使用。对于任何平台(不仅仅是嵌入式环境),在实时应用程序中每帧抛出和捕获异常都是不良的设计/实现,通常是不可接受的。

-1

嵌入式/实时开发通常有3或4个限制 - 特别是当它涉及内核模式开发时

  • 在处理硬件异常时,通常会出现各种限制 - 操作不能抛出更多的硬件异常。C++的隐式数据结构(虚表)和代码(默认构造函数、运算符和其他隐式生成的代码以支持C++异常机制)无法放置,并且因此不能保证在执行此上下文时将其放置在非分页内存中。

  • 代码质量 - 一般情况下,C++代码可以隐藏许多看似微不足道的语句中的复杂性,使得代码难以进行视觉审计以检测错误。异常将处理与位置分离,使得难以证明测试代码覆盖率。

  • C++公开了一个非常简单的内存模型:new从无限的自由存储器中分配,直到用完为止,并抛出异常。在内存受限的设备上,可以编写更有效的代码,以显式使用固定大小的内存块。C++的隐式分配几乎在任何操作上都使得无法审核内存使用。此外,大多数C++堆具有令人不安的特性,即没有可计算的上限,可以花费多长时间来进行内存分配 - 这再次使得难以证明实时设备上算法的响应时间,其中固定的上限是可取的。


3
第三点完全错误 - 你可以在类或命名空间范围内重载 operator new() 来以任何你喜欢的方式分配内存。或者在不适当使用 new 的情况下使用自己的分配器。 - Mike Seymour
2
"几乎任何操作都会隐式分配内存" - 你的C++代码看起来不像我的C++代码。当然,你必须理解何时发生复制,但在C语言中,规则是“如果你不调用函数,你知道正在发生什么”。在为实时工作编写的符合基本标准的C++中,规则是“如果你不调用函数或使用持有动态分配资源的类型,你知道正在发生什么”。记录和识别哪些类型分配内存并使用命名方案来突出显示它并不是那么困难。然后,在关键上下文中不要复制它们。 - Steve Jessop
1
@VJo和Steve:惯用的C++代码利用STL进行通用编程。这意味着没有一个运算符是像它们看起来那么简单。你可以在C中创建超级复杂的东西,但C++本身就已经非常复杂了。我认为,如果你不使用STL/通用编程技术,那么用C++浪费时间。 - Chris Becke
3
问题是,“你能否以一种方式编写C++代码,以便您知道您的代码是否分配了内存?”如果对C++有很好的了解,并且稍加小心,那么是可以做到的。对于内存分配的具体情况,它并不比记录各种操作提供的异常保证更难。对于其他在关键上下文中被禁止的事情,可能会更加困难,更类似于例如在C语言中跟踪信号处理程序中可以安全执行的操作。如果“惯用的C ++”意味着“在每个函数中创建一个向量”,那么很抱歉,您不能这样做。 - Steve Jessop
1
Chris:当你说“STL /泛型编程技术”时,你把两个非常不同的东西混在一起了。在使用C++进行泛型编程方面,确实有非常有用的方法,而这些方法并不涉及STL。更具体地说,我会说STL是某种类型应用程序的“惯用C ++”,通常不涉及内核编程,而C ++在该应用程序范围之外也很有用。(异常是否超出该范围?我不知道-但这就是问题所在。) - Brooks Moses
显示剩余8条评论

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