ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

异常与 RTTI 的代价

异常与 RTTI 的代价 ① 钩子try/catch 不经过 if为什么总被说贵try/catch不经过if为什么总被说贵真相是反直觉的不抛异常时几乎零成本这就是零成本异常模型代价全藏在会抛的那条路径 一张编译期就生成好的隐藏异常表里。这一集我们回答三个问题为什么try块本身免费抛一次异常机器到底干了什么typeid和dynamic_cast谁贵、贵在哪② 源码 vs 汇编对照intmay_throw(intx){if(x0)throw-1;returnx*2;}intuses_try(intx){try{returnmay_throw(x);}catch(inte){returne;}}structBase{virtual~Base(){}};structDer:Base{};booluse_rtti(Baseb){returntypeid(b)typeid(Der);}booluse_dyn(Baseb){returndynamic_castDer*(b)!nullptr;}正常路径不抛—— try 块零开销uses_try(int): testl %ecx, %ecx js .L12 ; 只有 x 0 才进入异常路径 leal (%rcx,%rcx), %eax ; 正常路径就是 return x*2 ret抛异常路径.L12: call may_throw.part.0 call __cxa_begin_catch ; 进入 catch(int) movl (%rax), %eax call __cxa_end_catch ... may_throw.part.0: ; 真正抛的代码 movl $4, %ecx call __cxa_allocate_exception ; 分配异常对象一个 int movl $-1, (%rax) call __cxa_throw ; 抛出隐藏的异常表LSDA—— 编译期静态生成映射try 区域 → 处理代码.LLSDA19: ... .uleb128 .LEHB0-.LFB19 ; try 区域起点 .uleb128 .LEHE0-.LEHB0 ; try 区域长度 .uleb128 .L11-.LFB19 ; landing pad处理代码地址 ...RTTItypeid vs dynamic_castuse_rtti(Base): ; typeid 比较 leaq _ZTI3Der(%rip), %rdx ; typeid(Der) 的 type_info movq (%rcx), %rax ; 取对象 vptr movq -8(%rax), %rcx ; 真实 type_info 在 vptr-8 处 jmp type_info::operator use_dyn(Base): ; dynamic_cast call __dynamic_cast ; 整个交给运行时库RTTI 表.rdata每个多态类型一段只读数据_ZTS4Base: .ascii 4Base\0 ; 类型名字符串 _ZTI4Base: .quad __class_type_info16 ; type_info 对象 .quad _ZTS4Base _ZTI3Der: .quad __si_class_type_info16 ; 单继承 type_info .quad _ZTS3Der .quad _ZTI4Base ; 指向基类的 type_info③ 为什么这么设计零成本异常模型Itanium/SEH 阵营的主流设计编译期不往正常代码路径里插任何检查指令而是把try 区域 → 处理代码的映射表LSDA静态存进数据区。抛出时运行时库__cxa_throw沿调用栈查表找 handler。所以异常贵的正确理解是可以用 try不贵正常路径零开销抛异常很贵分配对象、展开栈、沿途析构局部对象、查表匹配量级常在毫秒级。RTTI每个多态类型在.rdata里有一份type_info类型名字符串 继承关系。typeid通过 vptr 前面的指针拿到type_info比较dynamic_cast要沿继承链运行时查证是纯运行时库调用比typeid贵。为什么 type_info 在 vptr-8vptr 指向虚表内部而虚表的前 8 字节正好放 typeinfo 指针——一个对象头指针同时能查函数和查类型。④ 深入一异常展开stack unwinding到底走了多远抛异常的成本大头在栈展开。throw之后运行时库Itanium 下的__cxa_throw→_Unwind_RaiseException做的事分配异常对象__cxa_allocate_exception本集movl $4, %ecx就是给 int 分配 4 字节沿调用栈逐帧向上对每一帧查 LSDA看这个帧有没有try区域能捕获当前异常类型找到能捕获的帧开始展开执行该帧内从 try 起点到抛点之间所有存活局部对象的析构这就是 RAII 保证异常时资源也被释放的机制E07 讲过进入 landing pad调用__cxa_begin_catch让 handler 能访问异常对象执行 catch 块清理__cxa_end_catch必要时__cxa_free_exception释放异常对象。所以抛一次异常不是跳个 goto而是一场带对象析构的运行时库协作。栈越深、沿途局部对象越多展开越贵。这就是异常只用于异常路径的机器理由。⑤ 深入二LSDA 的表驱动为什么是工程胜利比较两种异常实现检查式老式/部分嵌入式每进入一个 try 块都插入是否异常检查指令——正常路径也要付钱表驱动/零成本主流正常路径零指令表只在异常真的发生时被查。表驱动胜出靠的正是把成本从正常路径挪到异常路径。这和冷热分离E04、“优化手术”E19是同一个思想别让高频路径为低频路径买单。这也是为什么现代 C 编译器默认开异常-fexceptions却几乎不影响正常代码性能。⑥ 常见误区误区 1“try 块本身很贵”错。正常路径零开销LSDA 表是数据不进执行路径。误区 2“异常和 return code 差不多快”错。throw是分配 栈展开 析构 查表比return贵几个数量级。误区 3“typeid和dynamic_cast一样贵”typeid比较 type_info 指针快dynamic_cast是运行时库沿继承链查证慢。误区 4“-fno-exceptions只是关语法”它还去掉异常表/运行时开销代码更小更确定但std::vector越界等靠异常报告的路径会变成abort或 UB。误区 5“异常展开会自动析构所有局部对象”只析构已构造完成的局部对象编译器在展开表里记录每个对象的构造完成点。手动管理资源裸 new不在其列——用 RAII 让析构自动发生。误区 6“typeid只能用于多态类型”typeid对任意类型都可用只是非多态类型的typeid是编译期常量、不查 vptr对多态对象才走vptr → vptr-8 → type_info的运行时路径。同理dynamic_cast也要求多态类型有虚函数否则编译报错。误区 7“-fno-rtti只影响调试信息”它还让typeid/dynamic_cast无法编译、去掉.rdata里的_ZTI...数据。体积更小但基于 RTTI 的序列化/断言也要一并改造。⑦ 实战启示异常只用于异常路径别当控制流每抛一次都是分配 栈展开 一路析构。解析文本、预期频繁失败的场景用返回值 /std::optional。嵌入式/实时场景可-fno-exceptions以及-fno-rtti换来更小、更确定的代码代价是异常语法不可用。dynamic_cast有真实成本热点别用需要多态类型判断时优先虚函数或typeid一般只比较指针便宜。RTTI 让每个多态类型多一段只读数据类型名等字符串常驻可执行文件开-fno-rtti可省但dynamic_cast/typeid也失效。异常规格noexcept是关键优化信息标记noexcept后编译器能跳过异常处理路径代码更小更快E19 会看到。⑧ 扩展专题一noexcept 与异常的开销联动noexcept不只是声明不抛它直接改变生成的代码noexcept函数编译器不生成/不预留异常展开表调用它可以走无异常快速路径析构函数默认 noexcept所以析构里别让可能 throw 的代码逃逸否则 terminatestd::vector的移动/拷贝策略E14 会展开noexcept移动构造让 vector 扩容时敢用移动而非拷贝——这是性能分水岭。反汇编里带noexcept和不带的函数异常表LSDA的大小和 handler 数量会明显不同。给不该抛的函数加noexcept既是契约也是优化。⑨ 扩展专题二SEH vs Itanium——两套异常模型本系列用的是 Itanium C ABIg/clang但 Windows/MSVC 用SEHStructured Exception Handling。差异有Itanium表驱动 运行时_Unwind库异常对象经__cxa_allocate/throwSEH基于帧内异常记录_except_handler3等每帧在栈上存异常处理信息函数入口出口有__try相关记录结论无论哪套主流实现都走正常路径低开销路线跨平台写 C 时不用纠结实现细节但要知道异常不是零成本的。⑩ 扩展 FAQQthrow的对象怎么销毁Acatch结束后由运行时释放__cxa_end_catch→ 必要时__cxa_free_exception。异常对象是独立分配的不是栈对象所以能跨越函数边界存活到 handler。Qcatch(...)能抓住什么A所有异常包括非 C 抛出的。但它是不知道类型的最后防线通常只用于收尾后重新抛出catch(...) { cleanup(); throw; }。Qtypeid对非多态类型能用吗A能但typeid(T)类型参数是编译期常量typeid(*obj)表达式参数只有当对象是多态时才走 vptr 查表否则也是编译期已知。Q为什么type_info是每类型一段只读数据A因为比较需要稳定的身份标识。每个多态类型在.rdata有一段_ZTI...数据含类型名 继承关系typeid比较的就是这些数据的地址/内容E23 会讲 mangled 名称。Q异常对象分配可以避免吗Athrow通常要分配但只抛不接或某些实现会优化。工程上别依赖异常不分配——那是实现细节。⑪ 扩展实验看 LSDAg -O2 -S E10_except.cpp找.LLSDA段确认try 区域 → landing pad的映射表。正常路径零指令对照uses_try与may_throw的正常路径汇编确认没有额外的异常检查指令。typeidvsdynamic_cast对比反汇编use_rtti比较 type_info和use_dyn调__dynamic_cast数指令条数。-fno-exceptions对比g -O2 -fno-exceptions -S看异常相关调用是否消失、LSDA 是否为空。析构次数实验在深层函数里 throw沿途对象的析构函数打印日志——看展开时析构调用顺序先构造的后析构。⑬ 扩展专题三异常安全三级别从汇编看为什么异常安全级别是 C 面试常客但它的机器根源就在本集基本保证抛异常后对象保持有效可能不是原值。本质是要么完成要么在展开路径上正确析构回滚。强保证要么成功要么状态完全不变回滚。实现上常靠先做副本成功才替换copy-and-swap。不抛保证绝不抛。用noexcept声明 内部只做不抛操作如整型运算、交换指针。从汇编看强保证的副本 替换意味着多一次构造/拷贝/析构E07 的隐藏代码 E05 的拷贝成本不抛保证则让编译器敢省掉异常展开路径。异常安全等级越高正常路径可能越贵——所以强保证只用在必要处。⑭ 扩展专题四RTTI 的成本账怎么算RTTItypeid/dynamic_cast的真实成本分三块空间每个多态类型在.rdata多一段_ZTI...类型名 继承表。类型越多可执行文件越大。时间typeid一次 vptr 取 一次 vptr-8 取 一次比较通常几纳秒内。时间dynamic_cast运行时沿继承链查证可能回溯多个基类比 typeid 贵一个量级跨多重继承的dynamic_cast更贵要查多个子对象路径。取舍能用虚函数表达行为差异就别用 RTTI 判型必须判型用typeid比较只有不确定能否转、要安全降级才用dynamic_cast还要承受它可能返回空/抛std::bad_cast。⑮ 扩展 FAQ第二轮Q异常和assert有什么区别Aassert是调试期检查release 可能关掉失败直接终止异常是可恢复的错误传播机制。用途不同前者防程序 bug后者处理运行时错误。Q为什么main里不 catch 会 terminateA未捕获异常到达main之外运行时调用std::terminate。所以顶层 catch(…)“常用于记录后安全退出”。Qnoexcept函数真的不会抛吗A声明noexcept后若还抛会直接std::terminate比没声明更危险。所以noexcept是承诺不是摆设。Qstd::bad_alloc怎么捕获A它是std::exception的派生类catch (const std::exception)能捕获专门处理可用catch (const std::bad_alloc)。Q为什么说不要用异常做正常控制流A因为每次throw是分配 栈展开 析构 查表的重活毫秒级而if/return是纳秒级。高频路径用异常 性能灾难本集汇编就是证据。Qcatch 顺序影响性能吗A异常类型匹配是运行时逐 handler 试的。catch (const Base)在前会先匹配派生异常引用保多态但更具体的在前仍是最佳实践——既减少误捕也让最常见类型最先命中。Qnoexcept与throw()有区别吗AnoexceptC11 起是动态异常说明的替代旧的throw()等价于noexceptC17 移除。noexcept若抛会terminate语义更硬、优化信息更多。⑯ 扩展实验第二轮析构顺序观察struct A{~A(){puts(~A);}}等函数里按序声明几个中间throw看展开时析构顺序逆序。noexcept汇编对比同一函数带/不带noexcept编译对比 LSDA 和调用约定是否省略异常处理。-fno-rtti对比编译带typeid的代码看编译错误和.rdata里 RTTI 数据是否消失。dynamic_cast沿链成本深继承链5 层 多重继承下的dynamic_cast反汇编__dynamic_cast调用对比浅继承的差异。异常吞吐实测循环里throw/catch1 万次 vs 循环里 if/return 1 万次计时对比——把数量级差距变成数字。⑱ 扩展专题五异常对象与复制的隐藏成本catch (int e)会拷贝异常对象int 便宜。catch (const std::exception e)则引用不拷贝——这是为什么按引用捕获是推荐写法按值捕获派生异常切片 拷贝异常对象大时昂贵E07 的隐藏拷贝又出现按引用捕获零拷贝且保有多态能访问派生类字段E08 的知识。从汇编看catch (int e)对应__cxa_begin_catch后一次movl (%rax), %eax取异常对象值catch (const std::exception)则直接拿(%rax)当引用。捕获方式不同拷贝成本不同——这是异常代码里常见的隐藏性能点。⑲ 扩展专题六什么时候异常是正确的选择讲了这么多异常成本别误以为异常是坏的。它是对的工具适用场景构造函数失败构造函数没有返回值throw是唯一可靠的失败报告途径返回std::optional也行但要处理半构造库的契约层接口约定此函数失败抛std::runtime_error调用方用 RAII 守护资源代码比层层if (err)更清晰操作符重载operator[]越界、operator new失败没有返回值可用来报错异常是天然通道。真正要避免的是用异常表达预期中的普通流程如解析文本时每个 token 都 try。这类场景用std::optional/返回值成本低一个数量级且意图更清楚。⑳ 扩展 FAQ第三轮Qthrow一个指针/基本类型合法吗A合法但少见。约定是抛对象最好派生自std::exception因为 catch 靠类型匹配、按引用捕获才高效安全。Q异常会破坏栈上的局部对象吗A不会破坏但会析构展开时对已构造局部对象逐一调析构。这既是 RAII 的保障也是展开很贵的原因之一。Q-fno-exceptions后std::vector::at还安全吗Aat用异常报越界关异常后可能直接abort或未定义。这也是为什么容器越界检查默认走operator[]不检查at检查。Q为什么异常展开要按帧查表不能直接跳A因为要正确析构每一帧的局部对象还得匹配 catch 类型。这些信息都在 LSDA 里只能逐帧查。直接跳会跳过析构破坏 RAII。Q性能敏感项目完全不用 RTTI/异常是极端吗A游戏引擎、嵌入式常这么干-fno-rtti -fno-exceptions用typeid/dynamic_cast的替代方案手工类型标签、虚函数、std::variant。代价是手写这些机制收益是更小更确定的二进制。㉑ 扩展实验第三轮按值 vs 按引用捕获对比catch (std::exception e)与catch (const std::exception)反汇编看拷贝指令差异。构造函数 throw在构造函数里throw观察半构造的成员是否被正确析构E07 的已构造部分才析构。noexcept容器扩容联动std::vector存不可拷贝但可移动的类型比较移动构造是否noexcept时扩容行为差异E14 预告。计时异常 vs optional同样可能失败的函数分别用 throw/catch 和std::optional实现1 万次循环计时对比数量级。跨平台异常若方便在 MSVC 下编译同一段异常代码对比 SEH 与 Itanium 的汇编形态差异。㉓ 扩展专题七从零成本异常到返回码——完整选型谱面对可能失败的操作C 的选型谱大致是成本从低到高方案形态成本适用返回值/标志位bool ok()纳秒级零隐藏成本预期频繁失败std::optional值或空纳秒级无堆分配可能无结果std::expectedC23值或错误纳秒级无堆分配值或错误信息错误码 引用输出int err, out纳秒级传统 C 风格异常throw/catch正常路径零抛时毫秒级真正异常路径核心心法用最低成本满足需求的方案。预期失败的解析 → optional/expected本不该发生的错误 → 异常或 assert。这样代码既清晰性能也落在谱系的低端。㉔ 扩展实验第四轮同一逻辑三实现解析一个可能失败的输入分别用异常、optional、返回值实现1 万次循环计时——把本集的成本谱变成你自己的数字。检查 LSDA 大小一个含 3 个 try 块的函数 vs 无 try 的函数-O2 -S对比.LLSDA段大小直观感受表驱动的空间成本。noexcept全链路给调用链每个函数加noexcept对比优化后汇编的差异调用是否少了异常处理路径。析构 vs 异常展开构造一棵含 10 个局部 RAII 对象的调用栈throw 看展开析构调用次数与顺序。㉖ 扩展专题八为什么游戏引擎常常关掉异常游戏引擎、实时渲染、嵌入式控制器是关异常的重灾区-fno-exceptions -fno-rtti。理由正是本集的核心确定性与体积异常表LSDA和 RTTI 数据占可执行文件空间关掉后二进制更小、加载更快。帧预算游戏每帧有固定时间预算一次throw的分配 栈展开 析构可能瞬间吃掉整帧预算造成卡顿——这是异常不好预测的典型。替代方案成熟用错误码、std::optional、自定义错误类型 断言配合状态机日志足够覆盖游戏内错误处理。编译器优化面没有异常时函数不需要为可能被展开预留栈帧信息某些优化更激进。但要诚实关异常不是性能魔法因为正常路径本来就零开销真正的收益是确定性 体积 更简单的优化分析。对绝大多数业务/服务端代码保留异常是合理选择——它在真正出错时给出更安全的行为RAII 保证清理。这条权衡就是本集零成本异常 抛时昂贵结论的直接推论。㉗ 悬念异常和 RTTI 把运行时的复杂性带进来了。下一章我们回到现代 C你天天写的 lambda语法糖编译出来其实是一个隐藏的 struct——闭包对象。
返回列表