交易技术

高频交易系统内存池设计:如何从底层消除动态分配的延迟抖动

凌晨两点四十七分,策略信号刚刚触发。委托单从策略线程发出去,柜台回执正常,风控放行,撮合逻辑也没有报错。真正拖慢这笔交易的,是对盘线程里一行看似无害的 new:它撞上了分配器的竞争路径,扫描空闲块,等待同步,又把一块并不连续的内存交给了协议对象。…

高频交易系统内存池设计:如何从底层消除动态分配的延迟抖动

一百微秒过去了。买价向上扫了三个跳,成交均价比回测差了整整一个波动单位。那笔交易的损失当然不能简单归因于一次内存分配,但如果系统在每次盘口脉冲中都给自己制造这种不可预测的停顿,策略优势迟早会被这些细小的延迟毛刺一点点吃掉。

通用分配器是为通用吞吐量设计的,不是为延迟边界设计的。它要同时服务日志、网络库、业务对象、后台线程和各种生命周期不可控的请求,必须在内存利用率、线程并发、碎片控制和平均性能之间做折中。高频交易系统则相反:它更关心某一次分配究竟会不会突然变慢,以及最坏情况下这条路径会把订单推迟多久。

在低频策略里,几微秒可能只是监控图上的一个尖峰;在做市、套利和盘口跟随策略里,它可能对应一次错过排队位置、一次撤单失败,或者一次本来可以避免的逆向成交。内存池的意义,不是把每一次分配从几十纳秒压到更低,而是让热路径里的分配时间尽可能落在一个可解释、可复现的范围内。

动态内存分配的隐形成本:通用分配器为何会成为高频交易的噩梦

锁竞争只是第一层问题

任何看过 [Linux glibc](/articles/lmax-disruptorde-dan-sheng-di/) 内存分配行为的人都知道,线程本地缓存能够吸收一部分小对象分配。以常见的 64 位配置为例,相关资料和实现参数中经常会提到线程本地缓存可直接服务的请求上限约为 1032 字节,以及每种尺寸类别默认缓存若干个块。

这里必须把话说准确:1032 字节和默认缓存数量是特定版本、特定配置下的通用分配器参考值,不是所有 64 位系统、所有 glibc 版本都必然如此。至于线程本地缓存的额外内存开销,更不能简单写成一个适用于生产环境的固定数字。它会受到分配器版本、线程数量、尺寸类别、缓存参数和实际使用情况影响。即使某次实验估算出约 236 KB,也只能视为该实验环境下的参考结果,不能当作普遍事实,更不能据此预测一台高频交易服务器的实际内存占用。

对普通应用而言,线程本地缓存已经是相当有效的优化。但这并不意味着热路径就此安全。超过本地缓存服务范围的对象,会进入更复杂的分配路径。日志缓冲区、协议解析时复制出来的大段报文、盘口深度快照、风控上下文,往往都比一个简单的订单结构体更大。多个线程同时申请这类对象时,访问共享分配区域就可能产生竞争。

分配器通常会尝试通过创建更多内存区域来分散不同线程之间的竞争。这个策略对多线程服务有现实价值,却不能消除问题,只是把问题从“所有线程争同一把锁”变成了“更多内存、更复杂的局部性和更难预测的回收路径”。如果线程与内存区域的绑定关系、对象大小分布或释放时机发生变化,延迟分布也会跟着变化。

高频系统最讨厌的,恰恰不是一次固定的慢,而是同一段代码有时几十纳秒、有时几微秒。平均数会把这种差异抹平,交易结果却不会。

不确定的扫描路径会放大尾延迟

当本地缓存无法满足请求时,分配器可能需要寻找合适的空闲块。空闲块的大小、相邻块的状态、线程当前使用的内存区域,以及此前一段时间内的分配和释放顺序,都会影响这次搜索。

这意味着,延迟不只取决于“我要申请多大一块内存”,还取决于这块内存之前被谁申请过、如何释放、系统当时的碎片状态,以及其他线程正在做什么。对于普通业务,这种不确定性通常可以接受;对于要求稳定响应边界的交易线程,它就是隐藏在调用接口后面的随机变量。

