F-Stack 2.0 前瞻 4 : F-Stack ff_rss_check 优化实践 2 – 从静态端口表到 rte_thash 反算,多队列客户端选源端口的三层加速

1. 本功能主要作用和特点

F-Stack 在多队列/多进程(share-nothing)部署下,进程作为客户端主动发起 TCP/UDP 连接时,必须选出一个”聪明”的本地源端口——让该连接的回包(SYN-ACK、响应数据)经网卡 RSS 哈希后落回发起连接的这个进程的接收队列。选错队列的后果是连接建立不起来或数据”串门”到别的进程,这在多进程架构下是致命的。这个选端口的活就是 ff_rss_checklib/ff_dpdk_if.c)干的。

选端口有两个约束:既要保证回包落本队列(RSS 亲和),又要保证端口没被占用(四元组唯一)。原始实现是逐端口软算 Toeplitz hash 扫描,平均每次建连要几百个 tsc、且在高进程数下可能反复重试几十次,成为短连接场景的建连瓶颈。

本功能围绕这条选端口路径做了三层优化:

  • 静态表快路径(上游 F-Stack 官方已有,commit e54aa4317,随 13.0→15.0 升级一度丢失后回迁):预先算好”对某个远端四元组,哪些本地端口落本队列”,建连时直接查表轮转取端口,省掉逐端口软算。
  • rte_thash 反算路径(本仓库超越上游的新增):静态表未命中时,用 DPDK 的 rte_thash_adjust_tuple() 按目标队列直接反算出满足约束的源端口,替代逐端口软扫描

【注意】thash反算可选端口范围受限(因为只是改动部分bit位),所以并发连接很大,所需端口很多时需要斟酌使用。

  • IPv6 全链路(上游也没有,全新增):把上面两条路径,以及最早的 ff_rss_check 完整扩展到 IPv6(16+16+2+2 的 36 字节 tuple)

另外还有两处配套:反算后的二次软算复核做成运行时开关(默认关,性能优先)、bind(addr,0) 后 connect 的端口延迟分配(对齐 Linux 的 IP_BIND_ADDRESS_NO_PORT 语义 + RSS 亲和)。

这套东西就一个特点:热路径分层降级、每层独立可用、错队列零容忍。静态表命中走最快路径,未命中走 thash 反算,反算失败回退软算扫描(端口都被占用不会回退),任何一层都保证最终选出的端口经独立软算确认落本队列(或按开关明示接受轻微分发不均)。

2. 本功能的主要适用场景

2.1 多进程/多队列部署下的客户端短连接

这是最典型的场景。F-Stack 经典部署是 1 primary + N secondary 共享网卡队列,每个进程只收自己队列的报文。进程做客户端(比如 nginx 反向代理连上游、网关主动探测)时,如果选出的源端口哈希不到自己的队列,回包就落错进程。连接越短、建连越频繁,选端口的开销占比越大,本功能收益越明显。

2.2 反向代理 / 网关类高频建连

nginx_fstack 反代场景:客户端用长连接打进来,nginx 作为客户端用短连接连上游。每一条上游连接都要走一次选端口,静态表对这种”远端地址相对固定”的场景命中率极高(上游规则里配好常用的 upstream 即可)。

2.3 需要精确控制回包落核的多队列应用

任何关心”回包落本核”的应用都适用——不只是性能问题,多进程架构下回包落错队列意味着功能不可用。

2.4 不太适合的场景

  • 纯服务端 listen + accept 的应用(选端口只发生在主动 connect,纯监听不触发),用不到本功能
  • 远端地址完全随机、无法预先配置静态表、且并发连接特别高的场景(静态表不命中,退化为 thash 反算(可选端口范围受限严重,斟酌使用)/软算,功能正确但收益打折)
  • 单队列部署(只有一个队列,选什么端口都”落本队列”,走的是零开销的短路路径)

3. 本功能的架构特征

3.1 选端口三层决策路径

一条 connect 进来,内核侧 in_pcb_lport_destfreebsd/netinet/in_pcb.c)按下面的顺序选择源端口:

三层路径逐层降级、各自独立:静态表是纯查表(最快),thash 反算是数学反解(次快,单候选百 ns 级),软算扫描是全量 Toeplitz 计算(最慢但永远正确)。任何一层失败都不影响正确性,只是慢一点。

3.2 反算的核心:按”回包字段序”定位源端口

这里有个非常反直觉的设计点,是整个 thash 反算正确性的基石。

Toeplitz hash 是非对称的:hash(src, dst, sport, dport) ≠ hash(dst, src, dport, sport)。出向包(本地→远端)和回包(远端→本地)经网卡 RSS 落不同队列。我们反算源端口的目的是让回包落本队列,所以必须按回包的字段序构造 tuple:

出向包:srcIP=local  dstIP=remote  srcPort=local  dstPort=80
回 包:srcIP=remote dstIP=local   srcPort=80     dstPort=local ← 本地端口在 dstPort 字段!

因此反算的 helper 要加在回包 tuple 的 dstPort 字段位上:IPv4 tuple 12 字节,本地端口在 byte10 = bit80(不是出向包的 sport 字段 byte8 = bit64);IPv6 tuple 36 字节,本地端口在 byte34 = bit272(不是 bit256)。代码里 FF_RSS_THASH_V4_SPORT_OFF=80FF_RSS_THASH_V6_SPORT_OFF=272lib/ff_dpdk_if.c:176/187),注释里明确写了这个”回包 dstPort 字段”的理由。

【注1】这个设计不是一开始就对的。最早的实现按出向包 sport 字段反算(v4 offset=64),结果 listen 成功但连接不通——出向包落对了、回包落错了。修正成 dstPort 字段后才端到端打通。详见 4.5。

3.3 三方 key 对齐架构

thash 反算要成立,有个前置条件:反算用的 RSS key、软算复核用的 key、网卡实际用的 key 三者必须完全一致。架构上做了两件事保证:

  • ff_rss_thash_build_key(port_id, reta_size)lib/ff_dpdk_if.c:3459)在 dev_configure 之前串行构造 v4→v6 的 thash ctx,并把构造产出的统一 key(KEY_FINAL)发布到全局 rsskey,由调用方在 dev_configure 时编程进网卡——三方从此共用同一把 key
  • secondary 进程走 rte_thash_find_existing 复用 primary 已建的同名 ctx,不再各自独立初始化(这也顺带解决了多进程下 ctx 重复初始化的 EEXIST 报错)

4. 具体做了哪些改造、遇到了哪些问题

后续比较枯燥,不需要深入研究实现过程的可以跳过本节,直接看第 5 节怎么用。

4.1 起点:上游的静态表优化(e54aa4317)

这套机制的源头是 F-Stack 官方的一次优化(commit e54aa4317,官方 wiki 有介绍,公众号文章 https://mp.weixin.qq.com/s/x5wWecqEKWGdcVbhne78tw)。核心思路就是空间换时间:启动时按配置的 (本地地址, 远端地址, 远端端口) 规则,把”哪些本地端口会哈希到本队列”预先算好存进 ff_rss_tbl,建连时查表轮转取端口,把”动态计算”变成”静态查找”。官方实测:常规场景 QPS 提升约 2~6%,特殊场景(8/16 进程)提升可达 36.94% / 38.79%——某些进程数配置下原始实现的随机选端口次数远超数学期望(8 进程时实测要试 46~60 次,理论期望 8~12 次),查表直接把这部分开销干掉了。

4.2 问题一:13.0→15.0 升级把内核侧钩子丢了(R-A 回迁)

升级 FreeBSD 13.0 → 15.0 时,用户态接口(ff_rss_checkff_rss_tbl_init/set/get_portrange 等)都完整保留了,但内核侧消费这些接口的 #ifdef FSTACK 钩子没有移植——15.0 的 in_pcb.cINPLOOKUP_LPORT_RSS_CHECK 宏只剩一个没人用的 #define,整个 RSS 选端口机制在内核侧完全失效,退回原生随机选端口。

修复(commit 22462f58d):按 15.0 的代码结构重新回迁。15.0 相对 13.0 有三个适配点:in_pcb_lport_dest 形参变成了 const struct inpcb *(RSS 逻辑只动局部变量);in_pcbconnect_setup 被合并进 in_pcbconnectff_in_pcbladdr 的对接点跟着挪);lookup 系列调用统一多了 RT_ALL_FIBS 入参。回迁后真机实测:primary/secondary 各 connect 200 次,200/200 全部落本队列。

4.3 改造二:thash 反算替代逐端口扫描(R-B)

静态表命中是快路径,但未命中时仍要逐端口软算。用 DPDK 24.11.6 的 rte_thash_adjust_tuple() 反算可以把这个 O(端口数) 的扫描变成直接反解。关键是把”落本队列”精确翻译成反算的 desired_value:本仓库落队列判定是 ((hash & (R-1)) % Q) == queueid,所以 desired = queueid + (rand % ceil(R/Q)) * Q,让反算出的 hash 低位精确落进目标队列的集合。反算成功后再用软算 ff_rss_check 复核一遍(可选,用于DEBUG,默认关闭提升性能)。adjust_sport 内 attempts 用尽(如端口都被占用,因为反算端口范围有限)则返回 -1,由内核侧 in_pcb_lport_dest 的 do-while 全端口扫描 + ff_rss_check 软算兜底

4.4 改造三:IPv6 全链路(R-C)

上游的静态表是 IPv4-only。按方案 A 全新增 v6 独立符号(ff_rss_check6ff_rss_tbl6_*ff_rss_adjust_sport6),不动 v4 任何结构和签名。内核侧在统一的 in_pcb_lport_dest 里加 AF_INET6 并行分支、in6_pcbladdr 对接。IPv4 零回归的证据是硬邦邦的:R-C 相对 R-B 的 git diff 里 in_pcb.c 是 +86/-0,纯新增无一行 v4 删除。

4.5 问题二:Toeplitz 非对称——反算要按回包字段序(c42340d5 / R-G)

thash 反算落地后,物理机上出现诡异现象:listen 成功但连接不通,IPv4 正常、IPv6 不行(后来确认是两版都有这个坑,先修的 v4 后对称修 v6)。根因就是 3.2 讲的 Toeplitz 非对称:最初按出向包的 sport 字段位反算(v4 offset=64、v6 offset=256),结果出向包落对了队列、回包 SYN-ACK 落错队列,握手当然完不成。

修复(IPv4 commit c42340d5,IPv6 对称移植 R-G):把 helper offset 改到回包 dstPort 字段位——v4 64→80、v6 256→272,tuple 按回包字段序填充,复核也按回包字段序调 ff_rss_check。修复后物理机端到端验证:v6 多队列主动 connect 的回包 100% 落本进程队列、0 落错,IPv4 零回归。

4.6 问题三:add_helper 改写 key 导致三方 key 不一致(R-F)

修复 offset 后单测里发现一个更隐蔽的问题:thash 反算的”单候选等价率”只有 ~22-27%(意味着反算出的端口大概率过不了软算复核,得重试 3~6 次)。排查到最后发现根因是 rte_thash_add_helper 会用 LFSR 原地改写 ctx->hash_key——于是 rte_thash_adjust_tuple 用改写后的 key 反算,而 ff_rss_check 软算和网卡硬件用的是原始 key,三方 key 不一致,那 22-27% 纯属巧合命中。

修复:ff_rss_thash_build_key 在 dev_configure 前串行构造 ctx、发布统一 KEY_FINAL 到全局 rsskey 并编程进网卡,三方对齐后等价率应达 ~100%。

4.7 改造四:recheck 复核默认关闭(R-D)

三方 key 对齐后,反算结果的可信度上来了,那道”反算成功后强制软算复核”的保险丝就成了纯开销——microbench 实测单次 ff_rss_check 复核约 99.4 ns/call,而 recheck=0 的热路径只要 ~0.31 ns/call,差了约 300 倍;换算到每连接(平均 3.9 次 adjust 调用)约省 390 ns,v6 reta=512 长尾场景每连接能省约 2 us。

于是把复核做成运行时开关 recheckconfig.ini [rss_check] 段),默认 0(性能优先),debug/运维时开 1 维持零容忍硬门。失败兜底链(attempts 用尽 → 软算扫描)不受影响。

4.8 改造五:bind-then-connect 端口延迟分配(R-E)

还有一个漏网路径:应用先 bind(local_addr, 0)connect(remote)。原生 FreeBSD 在 bind 阶段就分配了匿名端口,connect 时发现”端口已经有了”就绕过了 RSS 选端口逻辑,选出的端口不落本队列。这正好对齐 Linux IP_BIND_ADDRESS_NO_PORT 的语义——端口推迟到 connect 按完整四元组分配。

修复(commit ff9e3c449,+16/-1,全宏门控):v4 的 in_pcbbind 入 hash 块加 lport != 0 门控、in_pcbbind_setup 的端口分配加 #ifndef FSTACK 门控;v6 对称改造。bind 阶段不固化端口 → connect 天然进 RSS 选端口分支。另有一个有意思的坑:nginx 会先调 setsockopt(IP_BIND_ADDRESS_NO_PORT),这个选项的 Linux 数值 24 恰好撞上 FreeBSD 的 IP_BINDANY=24——v4 被静默误设成透明代理、v6 直接 EINVAL。在 syscall 转换层拦截(commit a2537e143)后彻底解决。

4.9 测试与验证情况

  • 单测:39 run / 36 PASSED / 3 SKIPPED(3 个 SKIPPED 是 DPDK EAL 在单测环境无法 init 的既有降级),v4/v6 full-loop 落队列 100% 硬断言全过
  • 真机(R-A 软算路径):primary/secondary 各 200 connect,200/200 落本队列
  • 物理机(v6 reverse-path):具备真实 v6 RSS 能力的网卡上端到端通过,0 落错
  • 本机 virtio reta_size=0:thash ctx 无法初始化,真机上走的是软算降级路径(这恰好验证了降级链的正确性),thash 路径的正确性由单测 reta=128/512 全量覆盖

5. 如何使用、如何配置、使用效果

5.1 配置

config.ini 的 [rss_check] 段(示例):

[rss_check]
# 总开关:1=启用静态 RSS 表选端口,0=关闭(默认)
enable=1
# debug 开关:1=thash反算成功后强制软算复核(默认 0,性能优先)
recheck=0
# thash 反算开关(与静态 RSS 表的 enable 解耦):1=多队列下启用反算(默认),0=只走软算扫描
thash_adjust=1
# 静态表规则:<网卡端口ID> <本地地址> <远端地址> <远端端口>,多条用分号分隔
rss_tbl=0 192.168.1.1 192.168.2.1 80;0 192.168.1.1 192.168.2.1 443

静态表规则建议配好常用的 upstream(远端地址+端口),命中率越高收益越大;同一 saddr/sport 二元组最多 16 个、同组最多 4 个 daddr,超出的配置被忽略。

5.2 使用效果

静态表路径(官方实测,e54aa4317 时代数据):

lcores原始动态 QPS静态表 QPS提升
138,09338,456+0.95%
273,56675,009+1.96%
4139,205142,325+2.24%
6196,471202,068+2.85%
8201,823276,368+36.94%
12371,362394,151+6.14%
16398,376552,894+38.79%

常规场景 2~6%,8/16 进程这种”原始实现随机重试爆炸”的场景 35%+。

thash 反算路径(本仓库实测):

  • recheck=0 热路径 ~0.31 ns/call vs recheck=1 的 ~99.5 ns/call(microbench,约 300 倍差距)
  • 每连接省约 390 ns(v4)、v6 reta=512 长尾省约 2 us
  • full-loop 落队列 100%(单测硬断言),错队列零容忍在 recheck=1 下由软算复核守护

