2026年7月27日
毁掉我尾延迟的那个引导连接监听器
一个看似无害的非阻塞 accept() 循环,让低至数微秒的 libfabric 路径出现了毫秒级尾部停顿。
我正在为 Goblin Core 的 AWS EFA 低延迟网络路径做验证,一段本应无害的 TCP 代码却开始让整个服务器停顿。
我正在为 Goblin Core 添加低延迟 AWS 网络支持。
AWS EFA 通过 libfabric 暴露网络接口。租用昂贵的云端机器以后,才发现客户端、服务器、完成队列、接收缓冲区或小消息处理存在问题,实在不是好时机。因此,我先在自己的硬件上验证了完整的 libfabric 路径。
我用一条直连的 100 Gb/s Mellanox ConnectX-5 链路,连接了两台已有 14 年历史、各带四个 CPU 插槽的 Intel Xeon 服务器。
按照云计算的标准,这些机器已经很古老了,但它们仍然是有用的测量仪器。更重要的是,它们属于我。我可以绑定进程、排除干扰、修改服务器、重新运行测试,并一直调查下去,直到真正理解结果。
我测试了多种组合:
- 普通的内核 TCP;
- libfabric 的软件
tcpprovider; - 通过 Mellanox 硬件运行的
verbs;ofi_rxm; - Redis RESP2 和 Goblin Core 的紧凑 SBE 协议;
- 自动小消息注入和强制使用
fi_send。
每项测试的流水线深度都是一:发送一个操作,等待完整响应,验证响应,然后再发送下一个操作。
这是延迟测试,不是试图填满一条 100 Gb/s 的链路。
第一个结果
在八种 Redis 风格的操作上,最快配置的往返延迟 p50 算术平均值为 6.39 微秒。
普通的 Redis 兼容基线——内核 TCP 上的 RESP2——耗时 90.33 微秒。
| 路径 | 协议 | p50 平均值 |
|---|---|---|
| 内核 TCP | RESP2 | 90.33 µs |
Libfabric tcp |
RESP2 | 39.59 µs |
verbs;ofi_rxm |
RESP2 | 7.38 µs |
verbs;ofi_rxm |
SBE | 6.39 µs |
最快路径的延迟降到了普通内核 TCP 加 RESP2 的 1/14.1。
这八种操作是:
PING、SET、GET、HSET、HGET、ZADD、ZSCORE 和 Pub/Sub。
Pub/Sub 测试并没有在服务器确认 PUBLISH 时停止计时。只有当另一条独立连接上的订阅者收到并验证了消息,计时才结束。
每个分布都包含 20,000 次预热操作,随后进行 200,000 次计量操作。每个响应都经过正确性检查。
RESP 并不是主要问题
最快配置使用了 SBE,也就是 Goblin Core 的紧凑二进制协议。
但在相同的 verbs;ofi_rxm 传输上,RESP2 的平均延迟仍然只有 7.38 微秒。
这一点很重要。
二进制格式确实有帮助,但大部分改善来自传输方式以及围绕它的软件路径变化。通过 libfabric verbs 运行的 Redis 兼容 RESP,仍然比 Goblin Core 普通套接字实现上的 RESP 快十二倍以上。
就连 libfabric 的软件 tcp provider 也很有意思。使用 RESP2 时,它的平均延迟为 39.59 微秒,而普通内核 TCP 路径则是 90.33 微秒。
这并不能证明 libfabric TCP 在所有情况下都比套接字更快。它说明的是一个更窄的结论:在这台服务器上,很大一部分延迟存在于网络本身的上层或周边。
一块 100 Gb/s 网卡不会自动赋予应用程序低延迟。
解析器、缓冲区所有权、系统调用模式、轮询模型、完成事件处理、NUMA 放置以及应用架构,仍然决定着一个请求能以多快的速度变成一个经过验证的响应。
然后尾部出了问题
中位数看起来很好。
尾部却不是。
较早的一轮验证中,SBE/RDM 的平均 p99.99 大约为 857 微秒。
与个位数微秒的中位数放在一起,这个数字大得荒唐。一定有什么东西在周期性地让服务器停下来。
我首先怀疑的是消息重排。
这是个合理的解释。客户端维护着一个有界重排窗口;一条丢失或延迟的序列,可能让后续消息都在它后面等待。
于是,我为这条路径添加了测量工具。
在将近两百万条发布者响应和 220,001 条订阅者响应中,客户端观察到:
- 没有序列缺失;
- 没有重复;
- 没有重排窗口溢出;
- 没有滞留帧;
- 没有仍在等待的重排消息。
服务器的请求计数器也完全正常。
传输层并没有失去对消息的控制。
造成停顿的原因要无聊得多。
引导 TCP 监听器
Goblin Core 的轮询服务器仍然保留着一个普通 TCP 监听器,用于建立引导连接。
每次经过自旋循环时,无论是否有连接正在等待,服务器都会调用一次非阻塞 accept()。
这些调用大多返回 EAGAIN。
看起来没有危害。
事实并非如此。
在 Linux 上,即使一次失败的 accept(),也可能在发现没有连接可接受之前创建并销毁与套接字、文件和 inode 有关的对象。
这些对象最终都必须被回收。
跟踪结果显示,RCU 软中断会成批反复运行,其中一些持续了大约 0.4 到 1.1 毫秒。
那条光鲜的 100 Gb/s 低延迟路径,会周期性地被一个引导监听器拖住;它只是在反复询问 Linux,门外是否有人。
修复
修复很简单:
调用 accept() 前先检查监听器是否就绪,只有确实有连接等待时才把它们处理完。
修改前的一次跟踪中,有 4,119 次 RCU 软中断运行时间超过 100 微秒。
修改后的一次两秒活动跟踪中,每次软中断运行都保持在 100 微秒以内。最长的一次是 20.6 微秒。
紧接着进行的对照测试,把 SBE/RDM 的平均 p99.99 从:
857.47 微秒
降到了:
29.47 微秒
后来一次完整的 provider 矩阵测试再次复现了结果,测得 30.83 微秒。
中位数原本就已经很好。
一种不必要的系统调用模式却毁掉了尾部。
无聊的代码同样拥有整台机器
这个监听器不属于设计中有趣的部分。
它不是 RDMA。
它不是紧凑协议。
它不是 Redis 数据结构。
它不是 Mellanox 网卡。
它只是一条很小的引导路径,在错误的循环里做着不必要的工作。
这种情况在低延迟系统里很常见。最受瞩目的核心机制可能完全正确,而附近某个看似无害的东西却主导了失败分布。
唯一可靠的应对方式,是测量完整系统、保留异常结果,并追踪背后的机制,而不是设法把异常解释掉。
这项测试证明了什么
这项工作在两台物理机器之间验证了 Goblin Core 的 libfabric RDM 实现,其中包括:
- 真正的请求与响应验证;
- RESP2 和 SBE 两种协议;
- 小消息注入;
- 显式
fi_send和发送完成事件处理; - 预先发布的接收缓冲区和接收完成队列;
- 通过独立发布者与订阅者连接运行的 Pub/Sub;
- 有界消息重排;
- 稳定的低微秒级中位数;
- 修复监听器后,处于几十微秒范围内的 p99.99。
它还表明,与网卡上印着的标称速率相比,传输方式和周围的软件架构可能重要得多。
它没有证明什么
这不是 AWS EFA 硬件基准测试。
这些本地机器没有原生 efa provider。我使用的是 AWS 的 libfabric 构建,通过直连的 ConnectX-5 以太网链路运行其中的 tcp 和 verbs;ofi_rxm provider。
EFA 仍然需要单独测试。
这项工作的目的,是带着一个已经验证的实现、一个可复现的基准测试工具、经过验证的发送和接收路径,以及一条已知可靠的本地基线进入 AWS。
而不是把本地 verbs 结果改名叫作 EFA 结果。
我喜欢的部分
这是我最喜欢的那类系统问题。
从一个差得出人意料的数字开始。
拒绝用平均值把它抹掉。
为最明显的嫌疑对象添加测量工具。
发现最明显的嫌疑对象是无辜的。
跟踪整台机器。
找到一小段无聊的代码;它在一个微秒级系统中制造着毫秒级停顿。
修好它。
重新运行所有测试。
最终得到的不只是一个更快的基准测试结果,而是一个解释。
现在,代码已经为下一台机器做好了准备。