更麻烦的是,慢路径不一定在压测开始时出现。系统刚启动时,堆布局通常很干净,数据结构的生命周期也比较规整。连续运行一段时间之后,行情对象、撤单对象、日志对象和临时解析对象交错创建、销毁,碎片逐渐形成。你可能在早盘压测中得到很漂亮的数字,到了午后高峰才看到高分位延迟开始抬头。

因此,不能只测“空池状态下的一次分配”。至少要把以下几种状态分别测出来:

  • 池容量充足、对象尺寸固定时的稳定路径;
  • 多种对象尺寸交错申请和释放时的碎片路径;
  • 多线程同时申请大对象时的竞争路径;
  • 接近容量上限、不得不进入兜底逻辑时的耗尽路径;
  • 长时间运行后,缓存和内存布局已经发生变化的稳态路径;
  • 线程被软中断、监控采样或后台任务打断时的调度路径。

缓存抖动不会总是出现在分配函数里

动态分配的另一个代价,是对象地址通常缺少稳定的空间局部性。一次分配得到的对象可能位于某条缓存线,下一次同类型对象却被放到了完全不同的位置。代码本身只增加了几条加载指令,实际访问却可能经历更多的一级缓存未命中、二级缓存未命中,甚至需要访问远端内存。

对象释放之后,下一次申请未必会重新得到同一片区域。对拥有大量短生命周期对象的行情解析器来说,这种地址漂移尤其明显:报文到达、解析、生成临时节点、完成风控、对象销毁,整个过程可能只持续几十微秒,但每个阶段都在改变堆的局部结构。

这也是为什么只对 malloc 调用本身计时往往不够。分配函数返回得很快,不代表后续访问也快。更有价值的测量方式,是把对象申请、初始化、字段写入、跨线程传递和释放作为一个整体观察,并同时采集缓存未命中、上下文切换、软中断和非统一内存访问远端访问等信息。

把平均延迟贴到屏幕上自我安慰,是高频交易工程里最贵的幻觉。

内存池真正解决的不是一个函数调用,而是对象从出生到死亡这一整段路径的不确定性。对象尺寸固定,地址来自预先准备好的区域,生命周期有明确边界,分配和归还可以采用批量操作,缓存行为也更容易通过压测复现。

当然,内存池并不会自动消除所有延迟。对象初始化、字段清零、引用计数、跨线程传递、缓存线失效和最终归还,仍然需要时间。它做的是把这些成本变成工程师能够提前规划、测量和约束的成本。

DPDK 内存池架构:用每核心缓存和无锁结构实现确定性分配

DPDK 的 rte_mempool 是把高频分配中的不确定性前置成设计参数的典型方案。它通常管理固定大小的对象,空闲对象由环形结构维护,并可以配合每核心缓存、对象对齐和非统一内存访问节点归属使用。

这套架构的价值并不在于“无锁”两个字本身。无锁结构也会产生原子操作、缓存线争用和内存序开销。真正重要的是,它允许工程师把访问模式限制在一个相对清晰的范围内:核心平时从本地缓存获取对象,本地缓存不足时再批量访问共享池;归还对象时也尽量先放回本地缓存,达到阈值后再批量回收。

每核心缓存把跨核同步摊薄

多个核心同时访问共享环形结构时,比较并交换操作,也就是通常所说的比较交换,很容易成为热点。即使没有传统互斥锁,多个核心不断修改同一条缓存线,也会导致缓存线在核心之间来回转移。

每核心缓存的做法,是让绝大多数分配请求停留在当前核心。只有当本地缓存耗尽,或者本地缓存积累到上限时,才与共享内存池交换一批对象。于是,跨核同步从“每次分配一次”变成“每若干次分配一次”。

这里的批量大小不是越大越好。缓存太小,核心频繁访问共享池,原子操作和缓存线争用上升;缓存太大,又会让对象长期滞留在某个核心附近,其他核心看得到池里还有容量,却拿不到真正可用的对象,最终造成容量利用率和内存占用之间的矛盾。

实际配置时,需要同时看三件事:

  • 单个核心在峰值行情中的对象消耗速度;
  • 对象从申请到归还的平均和最长生命周期;
  • 核心之间是否存在明显的不对称负载。