5.3 注意事项

  • 静态表与 thash 反算是”加速层”,最终正确性由软算兜底链保证,任何一层失败自动降级,不需要手动干预
  • 在不支持RSS的环境(如本机 virtio 网卡) reta_size=0,thash 反算在真机会自动降级为软算——这是设计行为不是 bug

6. 延伸阅读:

F-Stack 2.0 前瞻 3 : F-Stack 用户态栈与内核栈自动双栈共存 – 一个 listen 同时服务 DPDK 网卡和本机回环

1. 这功能解决什么问题

F-Stack v2.0(预计 2026.10 正式 release)引入的内核栈共存能力,解决 F-Stack 最经典的一个使用痛点——DPDK 接管网卡后,本机 curl 自己监听的服务会被内核报 Connection refused。

F-Stack 把网卡绑定给 DPDK 后,这张网卡上的流量完全绕过 Linux 内核协议栈,直接进 F-Stack 用户态 FreeBSD 栈。后果是:你在 F-Stack 里 listen 了 80 端口,从同网络另一台机器 curl 你的网卡 IP 能通,但在本机 curl 127.0.0.1 或 curl 本机 IP 会报 Connection refused——因为本机请求走的是 Linux 内核栈,而内核对 F-Stack 的这个 80 端口一无所知。

这个现象在 issue 里被反复问,官方给的标准答案一直是”从别的机器测”或者”用 KNI 回灌”。issue #511/#585/#741/#849 全是同一件事。

我们做的就是把这件事从”手动 workaround”变成”默认行为”:同一个 socket、同一个 listen(80),同时跑在 F-Stack 用户态栈(DPDK 网卡,业务高速路径)和 Linux 内核栈(本机回环/管理面)上。远端 curl 网卡 IP 走 F-Stack,本机 curl 127.0.0.1 走内核,两条路同时通,而且两栈的事件在同一个 epoll/kqueue 事件循环里统一处理。

【注意】F-Stack 用户态栈始终在位、始终承担业务高速路径,内核栈只是”并行附加”的第二条栈,用来承接本机/管理面/客户端访问,绝不是在旁路或替代 F-Stack。

这里先说清楚它不是什么,免得理解偏差:

  • 不是把 socket 旁路到内核(早期有个错误实现就是这么干的,后文 4.1 详述,已回退)
  • 不是”整进程默认走内核栈”(那是反 F-Stack 的,明确不做)
  • 不是 KNI 报文回灌(那是另一套独立机制,跟本功能无关)
  • 不是连接迁移/透明代理(一个 TCP 连接物理上只存在于收到 SYN 的那一栈,无法”双栈”)

2. 主要适用场景

2.1 本机直访自己监听的服务

这是最直接的场景。开发和运维时,你总想在本机 curl 一下自己刚起的服务确认存活,而不是每次都要开另一台机器。开了双栈后,本机 curl 127.0.0.1:80 直接通。

2.2 服务端双向可达

同一个 listen(80):远端经 DPDK 网卡访问 <DPDK_NIC_IP>:80 走 F-Stack 高速路径,本机经 127.0.0.1:80 走内核栈,一个 socket 两用,不用为内核侧额外开一个 SOCK_KERNEL socket,也不用写任何 marker。

2.3 客户端连内核服务

作为客户端要连本机或外部的内核服务(比如本机的某个守护进程、管理面 API),用双栈 connect 或纯内核的 SOCK_KERNEL 都能做到,不用绕道。

2.4 需要内核可见性的监控场景

F-Stack 监听端口在 Linux 的 ss/netstat 里是看不到的(那是用户态栈)。开了双栈后,ss 能看到内核侧的 80 监听,监控和健康检查都方便了。issue #593/#594 里官方也拿 kernel_coexist 当”让端口内核可见”的答案。

2.5 不太适合的场景

  • 需要真·双工于两栈的单条连接(一条 fd 上的数据要么走 F-Stack 要么走内核,不可能同时两栈都收发)——默认双栈 connect 只是”两栈各建一条、F-Stack 主”,纯内核客户端请用 SOCK_KERNEL
  • 用 select/poll 做多路复用的场景(后文 4.6 详述,内核 fd 装不进 fd_set,不共存)
  • 想省掉 F-Stack 只留内核的场景(本功能不提供”整进程默认内核”,那是旁路)

3. 架构特征

3.1 一个 listen 双栈服务

3.2 fd 三态路由

这是整个功能的核心模型。一个 fd 进来,按数值区间和映射表分成三种形态:

三类 fd 空间互不冲突:F-Stack fd < 65536,内核 encode fd ≥ 0x40000000,host fd 受 RLIMIT_NOFILE。

【注意】关键设计是”热路径不查 map”。accept 出来的连接 fd 一定是单栈的——F-Stack 侧连接返回原始 fd,内核侧连接返回 encode fd。之后 recv/send 只做一次 ff_is_kernel_fd 判断就路由了,不查映射表,数据热路径零额外开销。双建/双驱动只发生在 socket/bind/listen/close 这些一次性操作上。

3.3 双层开关保证零回归

三重零回归保证:编译宏关、运行期开关关、SOCK_FSTACK marker,三者任一满足就退化成纯 F-Stack。

4. 改造工作与遇到的问题

后续比较枯燥,不需要深入研究实现过程的可以跳过本节,直接看第 5 节怎么用。

4.1 走过的弯路:v3 纯内核旁路(已回退)

这个功能不是一步到位的,中间踩过一个方向性的大坑。

最早一版(v3,commit 0748eff94)把 ff_socket(SOCK_KERNEL) 直接接到纯宿主的 socket(),完全绕开 F-Stack。看似解决了”本机能通”,但这是根本性错误——它旁路了 F-Stack,违背了”F-Stack 始终在位”的铁律。性能基线报告也坐实了这个问题:v3 测的 A/B 两版本都是纯内核,根本没测”共存”。

回退后重新按正确范式做:应用跑在 F-Stack 上,per-fd 用 SOCK_KERNEL 附加走内核栈,两者同进程共存。

【注3】这个弯路值得记住:共存不是”要么 F-Stack 要么内核”的二选一,而是”F-Stack 为主、内核并行附加”。一旦做成旁路,性能基线、稳定性评估全都会失真。

4.2 v5:编译宏门控 + per-fd 二选一

回退后第一版正确实现是 v5(commit ba148589d),做了两件事:

  • 用编译宏 FF_KERNEL_COEXIST 把全部共存代码包裹起来,lib/Makefile 默认关,保证宏关时 libfstack.a 与原 F-Stack 逐字节一致(nm 比对共存符号为 0)
  • per-fd 二选一语义:带 SOCK_KERNEL 建内核 fd(编码成 ≥0x40000000 的高位 fd),否则走 F-Stack 原路径

这一版已经实测可用,但语义是”每个 fd 自己二选一”。想本机也通,得额外写一个 SOCK_KERNEL 的 socket,麻烦。

4.3 v6:默认升级为自动双栈

v6(commit 13b418191)把默认语义从”二选一”升级为”自动双栈”:

  • 默认(无 marker)的 ff_socket 同时建 F-Stack fd + 内核 host fd,登记 ff_native_fd_map[fstack_fd]=host_fd,返回 F-Stack 原始 fd
  • ff_bind/ff_listen/ff_close 对该双栈 fd 同时驱动两栈
  • marker 变成”单栈覆盖”:SOCK_KERNEL 仅内核、SOCK_FSTACK 仅 F-Stack

核心新增是那张 65536 项的无锁映射表 ff_native_fd_map(仿 adapter 的 fstack_kernel_fd_map),以及 accept 的”单栈归属”——双栈 listen fd accept 返回单栈连接 fd,F-Stack 侧连接返回原始 fd、内核侧连接返回 encode fd。

4.4 踩到的坑:头改动没 clean 重编导致 ABI 偏斜

性能基线实测时踩了个构建卫生的坑:给 struct ff_config 的 stack 子结构加了 int kernel_coexist 字段后,改变了它后面 log 子结构(含 log.f)的偏移。而 lib 的 Makefile 不跟踪头依赖,增量构建残留了混用新旧 ff_config.h 布局的 .o——ff_log.o 用旧偏移读 log.f,读到了新布局里别的非零字段,fclose 直接段错误。

修复很简单:清掉全部 .o 和 libfstack.a 全量重编。但这暴露了一个规律——对 ff_config.h 这类结构头的改动,必须 clean 后全量重编 lib,增量编译会掩盖 ABI 偏斜。

4.5 踩到的坑:kqueue 模型应用感知不到内核侧事件

R7 落地后自动双栈对 ff_epoll_* 是完整的,但直接用 ff_kqueue/ff_kevent 的应用(比如 example/main.c 用的就是 kqueue 模型)感知不到内核侧连接。实测:内核 TCP 完成了握手、GET 进了内核缓冲并被 ACK(抓包 ack 73 吻合),但应用永不被唤醒去 accept 内核 listen fd,本机 curl 127.0.0.1:80 返回 http_code 000(6 秒超时)。

根因:ff_kqueue/ff_kevent 完全没有 FF_KERNEL_COEXIST 路由,只把 F-Stack listen fd 注册进 F-Stack kqueue,双栈 listen 的内核侧 host fd 从未进入任何事件后端。

修复(R9,commit 03f244ac1):对称仿 ff_epoll 做 kqueue 共存——ff_kevent 的 changelist 里,ident 为内核 fd 或双栈 fd 的 EV_ADD/EV_DELETE 映射到 host epoll_ctl(EVFILT_READ↔EPOLLIN、EVFILT_WRITE↔EPOLLOUT),eventlist 先取内核就绪再合并 F-Stack 就绪。

4.6 踩到的坑:IPv6 双建端口冲突

还是 R9 发现的。开了 -DINET6 后,默认 ff_socket(AF_INET6) 双建,host 侧 IPv6 socket 绑 [::]:80 时,因为本机 net.ipv6.bindv6only=0,[::] 连带占用了 IPv4,跟同进程 host 侧的 0.0.0.0:80 冲突,实测 errno=98 EADDRINUSE,进程直接起不来。

修复:给 host 侧 IPv6 socket 设 IPV6_V6ONLY=1,让它只处理 IPv6、跟 host IPv4 同端口共存。

4.7 收口:补齐剩余接口的内核路由

R8(commit 55a84f313)补齐了 sendmsg/recvmsg/getpeername/getsockname/shutdown 的内核 fd 路由;R10(commit c6f5918b8 + 2422d12eb)又补齐了 readv/writev/ioctl/dup/dup2。其中有几个值得说的点:

  • ioctl 的 request 编码在 Linux 和 FreeBSD 不同源,内核 host fd 必须用原始 Linux request 直传 host libc,不能经 linux2freebsd_ioctl 翻译
  • dup2 一端内核 fd 一端 F-Stack fd 的”混栈”语义不成立,明确拒绝 errno=EINVAL,不臆造
  • select 不共存:encode 内核 fd(≥0x40000000)远超 fd_set 的 FD_SETSIZE(1024),装不进去,硬限制
  • poll 也不共存:合并复杂度高、回归风险大,保守降级为文档限制

内核 fd 想多路复用,用 ff_epoll_* 或 ff_kqueue 就行。

4.8 性能无回归

这是最关心的点,实测数据(v6 双栈口径,真机 wrk):

档位共存关 A0双栈开 A1Δ
T1 (-t2 -c10)28,21627,729−1.73%
T2 (-t4 -c100)202,805206,219+1.68%
T3 (-t8 -c500)120,702127,784+5.87%

吞吐差异全落在 trial 噪声内,p99 基本相等。逻辑上也说得通——双建成本只在 listen socket 建立时一次性付出,keep-alive 连接的数据热路径单栈、不查 map,所以 F-Stack 业务快路径无可测量回归。

5. 如何使用、配置与效果

5.1 编译

默认编译不含共存代码,要开启得编译时加宏:

cd f-stack/lib && make clean && make FF_KERNEL_COEXIST=1 -j$(nproc)

应用侧如果想用 marker(SOCK_KERNEL/SOCK_FSTACK),编译应用时也要加 -DFF_KERNEL_COEXIST(否则这两个宏不可见)。只用默认双栈则不需要。

5.2 配置

config.ini 的 [stack] 段加 kernel_coexist:

[stack]
# 0=禁用(纯 F-Stack),1=启用(默认 socket 自动双栈)
kernel_coexist = 1

注意:这个运行期开关只在编译宏开了 FF_KERNEL_COEXIST 时才生效。编译宏关,或者这里设 0,都是纯 F-Stack,零回归。

5.3 用法

默认双栈(什么都不用加):

int s = ff_socket(AF_INET, SOCK_STREAM, 0);   /* 双栈:F-Stack fd + 内核 host fd */
ff_bind(s, &addr80, sizeof(addr80));           /* 双驱动:两栈各 bind 80 */
ff_listen(s, backlog);                          /* 双驱动:两栈各 listen */
ff_epoll_ctl(ep, EPOLL_CTL_ADD, s, &ev);        /* 双注册:kqueue + 内核 epoll */

/* 远端 curl <DPDK_NIC_IP>:80 走 F-Stack,本机 curl 127.0.0.1:80 走内核,皆可达 */

需要单栈时用 marker 覆盖:

int konly = ff_socket(AF_INET, SOCK_STREAM | SOCK_KERNEL, 0);  /* 仅内核 */
int fonly = ff_socket(AF_INET, SOCK_STREAM | SOCK_FSTACK, 0);  /* 仅 F-Stack */

5.4 效果

功能正确性(真机实测,commit 13b418191):

  • 单 listen(80):本机 curl 127.0.0.1:80 = 200(内核侧),远端 ssh → <DPDK_NIC_IP>:80 = 200(F-Stack 侧)
  • ss 能看到内核侧 80 监听,ff_netstat 能看到 F-Stack 侧 80 监听

零回归(三层保证):

  • 编译宏关:libfstack.a 共存符号为 0,与原 F-Stack 逐字节一致
  • kernel_coexist=0:双建/双驱动运行期短路
  • SOCK_FSTACK marker:仅 F-Stack

性能(第 4.8 节):T1/T2/T3 三档吞吐差异全在噪声内,热路径不查 map。

5.5 已知限制

  • select 不支持内核 fd(encode fd 装不进 fd_set),poll 也不共存——内核 fd 用 epoll/kqueue
  • 单条连接不能真双工于两栈——默认双栈 connect 是”F-Stack 主 + 内核并发建连备援”,纯内核客户端用 SOCK_KERNEL
  • 可观测统计(ff_stack_get_stats)当前未实现,两栈 fd 数/事件数看不了
  • IPv6 依赖 host 侧 V6ONLY,本机 bindv6only 相关行为已在 R9 处理

6. 延伸阅读

  • 完整 spec:docs/kernel_event_support_spec/zh_cn/(00-10 + plan-r9/plan-r10)
  • 三层架构:docs/zh_cn/F-Stack_Architecture_Layer1_System_Overview.md
  • 知识图谱:docs/zh_cn/KNOWLEDGE_GRAPH_WIKI.md(2A 节 FF_KERNEL_COEXIST delta)
  • 相关 issue:#511/#585/#741/#849(本机 curl connection refused 场景)

F-Stack 2.0 前瞻 2 : F-Stack 主进程瘦身 primary_slim – 把 primary 单点故障从紧急事故降级为计划内维护

1. 这功能解决什么问题

primary_slim 是 F-Stack v2.0(预计 2026.10 正式 release)引入的一个运行开关,解决 DPDK 多进程模式下 primary 进程是单点的问题。

