Skip to main content

HFT 热路径错误处理:异常机制、optional 与最佳实践

·3 mins

本文从 C++ 异常机制与 std::optional<T> 的实现原理出发,说明二者在延迟与可预测性上的差异,并给出在高频交易(HFT)热路径上的错误处理最佳实践。


1. 为什么热路径要单独考虑错误处理 #

在 HFT 系统中,报价、下单、风控等逻辑往往在热路径上执行:每笔行情或每次 tick 都会触发,执行频率高、对延迟和抖动极其敏感。错误处理方式会直接影响:

  • 延迟:异常抛出时的栈展开、析构会带来不可预测的延迟尖刺;
  • 可预测性:热路径上应尽量无分支或分支可预测,避免因“可能失败”的 API 引入额外分支或控制流;
  • 局部失败隔离:单个标的或档位失败不应拖垮整次处理,但实现方式不能以牺牲延迟为代价。

因此,热路径上的错误处理需要显式设计,而不是依赖“通用”的异常或包装类型。


2. C++ 异常机制(try-catch)原理 #

2.1 基本流程 #

当程序执行到 throw expr 时:

  1. 构造异常对象:用 expr 构造一个异常对象(可能拷贝或移动);
  2. 栈展开(stack unwinding):从当前 throw 点开始,沿调用栈向上逐层退出,每退出一层就析构该层中的局部对象(按构造的逆序);
  3. 查找 catch:在调用栈上寻找与异常类型匹配的 catch 子句;
  4. 匹配成功:进入 catch 块执行,然后从 catch 之后继续执行(或返回到调用方);
  5. 匹配失败:若一直到 main 仍未匹配,则调用 std::terminate(),进程终止(可能产生 coredump)。

因此:

  • 有 catch:异常被接住 → 栈展开 + 执行 catch → 程序继续运行,但本帧已付出栈展开和析构的代价;
  • 无 catch:未捕获异常 → std::terminate()进程退出,不再存在“本帧延迟”的问题。

热路径上若使用 try-catch,我们讨论的是“有 catch、程序继续”的情形:不抛时几乎不付出额外成本,一旦抛就会在本帧产生一次高延迟。

2.2 Zero-cost 异常模型:成本被挪去了哪里 #

主流编译器(GCC、Clang、64 位 MSVC)采用表驱动的零成本异常:正常路径不插入任何登记指令(老的 SJLJ 模型每进一次 try 都要 setjmp 登记,人人路路付费);异常处理所需的信息被编译成静态表(.eh_frame unwind 表、.gcc_except_table 类型表)和独立的清理代码(landing pad,放在 .text.unlikely 冷区),只在真正 throw 时才被查询和执行

所以 “zero-cost” 的准确含义是:成本没有消失,而是全部集中到 throw 的那一次,并且被安排在永远冷的代码和数据里。要评估它对 HFT 的影响,必须把 throw 那一刻发生的事完整拆开。

2.3 throw 的完整代价链 #

在 Linux/Itanium C++ ABI(GCC/Clang 均为这套)下,throw expr 触发的不是一次跳转,而是一条很长的运行时流水:

① 异常对象分配:__cxa_allocate_exception——里面是 malloc。异常对象要活过栈展开,不能放栈上,所以 libstdc++ 先调 malloc(失败才退化到线程局部的 emergency buffer)。热路径禁 malloc 是 HFT 铁律,而 throw 的第一步就踩上:可能碰分配器锁,可能触发 brk/mmap 系统调用。

② 两遍栈展开(two-phase unwind)_Unwind_RaiseException 沿调用栈走两遍

  • Phase 1(search):从 throw 点逐帧向上,每帧拿返回地址查该帧的 unwind 信息(FDE),调用 personality routine(__gxx_personality_v0)询问 “这一帧有没有能接住该类型的 catch”。类型匹配走 RTTI——跨共享库时 type_info 比较可能退化成 mangled name 的 strcmp
  • Phase 2(cleanup):确认有 handler 后再走一遍,这次真正恢复寄存器、逐帧执行 landing pad(析构局部对象),直到落进 catch。

为什么要两遍?因为若 phase 1 发现无人接住,栈必须原封不动地交给 std::terminate()(保留干净的 coredump)。代价与栈深度、每帧析构数量成正比。

③ 查表之前要先 “找到表”——历史上这里有全局锁。用返回地址查 FDE,先要确定这个 PC 属于哪个共享库——传统实现走 dl_iterate_phdr,内部持 glibc 的 dl_load_lock 全局锁。后果:多个线程同时 throw 会在这把锁上串行化,WG21 提案 P2544 实测过并发抛异常的吞吐随核数增加反而崩塌。glibc 2.35 的 _dl_find_object 配合 GCC 12 之后基本无锁化,但生产环境里更老的 glibc 仍在坑内。