例如,行情接收核心可能快速消耗报文对象,解析核心和策略核心则以不同速度归还对象。如果所有核心都使用同样大小的本地缓存,未必能得到最好的结果。批量大小与缓存大小应结合实际消息流量测试,而不是照抄某个示例配置。

容量规划不能只看平均对象数

内存池容量最常见的错误,是根据平均并发量规划,而不是根据极端负载规划。平均每个时刻只需要几千个对象,并不意味着池里准备几千个对象就够了。行情突发、批量快照、交易所重连、风控重试和下游线程短暂阻塞,都可能让对象在池中停留更久。

容量不足时,系统通常有三种选择:

1. 让申请失败并丢弃当前消息;

2. 进入慢速兜底路径,调用通用分配器;

3. 阻塞等待其他线程归还对象。

三种方式都可能在交易系统中造成严重后果。第一种会改变策略看到的市场状态,第二种让动态分配重新进入热路径,第三种则直接把线程调度的不确定性引入订单链路。内存池设计不能只报告“容量够不够”,还要明确池耗尽时系统究竟会怎么做。

DPDK 文档和常见实现建议中,经常会看到对象数量采用二的幂减一的形式,例如 1023、2047、4095;启用每核心缓存时,还要让对象总数与缓存大小之间保持适合批量交换的关系。这里的数字和形式是数据结构实现上的工程建议,不是所有场景都必须遵循的硬性规则。最终仍要以所使用的 DPDK 版本、内存池实现和目标负载测试结果为准。

参数推荐做法工程意义
对象数量可考虑采用二的幂减一,例如 1023、2047便于环形结构进行高效索引计算
对象总数与缓存大小尽量满足适合批量交换的数量关系减少对象长期滞留在共享池中的情况
获取和归还方式优先使用批量操作摊薄跨核心同步和原子操作成本
缓存归属让本地缓存与工作核心对应减少无谓的跨核心访问
对象尺寸按真实消息类型拆分内存池避免小对象占用大块内存造成浪费
非统一内存访问归属在访问它的节点上建立内存池减少远端内存访问带来的固定开销
耗尽处理明确失败、丢弃或降级策略避免隐式回退到不可控的慢路径

对象尺寸拆分也很重要。把订单、行情报文、深度快照和日志上下文全部塞进同一个大对象池,看起来管理简单,实际却会产生明显的内部碎片。小对象每次都占用大对象的槽位,池容量很快被“浪费掉”;反过来,如果对象池拆得过细,又会增加配置复杂度和监控难度。

一个更稳妥的做法,是按照生命周期和尺寸建立少量明确的池:短报文池、订单上下文池、深度快照池,以及专门用于异常或控制面的备用池。不要为了追求形式上的统一,把拥有完全不同生命周期的对象硬塞在一起。

栈式内存池与线程模型

DPDK 的栈式内存池适合某些流水线场景:对象在一个阶段生成,在后续阶段释放,后进先出的顺序能够改善时间局部性。刚刚归还的对象更可能留在处理器附近的缓存中,下一次重新使用时不必从很远的位置取回。

但栈式结构的选择,必须与线程模型一起看。普通栈模式可能在没有线程抢占的前提下获得更好的平均性能;如果持有锁的线程被抢占,其他访问者就会短暂等待。无锁栈模式可以避免这种阻塞,但它并不意味着没有成本,原子操作和更复杂的并发控制仍然会影响实际延迟。

高频交易服务器里,“线程不会被抢占”也不能只靠口头假设。核心亲和性、实时调度策略、内核线程、软中断、监控采样和看门狗线程都可能改变现场。DPDK 内存池实现对抢占有明确约束:使用同一内存池的逻辑核心,不应被执行相关任务的其他线程随意中断。

如果系统把热路径线程绑在独占核心上,却忘了把监控线程、日志线程和心跳线程迁走,设计上的无锁并不会自动变成运行时的确定性。核心隔离、处理器亲和性、批量收发策略和内存池配置必须作为一个整体部署。

C++17 PMR 与单调缓冲区:把生命周期整段买下来

DPDK 主要解决的是底层报文和固定对象的分配问题。到了策略代码层面,C++17 的多态内存资源,尤其是 std::pmr::monotonic_buffer_resource,提供了另一种非常实用的控制手段。