F-Stack 的标准多进程模式是 1 个 primary + N 个 secondary,每个进程独占一个 lcore 跑一份独立 FreeBSD 栈实例。secondary 崩了可以单独重启,不影响其他进程的队列和已建连接;但 primary 一旦异常退出,问题就大了——按照 DPDK 的设计,primary 是 IPC 唯一服务端、secondary 扩堆必须由 primary 代理、所有中断只在 primary 触发,所以传统认知是”primary 挂了整个进程组都得重启,所有连接受影响”。

这个诉求来自上游 issue #1078(https://github.com/F-Stack/f-stack/issues/1078),作者提的想法很直接:把 primary 独立出来,只做网卡队列设置、绑定、初始化这类控制面工作,收包和后续处理全部下移 secondary,这样 primary 就不那么容易异常,对连接的影响范围也小。

primary_slim 干的就是这件事:

【注意】primary_slim 把”primary 崩溃 → 必须立刻全组重启 + 约 1/N 连接立即中断”变成”primary 崩溃 → 数据面零损失、业务继续跑、崩掉的 secondary 可原地重启、集群进入控制面降级态、在计划内维护窗口择机全组重启”。

说白了,是把紧急故障转化为计划内维护,重启时机从被动变成可控。

这里先说清楚它不做什么,免得期待过高:

  • 不承诺”完全无需重启”——控制面降级态无法就地修复,最终还是要择机全组重启
  • 不承诺”省一个 CPU 核”——瘦身后的 primary 仍会空转占满一核,除非开空闲休眠
  • 不承诺 primary 不再是结构性单点——只是降低异常概率、缩小影响面
  • 不承诺 secondary 崩溃时连接不丢——每进程独立 FreeBSD 栈,没有连接迁移这回事

2. 主要适用场景

2.1 对 primary 单点稳定性敏感的生产部署

如果你的业务在多进程模式下跑,primary 一旦挂了要拖累全组,primary_slim 能让 primary 退出时数据面零损失。实测数据(后文详述):杀掉瘦身 primary 后,已建连接 12/12 零中断(26 轮探测无一次失败),新建连接 12/12 正常。

2.2 需要控制面与数据面解耦、缩小故障爆炸半径

多进程模式里,primary 名下如果持有 rx/tx 队列,它崩了这些队列就变成没人 poll 的孤儿队列,落到上面的流量全部黑洞化。primary_slim 让 primary 不持任何队列,数据面能力全部留在 secondary,primary 退出不带走任何数据面能力。

【注意】因为使用virtio创建的tap的限制,kni依然由主进程处理,primary挂了后kni会失效。

2.3 已经有外部守护/编排,想要分层恢复能力的场景

配合外部守护,可以做到:secondary 崩了原地重启 → primary 崩了进入降级态告警 → 择机计划内全组重启。这比”primary 一挂就紧急全组重启”从容得多。

2.4 不太适合的场景

单进程多线程模式(thread_mode=1)下没有独立 primary 进程,这个特性语义不存在,所以 primary_slim 与 thread_mode 是互斥的,配置校验会拦掉。如果只有 1 个进程(nb_procs < 2),也没有瘦身的意义,同样会被校验拦截。

3. 架构特征

3.1 改造前后对比

改之前(标准多进程):

改之后(primary_slim=1):

3.2 为什么队列能自然收缩、不产生孤儿队列

这是整个方案成立的关键,也是调研阶段最反直觉的发现。一开始担心”把 primary 摘出 lcore_list 会导致队列数减 1、queueid 错位、RSS reta 错位”,逐行坐实后发现根本不成立:

  • nb_queues 来源是该 port 的 lcore_list 长度,不是 nb_procs(lib/ff_dpdk_if.c:836)
  • queueid 是本进程 lcore 在 lcore_list 里的下标,与 proc_id 完全解耦(lib/ff_dpdk_if.c:487-491)
  • set_rss_table() 按同一个 nb_queues 重算 reta

所以把 primary 摘出 lcore_list 后,队列数、queueid、RSS reta、dispatch_ring 一致收缩,孤儿队列在代码层面自然消失。这正好是维护者(本人)提示词设想的”复用 lcore_list”路线的根本依据。

3.3 KNI 场景下的数据通路(最复杂的情况)

primary_slim=1 且 KNI 开启时,数据通路是三条线,涉及一个关键竞态修复(后文 4.3 详述)。最终稳定形态:

ICMP 请求入站:
client → 物理网卡(Port0) → secondary rx_burst(q0)
  → ff_kni_enqueue → KNI ring
  → primary kni_process_tx → rte_eth_tx_burst(Port1=virtio_user0)
  → veth0(tap) → kernel 协议栈 → 生成 ICMP reply

ICMP 回复出站(inject ring 路径):
kernel → veth0 → virtio_user0
  → primary kni_process_rx → enqueue 到 kni_inject_rp ring
  → owner secondary 用自己的 tx_queue 发出
  → 物理网卡 → client

核心分工:secondary 负责数据面收包入队 KNI ring,primary 独占 virtio_user vdev 的 TX/RX(因为 vdev 只有 primary 能合法操作),KNI 收到的回复包经 kni_inject_rp ring 转发给 owner secondary 用自己的队列发出。这样既绕开了”secondary 不能操作 virtio_user vdev”的 DPDK 硬约束,又消除了跨进程共享 TX queue 的竞态。

4. 改造工作与遇到的问题

后续比较枯燥,不需要深入研究实现过程的可以跳过本节,直接看第 5 节怎么用。

4.1 PoC 阶段:3 行代码验证核心命题

正式立项前先做了个最小 PoC,只改 3 行代码(_poc_primary_slim.patch,2 hunk):

  • lib/ff_dpdk_if.c:508-510:rte_exit 条件追加 && rte_eal_process_type() != RTE_PROC_PRIMARY,解除 B1 阻塞点(”lcore %u has nothing to do”)
  • lib/ff_dpdk_if.c:535:mbuf 池 RX 项去掉对本进程队列数的依赖,解除 B3 阻塞点(池规模归零)

配置是 lcore_mask=3 + [port0] lcore_list=1(primary 的 lcore0 不在列表内)。

3 行代码 + 1 项配置就验证了核心命题:瘦身 primary 崩溃后已建连接 12/12 零中断、新建连接 12/12、性能无回归。这也直接说明了为什么这个改动值得做——代价极小,收益是质变。

【注意】PoC 用 rte_eal_process_type() != RTE_PROC_PRIMARY 无条件放宽只是为了最小化 diff,正式实现改成了 primary_slim && RTE_PROC_PRIMARY 有条件门控,避免影响默认路径。

4.2 正式实现:从”自动推导”到”显式开关 + 校验链”

PoC 能用但不严谨(靠”自动推导”而非显式开关),正式实现补了完整的开关和校验链。核心提交是 1c28aaa2d(M1)+ f7961b083(M2~M4)。

交付的东西:

  • [dpdk] primary_slim=0/1 显式开关,默认 0,关闭时零回归
  • 开启后 primary 在 init_lcore_conf 里 early return,不分配 rx/tx 队列,不进收发包主循环
  • 配套 primary_slim_idle_sleep(默认 1000us)解决 CPU 空转
  • 三道校验:primary lcore 不得在 lcore_list(V2)、与 thread_mode 互斥(V4)、nb_procs >= 2(V5)
  • ff_is_slim_primary() API

4.3 踩到的坑:CPU 空转

这是实测发现的第一个负面问题。瘦身后的 primary 不持队列、不收包,但 ps -o pid,pcpu 一看还是 99.8% 占满一核。原因是 main_loop 仍在紧密空转(跑 timer 和 msg_ring 处理),而 config.ini 的 idle_sleep=0 意味着无空闲休眠。

【注意】primary_slim 的收益是稳定性,不包含 CPU 节省。如果用户期望”瘦身 = 省一个核”,这个预期必须纠正。正式实现引入了 primary_slim_idle_sleep(默认 1000us),否则在核数紧张的部署里就是白烧一个核。

4.4 踩到的坑:KNI 场景下的跨进程 TX 队列竞态

这是 post-implementation 阶段发现的最棘手的问题,分两阶段修复。

第一阶段(f23f1a464):primary_slim=1 + KNI 开启时,KNI 的 runtime TX/RX 应该由 primary 执行(因为 virtio_user vdev 只能 primary 操作)。但这样一改,primary 的 kni_process_rx 里调 rte_eth_tx_burst(Port0, queue_id=0) 把 kernel 回复包注入物理网卡,而 primary_slim=1 时 queue0 归属第一个 secondary worker0——两个进程并发操作同一个 TX queue,desc ring 会损坏、mbuf 会泄漏。DPDK 多进程模型里每个 TX queue 只能一个进程独占,没有进程级锁。

第二阶段(f250ad1ea):引入 kni_inject_rp 共享 ring 彻底消除竞态。primary 收到 KNI 回复包后不直接 TX Port0,而是 enqueue 到 inject ring,由 owner secondary dequeue 后用自己的 tx_queue_id 发出。方案选型时排除了另外两个:

  • 预留专用 queue(nb_queues+1,KNI 用最后一个 queue):本地 virtio 不支持 RSS,无法用 RETA 隔离额外 rx queue,vhost backend 会向所有启用的 qp 分发,额外 rx queue 无人 poll 会 desc ring 满导致 backpressure,不可行
  • rx!=tx 不等队列:virtio PMD 按 max(rx,tx) 分配 vq,未 setup 的 rx queue 会被后端写入导致 crash,不可行

inject ring 是唯一可行的路径。

4.5 踩到的坑:primary_slim=0 + owner_proc_id 误伤 KNI

实现 commit 1c28aaa2d 把 KNI 调用条件从 ff_kni_is_owner_thread() 改成了 ff_kni_is_runtime_owner(),但没考虑到 primary_slim=0 时 owner_proc_id 生效会导致 primary 不再处理 KNI,反而误伤了默认模式。修复(e075e534f)改成条件分支:primary_slim 时用 runtime owner 判定,否则用 owner thread 判定。

4.6 一个遗留问题:primary 退出清理其实效果有限

文档 14 专门分析了这个。跳过 rte_eal_cleanup() 的设计意图是保护 secondary 依赖的共享资源,但逐函数坐实后结论是实际效果几乎为零——hugepage 用 MAP_SHARED,primary 退出内核只 munmap 自己的映射不影响 secondary;config 文件不在 cleanup 里删除;mp_socket unlink 不影响 secondary(有自己的 fd)。

真正缺失的是没有通知 secondary 退出,primary 退出后 secondary 变成孤儿进程进入控制面降级态。但按设计哲学 primary 本来就不该正常退出,这个路径是”万一”的兜底。

【注意】这里对文档 10 里”避免 rte_eal_cleanup() 拆除共享资源”的说法做了修正——经源码坐实,rte_eal_cleanup() 实际上不拆除 secondary 依赖的共享资源,这个说法不完全准确。skip cleanup 的真正价值是保守策略:避免 cleanup 过程中 pthread_cancel 等操作的未定义行为。

4.7 issue 原文两处判断被实测修正

这个值得单独说,因为它是整个调研最有价值的部分——初始回复的两个前提假设,实测后都不准确:

  • “primary 一旦异常退出,整个进程组都得重启” → 部分不成立。其余进程继续服务,崩掉的 secondary 可原地重启(只要还有进程持有 /dev/uioX,uio refcnt >= 1),无需立即全组重启。但 primary 本身不能原地拉回(新 primary 会 EBUSY 失败,这是 EAL 的设计意图),所以还是要择机全组重启。
  • “所有连接都会受到影响” → 不成立。传统模式下只有哈希到 primary 队列的那部分流量受影响(2 进程约 1/2,3 进程约 1/3),瘦身后零影响。
  • “secondary 异常了可以重启,不影响其他进程” → 成立,E2d 实测验证。

5. 如何使用、配置与效果

5.1 配置

在 config.ini 的 [dpdk] 段加 primary_slim,在 [kni] 段加 owner_proc_id:

[dpdk]
lcore_mask=f             # primary(lcore0) + 1 个 secondary(lcore1)
primary_slim=1           # 0(默认)=关闭,1=primary 瘦身
primary_slim_idle_sleep=1000   # 默认 1000us,避免 primary 空转占满一核,后续因为kni还需要主进程处理,可以根据需要适当调整该值

[port0]
lcore_list=1-3             # 关键:primary 的 lcore0 不在列表内,队列全归 secondary

[kni]
enable=1
method=reject
owner_proc_id=1           # primary_slim=1 + KNI 时指定 runtime owner secondary
tcp_port=1-65535

配置校验会自动拦截三种非法组合:primary_slim 与 thread_mode 互斥、nb_procs 必须 >= 2、primary lcore 不得在 lcore_list 里。

5.2 启动

启动方式不变,还是 start.sh 管多进程:

./start.sh -c config.ini -b ./example/helloworld

5.3 效果

功能正确性(文档 05 实测):

  • 杀瘦身 primary 后已建连接 12/12 零中断,26 个探测轮次无一次失败
  • 杀瘦身 primary 后新建连接 12/12 正常
  • QPS:slim primary 存活时 122.4k(相对单进程基线 122.3k,+0.08%);slim primary 崩溃后 127.7k(+4.5%),均 0 失败请求

KNI 场景(文档 13 实测):

  • ping 5/5 0% 丢包,rtt 0.512~1.329ms
  • HTTP 200

结项验证(文档 15):

  • 物理机功能测试、性能压测通过
  • 默认标准多进程模式 10 分钟以上大流量回归压测零回归
  • 多线程模式(thread_mode=1)10 分钟以上大流量回归压测零回归

5.4 已知边界

  • 全部实测在 virtio 设备 + igb_uio 的虚拟化环境完成,物理机功能/性能压测已补做通过,但 VFIO 下的 refcnt 语义与设备运行态保持行为仍标注为未充分验证
  • primary 不可原地拉回,控制面降级后需择机计划内全组重启
  • 优雅退出未实测,primary 退出建议走强杀而非 rte_eal_cleanup()
  • 长期稳定性(小时级)未充分观测,10 分钟以上大流量回归通过

6. 延伸阅读:

  • 完整调研与实现文档:docs/primary_slim_spec/zh_cn/(00-15)
  • 结项报告:docs/primary_slim_spec/zh_cn/15-结项报告.md
  • 三层架构:docs/zh_cn/F-Stack_Architecture_Layer1_System_Overview.md
  • issue 原文:https://github.com/F-Stack/f-stack/issues/1078

F-Stack 2.0 前瞻 1 : F-Stack 原生多线程支持 – 单进程多线程多协议栈实例

1. 本功能主要作用和特点

F-Stack 原生多线程支持(native-mt,thread_mode=1)是 F-Stack v2.0(预计2026.10正式release) 引入的一种新运行模式:在单进程内启动 N 个线程,每线程运行一份完全独立的 FreeBSD 协议栈实例,线程间 share-nothing、无锁,靠网卡 RSS 分队列做数据面分流,靠无锁 ring 做跨线程分发。

1.1 核心特点

特点说明
单进程多线程不再启动 primary + N 个 secondary 进程,改为一个进程内 N 个 lcore 线程
多协议栈实例每线程一份独立 vnet(VNET/VIMAGE 隔离),含独立 ifnet/PCB/路由表/端口分配
share-nothing 无锁数据面线程间无共享可变状态,无锁竞争,线性扩展
零回归 opt-inthread_mode=0(默认)时多进程模式逐字节不变
API 向后兼容ff_init/ff_run 签名不变,应用无需改动即可在新模式运行

1.2 与多进程模式的对比

维度多进程模式(默认)native-mt 模式
进程数1 primary + N secondary1 个进程
线程数每进程 1 个主线程N 个 lcore 线程
协议栈实例每进程一份每线程一份(VNET 隔离)
共享内存跨进程共享 hugepage/mempool同地址空间,直接访问
IPC 开销跨进程 IPC(如ring) 通信无跨进程 IPC,直接内存访问
进程管理需 start.sh 管理多进程单进程管理简单
容错隔离进程崩溃互不影响线程崩溃可能影响整个进程