④ 全程冷内存。unwind 表、类型表、landing pad 在不抛异常时从不被触碰——首次 throw 时这些页可能根本不在物理内存里(demand paging),要吃 major page fault 从磁盘调页。首次 throw 可以比后续慢两个数量级。

⑤ 事后余震。栈展开扫过大量冷代码冷数据,把热路径工作集从 L1/L2 里挤出去——异常处理完之后的接下来几十条消息也变慢。这部分不出现在 “throw 耗时” 的直接测量里,却真实存在于 p99 中。

2.4 量级:把数字摆出来 #

路径典型耗时
返回错误码 / std::expected 分支~1 ns
throw + catch,浅栈、热态1~5 μs
深栈、多析构数十 μs
首次 throw(冷表、缺页)数十~数百 μs
多线程并发 throw(老 glibc,锁竞争)无上界,随核数恶化

对照延迟预算:加密 HFT 的软件路径 tick-to-trade 通常为个位数到几十 μs,传统 HFT 亚微秒。一次热态浅栈的 throw ≈ 烧掉整个延迟预算,一次冷 throw ≈ 烧掉几十倍预算。失败路径与成功路径的成本比从 1000:1 起步——这是错误码方案(失败与成功同量级)与异常方案最本质的数字差异。

2.5 为什么在 HFT 中不可接受 #

真正的论证不是 “它慢”,而是以下四条叠加:

  1. 成本恰好集中在最坏的时刻。异常什么时候抛?断线、行情畸形、订单被拒、限速触发——这些事件与市场剧烈波动强相关。try-catch 把最大的延迟,精确地安排在最需要低延迟的时刻。平稳期的均值无关紧要,压力时刻的行为才是系统的真实水平。
  2. 成本无界且不可预算。错误码的失败路径是常数级、可测量、可写进延迟预算的;throw 的成本取决于栈深、析构数量、页面冷热、glibc 版本、其他线程此刻是否也在抛——无法为它写 SLA。HFT 要的不是 “平均快”,是 “可预测”,而 throw 在设计上就是反预测的。
  3. 故障会跨线程传染。慢路径线程(如断线重连逻辑)在交易所故障时反复抛异常,通过 unwind 全局锁与 malloc 分配器锁,能把热路径线程偶发 throw 的耗时从微秒放大到毫秒。模块间本应隔离的故障域,被异常运行时的共享基础设施连通了。
  4. “zero-cost” 的另一面是 “永久冷”。表驱动模型把成本从常规路径挪走的代价,是异常路径的代码与数据永远不进缓存——它在架构上保证了 “抛的那次一定是最慢形态”。对吞吐系统这是好交易,对尾延迟系统这是最坏的交易。顺带,happy path 也非真零成本:可抛调用限制指令重排、对象必须保持可析构状态,优化器被束缚;.eh_frame 与 landing pad 使二进制膨胀,间接增加 icache/iTLB 压力。

准确的结论表述:不是 “HFT 不能用异常”,而是热路径上任何可能执行到 throw 的代码都不可接受;异常只留给冷路径(启动、配置加载、不可恢复错误——抛了就打算死的场景)。热路径用错误码/哨兵/optional,失败也是普通分支,成本恒定在纳秒量级。

2.6 小结:try-catch 在热路径上的问题 #

维度说明
不抛时成本很小但非零:优化受限、二进制膨胀;且热路径语义上仍依赖“可能抛”的调用;
抛时malloc + 两遍栈展开 + 查表 + RTTI 匹配 + 析构,热态微秒级、冷态可达数百微秒;
并发抛unwind 查表历史上持全局锁,多线程同时抛互相拖慢(P2544);
控制流异常是“非局部控制流”,不利于热路径的简单、可预测结构;
与 HFT 目标失败成本无界、且集中于市场压力时刻,与可预测延迟的目标根本冲突。

结论:热路径上更稳妥的做法是让可能失败的调用不抛异常,改用返回值/哨兵/出参表示失败,由调用方显式分支处理(如 if (!ok) continue;)。


3. std::optional 原理与特性 #

3.1 类型与布局 #

std::optional<T> 表示“要么有一个 T 类型的值,要么没有值(空)”。典型实现:

  • 存储:一块足够容纳 T 的存储(可能用 placement new),外加一个判别位(通常是一个 bool 或与 T 打包的标记),表示当前是否“有值”;
  • 尺寸:约 sizeof(T) + 1 字节(或对齐后的等价物),即比 T 多至少一个字节;
  • 无堆分配:optional 本身不涉及动态内存,对象通常与包含它的结构体/栈帧一起分配。

因此从内存与分配角度,optional 是“轻量”的,但会引入额外的判别位和一次条件判断

3.2 接口与语义 #

  • has_value() / operator bool():是否包含值;
  • value():有值时返回 const/非 const 引用,无值时抛 std::bad_optional_access会抛异常,热路径应避免对空 optional 调用 value());
  • value_or(u):有值返回值,无值返回 u
  • *opt / opt->:有值时解引用,无值时未定义行为(不检查)。