它的设计逻辑很直接:对象不是一个个独立释放,而是按照一批消息、一次计算周期或一个明确的处理阶段集中管理。只要上层能够把生命周期划分清楚,单调缓冲区就可以把大量细碎的申请和释放变成连续分配以及一次性回收。

单调分配适合短生命周期批次

在当前缓冲区空间足够时,单调资源按顺序向前推进指针。它不需要为每个对象寻找合适的空闲块,也不需要在每次析构时把小块内存重新挂回空闲链表。普通释放操作不会单独回收对象,内存只会在资源销毁或显式调用 release() 时集中归还。

这很适合行情解析的局部阶段。例如,一条深度快照到达之后,解析器需要临时构造价格档位、聚合节点和校验结果。只要这些对象都会在本次快照处理结束后失效,就没有必要为每个子对象设计独立释放路径。把它们放进一个批次资源,处理完成后统一释放,代码更简单,延迟也更容易控制。

但这里有一个前提:生命周期必须真的一致。如果某个对象会被策略线程保存到下一轮计算,另一个对象只在当前函数内使用,把二者放进同一个单调资源,就会出现两种问题。要么为了保留一个对象而长期占住整块缓冲区,要么过早调用 release(),导致仍被使用的对象失效。

内存资源不是垃圾回收器,它不会替你判断对象是否还活着。生命周期设计错误时,单调资源只会让错误更隐蔽。

初始缓冲区要覆盖热路径峰值

单调资源的初始缓冲区耗尽后,会向上游资源申请新的缓冲区。具体增长策略由实现决定,但无论如何,这意味着热路径上可能突然出现一次大块分配。

工程上的常见做法,是依据峰值对象数量、对象大小、对齐填充和容器额外开销估算初始容量,再通过压测验证。比如峰值估算为 1300 个对象,可以考虑按二的幂预留到 2048,但这只是起点,不是结论。真正需要测量的是:

  • 最长一批行情包含多少个对象;
  • pmr::vector 扩容是否额外申请了空间;
  • 对象对齐后是否产生明显填充;
  • 异常分支是否使用了同一个资源;
  • 资源耗尽时,系统是否悄悄回退到堆分配。

尤其要注意容器的容量增长。即使对象本身都来自单调缓冲区,容器扩容仍然可能申请一块更大的连续区域,并复制或移动已有元素。如果策略代码在热路径中不断向一个未预留容量的 pmr::vector 追加数据,内存池优化可能被容器扩容抵消。

因此,资源优化不能只看“换成了 std::pmr 没有”,还要看容器是否提前 reserve,字符串是否发生隐式增长,异常路径是否使用不同的资源,以及上游资源是否可能调用系统分配器。

线程安全边界必须写在设计里

monotonic_buffer_resource 本身不是线程安全的。如果多个策略线程共享同一个资源,必须通过外部同步保护;而一旦在热路径上加互斥锁,原本想消除的竞争又被重新引入。

更常见的方案,是每个工作线程维护自己的资源,或者为每个核心设置独立的资源池。这样做会增加内存占用,但可以把所有权和访问关系变得清楚。代价包括:

  • 每个核心都要预留自己的峰值缓冲区;
  • 某个核心突发流量时,其他核心的空闲内存无法直接共享;
  • 线程迁移后,原来的本地资源可能不再对应当前核心;
  • 跨线程传递对象时,必须确保资源的生命周期长于所有使用者。

如果系统采用生产者—消费者流水线,最好明确对象在哪个阶段创建、在哪个阶段销毁。不要让一个线程创建的资源对象跨越多个核心自由漂移,却又希望整个过程保持本地性。跨线程传递本身不是问题,所有权不清才是问题。

DPDK 内存池与 PMR 不应被强行二选一

底层网络接收通常需要固定尺寸、可复用、与网卡缓冲区配合的对象,DPDK 内存池更合适;策略层临时解析、批次计算和短生命周期容器,则更适合使用 PMR。两者可以分层使用。