2. 本功能的主要适用场景

2.1 单机多核部署的简化管理

多进程模型需要 start.sh 脚本启动 1 个 primary + N 个 secondary 进程,进程管理、信号处理、资源清理都较复杂。native-mt 模式只需启动一个进程,大幅简化部署运维。

2.2 减少多线程应用迁移成本

某些应用原本是多线程模式,迁移到原有的多进程 F-Stack 需要较大的迁移成本,native-mt 模式让应用在单进程内获得多核扩展能力,而不再需要 fork 或启动多个独立进程等迁移改造。

2.3 减少跨进程 IPC 开销

多进程模式下,某些应用的共享控制数据可能需要 IPC (如共享内存或ring等方式)才能在进行跨进程共享,native-mt 模式下这部分共享数据可直接在同进程内访问。

2.4 业界主流模型对齐

mTCP、Seastar 等高性能用户态网络框架普遍采用 thread-per-core + share-nothing 的单进程多线程模型。native-mt 让 F-Stack 与业界主流架构对齐。

3. 本功能的架构特征

3.1 整体架构

3.2 per-thread 隔离策略

native-mt 的核心挑战是:FreeBSD 协议栈有数百个全局变量,如何在单进程内让每线程拥有独立副本?答案是分四类隔离:

3.3 数据面无锁通信

跨线程通信使用 DPDK 的无锁 ring,保持多进程模式已有的语义:

  • dispatch/kni_ring:保持 RING_F_SC_DEQ(MP+SC),多 worker 写、单 owner 读
  • msg_ring:已数组化 msg_ring[RTE_MAX_LCORE],每线程用自己 lcore 索引,SP+SC
  • mempool:按 NUMA socket 共享,DPDK per-lcore cache 开箱 MT-safe,无需改造

4. 改造工作与遇到的问题

后续比较枯燥,不需要深入研究实现过程的可以跳过本节。

4.1 改造里程碑总览

native-mt 的改造按危险度递增分 8 个里程碑(CM0-CM7):

里程碑目标危险度关键 commit
CM0低垂果实 + 脚手架e79ceb9f0
CM1配置层 thread_mode 开关3c31cc540
CM2lcore_conf per-lcore 化 + DPDK 拉 N lcoree79ceb9f0
CM3底座全局 per-thread(pcpu/thread0/callout)e79ceb9f0
CM4VIMAGE 可行性 PoC(关键卡点)高/存疑86e0f76b0
CM5初始化重构(per-thread 栈实例 init)极高717843004 + 7495e70c0
CM6KNI owner 线程 + msg_ring per-thread6d74d59e0
CM7联调 + 回归 + 性能基线fea49af6d + be4233709

4.2 CM0-CM3:per-thread 化基础

问题1:msg_iov_tmp 全局变量数据错乱

ff_syscall_wrapper.cmsg_iov_tmp/msg_iovlen_tmp 是全局数组,多线程并发调用 ff_readv/ff_writev 会互相覆盖。这是最低垂的果实——恢复 __thread 即可修复,多进程模式亦无害。

问题2:lcore_conf 全局单例

ff_dpdk_if.c:123lcore_conf 是全局单例,30+ 引用点。改为 lcore_conf[RTE_MAX_LCORE] 数组,每线程用 rte_lcore_id() 索引自己的那份。同时引入 ff_cur_lcore_conf() 宏作为间接层,降低后续改动面。

问题3:pcpu/thread0/callout 底座全局

  • pcpupff_freebsd_init.c:69)→ 每线程一份 __thread struct pcpu
  • thread0/proc0ff_init_main.c)→ 每实例一份
  • cc_cpu callout(ff_kern_timeout.c:180)→ __thread struct callout_cpuCC_SELF() 返回本线程实例

4.3 CM4:VIMAGE 可行性 PoC(关键卡点)

这是整个项目最大的不确定项。VIMAGE 是 FreeBSD 原生的虚拟网络栈子系统,启用后 curvnet = curthread->td_vnet 按 per-thread 隔离数百个网络栈全局。但 f-stack 大量阉割了 FreeBSD 用户态代码,VIMAGE 能否跑通需实测验证。

PoC 结果:VIMAGE 可用(route B 验证通过)

  • opt_global.h#define VIMAGE 1
  • 每线程 vnet_alloc() 创建独立 vnet 并设 td_vnet
  • VNET_SYSINIT 每 vnet 各跑一份,V_tcbinfo/V_rt_tables 成功隔离

4.4 CM5:初始化重构

问题4:mi_startup/SYSINIT 一次性机制

ff_init_main.cmi_startup 跑完会打勾 SI_SUB_LAST,二次调用空转。需拆分:全局一次性 init(EAL/UMA/mutex/vnet0)+ per-thread 栈实例 init(vnet_alloc/td_vnet/VNET_SYSINIT/pcpu_i/thread0_i)。

问题5:worker cred 挂全局 prison0(R1 缺陷)

这是 native-mt 最隐蔽的 bug。症状:2 线程压测 0 req/s,client 反复重传 SYN,f-stack 从不回 SYN-ACK。

根因:worker 线程的 cred 挂在全局 prison0 上,而 FreeBSD socket 的 vnet 取自 cred 的 prison(CRED_TO_VNET(cred)),不是 curvnet。导致 worker 所有 socket/ifioctl 被静默重定向到 vnet0,worker vnet 无接口地址、无默认路由(ENETUNREACH),app listen 的 PCB 全建在 vnet0,而数据面在 worker vnet 查 PCB → 收到 SYN 不回 SYN-ACK。

修复:lib/ff_freebsd_init.c 新增 ff_worker_prison_init(),给每个 worker 分配独立 prison,使其 CRED_TO_VNET 指向自己的 vnet。

问题6:worker pcpu cpuid 越界(R2 缺陷)

修复 R1 后,worker vnet 的 PCB 表被真正使用,压测时 in_pcblookup_mbuf SIGSEGV。

根因:worker 的 pcpu_init()rte_lcore_id()(=2)作 cpuid,但本 build 非 SMP(MAXCPU==1)、mp_maxid==0,UMA/SMR per-cpu 数组只按 1 CPU 分配 → zpcpu_get() 越界。

修复:ff_pcpu_thread_init() 改传 0(当时方案)。后续在文档 17 的 SMP-aware 改造中彻底修复——每线程拥有稠密、独立的 pcpu 槽位。

4.5 CM6-CM7:KNI/工具链归属与联调

问题7:KNI 归属

多进程模式下 KNI 由 primary 进程独占(virtio_user vdev 只能由 primary 创建)。native-mt 无 primary/secondary 概念,KNI 改由单一指定线程(线程 0)独占持有。现有 kni_rp/bitmap 已按 rte_lcore_id() 命名,天然可 per-lcore,改动集中在门控判定。

问题8:worker 时钟缺口

症状:2 线程吞吐从修复前的 0 提升到 557 req/s,但仍远低于 1 线程的 ~209k req/s。

根因:worker 线程的 FreeBSD 时钟从未被驱动。init_clock() 仅在主线程调用一次,worker 的 freebsd_clock__thread)是零初始化(expire == 0),ff_hardclock_job 永不触发 → worker 的 callwheel 永不推进 → vnet_i 上 syncache 超时、TCP 重传、delayed ACK 全部瘫痪。

修复:新增 ff_hardclock_worker()(只推进本线程 callwheel,不触碰全局 ticks/timecounter)+ init_clock_worker()(worker 在 main_loop 中注册自己的定时器)。

4.6 后续优化:SMP-aware pcpu 视图与去全局锁

问题9:SMR per-cpu 槽位共享的 UAF 窗口

R2 修复后所有 worker 的 pc_zpcpu_offset 均为 0,共享同一份 SMR per-cpu 槽位。理论窗口:A 线程 smr_exitSMR_SEQ_INVALID 可能清掉 B 线程的 read section 标记 → smr_poll 误判无读者 → PCB 提前回收(UAF)。

修复(commit c7996a94f):定义 SMP,设置 mp_ncpus/mp_maxid/all_cpusnb_threads,每线程拥有稠密 pcpu id 和 per-thread curcpu,每线程独占不相交的 UMA/SMR per-cpu 槽位。

问题10:uma_crit_lock 全局自旋锁

f-stack 曾为保护 UMA per-cpu cache 加了全局自旋锁 uma_crit_locklib/include/vm/uma_int.h)。在每线程独占 per-cpu 槽位后,该锁已无必要。

修复(commit 57b612d16):移除 uma_crit_lockcritical_enter/critical_exit 变为 no-op,恢复 UMA per-cpu cache 的无锁快路径。

5. 如何使用、配置与效果

5.1 配置方法

config.ini[dpdk] 段新增 thread_mode 配置项:

[dpdk]
# lcore_mask 指定使用的 lcore 集合,thread_mode=1 时每个 bit 对应一个线程
lcore_mask=0xf           # 4 个 lcore = 4 个线程

# thread_mode: 0=多进程模式(默认),1=单进程多线程多栈实例
thread_mode=1

# 其他配置项与多进程模式相同
idle_sleep=20
hz=100

[port0]
addr=<DPDK_NIC_IP>
netmask=<NETMASK>
broadcast=<BROADCAST_IP>
gateway=<GATEWAY_IP>
lcore_list=0,1,2,3       # 与 lcore_mask 对应,可以忽略

5.2 启动方式

native-mt 模式下只需启动一个进程(不再需要 start.sh 脚本管理多进程):

# 编译(make clean 后全量重编)
cd f-stack/lib && make clean && make -j$(nproc)
cd f-stack/example && make clean && make

# 启动(单进程)
./example/helloworld --conf config.ini --proc-type=primary --proc-id=0

5.3 使用效果

性能基线实测(virtio 网卡环境)

配置线程/进程数req/s延迟 (avg)说明
thread_mode=1, 2 线程2233,380修复后多线程
thread_mode=0, 2 进程2231,570多进程对照

关键发现:

  1. 多线程线性扩展:2 线程 233k req/s,与 2 进程 231k 持平,验证 share-nothing 无锁模型的线性扩展能力
  2. 60 秒 soak 稳定:400 连接持续 60 秒达 497k req/s、2983 万请求零错误
  3. 【注意】thread_mode=0时单进程程数据确认为当时噪声,不在本文展示,实际性能单进程和单线程都差不多

零回归保证

thread_mode=0(默认)时所有路径走既有 primary/secondary 分支,字节级不变。已通过 10 分钟以上大流量回归压测验证。

5.4 注意事项与限制

限制说明
fd 不可跨线程共享每个线程的 fd 表独立(VNET 隔离),同 fd 在不同线程含义不同,与多进程模式时单进程内的fd语义限制相同
KNI 单线程独占KNI 由线程 0 独占持有,其他线程不处理 KNI(其他线程需要kni处理的包通过kni_rp转发至线程0,流程与多进程模式相同)
工具链兼容优先保留外部工具进程(--proc-type=secondary)attach 方式,向后兼容
VIMAGE 依赖启用 VIMAGE 需完整编译,部分 f-stack 阉割的 FreeBSD 子系统可能不完整
物理网卡 RSS多线程真实扩展性依赖网卡 RSS 能力,无 RSS 网卡多线程扩展受限,与多进程模式相同