使用 optional 的正确方式通常是:先 if (opt.has_value()) 再使用 *optopt->,或使用 value_or,从而在热路径上不触发异常

3.3 与异常、哨兵、bool+出参的对比 #

方式异常额外分支体积类型安全常见 HFT 用法
try-catch抛时高成本无(控制流在 catch)热路径不推荐
std::optional仅误用 value() 时has_value() 一次sizeof(T)+ 判别位小类型、非最内层热路径可接受
哨兵值(如 0、-1)一次比较需约定首选
bool Func(T& out)一次 if常用

optional 的“问题”不在于实现复杂,而在于:多一个类型包装、多一次 has_value 分支。在追求“能省则省”的 HFT 热路径上,若已有天然哨兵(如 accountId 的 0),直接用哨兵更贴合理念;若没有天然哨兵且希望类型清晰,用 optional 小类型(如 optional<double>optional<uint32_t>)是合理折中。


4. HFT 热路径错误处理最佳实践 #

4.1 总体原则 #

  1. 热路径上不抛、不接异常:所有“可能失败”的调用通过返回值或出参表示,调用方用 if 判断并 continue/return,避免 try-catch。
  2. 可预测延迟:热路径只做“在正确前置条件下必然成功”的操作,或对失败做一次简单分支(如跳过当前 symbol/level),不引入异常带来的栈展开。
  3. 局部失败隔离:单个标的/档位失败只影响该单元,通过“返回失败 + 上层 continue”实现,而不是依赖异常在 catch 里“吞掉”后继续。

4.2 错误表示方式的选择 #

  • 优先:哨兵值
    若类型本身有天然无效值(如 ID 用 0 表示无效、索引用 -1),直接返回该类型,用哨兵表示失败;无额外类型、无 optional 的 has_value 分支,布局紧凑。

  • 常用:bool + 出参
    bool getOrder(Order& out);bool getConfig(Config& out);:成功写 out 并返回 true,失败返回 false。调用方一次 if 即可,无异常、无 optional。

  • 可选:std::optional
    适用于“需要返回一个可能不存在的值”、且 T 为小类型(如数值、句柄)时。避免在热路径返回 optional<BigStruct>(可能带来移动/拷贝);使用时不调用 value(),只用 has_value() + *optvalue_or,保证热路径无异常。

4.3 结构组织 #

  • 提前校验与缓存:配置查找、账号解析、合约信息等,在每 tick 入口或启动时完成,热路径只做数组/缓存访问和算术,减少“可能失败”的调用。
  • “不应发生”用 assert:非法档位、空指针等,在 Debug 用 assert 捕获;Release 上若发生,可走单一“逃逸路径”(如记日志并安全退出或降级),而不是在热路径内对每个 item 抛异常或做复杂分支。
  • 单一职责:热路径函数尽量只做“在已校验前提下必然成功”的事;可能失败的操作要么移到路径外,要么改为返回 bool/哨兵/optional,由上层统一处理。

4.4 热路径检查清单(简要) #

  • 热路径内无 try-catch;
  • 热路径内无可能抛异常的调用(或已改为返回 bool/哨兵/optional);
  • 失败用返回值 + if + continue/return 处理,实现“单点失败不拖垮整体”;
  • 若用 optional,仅用于小类型,且不调用 value(),只用 has_value() / *opt / value_or
  • 能哨兵则哨兵,能 bool+出参则 bool+出参,optional 作为可读性与类型安全的折中;
  • 可能失败的重逻辑(查表、建单等)尽量前移或缓存,热路径只做轻量、必成功操作。

5. 总结 #

  • try-catch:zero-cost 模型把全部成本集中到 throw 的那一次——malloc、两遍栈展开、冷表查询、RTTI 匹配,热态微秒级、冷态数百微秒、并发抛无上界;且失败成本恰与市场压力时刻重合。热路径上任何可能 throw 的调用都不可接受,异常只留给冷路径。
  • std::optional:实现为 T + 判别位,无异常、语义清晰;会多一次 has_value 分支和少量体积,在 HFT 热路径上可用作“无天然哨兵时的折中”,且仅用于小类型、并避免对空 optional 调用 value()
  • HFT 热路径最佳实践:热路径零异常;错误用哨兵值或 bool+出参表示,可选地在小类型上使用 optional;提前校验与缓存,热路径只做必成功或“一次 if 处理失败”的轻量逻辑,从而保证延迟稳定、可预测。

6. 延伸阅读 #

  • Itanium C++ ABI: Exception Handling — 两遍 unwind 与 personality routine 的规范定义。
  • WG21 P2544R0, C++ exceptions are becoming more and more problematic — 并发 throw 随核数崩塌的实测数据。
  • WG21 P0709, Zero-overhead deterministic exceptions — Herb Sutter 对现行异常模型的系统性批评与替代设计。
  • glibc _dl_find_object(2.35+)与 GCC 12 的无锁 unwind 查表改进说明。