例如,网卡接收环拿到固定大小的报文对象,完成必要的校验后,把其中的字段解析到当前策略线程的单调资源中。策略计算完成,临时对象随批次释放;底层报文对象则按明确的所有权规则归还给 DPDK 内存池。这样,底层池负责稳定的对象供给,上层资源负责局部生命周期管理。

真正需要避免的是在两层之间反复复制和转换。每次复制都可能重新申请内存,或者把一个本来可以零拷贝处理的报文变成新的堆对象。内存池并不自动消除复制成本,系统仍然要检查对象是否真的被原地访问、字符串是否发生隐式复制、容器是否重新分配。

超越平均延迟:如何评估内存池在极端负载下的尾部抖动

一个内存池的平均分配延迟做到几十纳秒,听起来非常漂亮。但只要高分位延迟突然跳到几微秒,做市策略就可能在一次行情脉冲中被对手盘精准打穿。平均值只能说明大多数请求发生了什么,不能说明那次真正影响订单的请求发生了什么。

先定义测量边界

测量内存池时,最容易犯的错误是只包住一次获取和归还。这样的数字适合比较两个实现的局部指令成本,却不能代表交易系统真正承担的代价。

至少应当区分以下几个时间段:

1. 从申请对象到对象可写入的时间;

2. 对象初始化和字段填充的时间;

3. 对象在同一核心内被连续访问的时间;

4. 对象跨核心传递到消费者的时间;

5. 对象归还到本地缓存或共享池的时间;

6. 池耗尽、缓存补充和兜底分配的时间。

如果只测第一段,很容易得出“内存池已经足够快”的结论;而真正的延迟可能藏在第二段和第四段。一个对象申请得很快,却因为地址位于远端内存节点,后续字段访问变慢,最终仍然会拖高订单路径。

必须观察多个百分位

工程上至少要同时观察第 99、99.9 和 99.99 百分位,最好还要记录最大值以及超过业务阈值的次数。不同百分位回答的是不同问题:

  • 第 99 百分位反映常态高负载下的大部分体验;
  • 第 99.9 百分位反映突发行情中较常见的异常;
  • 第 99.99 百分位更接近交易系统真正需要防范的极端样本;
  • 最大值可以帮助定位抢占、缺页、软中断和池耗尽等离群事件。

测量时不能只跑几秒钟。过短的测试无法覆盖缓存状态变化、后台线程干扰、内存池冷热切换和长时间碎片累积。更合理的方式是让系统先进入稳定运行状态,再持续制造正常流量与突发流量的混合负载,并保存每个阶段的延迟直方图。

另外,测试数据不能只使用一种固定对象尺寸。固定尺寸的微基准非常适合测量内存池本身,却无法模拟真实系统中报文长度变化、不同业务类型交错和批次大小变化造成的影响。

池耗尽必须作为一等测试场景

内存池有容量上限,这是它的优点,也是它必须面对的边界。容量明确,系统才能知道风险在哪里;但如果耗尽处理没有设计好,内存池最终只会把问题推迟到更难观察的地方。

需要单独记录:

  • 申请失败次数;
  • 本地缓存为空的次数;
  • 共享池补充批次的数量和大小;
  • 进入通用分配器的次数;
  • 因等待对象归还而阻塞的次数;
  • 因对象不足而丢弃或延迟处理的消息数量。

其中最危险的是“隐式兜底”。代码表面上使用了内存池,容量不足时却自动调用 malloc。在正常流量下,这条路径几乎不会出现,因此很容易逃过测试;到了极端行情,它又恰好与系统负载、网络中断和订单激增同时发生,延迟会出现叠加式恶化。

对于不可丢失的控制消息,可以预留独立的应急池;对于行情快照等可降级数据,可以定义明确的丢弃或合并规则。关键不是所有消息都必须保留,而是系统必须按策略风险决定哪些对象可以牺牲,哪些对象不能被慢路径拖住。

把调度干扰纳入内存池测试

DPDK 内存池不可抢占的约束,不能只在代码评审里写一句。测试环境必须尽量复现生产部署:核心隔离、线程亲和性、网卡中断、内核线程、软中断以及监控采样都要纳入观察范围。

