For the complete documentation index, see llms.txt. This page is also available as Markdown.

朴素的网桥

朴素的网桥。连接河流、沟壑、峡谷、山谷两岸的桥梁……等等,我们说的不是这种桥。if_bridge 把两条或更多以太网链路(IEEE 802 网络,如果你较真的话)连起来,相当于把你的电脑变成一台交换机,还不用一堆乱糟糟的线缆。

if_bridge 的常见用法之一是把多台虚拟机(或 VNET Jail)接到真实网络接口上。

例如,这台机器上跑了几台 bhyve 虚拟机:

vmbridge: flags=8843<UP,BROADCAST,RUNNING,SIMPLEX,MULTICAST> metric 0 mtu 1500
     ether 02:ec:9a:db:86:01
     inet 172.16.1.1 netmask 0xffffff00 broadcast 172.16.1.255
     id 00:00:00:00:00:00 priority 32768 hellotime 2 fwddelay 15
     maxage 20 holdcnt 6 proto rstp maxaddr 2000 timeout 1200
     root id 00:00:00:00:00:00 priority 32768 ifcost 0 port 0
     member: tap5 flags=143<LEARNING,DISCOVER,AUTOEDGE,AUTOPTP>
             ifmaxaddr 0 port 11 priority 128 path cost 2000000
     member: tap4 flags=143<LEARNING,DISCOVER,AUTOEDGE,AUTOPTP>
             ifmaxaddr 0 port 10 priority 128 path cost 2000000
     member: tap2 flags=143<LEARNING,DISCOVER,AUTOEDGE,AUTOPTP>
             ifmaxaddr 0 port 7 priority 128 path cost 2000000
     groups: bridge
     nd6 options=9<PERFORMNUD,IFDISABLED>

这些虚拟机连到 tap2、tap4、tap5 接口。它们都能在该(虚拟)局域网上相互通信,或通过 172.16.1.1 地址与主机通信。主机还贴心地跑着 DHCP 服务器和 NAT 防火墙,把虚拟机的流量转换到公共接口,让它们能访问互联网。

问题

这套配置没什么特别不对,但要是找不出问题,这篇文章就太短太无聊了。

好在我们有问题——至少对某些用户来说可能有问题。它不显眼,但 if_bridge 的扩展性不够好。事实上,目前的扩展性非常差。

为了演示问题,我们搭个简单测试环境:两台机器用两条 Chelsio T62100 40Gbps 链路相连。第一台机器从第一个接口发出流量,并期望从第二个接口收到。第二台机器用 if_bridge 把两个接口连起来。

Chelsio 驱动支持 netmap(4),意味着我们可以用 pkt-get(pktgen-g2017.08.06_1)生成流量。netmap(4) 很快。我不打算证明这一点——你姑且信我。是的,真的。如果你怀疑我说的每一句话,我们走不下去的!

于是,用下面两条命令(第一条接收流量,第二条生成流量),就能跑一次简单的性能测试:

我们生成 60 字节的包。这有充分理由,不只是为了找别扭。好吧,实际上就是为了找别扭。一般而言,机器传输少量大包比传输大量小包更轻松。这容易理解,因为每个包都有一定开销(如上下文切换、判断发往何处、至关重要的包间小憩……)。所以包越大越高效。真实网络中你想要大包,但测试时我们关注最坏情况,因为这时改进的效果最明显。

测试运行后,会看到类似这样的输出:

我们其实看到了两个 pkt-gen 进程的输出:一个来自发送进程,一个来自接收进程。我们设法每秒发送 2700 万包(累加起来接近 13 Gbps),但只收到 370 万包。其余的包都丢了。

为这些可怜的丢失包默哀 370 万个短暂瞬间后,我们可以问为什么。如果你只从本文记住一件事,请记住:做基准测试时最重要的提问是“为什么?” “为什么我们看到这个数字?” “为什么不是更高?” “为什么不是更低?”

我恰好知道——你姑且信我——如果把这些流量路由而非交换,能转发多得多的包。这看起来奇怪,因为路由比交换更费事。

幸好 FreeBSD 有出色的工具,帮我们搞清系统时间花在哪里。这类工作中,pmcstat(8) 工具无可替代。

下面会生成一张极具启发性的火焰图:

pmcstat 是 FreeBSD 基本系统的一部分。stackcollapse-pmcflamegraph 工具出自 Brendan Gregg(https://github.com/brendangregg/FlameGraph)。

不管怎样,看图时间到:

这图看起来有点怪,但它展示的最重要信息是系统时间花在哪里。纵轴是调用栈。从下往上读,它告诉我们 fork_exit() 调用了 ithread_loop(),后者又调用了……

调用栈底部其实不太有意思。

我们来弄清系统本可以转发包时在做什么。找一个似乎耗时不短的函数。最宽的函数——也就是我们花最多时间的地方——是 bridge_input()。我们 93% 的时间花在那里。

它看起来和网桥工作有关。为什么我们花这么多时间在里面?我们在 bridge_input() 里的大部分时间其实是在执行 __mtx_lock_sleep(),而不是像 bridge_forward() 那样有用的事。

我们不该打盹。为什么我们在睡觉?还有,mtx 是什么?

互斥锁

__mtx_lock_sleep()mutex(9) 代码库的一部分。函数名里写作 mtx,是因为程序员懒得写全。互斥锁提供互斥。它们帮助确保同一时刻只有一个 CPU 核能访问特定内存。为什么需要这个?

最简单的例子:想象两个 CPU 核同时对一个数字加一。再想象一个总是出岔子的宇宙,吐司总是涂黄油面朝下落地。这想象可能要费点劲。不?你已经身处其中了?太好了。

那么,可能发生这样的操作顺序:

糟糕。突然 1 + 1 + 1 等于 2。解决这个问题的一种办法是确保 CPU2 不能同时运行这段代码,这正是互斥锁帮我们实现的。

if_bridge(4) 代码中还有更复杂的流程,但这引出了互斥锁的用途。

互斥锁的问题在于,它们虽然确保内部数据结构同一时刻只能被一个 CPU 核访问,但这也意味着同一时刻只能在一个核上做有用功。

事实证明,if_bridge(4) 用单个互斥锁保护它所有的内部数据结构(比如“此网桥连接 igb0 和 igb1”),所以无论你有多少 CPU 核,同一时刻只有一个能通过网桥转发包。

结果,我们每秒只能转发约 390 万包。

改进

一种可能的改进方式是把互斥锁改为读写锁(见 rwlock(9))。读写锁区分读访问和写访问。如果代码只读数据不修改它,我们可以允许多个 CPU 核同时读取。只有当数据将被修改时,我们才需要禁止其他所有 CPU 核访问,包括只想读数据的那些。

这已经能大幅改善。沿此思路的原型把吞吐量提升到每秒约 800 万包。

做得更好

FreeBSD 13(尚未发布,目前为 CURRENT)带来了处理此类问题的新方式。它叫 epoch(9),由比我聪明得多的人设计和实现。

为本文起见,假设我其实懂它,这样我能解释它做什么。

epoch(9) 设施源自 ConcurrencyKit(http://concurrencykit.org)。配合 ConcurrencyKit 列表(ck_queue),我们无需获取锁(互斥锁或读写锁皆可)就能安全使用受保护的数据结构。

对于 if_bridge(4),主要数据结构是列表,典型的修改是向列表添加或从中移除条目。

向列表添加条目时,我们需要确保列表本身始终处于有效状态,但不太关心某个 CPU 核是否能看到该条目。这意味着只要我们小心,先把新数据结构完全填充好再插入列表,就能在其他核遍历列表时安全插入。

从列表移除条目时,需要更加小心。我们可以把数据从列表移除,但在确认无人再使用之前不能真正删除它。

这正是 epoch(9) 设施的用途。我们要读受保护数据时,通过 NET_EPOCH_ENTER() 包装器通知 epoch(9) 系统我们将访问这些数据。访问完,通过 NET_EPOCH_EXIT() 通知它。

这让系统能跟踪谁可能还在访问我们将要移除的数据。简单说,我们告诉它要删除某物时(通过用 NET_EPOCH_CALL() 注册执行最终清理的回调),它会等待每个调用过 NET_EPOCH_ENTER() 的 CPU 核也调用 NET_EPOCH_EXIT()

到那一刻我们能确信无人还在使用该数据,可以安全删除。

还有一些额外复杂性,因为 ConcurrencyKit 列表在被修改时可安全读取,但不能同时有多个 CPU 核尝试修改。

if_bridge(4) 中,我们用现有互斥锁保护对这些列表的写访问。最终结果是我们仍然一次只能对网桥状态做一次修改(例如添加接口或学习某 MAC 地址使用哪个接口),但修改期间其他 CPU 核能继续处理包。

记住单个 CPU 核每秒能处理 370 万包,所以可以放心假设每秒也能处理数十万乃至数百万次状态变更。

最终测量

采用基于 epoch 的方案后,性能测试显示:

所以,我们现在每秒转发约 1860 万包,提升 5 倍。也有一张对应的漂亮图(火焰图):

这里我们看到,我们不再把全部时间(其实是任何时间)花在 __mtx_lock_sleep() 上,而是在做有用功。几乎全部时间都用在直接与“判断包要去哪里并发送出去”相关的工作上。

结语

本文讨论的是进行中的工作,所以如果你发现 bug:干得漂亮!告诉作者,他可能还没注意到。

感兴趣的读者可在以下地址找到待提交的补丁:

本项目由 FreeBSD 基金会赞助支持。


KRISTOF PROVOST 是一名自由嵌入式软件工程师,专注于网络和视频应用。他是 FreeBSD 提交者、FreeBSD 中 pf 防火墙的维护者,以及 EuroBSDCon 基金会董事会成员。他有个不幸的倾向——会撞上 uClibc 的 bug,并且对 FTP 痛恨至极。别和他谈 IPv6 分片。

最后更新于