6. 参考资料

  • 三层架构文档docs/zh_cn/F-Stack_Architecture_Layer1_System_Overview.md
  • native-mt specdocs/native_mt_spec/zh_cn/(00-17 完整设计文档)
  • 知识图谱docs/zh_cn/KNOWLEDGE_GRAPH_WIKI.md
  • issue 汇总docs/zh_cn/f-stack-issue-ana.md(#430/#571/#807/#855 等多线程相关 issue)
  • 代码提交e79ceb9f0(CM0-CM3)→ 86e0f76b0(CM4 VIMAGE)→ 7495e70c0(CM5 多栈实例)→ 6d74d59e0(CM6 KNI)→ 82b409faf(worker 时钟)→ ff09a17b2(prison 隔离)→ c7996a94f(SMP-aware pcpu)→ 57b612d16(去全局锁)

F-Stack 2.0 前瞻0 : F-Stack 使用 CodeBuddy 开发工作流清空代办列表的实践

本文介绍 F-Stack 工程,通过在 CodeBuddy 使用自建 Skill + 知识库 + spec + harness 多 agent 门禁体系,2026 年大功能全由 AI 辅助完成的实践,主要完成或完善的大功能后续则会有 F-Stack 2.0 前瞻一系列系列的文章分别单独介绍。

1. 本功能主要作用和特点

AI 发展到 25 年底 26 年初之后,在实际项目中逐步可用了,从现网业务的单元测试、小功能逐步扩展到大工程的复杂任务(非 F-Stack,如 DNS 支持 io_uring 代替 epoll 性能总体提升 25% 左右),而我们在 F-Stack 上也从 2026年5月后开始用 CodeBuddy 做 AI 辅助开发,到今天已经把「怎么让 AI 在这个大 C 语言项目里干活干得靠谱」沉淀成了一套可复用的工作流——三个自建 Skill(f-stack-dev-rule / f-stack-info-search / f-stack-issue-process)+ 知识库(架构文档/知识图谱) + spec 文档驱动 + harness 工程化多 agent 流程 + 全链路门禁体系。这篇把这套工作流捋一遍:由哪些部分组成、每部分解决什么问题、2026 年实战效果如何。

从 5 月中旬至今 8 月中旬的 3 个月时间里,我们通过这套基于 CodeBuddy 的工作流由 AI 辅助完成的大功能清单(数据来自项目 iwiki 工作清单),成功将限于人力原因历史一直积攒的待办列表近乎清空,也算是为这段经历划上一个还算可以的分号(放心,F-Stack 后续会一直参与进行维护),当然 token 消耗也是杠杠的,单 F-Stack 这些功能大约消耗了 5-60 亿左右的 token 吧,且 7-80% 是 opus-4.6 ~ opus-5.0,公司的 token 额度支持是必不可少的。

【注意】耗时仅是功能开始和结束时间统计,中间会穿插做很多其他事情,不代表单独本功能实际 AI 辅助开发的用时,实际用时要更少,所以抛开实际上线流程,单纯讲开发自测流程,AI 的提效真不是盖的。

功能完成时间耗时
LD_PRELOAD 信号量 → 无锁 ring IPC2026.05.2510 天
FreeBSD 13.0 → 15.0 协议栈升级2026.06.0910 天
DPDK 升级到 24.11(dev 分支)2026.06.101 天
单元测试框架(Unity/CMocka)2026.06.112 天
接收零拷贝 ff_zc_mbuf_read2026.06.111 天
发送零拷贝原生化(去掉 MAGIC 魔改)2026.06.121 天
lib 库本地 socket 访问,后续考虑会移植到 1.21(LTS)2026.06.184 天
io_uring 对标调研,最终结论 F-Stack 当前不会去做 io_uring 对标2026.06.181 天
ff_rss_check 优化(IPv6 + rte_thash),后续会考虑部分移植到 1.21(LTS),rte_thash(DPDK-19.11不支持)需进一步调研2026.07.163 天
MTU 修改支持(jumbo frame),后续考虑会移植到 1.21(LTS)2026.07.223 天
LRO 支持 + TSO 完善,后续考虑会移植到 1.21(LTS)2026.07.231 天
基于 VIMAGE/VNET 的原生单进程多线程多协议栈(native-mt)2026.08.055 天
issue 批量处理 320 → 0,完全可以修改下复用到其他项目issue/工单的处理中2026.08.10持续

这些功能的统一分工模式是:所有代码由 AI 完成,人工只做提示词、spec 文档、plan、纠正方向、结果审核和测试验收(开发的虚拟机环境由 CodeBuddy 根据工作流自动进行单元测试和运行测试进行验收,人工额外做物理机的测试验收)。几个关键词:

  • 规约先行:AI 在 F-Stack 里干活的第一件事不是写代码,是加载 f-stack-dev-rule——13 节强制规约零容忍,把”AI 容易翻车的点”(rm/kill/chmod、增量编译、config.ini 污染、真实 IP 泄漏、注释风格)全部前置成规则
  • skill 分层:三个 skill 各管一段——dev-rule 管”怎么改”、info-search 管”怎么搜”、issue-process 管”issue 怎么处理”,另有一批通用 skill(spec 驱动、harness 工程、C 语言开发、单测等)可以对应安装,这篇不展开
  • harness 多 agent 门禁:大任务强制走”调研出中文 spec → 人工审核 → 多 agent 实现 → 门禁审核 → 里程碑提交”的完整链路,写审分离、单步打回上限 3 次

2. 本功能的主要适用场景

2.1 大功能开发(调研 + spec + harness 实现 + 验收)

F-Stack 里凡是”一个新功能/一次大升级”级别的任务,都走 harness 流程:先多 agent 调研产出中文 spec 文档(放 docs/<FEATURE>_spec/zh_cn/,具体调研过程见 f-stack-info-search 这个skill),人工审核通过后另起多 agent 做实现和验收,按里程碑多次提交,验收完成后才翻译英文 spec,包括 freebsd 13→15 升级、native-mt、ff_rss_check 优化 等都是这么干的。

2.2 issue 分析与批量处理

f-stack-issue-process skill 定义了 issue 处理三步 SOP(读全文 → 搜资料 → 综合判断),配合五类结论模板。2026 年项目把 issue 从 320 个清理到 0 个,全程半自动化:AI 按 SOP 分析归档、人工确认或修复后回复关闭,处理了众多咨询类 issue,并对功能类 issue 修复和新增了很多问题或功能。

2.3 性能优化攻坚(需要对照实验的)

ring IPC 性能优化、RSS check 优化这类”优化”任务,AI 的价值不在写代码而在执行对照实验纪律:对照组先于优化、数据先于理论、每条假设配套可证伪的物理量。ring IPC 的 v1~v3.7 七轮迭代就是 AI 在规约约束下自我证伪的完整案例。

2.4 不太适合的场景

  • 一句话的小改动:直接说需求就行,别套 harness 流程(任务规模判断规约里小任务直接执行)
  • 需要物理环境验证且环境不齐的:规约要求”未实际执行不臆测”,环境不满足时 AI 只能如实标注「未坐实」,硬要结论反而有害
  • token 额度紧张时期:native-mt 这种”堪比重做一个小 f-stack”的任务消耗巨大(iwiki 原话),适合额度重置后再做

3. 本功能的架构特征

3.1 工作流总图

3.2 三个自建 Skill 的分工

f-stack-dev-rule(怎么改):F-Stack 工作区一切任务的强制规约合集,13 节零容忍。核心几条:Shell 操作必须走 rm_tmp_file.sh/kill_process.sh/chmod_modify.sh 三个脚本(严禁直接 rm/kill/chmod);改代码先 make clean 再完整编译(增量编译不作为依据);config.ini 本地测试值不入库;文档严禁真实 IP 用占位符;commit message 英文 1-3 句;lib 最小注释;多 agent 写审分离/bounce≤3/leader 轮询不提前退出;所有调研类任务必须用 info-search 搜资料;任务规模判断与 harness 流程;代码改动必须过单测和运行时回归测试。

【注意】即使有了这些限制规则,Agent 也是会经常跑偏,或者不遵守规则限制的,所以人工的值守和纠偏目前依然是不可或缺的

f-stack-info-search(怎么搜):一切提问/分析/调研类任务的资料搜索 SOP,五部分:查三层架构文档与知识图谱(docs/ 下 LAYER1/2/3 + KNOWLEDGE_GRAPH_WIKI)→ 查代码提交记录(本地 git log + DPDK 上游)→ 查关联 issue/PR(先查本地 issue 分析档案再 gh search)→ 查公开资料(DPDK Bugzilla/Patchwork/邮件列表)→ 内外网技术资料(博客/公众号/iWiki,中英双语关键词)。核心纪律是三方证据收敛:内部文档 + 外部资料 + 实际代码,不一致处以实际代码为准。

f-stack-issue-process(issue 怎么处理):三步 SOP——读 issue 全文(含全部评论,不可只看标题)→ 调 info-search 搜资料 → 综合判断。五类结论模板(已修复/有上游 patch 未合入/未修复/有 workaround/非 bug),关键约束是不可自动操作 issue:分析完必须人工确认后才能评论关闭,回复用英文只讲核心结论。每次处理后同步更新中英文 issue 分析档案(f-stack-issue-ana.md,可以不断增加新的知识点供后续的issue分析和问题调研使用)

3.3 门禁体系(AI 干活的刹车)

  • 写审分离铁律:写代码/文档与审核必须不同 agent,leader 严禁自写自审;纯调研/探测/汇总等单一角色可由 leader 兼任
  • leader 轮询:子 agent 全部完成前 leader 严禁提前退出;禁止无超时死等,旁路探测(读落盘文件、git 状态)优先于消息探测,否则 CodeBuddy 等 Agent 经常出现子进程因为各种原因异常退出导致的整个任务中断,需要人工继续任务的情况。
  • 异常回退:子 Agent 超时/卡死时 leader 接管或 spawn 新 agent 重做,但不得违反写审分离
  • 测试门禁:代码改动必须完成单测和运行时回归测试,同样受门禁规则限制

4. 具体做了哪些改造、遇到了哪些问题、怎么解决的

后续比较枯燥,不需要深入研究实现过程的可以跳过本节,直接看第 5 节怎么用。

4.1 skill 体系是怎么长出来的(从零散规则到三个 Skill)

这套体系不是一次设计出来的,是被实战问题逼出来的。从最早的裸奔,在 AI 开发过程中逐步踩坑晚上,并在后期整理合并成 f-stack-dev-rule 一个 skill 统一加载(13 节),替代分散的十余条分条规约。info-search 和 issue-process 则是从 issue 处理实战中提炼的:issue 处理要先搜索资料再下结论、搜索有固定的五部分流程、结论有五类模板、档案要中英同步——这些”怎么做对”的经验固化成了 skill。

skill 的落地方式是”库 + 源”双份:安装到 ~/.codebuddy/skills/ 供 CodeBuddy 加载,同时在 docs/zh_cn/skills/ 保留一份源码,规约明确”安装前若 use_skill 不可用,直接读取 docs/zh_cn/skills/ 下对应 SKILL.md 全文作为规约执行”。零容忍条款一旦违反任务直接打回。

【注意】规约里有一条值得单独说:rm_tmp_file.sh/kill_process.sh/chmod_modify.sh 三个脚本不是形式主义——2026 年 6 月 ZC-recv 实测期间就发生过一次误用 rm -rf 的违规(M2 报告里记录并修复)。规约约束的是 AI 最常见的危险动作,脚本带审计日志(chmod 快照到 /tmp/.trash/、审计到 /tmp/.chmod_audit.log),出了问题可回查。

4.2 实战问题一:AI 会”看起来很对,实际全错”

本地 socket 访问功能(2026.06.18,4 天)是典型的反面案例——iwiki 原话:”本功能 AI 表现不好,人工打回返工多次才最终完成”。这说明流程里最值钱的是”人工审核”和”打回”这两个环节,而不是 AI 一次写对的能力。对应到规约里就是 bounce≤3 的打回链:门禁失败必须打回上一步修复,同一单步打回超 3 次立即停止转人工决策,不许带病放行。

4.3 实战问题二:AI 会臆测,必须用”实事求是规约”压住

规约第 9 节(实事求是)和第 10 节(资料搜索)专门治这个:所有行动必须实际执行,严禁未执行就猜测给结果;代码/文档/外部数据源交叉验证,不一致处以实际代码为准;无法静态坐实或环境不满足的项,如实标注「未坐实/未执行」。ring IPC 性能分析的 v1~v3.7 七轮迭代是这个规约的最好注脚:v1 的单边代码分析错误、v2 的未验证假设、v3.3 的方案 C 实测劣化 4% 当天回退——每一步错误的证伪都靠实测数据,最终收敛出”ring 无 net win、生产推荐 sem”这个反直觉但正确的结论。

4.4 实战问题三:编译卫生和增量陷阱

AI 写 C 代码最常见的坑是增量编译:FF_ZC_* 开关切换后 make 按时间戳跳过 .o 重编译,导致内核 hook 缺失、http=000 的诡异现象(ZC M2 报告)。规约把”改代码先 make clean、编译验证以 clean build 通过为准、增量编译通过不作为依据”写死,还附了本机两个已知坑的规避(IDE safe-delete hook 拦截 make clean 用 PATH 前缀规避、make -j16 竞态先 make machine_includes)。freebsd 13→15 升级踩的”宏改名丢包裹”坑(UMA_MD_SMALL_ALLOC → UMA_USE_DMAP)和”结构头改动未 clean 重编 ABI 偏斜”坑,也都沉淀成了规约条款。

4.5 实战问题四:token 消耗与任务切分

native-mt 的 iwiki 备注很诚实:”本功能超级复杂,堪比重做一个小 f-stack,消耗 token 较多”,残留风险如实记录后暂停,等 token 额度重置后继续。这说明 harness 流程还有一层现实约束:任务要按 token 预算切里程碑,做不完的宁可如实留档(残留风险清单),也不要糊弄收尾。issue 批量处理 320→0 则是另一个方向的实践——用内网版 OpenClaw/Hermes 按 SKILL 的 SOP 半自动化处理,穿插在 token 紧张期(因为内网版 GLM-5.2不计算额度)。

【注意】截止目前(2026.08.17)大任务初始的调研、架构文档、任务拆分等任务依然推荐使用高级模型(额度消耗非常非常大,需要特别注意,主要是目前其他部分模型在大任务的调研分析和架构设计上表现的依然一言难尽,就不实际举例了),完成任务拆分后实际的代码编写等小任务或具体任务可以交由较轻量模型(但也应使用 GLM-5.2 以上模型)执行。

4.6 2026 年各功能沉淀的文档资产

每个大功能都按 harness 流程留下了完整文档:freebsd_13_to_15_upgrade_spec、zc_stack_user_spec、ld_preload_ring_spec、mtu_change_spec、lro_tso_spec、native_mt_spec、rss check 优化 spec 等,加上三层架构文档(LAYER1/2/3)和知识图谱(KNOWLEDGE_GRAPH_WIKI)。这些文档反过来又成了 info-search 的搜索源——文档 → 代码 → 决策形成了闭环。

【注意】新功能更新后应该对应更新架构文档和知识图谱,否则 info-search 的搜索源会失真,后续调研拿到的是过期结论。更新可以靠流程约束(里程碑里带文档更新项),也可以靠自动化——比如 git 操作自动触发知识图谱重建(此前每次提交后 GitNexus 在后台更新知识图谱即是此类机制)。

5. 如何使用、如何配置、使用效果

5.1 Skill 安装与加载

三个 skill 都装在 ~/.codebuddy/skills/(f-stack-dev-rule / f-stack-info-search / f-stack-issue-process),CodeBuddy 会话中用 use_skill 按需加载。F-Stack 工作区的任何任务第一步先加载 f-stack-dev-rule;提问/分析/调研类任务必须加载 f-stack-info-search;issue 分析处理加载 f-stack-issue-process(其搜索环节内部调用 info-search)。

5.2 配套通用 Skill(对应安装,这篇不展开)

spec 驱动开发(spec-driven)、harness 工程(harness-engineering)、C 语言开发/单测(c-pro、c-unittest-expert 等)、C 精准外科手术式修改(c-precision-surgery)等通用 skill 与 F-Stack 自有三个 skill 组合使用——自有 skill 管”F-Stack 特有约束”,通用 skill 管”通用工程方法”。

5.3 使用效果(2026 年实测数据)

维度数据
AI 辅助完成大功能13 个
最大单任务FreeBSD 13→15 升级(编译矩阵 5 格全绿)
issue 清理320 → 0(2026.08.10 清零)
编译基线lib error 0 / warning 51(既有 baseline,新改动零新增 warning)
文档资产7 个功能 spec 目录 + 三层架构文档 + 知识图谱 + 中英双语 issue 档案
博客文章F-Stack 2.0 前瞻系列 8 篇(含中英文版),后续将陆续发出。

5.4 对使用者的建议

  • 先把规约读一遍再让 AI 动手:dev-rule 的 13 节每条都是实战换来的,AI 违反任何一条任务都会被人工打回
  • 大任务别跳过 spec 环节:中文 spec + 人工审核是成本最低的方向纠偏点,本地 socket 功能的返工证明”跳过调研直接写代码”更贵
  • 信任门禁不信单次输出:AI 写的东西必须过独立 agent 审核,bounce 是正常流程不是失败
  • 让 AI 如实说”不知道”:环境不满足就标「未坐实」,这比硬给结论值钱得多

6. 总结

虽然 CodeBuddy 还是存在不少问题吧,比如 Loop 总是中断,总是需要人工去提醒他继续,即使加了各种限制规则依然不能避免该问题,但是这个“Buddy”确实极大幅度的提升了我们功能开发的效率,还是需要非常非常感谢。

7. 延伸阅读:

  • 开发强制规约:docs/zh_cn/skills/f-stack-dev-rule/SKILL.md
  • 资料搜索技能:docs/zh_cn/skills/f-stack-info-search/SKILL.md
  • issue 处理 SOP:docs/zh_cn/skills/f-stack-issue-process/SKILL.md
  • 三层架构文档:docs/zh_cn/01-LAYER1-ARCHITECTURE.md
  • 知识图谱:docs/zh_cn/KNOWLEDGE_GRAPH_WIKI.md
  • 历史 issue 档案:docs/zh_cn/f-stack-issue-ana.md
  • 2026 年各功能 spec:docs/<FEATURE>_spec/(freebsd_13_to_15_upgrade_spec、zc_stack_user_spec、ld_preload_ring_spec、mtu_change_spec、lro_tso_spec、native_mt_spec 等)

F-Stack ff_rss_check()优化介绍

1. 概述

本文档旨在介绍 F-Stack 中对 ff_rss_check() 函数的一项重要优化。该优化通过引入一个预计算的静态端口查找表,显著提升了应用程序作为客户端主动发起大量短连接时的性能,解决了原 ff_rss_check() 函数在高并发场景下可能成为性能瓶颈的问题。

核心优化提交:e54aa4317b5d81f9f8643e491d8ec0ec1e72282a

2. 背景与问题分析

ff_rss_check() 函数在 F-Stack 中负责一个关键任务:当应用程序作为客户端发起新的 TCP 连接时,为其分配合适的本地源端口。

  • 原有实现机制: 每次需要建立新连接时,都会动态调用 ff_rss_check()。该函数会根据目标服务的 IP 和端口(4元组信息),通过一个哈希计算来选择一个 RSS(Receive Side Scaling)友好的本地端口,以确保数据包能被高效地分发到正确的 CPU 核心上处理。
  • 性能瓶颈: 在需要频繁创建短连接(例如,处理 HTTP 请求且未开启 keep-alive)的场景下,每次连接建立都需要执行一次或多次端口选择和冲突检查。这个过程涉及多次的toeplitz_hash计算(平均每次耗时约300+tsc,平均次数至少为该端口的总队列(进程)数),在高并发压力下,会消耗可观的 CPU 资源,并成为限制连接建立速率的瓶颈。

3. 优化方案:静态 RSS 端口表

为了克服上述性能问题,优化方案的核心思想是:变“动态计算”为“静态查找”

  1. 预初始化静态表
    • 在应用程序启动阶段(ff_init() 时),根据配置文件中的预定义规则,预先计算并初始化一个静态的端口查找表(ff_rss_tbl),。
    • 该表包含了预先调用ff_rss_check()计算好的哪些本地端口发出去的包可以返回本进程对应的网卡队列,表中包含 {{远程地址,远程端口}:{本地地址, [所有可用本地端口]}}组合,以及一些辅助数据结构,如可用端口的起始、结束索引,上次选择的到的端口索引等。
  2. 高效的端口选择
    • 当需要建立新连接时,首先尝试从这个预先生成的静态表中进行查找。
    • 如果能找到一个与当前连接目标地址/端口匹配且未被占用的本地端口,则直接使用它。这个过程几乎是无锁且开销极低的(平均耗时约100-250tsc)
    • 最重要的是,静态查找表无需多次选择并计算RSS,只需要查找一次本地端口即可保证远程回包可以回到本进程的队列进行处理,极大提升了选择源端口的效率,进而提升总体的QPS性能。
  3. 优雅降级
    • 如果静态表中所有符合条件的端口都已被占用或该四元组未配置静态查找表,系统会自动降级到原有的 ff_rss_check() 动态计算流程,确保功能的正确性。

4. 配置说明

此功能需要通过配置文件(例如 config.ini)手动开启和定义。相关配置段如下:

# 启用或禁用静态 ff_rss_check 表。
# 若启用,F-Stack 将在 APP 启动时初始化该表。
# 此后当 APP 作为客户端连接服务器时,会首先尝试从该表中选择本地端口。
[ff_rss_check_tbl]
# 启用开关:0-禁用,1-启用。默认为 0。
enable = 1
# 定义端口分配规则(4元组)。
# 格式:<网卡端口ID> <本地地址 daddr> <远程地址 saddr> <远程端口 sport>
# 单个四元组内用空格分隔,多个四元组之间用分号分隔。
# 注意:saddr/sport 二元组最多支持 16 个,同一 saddr/sport 下最多支持 4 个 daddr。
# 因此,最多支持 64 种组合,超出的配置将被忽略。
rss_tbl=0 192.168.1.10 192.168.2.10 80;0 192.168.1.10 192.168.2.11 80;0 192.168.1.10 192.168.2.12 80

配置参数详解

  • enable: 总开关,必须设置为 1 才能启用此优化功能。
  • rss_tbl: 定义端口分配规则。每一条规则指定了网卡端口ID用于获取网卡的队列配置,对于特定的目标服务(saddr + sport),使用哪个预先分配好的本地地址(daddr)。

5. 性能提升

根据内部测试数据,该优化带来了显著的性能提升:

  • 测试场景: 客户端(wrk)向F-Stack Nginx发起http请求,使用长连接。F-Stack Nginx设置反向代理,作为客户端,频繁向多个远程服务器发起短 TCP 连接(未使用 keep-alive)。示例图如下所示:
  • 测试数据:如下表所示
  • 常规 QPS 提升约 2-6%左右,且提升的比列会随着进程数的增加而增加,因为进程数越多,原有的ff_rss_check()需要计算的平均次数就越多。
  • 某些特殊场景下提升可以达到35%以上,主要是因为在某些进程数的配置下,原始动态计算方式,随机选择源端口的次数会远超数学期望的总进程数+少量次数,导致需要大量额外调用ff_rss_check()进行计算,消耗了大量CPU资源。
    • 【注意】不同的upstream服务器配置(数量、IP、端口等)可能会导致不同的进程数存在类似问题,在本测试场景下配置8或16进程时,会出现该问题。导致该问题的具体原因则尚未完全明确,查看相关静态表、端口是否正在被占用和请求测试都正常。
    • 如下表所示分别为8和12进程Nginx调用ff_rss_check()次数的统计,可以看到8核时的随机选择次数明显超出了数学期望的(应该在8-12次左右),16核同理。
      • 其中randomtime表示内核参数net.inet.ip.portrange.randomtime的设置,因为F-Stack框架的特性,作为客户端选择源端口时此时完全随机选择效果更好,且F-Stack对随机性要求并不高,早前已经替换了性能好好很多的伪随机函数。
  • perf top截图

8进程间歇性随机(大部分不随机)时ff_rss_check()调用次数太多,热点很高

8进程完全随机,ff_rss_check()调用次数热点有一定降低

12进程,ff_rss_check()的热点则大幅下降

  • 8进程间歇随机和完全随机选择源端口QPS性能对比

6. 其他参数优化调整

F-Stack对应调整了部分参数,具体如下,其他业务如何配置可以根据自己业务的具体特点灵活调整

[freebsd.sysctl]
net.inet.tcp.delayed_ack=1 # 可以提升大并发的吞吐量
net.inet.tcp.fast_finwait2_recycle=1
net.inet.tcp.finwait2_timeout=5000
net.inet.tcp.maxtcptw=128 # 尽快释放TIME_WAIT状态的端口,增加空闲端口,减少in_pcblookup_local()的调用次数,进而减少ff_rss_check()的调用次数
net.inet.ip.portrange.randomized=1
# Always do random while connect to remote server.
# In some scenarios of F-Stack application, the performance can be improved to a certain extent, ablout 5%.
net.inet.ip.portrange.randomtime=0 # 对某些特定配置条件下原动态计算ff_rss_check()方式有一定性能提升

7. 总结

本次对 ff_rss_check() 的优化是 F-Stack 追求性优化能的一个典型例子。它通过以下方式解决了核心瓶颈:

  1. 空间换时间: 使用预计算的静态表换取运行时动态计算的开销。
  2. 减少调用次数: 极大降低了端口选择时的ff_rss_check()和in_pcblookup_local()的重试调用次数,实现总体系统2-6%,特殊极限场景35%以上的总体系统性能提升。
  3. 保证兼容: 通过降级机制保障了在任何情况下的功能正确性。

建议启用此功能的场景: 所有使用 F-Stack 作为客户端、需要高频率创建新连接(特别是短连接)的应用程序。启用后,只需在配置文件中预先定义好常用的目标服务地址,即可获得显著的性能收益。

【AI使用声明】本文初始版本使用DeepSeek-V3.1生成,然后进行人工调整;示意图片由元宝根据人工提供的提示词生成和修改

F-Stack如何修改TCP连接的MSS


本文仅介绍F-Stack服务器作为被动接受连接的一方如何在不修改程序的前提下如何相对简单的修改TCP建连时的MSS选项

TCP数据包在经过某些网络设备(如负载均衡等)时可能会被增加某些头(如vlan、gre、ipip等),但是又没有正确的减小syn包中MSS选项的值,导致后续的数据包加上额外的头后可能超过1500的MTU而无法正常收发包,此时我们可能无法控制中间链路的网络设备,就需要修改应用服务器的MSS配置,但最好上还是应该由中间网络设备进行兼容。

F-Stack-1.21.5(FreeBSD-11.0)

FreeBSD-11.0的ipfw工具不支持直接修改tcp的mss选项的值,必须要同时借助netgraph(工具:ngctl)来实现,参考命令如下

# 创建 tcpmss 节点并将其连接到 ng_ipfw 节点
ff_ngctl mkpeer ipfw: tcpmss 100 msshook

# 设置mss和hook函数
# 该命令会报错ff_ngctl: recv incoming message: Operation not permitted,实际为回显结果时f-stack的ff_ngctl对freesbd的ngctl的一个兼容性问题,不影响设置效果,仅影响设置成功后的回显
ff_ngctl msg ipfw:100 config '{ inHook="msshook" outHook="msshook" maxMSS=1440 }'

# 将流量导入 tcpmss 节点
ff_ipfw add 300 netgraph 100 tcp from any to any tcpflags syn out via f-stack-0

# 让数据包在被修改后继续由 ipfw 处理, 可选,也可以配置在config.ini
ff_sysctl net.inet.ip.fw.one_pass=0


参考:ng_tcpmss man page

F-Stack-1.22+(FreeBSD-13.0)

FreeBSD-13.0的ipfw工具通过pmod模块引入的方式支持了直接修改tcp的mss选项值的功能,命令参考如下:

ff_ipfw add 1000 tcp-setmss 1440 tcp from any to any tcpflags syn out

【注意:】目前除了dev分支最新代码默认支持了ip_fw_pmod模块,其他分支和release版本需要先打上下面这个patch,在F-Stack的ipfw开启ip_fw_pmod模块功能:ff_ipfw support ip_fw_pmod and tcpmod for tcp-setmss.

F-Stack通用方式

因为F-Stack并未实际实现修改网卡MTU的功能,所以可以通过修改用户态FreeBSD协议栈中网卡设备MTU的操作,来达到减小mss选项的值(MTU – 40),但网卡实际的MTU还是1500,并没有减小,从而达到也可以正常收发超过设置的MTU(如1480)大小的包(如1500),但这里借助了F-Stack的feature(不是bug)来实现的,并不建议作为标准方案使用,参考命令如下

ff_ifconfig f-stack-0 mtu 1480

Linux协议栈

linux协议栈对标ipfw,使用iptables修改mss选项值的命令参考

iptables -t mangle -A OUTPUT -p tcp --tcp-flags SYN,RST SYN -j TCPMSS --set-mss 1440

F-Stack 对 HTTP/3 的支持使用说明

Nginx 主线在1.25.x 版本中已经加入了对 HTTP/3 的支持,F-Stack 在等待了两个小版本之后,也移植了 Nginx-1.25.2 版本到 F-Stack 上,目前可以支持HTTP/3的测试使用,本文介绍移植过程中的一些兼容性改造及使用注意事项。

主要兼容项

主要是 F-Stack 的一些接口兼容和 FreeBSD 不支持一些 Linux 的部分选项,而 Nginx 的自动配置检测的是 Linux 是否支持,需要进行一些修改。

  1. 对 F-Stack 的 ff_recvmsg 和 ff_sendmsg 接口进行修改,兼容 Linux 接口,主要是部分结构体字段类型不一致的兼容,虽然结构体编译对齐后总长度是一致的。
  2. 关闭了 BPF sockhash 功能(bpf,SO_COOKIE)的功能检测和开始,该功能主要用于通过从 bpf 的 socket_cookie 直接获取数据,提升性能。
  3. 关闭了 UDP_SEGMENT 功能,主要功能设置 UDP 分段大小。
  4. IP_PKTINFO 选项不探测是否支持,强制使用 FreeBSD 的 IP_RECVDSTADDR 和 IP_SENDSRCADDR 选项。
  5. IP_MTU_DISCOVER 选项不探测是否支持, 强制使用 FreeBSD 的 IP_DONTFRAG 选项。
  6. IPV6_MTU_DISCOVER 选项不探测是否支持, 强制使用 IPV6_DONTFRAG 选项,该选项目前 FreeBSD 和 Linux 都支持。

编译过程

SSL 库

此处以 OpenSSL quic 为例,可以参考以下方式编译

cd /data/
wget https://github.com/quictls/openssl/archive/refs/tags/OpenSSL_1_1_1v-quic1.tar.gz
tar xzvf OpenSSL_1_1_1v-quic1.tar.gz
cd /data/openssl-OpenSSL_1_1_1v-quic1/
./config enable-tls1_3 no-shared --prefix=/usr/local/OpenSSL_1_1_1v-quic1
make
make install_sw

DPDK 和 F-Stack lib

总体编译方式不变,额外需要注意的是如果系统的 OpenSSL 库版本与上面使用的 OpenSSL quic 版本不兼容时,编译 DPDK lib 库时需要也使用上面的OpenSSL quic 库(通过配置 PKG_CONFIG_PATH 使用),参考以下方式编译

export FF_PATH=/data/f-stack
export PKG_CONFIG_PATH=/usr/local/OpenSSL_1_1_1v-quic1/lib/pkgconfig:/usr/lib64/pkgconfig:/usr/local/lib64/pkgconfig:/usr/lib/pkgconfig

mkdir -p /data/f-stack
git clone https://github.com/F-Stack/f-stack.git /data/f-stack

# DPDK lib
cd /data/f-stack/dpdk/
meson -Denable_kmods=true build
ninja -C build
ninja -C build install

# F-Stack lib
cd /data/f-stack/lib/
make
make install

# ff tools
cd /data/f-stack/tools
make
make install

F-Stack Nginx-1.25.2

Nginx 可以参考以下参数进行编译,如果有更多额外需求,自行调整相关配置

export FF_PATH=/data/f-stack
export PKG_CONFIG_PATH=/usr/local/OpenSSL_1_1_1v-quic1/lib/pkgconfig:/usr/lib64/pkgconfig:/usr/local/lib64/pkgconfig:/usr/lib/pkgconfig

cd /data/f-stack/app/nginx-1.25.2/
./configure --prefix=/usr/local/nginx_fstack --with-ff_module --with-http_ssl_module --with-http_v2_module --with-http_v3_module --with-cc-opt=-I/usr/local/OpenSSL_1_1_1v-quic1/include --with-ld-opt='-L/usr/local/OpenSSL_1_1_1v-quic1/lib/'
make
make install

测试使用注意事项

  1. keepalive_timeout = 65 # 因为 Nginx 的 quic 中将 keepalive_timeout参数值作为了读超时时间,所以不能设置为 0
  2. listen 443 quic; # 监听HTTP/3的时候不能设置REUSEPORT,否则多进程会有异常
  3. ulimit -n 100000 # 调大该参数值
  4. 其他使用注意事项可以参考 F-Stack 和 HTTP/3 相关配置文档

性能测试对比

这里不考虑现网实际客户端访问网站的延迟对比,仅考虑 F-Stack Nginx 和源生 Nginx 的性能对比测试。

但是在尝试了多种客户端后,仅 curl8 测试成功,但是只能测试单连接的延迟,这里不太关注。其他压测客户端工具 wrk-quic、h2load、Nighthawk 等在编译测试时都遇到了各种各样的问题,暂时未能成功测试,性能对比数据暂时缺失,如果有人有压测客户端,欢迎进行对比测试并提供测试数据。

F-Stack LD_PRELOAD 介绍

2026.05.26本文章已经更新

跳票许久许久的LD_PRELOAD功能模块(后续以 libff_syscall.so 代替)在 F-Stack dev 分支的 adapter/sysctall 目录下已经提交,支持 hook 系统内核 socket 相关接口的代码,降低已有应用迁移到 F-Stack 的门槛。下面将分别进行具体介绍, 主要包括libff_syscall.so 相关的架构涉及其中的一些思考,支持的几种模式以及如何使用等内容。

总体结论:

  • 原有应用程序的接入门槛比原本的 F-Stack 有所降低,大部分情况下可以不修改原有的用户应用程序和 F-Stack lib 的代码,而是仅修改libff_syscall.so相关代码即可适配。
  • 可以支持多 F-Stack 实例(即原 F-Stack 应用程序进程),每个 F-Stack 实例可以对应 1 个或多个用户应用程序。
    • 为了达到最佳的性能,建议一个用户应用程序(进程或线程)对应一个 fstack 实例应用程序,即为一组应用实例。
  • 每组应用实例的性能会略高于系统内核的性能,与单个标准 F-Stack 应用进程互有高低;单机整体的性能相比系统内核仍有较大的优势,但与标准 F-Stack 仍有差距。
    • 新的每组应用实例需要运行在两个 CPU 核心上,而标准 F-Stack 应用进程只需要运行在一个 CPU 核心上,总体而言性价比不高,是否使用可以视各业务的具体情况而定。
    • Nginx 600 字节的 body 内存应答测试中,长连接中相同数量的新应用实例于标准 F-Stack 应用进程,短连接中相同数量的新应用实例则略于标准 F-Stack 应用进程,见 Nginx 接入介绍章节,但使用的 CPU 几乎翻倍。

已知限制

自 2023 年以来 libff_syscall.so 持续迭代,原本作为开放问题的多个能力(如 forkaccept4__recv_chk 系列、epoll polling 模式以及 lock-free ring IPC 等)已经实现,详见下文 功能更新历程。下列限制仍在跟踪,欢迎社区共同完善:

  • 进程结束时仍可能存在内存泄漏与死锁风险。
  • 部分接口(如 sendmsgreadvreadmsg 等)线上场景使用较少,尚未做充分的性能优化与测试,仍需进一步打磨(较新加入的 accept4__recv_chk__read_chk__recvfrom_chk 等已覆盖)。
  • 项目在准生产环境中持续迭代,欢迎社区提供更长时间运行的稳定性反馈。
  • 在多 F-Stack 实例同时运行时暂不能作为客户端使用,例如 Nginx 的 proxy 场景;参考的改造思路如下:
  • @铁皮大爷:之前实现过类似的逻辑,在 hook 内再加一层 RSS。延迟建立 socket(仅在确定目的与来源后再选择由哪个 F-Stack 作为 worker 进程),同时要求网卡接收侧开启 RSS 对称哈希,保证出入方向能落到同一个 F-Stack worker。
    • app -> socket:暂存 socket 操作,创建 fd(fd1)返回给用户。
    • app -> bind:暂存 bind 操作,将 bind 参数与 fd1 绑定后返回给用户。
    • app -> connect:在 fd1 上追加 connect 参数,按 RSS 对称哈希选定某个 F-Stack 进程(worker),将暂存的 socketbindconnect 一并交给该 F-Stack 进程处理并等待同步结果。

功能更新历程 (2023-05-04 ~ 2026-05-25)

以下汇总自 v1.22 以来 adapter/syscall/ 目录的主要变更。

新功能

  • Lock-free rte_ring IPC(FF_USE_RING_IPC:以 DPDK SPSC ring 替代原先基于信号量的共享内存 IPC,从 fstack 主循环中彻底移除全局 ff_so_zone->lock。多核短/长连接实测中 ring 与 sem 性能相当或略低 2–4%,无跨 worker 锁竞争,并天然免疫启动期自旋锁饥饿。Ring 分支默认启用 v3.4 的优化(D2:sc->completion 唤醒;D5:内联 rte_ring_empty 快速空判断;D6:内联 dequeue burst 与 dispatch)。编译开关见附录 FF_USE_RING_IPC,完整设计与性能分析见 docs/ld_preload_ring_spec/
  • epoll polling 模式:在 RTT 敏感的事件等待路径上改善时延表现。
  • fork 支持:每个 fork 出的进程拥有自己的 FreeBSD struct thread,行为更接近 Linux kernel,解除了原先 LD_PRELOAD 下 fork 使用受限的状况。
  • accept4SOCK_CLOEXEC / SOCK_NONBLOCK 支持:新增 accept4 hook,并在 ff_socket 上支持 LINUX_SOCK_CLOEXEC / LINUX_SOCK_NONBLOCK 标志位。
  • glibc _FORTIFY_SOURCE 系列 hook:新增 __recv_chk__read_chk__recvfrom_chk,使开启 -D_FORTIFY_SOURCE 编译的应用能在 LD_PRELOAD 下正常工作。

功能完善与缺陷修复

  • FF_KERNEL_EVENT 模式下 kernel epoll fd 泄漏修复ff_hook_close 在启用 FF_KERNEL_EVENT 时同步关闭系统侧 epoll fd,解决长时间运行 Nginx 场景下观察到的内核 fd 泄漏。
  • ff_hook_syscall.c 中 cplen 计算修复:修复 hook 路径中错误的长度计算,并后续将代码风格统一到 ff_hook_accept
  • ff_hook_recvfromsh_fromlen 未初始化修复:在调用 ff_sys_recvfrom 之前初始化 sh_fromlen,修复返回 -1 的回归问题。
  • ioctl 函数原型冲突的编译错误修复(#942):解决新版工具链下函数原型冲突导致的构建失败。
  • Ring IPC 启动期饥饿修复:在 FF_MULTI_SC + idle_sleep = 0 场景下,nginx worker 在 attach 第二个 fstack 实例的 ff_so_zone 时可能表现为死锁;sem 路径在确认无在用 socket context 时做条件性的 unlock → pause → lock,消除饥饿且对正常负载零影响。
  • Ubuntu 22.04 / kernel 5.19 / gcc 11.4 编译错误修复:包含 pre-C99 声明问题,参考 #777。
  • 其他零碎编译 / 日志 / Makefile / 头文件打磨:包括 syscall 目录编译问题修复、日志清理,以及一系列围绕 ff_hook_syscall.cff_socket_ops.cff_socket_ops.hff_linux_syscall.cff_sysproto.hff_declare_syscalls.hMakefile 的细节改进。

libff_syscall.so 的编译

先设置好FF_PATHPKG_CONFIG_PATH环境变量

export FF_PATH=/data/f-stack
export PKG_CONFIG_PATH=/usr/lib64/pkgconfig:/usr/local/lib64/pkgconfig:/usr/lib/pkgconfig

adapter/sysctall目录下直接编译即可得到ibff_syscall.so的相关功能组件

cd /data/f-stack/adapter/sysctall
make clean;make all

ls -lrt
fstack
libff_syscall.so
helloworld_stack
helloworld_stack_thread_socket
helloworld_stack_epoll
helloworld_stack_epoll_thread_socket
helloworld_stack_epoll_kernel

下面将分别进行介绍各个组件的主要作用

fstack 实例应用程序

fstack应用程序对标的是标准版 F-Stack 中的应用程序,其运行与普通的 F-Stack 应用程序完全相同,包括配置文件及其多进程(每进程即为一个实例)的运行方式等, 具体运行方式可以参考 F-Stack 主目录的 README, 在执行 LD_PRELOAD 的用户应用程序前必须先运行 fstack实例应用程序。

fstack 应用程序的作用主要是底层对接 F-Stack API,其主函数ff_handle_each_context即为普通 F-Stack 应用的用户层 loop 函数,非空闲时或每间隔 10ms (受 HZ参数影响) 时会调用该函数去循环处理与 APP 对接的上下文,如果 APP 有对应的 API 请求,则调用实际的 F-Stack API 进行处理。

libff_syscall.so用户应用进程间通信使用 DPDK 的 rte_malloc 分配的 Hugepage 共享内存进行。

该函数对 libff_syscall.so 的整体性能有至关重要的影响,目前是复用了 F-Stack 主配置文件(config.ini)中的 pkt_tx_dalay参数,死循环并延迟该参数指定的值后才会回到 F-Stack 的其他处理流程中。

如果想提高 libff_syscall.so的整体性能,那么fstack实例应用程序与 APP 应用程序的匹配十分重要,只有当一个ff_handle_each_context循环中尽量匹配一次循环的所有事件时才能达到最优的性能,这里需要调十分精细的调优,但是目前还是粗略的使用 pkt_tx_dalay参数值。

【提示】pkt_tx_dalay参数的默认值为 100us, 较适合长连接的场景。如果是 Nginx 短链接的场景,则应考虑设置为 50us,可以可获得更好的性能。当然不同的用用场景如果想达到最优的性能,可能需要业务自行调整及测试。复用该参数也只是临时方案,后续如果有更优的方案,则随时可能进行调整。

libff_syscall.so

该动态库主要作用是劫持系统的 socket 相关接口,根据 fd 参数判断是调用 F-Stack的相关接口(通过上下文 sc 与 fsack 实例应用程序交互)还是系统内核的相关接口。

fstack实例应用进程间通信使用 DPDK 的 rte_malloc 分配的 Hugepage 共享内存进行。

【注意】在第一次调用相关接口时分配相关内存,不再释放,进程退出时存在内存泄漏的问题,待修复。

F-Stack用户的应用程序 (如 helloworl 或 Nginx)设置 LD_PRELOAD劫持系统的 socket 相关 API 时使用,即可直接接入 F-Stack 开发框架,可以参考如下命令:

export LD_PRELOAD=/data/f-stack/adapter/syscall/libff_syscall.so

确保 fstack实例应用程序已经正确运行的前提下,然后启动用户应用程序。

当然如果是改造用户的 APP 使用 kqueue代替 Linux 的 epoll 相关事件接口时,也可以在用户 APP 中直接链接该运行库, 可以参考相关示例程序helloworld_stackhelloworld_stack_thread_socket对应的源文件main_stack.cmain_stack_thread_socket.c,因为不是使用的LD_PRELOAD, 所以本文档不再详细介绍。

【重要提示】一组对应的fstack应用程序和用户应用程序最好运行在同一个 CPU NUMA 节点不同物理核上,其他场景(运行在同一个CPU核心、两个 CPU 核心跨 NUMA 节点,物理核和超线程核混用)都无法达到一组实例的最佳性能。

  • 特别的,如果 CPU 物理核心比较缺乏,可以考虑一组实例分别运行在对应的一组 CPU 的物理核心和 HT 核心上,虽然单组实例性能会有所下降(约 20% 左右),但可以使用更多的 CPU 核心,单机总性能可能会有所提升。

DEMO 演示程序 helloworld_stack*

其他编译生成的hello_world开头的可执行文件为当前libff_syscall.so支持的几种不同运行模式的相关演示程序,下一节进行具体介绍。

F-Stack LD_PRELOAD 支持的几种模式

为了适应不同应用对 socket 接口的不同使用方式,降低已有应用迁移到 F-Stack 的门槛,并尽量提高较高的性能,目前 F-Stack 的 libff_syscall.so 主要支持以下几种模式,支持多线程的 PIPELINE 模式、线程(进程)内的 RTC(run to completion)模式、同时支持 F-Stack 和内核 socket 接口的 FF_KERNEL_EVENT 模式和类似内核 SO_REUSEPORT 的 FF_MULTI_SC 模式。

支持多线程的 PIPELINE 模式

该模式为默认模式,无需额外设置任何参数直接编译libff_syscall.so即可。

在此模式下,socket 相关接口返回的 fd 可以在不同线程交叉调用,即支持 PIPELINE 模式,对已有应用的移植接入更友好,但性能上相应也会有更多的损失。

该模式除了单进程运行方式外,同时可以支持用户应用程序多进程方式运行,每个用户进程对应一个fstack实例应用程序的实例,更多信息可以参考附录的运行参数介绍。

【注意】以此默认方式接入 F-Stack 的应用程序只能使用 F-Stack 的 socket 网络接口,而不能使用系统的 socket 接口。

hook 系统 epoll 接口

对于已有的 Linux 下的应用,事件接口都是一般使用的是epoll相关接口,对于没有更多特殊要求的应用程序,可以直接使用默认的编译参数编译libff_syscall.so后使用,参考 DEMO 程序helloworld_stack_epoll, 代码文件为main_stack_epoll.c

【注意】F-Stack 的epoll接口依然为kqueue接口的封装,使用上依然与系统标准的epoll事件接口有一定区别,主要是事件触发方式和multi accept的区别。

使用 kqueue

当然libff_syscall.so除了支持使用LD_PRELOAD方式 hook 系统的 socket 接口的方式使用,也支持普通的链接方式使用,此时除了可以使用系统的epoll事件接口之外,还可以使用 F-Stack(FreeBSD)具有的kqueue事件接口,参考 DEMO 程序helloworld_stack, 代码文件为main_stack.c

该使用方式的性能比LD_PRELOALD使用系统epoll接口的方式有略微的性能提升。

线程(进程)内的 RTC(run to completion)模式

该模式需要设置额外的编译参数后来编译libff_syscall.so才能开启,可以在adapter/sysctall/Makefile中使能FF_THREAD_SOCKET或执行以下 shell 命令来开启。

export FF_THREAD_SOCKET=1
make clean;make all

在此模式下,socket 相关接口返回的 fd 仅可以在本线程内调用,即仅支持线程内的 RTC 模式,对已有应用的移植接入门槛稍高,但性能上相应也会有一定的提升,适合原本就以 RTC 模式运行的应用移植。

同样的,该模式除了单进程运行方式外,同时可以支持用户应用程序多进程方式运行,每个用户进程对应一个fstack实例应用程序的实例,更多信息可以参考附录的运行参数介绍。

【注意】以此默认方式接入 F-Stack 的应用程序同样只能使用 F-Stack 的 socket 网络接口,而不能使用系统的 socket 接口。

hook 系统 epoll 接口

其他同默认的 PIPELINE 模式,可以参考 DEMO 程序helloworld_stack_epoll_thread_socket, 代码文件为main_stack_epoll_thread_socket.c

使用 kqueue

其他同默认的 PIPELINE 模式,可以参考 DEMO 程序helloworld_stack_thread_socket, 代码文件为main_stack_thread_socket.c

FF_KERNEL_EVENT 模式

该模式可以同时支持 F-Stack 和系统内核的 socket 接口,需要设置额外的编译参数后来编译libff_syscall.so才能开启,可以在adapter/sysctall/Makefile中使能FF_KERNEL_EVENT或执行以下 shell 命令来开启。

export FF_KERNEL_EVENT=1
make clean;make all

在此模式下,epoll相关接口在调用 F-Stack 接口的同时会调用系统内核的相关接口,并将 F-Stack 返回的 fd 与系统内核返回的 fd 建立映射关系,主要为了支持两个场景:

  • 用户应用程序中有控制 fd 与 数据 fd 使用相同的 epoll fd, 如 Nginx。
  • 希望本机也可以同时访问用户应用程序监听的网络接口。
    • 如果希望单独与本机系统内核进行普通网络通信,需要额外调用socket接口,并需要指定type | SOCK_KERNEL参数,并为返回的 fd 单独调用 bind()listen()epoll_ctl()等接口,参考 DEMO 程序helloworld_stack_epoll_kernel, 代码文件为main_stack_epoll_kernel.c

【注意1】F-Stack 中 FreeBSD 的内核参数 kern.maxfiles不应该大于 65536(原默认值为 33554432),以保证 F-Stack 的 epoll fd 到系统内核的 epoll fd 的正确映射。

【注意2】Nginx 的无缝接入需要开启此模式,因为在 Nginx 中有多个控制 fd 与 数据 fd 使用相同的 epoll fd。

FF_MULTI_SC 模式

该模式为 Nginx 等使用内核SO_REUSEPORTfork子进程 worker 运行等特殊的设置为设置,需要设置额外的编译参数后来编译libff_syscall.so才能开启,可以在adapter/sysctall/Makefile中使能FF_MULTI_SC或执行以下 shell 命令来开启。

export FF_MULTI_SC=1
make clean;make all

在此模式下,用户应用程序与fstack实例相关联的上下文sc除了保存在全局变量sc中之外,会额外保存在全局的scs数组中,在fork()子进程 worker 时会使用 current_worker_id设置sc变量为对应 worker 进程 fd 对应的 sc,供子进程复制及使用。

Nginx 的reuseport模式的主要流程为,主进程为每个 worker 分别调用 socket()bind()listen()等接口,并复制到 worker 进程,而后 woker 进程各自调用epoll相关接口处理各自的 fd, 需要各自 fd 对应的上下文 sc 才能正确运行。

【注意】Nginx 的无缝接入需要同时开启 FF_THREAD_SOCKETFF_MULTI_SC 模式。

Nginx 接入libff_syscall.so介绍

Nginx(以 F-Stack 默认携带的 Nginx-1.16.1 为例)目前可以不修改任何代码直接以LD_PRELOAD动态库libff_syscall.so的方式接入 F-Stack,以下为主要步骤及效果。

编译libff_syscall.so

需要同时开启 FF_THREAD_SOCKETFF_MULTI_SC 模式进行编译

export FF_PATH=/data/f-stack
export PKG_CONFIG_PATH=/usr/lib64/pkgconfig:/usr/local/lib64/pkgconfig:/usr/lib/pkgconfig

cd /data/f-stack/adapter/sysctall
export FF_KERNEL_EVENT=1
export FF_MULTI_SC=1
make clean;make all

配置nginx.conf

以下为主要需要注意及修改的相关配置参数示例(非全量参数):

user  root;
worker_processes 4; # worker 数量
worker_cpu_affinity 10000 100000 1000000 10000000; # 设置 CPU 亲和性

events {
  worker_connections 1024;
  multi_accept on; # epoll 是封装 kqueue 接口,必须要开启
  use epoll;
}

http {
access_log off; # 关闭访问日志,用于提高测试时的网络性能,否则每次请求都需要额外调用系统的 write() 接口记录访问日志

sendfile       off; # 使用 F-Stack 时需要关闭

keepalive_timeout 0; # 视长连接/短链接的业务需要调整
  #keepalive_timeout 65;
  #keepalive_requests 200; # 默认每个长连接最多 100 个请求,视业务需要调整,长连接时适当提高此值可以略微提高性能
   
  server {
      listen       80 reuseport; # 应该设置 reuseport,与使用系统的内核的 reuseport 行为不太一致,但都可以提高性能

      access_log off;
       
      location / {
          #root   html;
          #index index.html index.htm;
          return 200 "0123456789abcdefghijklmnopqrstuvwxyz"; # 直接返回数据用以测试单纯的网络性能
      }
  }
}

【注意】此处的 reuseport作用是使用多个不同的 socket fd, 而每个 fd 可以对接不同的fstack实例应用程序的上下文sc来分散请求,从而达到提高性能的目的。与系统内核的reuseport行为异曲同工。

运行

假设运行4组 Nginx – fstack 实例应用程序,可以简单按照以下步骤进行

  • 运行 fstack 实例
  • 设置config.ini中的lcore_mask=f00,即使用 CPU 核心 9-11, 其他配置按照标准 F-Stack 配置进行。
  • 参考以下命令启动 fstack 实例,并等待一段时间待 fstack 主进程和子进程都启动完成
  cd /data/f-stack
  bash ./start.sh -b adapter/syscall/fstack
  • 运行 Nginx
  • 参考以下命令配置libff_syscall.so所需的环境变量
  export LD_PRELOAD=/data/f-stack/adapter/syscall/libff_syscall.so # 设置 LD_PRELOAD libff_syscall.so
  export FF_NB_FSTACK_INSTANCE=4 # 设置有 4 个 fstack 实例应用程序,前面 nginx.conf 中也配置了 4 个worker
  • 启动 Nginx
  /usr/local/nginx/sbin/nginx # 启动 Nginx

性能对比

测试环境

CPU:Intel(R) Xeon(R) CPU E5-2670 v3 @ 2.30GHz * 2

网卡:Intel Corporation Ethernet Controller 10-Gigabit X540-AT2

OS :TencentOS Server 3.2 (Final)

内核:5.4.119-1-tlinux4-0009.1 #1 SMP Sun Jan 23 22:20:03 CST 2022 x86_64 x86_64 x86_64 GNU/Linux

Nginx长连接

  • body 大小为 602 字节(不包括 http 头等)。
  • LD_PRELOAD 实际使用的 CPU 为几乎横轴 CPU 核心数的双倍,系统内核均衡软中断实际使用的 CPU 也远高于 worker 数量对应的 CPU 核心数量。
  • 限于时间所限,其中 LD_PRELOAD 的测试数据为以上测试环境的数据,其他为历史 40G 测试环境的数据,后续会更新为相同测试环境的数据。
  • 受网卡硬件所限,8核 LD_PRELOAD 测试带宽已经接近 10G 网卡线速(服务端出带宽9.xG,148万 RPS), 导致的与标准 F-Stack 的数据差异,实际CPU尚有一些空闲,后续应使用 40G/100G 网卡进行对比测试
  • pkt_tx_delay 参数为 100us。

Nginx短链接

  • body 大小为 602 字节(不包括 http 头等)。
  • LD_PRELOAD 实际使用的 CPU 为几乎横轴 CPU 核心数的双倍,系统内核均衡软中断实际使用的 CPU 也远高于 worker 数量对应的 CPU 核心数量。
  • 受 CPU 硬件所限(12C24HT * 2),LD_PRELOAD 测试只能测试12组应用实例组,即使用了全部 CPU 的物理核心,无法进行更多实例组的测试。
  • 8核之后 LD_PRELOAD 的性能不如标准 F-Stack 的性能,最主要是受用户应用程序和fstack应用程序的匹配度不高(ff_handle_each_context的循环次数及时间等)影响很大,并未完全达到性能极致,如果持续的精细化调整可以进一步提高性能,但是通用性也不高。
  • pkt_tx_delay 参数由 100us 调整到 50us。

附录:详细参数介绍

编译参数

本段总体介绍各个编译选项,所有参数都可以在adapter/sysctall/Makefile中开启或通过 shell 命令设置环境变量来开启。

DEBUG

开启或关闭 DEBUG 模式,主要影响优化和日志输出等, 默认关闭。

export DEBUG=-O0 -gdwarf-2 -g3

默认的优化参数为

-g -O2 -DNDEBUG

FF_THREAD_SOCKET

是否开启线程级上下文sc变量,如果开启,则 socket 相关 fd 只能在本线程中调用,一般可以略微提高性能, 默认关闭。

export FF_THREAD_SOCKET=1

FF_KERNEL_EVENT

是否开启epoll相关接口在调用 F-Stack 接口的同时调用系统内核的相关接口,并将 F-Stack 返回的 fd 与系统内核返回的 fd 建立映射关系, 默认关闭,主要为了支持两个场景:

  • 用户应用程序中有控制 fd 与 数据 fd 使用相同的 epoll fd, 如 Nginx。
  • 希望本机也可以同时访问用户应用程序监听的网络接口。
export FF_KERNEL_EVENT=1

FF_MULTI_SC

在此模式下,用户应用程序与fstack实例相关联的上下文sc除了保存在全局变量sc中之外,会额外保存在全局的scs数组中,在fork()子进程 worker 时会使用 current_worker_id设置sc变量为对应 worker 进程 fd 对应的 sc,供子进程复制及使用。 默认关闭。

export FF_KERNEL_EVENT=1

FF_USE_RING_IPC

是否将 libff_syscall.sofstack 实例之间的 IPC 从原有的信号量+共享内存方案切换为 lock-free 的 DPDK SPSC rte_ring,默认关闭。

export FF_USE_RING_IPC=1

启用该开关时,v3.4 的 ring 路径优化均作为默认行为合入(基于 sc->completion 的唤醒、内联 rte_ring_empty 快速空判断、以及内联 dequeue burst 与 dispatch),无需额外子开关。

性能摘要:在 LD_PRELOAD + FF_MULTI_SC 且 nginx worker 与 fstack 实例 1:1 的部署形态下,ring 与 sem 在 1 / 2 / 4 核短/长连接实测中相差均在 2–4% 以内。Ring 路径的主要价值是结构性的:主循环 lock-free、天然免疫启动期自旋锁饥饿、且不需要 fstack 侧的 zone 级锁。对于每个 worker 拥有独立 fstack 实例的生产部署,sem 路径仍是推荐配置;ring 路径主要作为未来”单进程内多线程共享 sc”或”多进程间共享 sc(worker 数量多于 fstack 实例数量)”扩展场景的预留能力。完整设计与性能分析见 docs/ld_preload_ring_spec/

运行参数

通过设置环境变量设置一些用户应用程序需要的参数值,如果后续通过配置文件配置的话可能需要修改原有应用,所以暂时使用设置环境变量的方式。

LD_PRELOAD

设置 LD_PRELOAD 的运行库,再运行实际的应用程序,可以参考以下命令

export LD_PRELOAD=/data/f-stack/adapter/syscall/libff_syscall.so

如果想通过gdb调试应用程序,则可以参考以下命令

export LD_PRELOAD=
gdb ./helloworld_stack_epoll
(gdb) set exec-wrapper env 'LD_PRELOAD=/data/f-stack/adapter/syscall/libff_syscall.so'

FF_NB_FSTACK_INSTANCE

设置fstack实例应用程序的实例数,用于和用户应用程序的进程/线程等 worker 数量相匹配,默认1。

export FF_NB_FSTACK_INSTANCE=4

建议用户应用程序 worker 数量与fstack实例应用程序尽量 1:1 配置,可以达到更好的性能。

FF_INITIAL_LCORE_ID

配置用户应用程序的 CPU 亲和性绑定的起始 CPU 逻辑 ID,16进制,默认0x4(0b0100),即 CPU 2。

export FF_INITIAL_LCORE_ID=0x4

如果用于应用程序可以配置 CPU 亲和性,则可以忽略该参数,如 Nginx 配置文件中的worker_cpu_affinity参数。

FF_PROC_ID

配置用户应用程序的进程 ID,可以配合FF_INITIAL_LCORE_ID参数设置 CPU 亲和性的绑定,10进制递增,默认0。

export FF_PROC_ID=1

如果用于应用程序可以配置 CPU 亲和性,则可以忽略该参数,如 Nginx 配置文件中的worker_cpu_affinity参数。

致谢

特别感谢以下外部贡献者自 2023-05-04 以来通过 pull request 与 commit 对 libff_syscall.so 做出的实质性扩展:

  • liujinhui-job —— 自 2025 年以来贡献最多的外部开发者,主要工作包括:fork 支持(PR #887)、accept4SOCK_CLOEXEC / SOCK_NONBLOCK 支持、__recv_chk / __read_chk / __recvfrom_chk 系列 _FORTIFY_SOURCE hook、epoll polling 模式、ff_hook_recvfromsh_fromlen 未初始化修复(PR #872),以及围绕 ff_hook_syscall.cff_socket_ops.cff_socket_ops.hff_linux_syscall.cff_sysproto.hff_declare_syscalls.hMakefile 的一系列细节优化。
  • zhaozihanzzh —— 修复 ff_hook_syscall.c 中的 cplen 计算错误(并将代码风格统一到 ff_hook_accept),以及在 FF_KERNEL_EVENT 模式下 ff_hook_close 内的 kernel epoll fd 泄漏问题。

上述贡献显著提升了 LD_PRELOAD 路径的完整性、正确性以及对 Nginx 的友好程度,欢迎社区继续提交 pull request 与建议。

如果用于应用程序可以配置 CPU 亲和性,则可以忽略该参数,如 Nginx 配置文件中的worker_cpu_affinity参数。

linux&F-Stack(FreeBSD)多vlan VIP策略路由设置

假设我们的服务器上有如下vlan和ip的配置

vlan 10:
IP:10.10.10.10 netmask:255.255.255.0 broadcast:10.10.10.255 gateway:10.10.10.1
外网VIP:110.110.110.0/24中的一个或多个IP,如110.110.110.110

vlan 20:
IP:10.10.20.20 netmask:255.255.255.0 broadcast:10.10.20.255 gateway:10.10.20.1
外网VIP:120.120.120.0/24中的一个或多个IP,如120.120.120.120

vlan 30:
IP:10.10.30.30 netmask:255.255.255.0 broadcast:10.10.30.255 gateway:10.10.30.1
外网VIP:130.130.130.0/24中的一个或多个IP,如30.130.130.130

外部主要访问各个vlan里的vip,需要系统应答包也正确的对应的vlan回复到该vlan的gateway地址上,下面为linux系统和F-Stack(FreeBSD)的策略设置方式

Linux 系统路由配置

创建 vlan 并设置 IP 地址

ip link add link eth0 name eth0.10 type vlan id 10
ifconfig eth0.10 10.10.10.10 netmask 255.255.255.0 broadcast 10.10.10.255 up
route add -net 10.10.10.0/24 gw 10.10.10.1 dev eth0.10

ip link add link eth0 name eth0.20 type vlan id 20
ifconfig eth0.20 10.10.20.20 netmask 255.255.255.0 broadcast 10.10.20.255 up
route add -net 10.10.20.0/24 gw 10.10.20.1 dev eth0.20

ip link add link eth0 name eth0.30 type vlan id 30
ifconfig eth0.30 10.10.30.30 netmask 255.255.255.0 broadcast 10.10.30.255 up
route add -net 10.10.30.0/24 gw 10.10.30.1 dev eth0.30

# 设置各 vlan 的外网 VIP
ifconfig lo.0 110.110.110.110 netmask 255.255.255.255
ifconfig lo.1 120.120.120.120 netmask 255.255.255.255
ifconfig lo.2 130.130.130.130 netmask 255.255.255.255

设置策略路由

echo "10 t0" >> /etc/iproute2/rt_tables
echo "20 t1" >> /etc/iproute2/rt_tables
echo "30 t2" >> /etc/iproute2/rt_tables

# 设置各 vlan 的路由表, 并设置对应的网关地址
ip route add default dev eth0.10 via 10.10.10.1 table 10
ip route add default dev eth0.20 via 10.10.20.1 table 20
ip route add default dev eth0.30 via 10.10.30.1 table 30

#systemctl restart network

# 从对应 VIP 出去的包使用对应 vlan 的路由表
ip rule add from 110.110.110.0/24 table 10
ip rule add from 120.120.120.0/24 table 20
ip rule add from 130.130.130.0/24 table 30

F-Stack(FreeBSD) 路由配置

前提条件:在 F-Stack 的 lib/Makefile中打开默认关闭的ipfw选项,并重新编译 F-Stack lib 库,各个工具及应用程序

# NETGRAPH drivers ipfw
FF_NETGRAPH=1
FF_IPFW=1

创建 vlan 并设置 IP 地址

ff_ifconfig -p 0 f-stack-0.10 create
ff_ifconfig -p 0 f-stack-0.10 inet 10.10.10.10 netmask 255.255.255.0 broadcast 10.10.10.255

ff_ifconfig -p 0 f-stack-0.20 create
ff_ifconfig -p 0 f-stack-0.20 inet 10.10.20.20 netmask 255.255.255.0 broadcast 10.10.20.255

ff_ifconfig -p 0 f-stack-0.30 create
ff_ifconfig -p 0 f-stack-0.30 inet 10.10.30.30 netmask 255.255.255.0 broadcast 10.10.30.255

# 设置各 vlan 的外网 VIP
ff_ifconfig -p0 f-stack-0.10 inet 110.110.110.110 netmask 255.255.255.255 alias
ff_ifconfig -p0 f-stack-0.20 inet 120.120.120.120 netmask 255.255.255.255 alias
ff_ifconfig -p0 f-stack-0.30 inet 130.130.130.130 netmask 255.255.255.255 alias

设置策略路由

# 设置各 vlan 的转发路由表(fib), 并设置对应的网关地址
ff_route -p 0 add -net 0.0.0.0 10.10.10.1 -fib 10
ff_route -p 0 add -net 0.0.0.0 10.10.20.1 -fib 20
ff_route -p 0 add -net 0.0.0.0 10.10.30.1 -fib 30

# 将对应 VIP 发出去的数据包设置对应 vlan 的 fib num,以便使用正确的转发路由表(fib)
ff_ipfw -P 0 add 100 setfib 10 ip from 110.110.110.0/24 to any out
ff_ipfw -P 0 add 200 setfib 20 ip from 120.120.120.0/24 to any out
ff_ipfw -P 0 add 300 setfib 30 ip from 130.130.130.0/24 to any out

参考资料

  1. F-Stack vlan 的支持与使用
  2. Nginx TCP 多证书透明代理及 Linux/F-Stack(FreeBSD) 路由相关设置
  3. linux 和 FreeBSD 相关工具的 man page