很多所谓“内存池抖动”,最后并不是对象获取本身的问题,而是线程在获取前后被打断了。一次上下文切换就足以把几十纳秒的路径变成微秒级延迟;如果此时还发生跨节点访问或共享缓存线争用,直方图会出现长尾。

可以把同一套内存池测试分成几个对照组:

  • 独占核心、无后台线程干扰;
  • 加入日志和监控线程;
  • 加入网卡软中断和定时任务;
  • 增加跨核心生产者与消费者;
  • 人为把池容量压到接近上限;
  • 让对象尺寸和生命周期按照真实行情分布变化。

如果只有第一组表现良好,问题不在于基准测试“太严格”,而在于系统的确定性只存在于实验室里。

TLSF 的常数时间不能直接变成生产承诺

TLSF,也就是双层分离适配这类实时分配器,在理论分析和学术测试中有不错的口碑。相关资料通常强调其常数时间复杂度,早期测试还曾给出过在特定处理器上少于 200 条指令的响应结果。

但这类数字必须放回原来的实验语境中理解。少于 200 条指令是特定实现、特定处理器、特定编译条件和特定测试路径下的实验结果,不是现代高频交易服务器上的固定延迟承诺。它既不能直接外推到今天的多核服务器,也不能代表包含缓存失效、线程调度、内存访问、对象初始化和网卡中断在内的完整交易路径。

处理器微架构、缓存层次、内存控制器、编译器、线程调度和网卡中断都可能改变常数项。复杂度是分析工具,不是生产环境的延迟保证。即便某个测试在理想条件下显示出稳定的响应,也不意味着生产环境一定能够维持同样的高分位延迟。

因此,TLSF 可以作为一种候选方案进行评估,但不能把论文或早期基准中的数字写成宣传语。真正需要做的是在目标机器、目标编译参数和目标负载下重新测量,并把最坏情况下的行为写进验收标准。测试还应覆盖碎片累积、不同尺寸交错分配、并发访问、缓存扰动和内存耗尽等场景。

你的内存池设计有没有效,不看平均延迟,看最差情况下的那一帧。

NUMA 感知与内存对齐:从硬件拓扑层面优化分配路径

内存池做得再精细,跨非统一内存访问节点访问仍然会把延迟打回原形。在双路服务器上,一个核心申请的内存如果实际位于另一个处理器连接的内存节点,就需要通过处理器之间的互联访问。对象申请本身可能没有立即暴露问题,后续读取和写入才会把远端访问的代价体现出来。

本地分配不等于本地访问

非统一内存访问优化不能只看“内存池在哪个节点创建”。还要确认对象由哪个核心申请、哪个核心初始化、哪个核心消费,以及线程是否会在运行过程中迁移。

典型的错误配置是:策略线程运行在节点 0,内存池建立在节点 1,网卡中断又集中在节点 0。系统在功能上完全正常,平均延迟也可能不难看,但每次访问对象都带着固定的远端代价,尾延迟在高负载下进一步放大。

DPDK 支持在初始化内存池时指定内存节点,让池中的对象优先位于访问它们的本地内存。每核心缓存也应当与相应节点的共享池对应。对于接收线程、解析线程和策略线程分属不同节点的流水线,则需要明确是复制少量关键字段,还是允许对象跨节点传递。没有免费的选择:复制消耗带宽,远端访问消耗延迟,必须根据对象大小和访问次数实测。

部署层面,进程启动时的处理器绑定、线程亲和性和内存亲和性应当一起配置,而不是只绑定进程所在的处理器。容器环境还要检查调度器是否允许线程迁移、内存限制是否导致分配落到非预期节点,以及监控工具是否改变了核心分布。

对齐要服务于访问模式

对象对齐不是越大越好。把所有对象都强行对齐到 128 字节,可能减少跨缓存线访问,却也会扩大对象尺寸、降低内存池容量,并使更多缓存线被无效占用。

如果对象起始地址没有合理对齐,一个对象可能跨越两条缓存线。访问它的关键字段时,处理器需要读取更多缓存线;当多个核心分别修改相邻对象中的不同字段时,还可能出现伪共享。对于订单状态、报价档位和队列节点这类高频更新结构,伪共享比单个对象跨缓存线更值得警惕。

设计对象布局时,可以把读多写少的字段、同一线程写入的字段和不同线程写入的字段分组处理。并不是每个字段都要独占一条缓存线,而是要根据生产者和消费者的关系决定边界。对齐参数应当配合对象尺寸、批量访问顺序和核心分工进行验证。

DPDK 支持对象对齐参数。64 字节通常对应常见缓存线大小,128 字节则可能适用于需要更大间隔的结构,但最终选择取决于目标处理器和数据布局。不要把缓存线大小当作一个永远不变的抽象常数,部署前应通过处理器信息和实际测量确认。

批量布局和通道交叉也会改变结果

内存控制器通常可以通过多个内存通道并行处理访问。对象地址如果集中在某个范围,或者池的布局与内存通道映射不理想,就可能造成通道负载不均。DPDK 的默认设计会尽量让对象地址分散到多个通道,但自定义内存布局、巨大页配置和节点绑定都可能改变最终结果。

批量获取对象时,还要考虑对象在内存中的排列顺序。连续对象便于预取和顺序访问,但如果多个核心同时处理相邻对象,也可能让缓存线在核心之间来回转移。把“连续性”当作绝对优点同样危险。对于单核心连续解析,连续布局通常有利;对于多核心并发写入,则需要留出足够的空间边界,避免伪共享。

真正有价值的优化,往往来自对访问模式的拆解:

  • 哪些字段由接收线程写入、策略线程只读;
  • 哪些字段会在风控和订单线程之间反复修改;
  • 对象是否会跨核心传递;
  • 对象的大小是否超过一个或多个缓存线;
  • 访问是顺序扫描,还是按价格档位随机查找;
  • 归还后多久会被再次取出;
  • 对象在归还后是否很快重新进入同一核心的工作集。

这些问题比“是否使用了某个特定内存池”更接近实际延迟。

从设计到验收:内存池不能脱离交易路径单独优化

内存池不是孤立组件。它会与网卡接收、报文解析、订单构造、风控检查、日志记录和线程调度共同组成一条延迟链路。只优化其中一个函数,往往只能得到局部漂亮的数字。

先画清对象的所有权

每一种对象都应该有清楚的创建者、使用者和归还者。对象从接收线程传给策略线程之后,谁负责归还?异常分支是否也会归还?撤单失败、连接重置和策略暂停时,仍然在途的对象如何处理?

所有权不清会造成两类问题。一类是重复归还,同一个对象被两个线程同时放回池中,最终破坏空闲对象链。另一类是遗漏归还,对象在某个异常分支中永远留在外面,池容量随着运行时间逐渐下降。

在高频系统里,内存泄漏不一定表现为进程内存不断增长。固定内存池中的对象被业务逻辑“遗忘”,同样会表现为可用容量减少,最终触发耗尽。监控必须同时记录系统总内存和每个池的在用对象数量。

不要让日志破坏热路径

日志是另一个经常把动态分配带回来的入口。格式化字符串、异常信息、调试上下文和异步日志队列都可能创建临时对象。如果交易线程为了记录一次订单状态而调用通用字符串拼接,前面设计好的内存池就可能在最后一步失去意义。

更稳妥的办法,是为热路径设计固定格式的事件结构,先把必要字段写入预分配的环形缓冲区,再由独立线程完成格式化和落盘。日志内容可以降级,交易线程的时间边界不能随日志系统的拥堵而改变。

同样的原则适用于指标采集。不要在每个对象申请时创建临时标签、字符串或动态容器。可以使用预先定义的计数器和固定维度,把统计工作从订单路径剥离出去。

预分配不等于无限预留

很多系统在优化时走向另一个极端:所有对象都提前分配,所有缓冲区都按最大值准备,最后服务器的内存被大量闲置对象占满。这样做确实可能降低分配抖动,却会损害非统一内存访问局部性、缓存利用率和整体容量。

合理的预分配应该有边界。对关键热路径,可以按峰值负载加上明确的安全余量;对低频控制面和异常面,则没有必要占用同等规模的固定池。不同对象应当根据交易风险和生命周期分别配置,而不是简单地把所有池都扩大。

容量规划还要考虑每核心缓存带来的重复预留。共享池里有一批对象,本地缓存里也有一批对象,多个核心加起来之后,真正可被其他核心立即使用的数量可能比配置值小得多。压测时要把这些隐藏占用统计出来。

验收应当以失败模式为中心

一套内存池设计是否合格,不能只看它在正常流量下跑得多快。更应该问:它在最坏情况下会怎样失败。

至少需要验证:

1. 本地缓存耗尽时,批量补充是否仍然满足延迟边界;

2. 共享池接近耗尽时,是否会触发隐式堆分配;

3. 单个核心突发流量时,其他核心是否能合理获得对象;

4. 生产者和消费者跨非统一内存访问节点时,尾延迟会增加多少;

5. 线程被监控、软中断或后台任务打断时,系统是否出现不可接受的毛刺;

6. 异常退出、连接重置和策略切换后,对象是否全部归还;

7. 长时间运行后,内存池的可用容量和延迟分布是否发生漂移;

8. 日志、指标和错误处理是否会绕过内存池重新进入通用堆分配。

这些测试结果应当保存为延迟直方图和资源使用曲线,而不是只留下一个“平均几十纳秒”的宣传数字。上线后的监控也应沿用同样的指标,否则压测和生产会变成两套互相无法解释的语言。

底线:把确定性当作内存管理的第一目标

内存池的本质,不是把分配做得更快,而是把分配做得可预测。在高频交易里,可预测的几百纳秒,往往比波动的几十纳秒更有价值。前者可以被纳入风控和下单节拍,后者只能在成交回放中解释为什么某一笔订单偏偏慢了。

把对象生命周期前置,把容量按极端负载规划,把批量获取和归还作为基本操作,把缓存绑定到本地核心,把非统一内存访问拓扑固定下来,再用高分位延迟而不是平均值验收。除此之外,还要把池耗尽、线程抢占、日志回退、容器扩容和异常分支全部纳入测试。

对于 glibc 线程本地缓存、TLSF 等方案,资料中的参数和基准数字都应当保留原本的实验边界。特定版本、特定处理器或特定编译条件下得到的结果,可以帮助我们理解机制、比较实现,却不能直接变成现代高频交易服务器的生产承诺。生产环境的延迟必须在目标机器、目标负载和完整交易链路中重新测量,尤其要观察高分位和最坏样本。

做不到这些,内存池很可能只是一个比 malloc 略快的替代品;做到了,它才算是交易系统的基础设施。它不负责创造策略优势,却能避免策略优势被底层运行时一次又一次地打断。

最后,别只在平稳行情里压测。让系统经历批量快照、报文突发、订单激增、跨核心传递和池容量逼近上限,再去看高分位延迟和最差样本。平均延迟贴在屏幕上很好看,但真正决定交易系统生死的,永远是那一次最不该发生、却偏偏发生了的延迟抖动。

Related reading: 量化策略回测指标的虚与实:如何看穿超高收益率背后的陷阱.

常见问题

为什么高频交易系统不能直接使用 glibc 的内存分配?
通用分配器需兼顾吞吐量、碎片控制等多种需求,其内部的锁竞争、复杂的空闲块扫描路径以及不可预测的回收策略,会导致交易路径出现不可控的延迟抖动。
如何避免内存池在多线程环境下的锁竞争?
采用每核心缓存(Per-core cache)架构,让绝大多数分配请求停留在当前核心,仅在本地缓存耗尽或溢出时才与共享池进行批量交换,从而摊薄跨核同步成本。
内存池容量应该如何规划?
不能仅依据平均并发量,必须根据极端负载(如行情突发、批量快照、重连等)进行规划,并预留明确的安全余量,同时需在设计中明确池耗尽时的处理逻辑。
C++17 的 PMR 内存资源适合高频交易吗?
适合。PMR 的单调缓冲区(monotonic_buffer_resource)通过将短生命周期的对象集中管理并一次性回收,能有效减少碎片并控制延迟,但需确保生命周期划分清晰且避免在热路径中触发容器扩容。
如何验证内存池在高频场景下的性能?
应在目标机器上模拟真实负载,重点观察第 99.99 百分位延迟及最坏情况下的抖动,并覆盖碎片累积、多线程竞争、NUMA 跨节点访问及池耗尽等极端测试场景。