TCP LRO 简介

TCP 大型接收卸载(TCP Large Receive Offload,TCP LRO)是一种特定于协议的方法,用于降低接收 TCP 段(TCP segment)时所需的 CPU 资源。它也是实现特定的,本篇文章介绍了它在 FreeBSD 内核中的实现。在任何给定时刻,TCP 通常用于单向通信,尽管 TCP 提供了双向通道。例如,当使用 TCP 作为传输协议的应用协议是请求/响应类型(如 HTTP)时,即是这种情况。

TCP LRO 可采用多种方式降低所需的 CPU 资源,包括:

  • 合并到达确认(acknowledgment,ACK),向 TCP 栈发送单个大的扩展 ACK,而非多个较小的 ACK。适用于 TCP 端点主要发送用户数据的情况。

  • 将多个传入的数据段合并成一个较大的数据块。这对于 TCP 端点主要接收用户数据时非常有用。

  • 绕过部分 IP 栈处理。因此,TCP LRO 需要在网络接口层截取数据包,这样它就能提高效率。

所有这些方法都专注于减少调用 TCP 栈的次数,最小化 CPU 所需的缓存未命中次数,通过将所有处理压缩到一个,一系列一起处理的数据包中。对于大多数 FreeBSD 驱动程序,使用的是单一的软件 TCP LRO 过程,尽管某些特定的硬件及其驱动程序确实支持硬件 TCP LRO。本篇文章仅讨论 FreeBSD 中的软件 TCP LRO。

TCP LRO 的发展

TCP LRO 的初步实现由 Andrew Gallatin 完成于 2006 年,特定于 mxge(4) 驱动程序。然后,在 2008 年,Jack Vogel 将其扩展到所有驱动程序。它有两个主要目标:

  1. 收集和合并小的传入数据段,向 TCP 提供一个更大的单一传入数据段,或者

  2. 收集多个 ACK,并向 TCP 栈呈现一个更大的单个 ACK。

这两种方法的实现目的是减少 TCP 接收路径被调用的次数,从而节省 CPU 资源。它的实现非常谨慎,只处理连续的段和没有 TCP 选项的段(唯一允许的 TCP 选项是时间戳)。这个初步实现在 FreeBSD 中保留了近十年,几乎从未改动,唯一的变化是 2012 年 Bjoern A. Zeeb 增加了对 IPv6 的支持。

排序的加入

到了 2016 年,TCP LRO 代码开始显现老态,随着在客户端和服务器上部署的网卡越来越快,每次驱动程序中断时接收的数据包越来越多。最初的实现仅允许收集和压缩来自八个不同连接的数据。这在连接数较少的工作负载下效果很好,但对于连接数很多的工作负载来说效果较差,因为每次中断时驱动程序会从不同的连接发送更多的数据包。在每次中断时,由于来自多个连接的数据包数量激增,单个连接看到的数据包之间的间隔小到足以适应八连接限制的可能性变得越来越小,直到 TCP LRO 基本上不起作用,尤其对于服务器端来说。

这时,Hans Petter Selasky 提出了一个绝妙的主意,他为驱动程序添加了一个可选路径,在提交给 TCP LRO 之前对传入的数据包进行排序。这意味着来自每个连接的所有数据包都可以一起处理。这也就意味着,你可以在每次中断时最大化 TCP LRO 的效果。这一改变大大改善了 TCP LRO 性能,同时仍允许旧驱动程序保持不变。

数据包排队

随着 TCP LRO 效果的提高,这种更高效路径的其他问题也开始显现,包括:

  1. TCP 的拥塞控制更喜欢看到每一个 ACK,因为 ACK 会推动其拥塞窗口。压缩 ACK 可能会妨碍拥塞控制算法。

  2. 现代 TCP 栈通常希望获取精确的往返时间(RTT)信息,压缩多个 ACK 会隐藏这一信息。

  3. TCP ECN(显式拥塞通知)的实现需要查看 IP 头部的标志,以便监控和响应来自网络的 ECN 信号,压缩数据和 ACK 在一定程度上遮蔽了这些信息。

  4. 如果 TCP 栈正在进行数据包限速(我们将在后续的文章中讨论数据包限速),那么当栈被禁止发送数据包时处理一系列 ACK 会增加开销。这是因为 ACK 不能发送,但在处理过程中会导致多个缓存未命中,之后在栈可以发送数据包时必须重新处理。

这些问题促使了一项新的优化,其中 TCP 栈允许 TCP LRO 代码直接将数据包排队,以便在下一次唤醒时处理。这样,当栈可以发送数据时,所有 IP 和 TCP 头部的数据就能在同一时间进行处理,并且揭示了所有 TCP 想要看到的信息(包括由于接收时间戳的添加,无论是在硬件上由网卡完成,还是在软件中由 TCP LRO 代码完成)。

压缩 ACK

这种新的排队机制运行良好,但在一系列 ACK 到达时,也引起了额外的缓存未命中。这是因为每个排队等待处理的数据包都会在处理时导致缓存未命中。在旧的压缩方案中,虽然会丢失一些信息,但由于只有一个缓存未命中会发生在多个到达的 ACK 中,因此进行了更优的优化。

这促使了另一项 TCP LRO 优化。当连续的 ACK 到达时,TCP LRO 代码现在可以将它们压缩成一个特殊的数据包,该数据包包含到达数据包信息的数组。这种压缩技术允许将所有以前丢失的数据(包括到达时间)以数组结构的形式呈现给 TCP 栈,从而只需要一次缓存未命中来访问这个特殊的数据包。需要注意的是,TCP 栈必须通知 TCP LRO 代码,表明它支持这种特殊类型的处理。

内外层头部

最后一组 TCP LRO 的优化与传入 IP 数据包的处理方式有关。最初,只支持包含使用 IPv4、IPv6 协议的 TCP 段的以太网帧。为了支持其他封装的 TCP 段,例如 VXLAN,它使得能够将以太网帧封装到 UDP 数据包中,数据包解析已经被扩展,以支持内层和外层头部。通过这种方式,具有 UDP 作为外层头部和 TCP 作为内层头部的数据包可以通过 TCP LRO 进行处理。假设网卡能够为这两种协议执行校验和卸载。

TCP LRO 的管理

如果网卡驱动程序支持 TCP LRO,它可以通过 ifconfig 命令的参数 lro-lro 启用、禁用之。

网卡驱动程序必须包含结构体 struct lro_ctrl,除了其他字段外,还包含指向以下内容的指针:

  • 一组包含指向 struct mbuf 和序列号的指针的数组。这些对的数量由 lro_mbuf_max 指定。

  • 一组 struct lro_entry。这些条目的数量由 lro_cnt 指定。

struct lro_entry 用于存储一个聚合的已接收 TCP 段集的信息。如果该条目未使用,它会包含在 lro_free 列表中。当它被使用时,它会包含在 lro_active 列表中,并且也可以通过哈希表 lro_hash 访问。

这两个列表和哈希表也包含在 struct lro_ctrl 中。

网卡驱动程序初始化 TCP LRO 特定数据的方式有两种。传统的方式是调用函数 tcp_lro_init()。应分配的 struct lro_entry 数量由加载器可调参数 net.inet.tcp.lro.entries 指定。在使用传统初始化方式时,数组中的对数没有条目。现代方式是使用函数 tcp_lro_init_args(),它允许调用者指定 lro_cntlro_mbuf_max。这意味着数组对也可能被分配。

无论使用哪种方式初始化 struct lro_ctrl,调用函数 tcp_lro_free() 都将释放所有已分配的资源。

将 TCP 段传递给 TCP LRO

网卡驱动程序有传统和现代两种方式将 TCP 段传递给 TCP LRO。如果将 TCP 段传递给 TCP LRO 失败,网卡驱动程序必须继续正常处理该 TCP 段。TCP LRO 失败的一个原因是如果网卡无法验证接收到的 IP 数据包的校验和。

使用传统方式将 TCP 段传递给 TCP LRO 时,网卡驱动程序会调用 tcp_lro_rx()。基本上,这会启动由 tcp_lro_rx_common() 完成处理,后者将在下一小节中介绍。将 TCP 段传给 TCP LRO 的现代方式(也要求使用现代初始化方式)是调用 tcp_lro_queue_mbuf()。这个函数仅为 TCP 段计算一个序列号,并将其与 TCP 段一起存储在数组中的下一个空闲条目中。如果通过此操作数组已满,则会调用 tcp_lro_flush_all(),这将在下一小节中说明。

无论使用传统方式还是现代方式将 TCP 段传递给 TCP LRO,若网卡未提供硬件接收时间,传递给 TCP LRO 的时间将被保存。

在 TCP LRO 中处理 TCP 段

当使用现代方式将 TCP 段传递给 TCP LRO 时,会执行一个额外的初始步骤。tcp_lro_flush_all() 会根据序列号字段对数组中的所有条目进行排序。这使得同一 TCP 连接的所有 TCP 段很可能会在数组中按接收顺序排列。然后,调用 tcp_lro_rx_common() 处理数组中的所有条目。从此时起,无论是使用传统方式还是现代方式传递 TCP 段到 TCP LRO,TCP 段的处理方式都是一样的。

tcp_lro_rx_common() 会解析 TCP 段,并利用该信息查找哈希表中相应的 struct lro_entry 条目。如果找到了该条目,TCP 段将被添加到 TCP 段的数据包链中。如未找到条目,则会创建一个新的条目,并将 TCP 段添加到该条目中。需要注意的是,当 TCP LRO 代码没有空闲条目时,会 flush 一个较旧的条目,这样就释放了该结构,供新分配使用。

网卡驱动程序和 TCP LRO 代码本身都可以触发 flush 操作,这将导致处理 struct lro_entry 条目中的信息,使其适合由 TCP 栈处理,如下一小节所述。

从 TCP LRO 传递信息到 TCP 栈

如果使用类似 TCP RACK 和 TCP BBR 的替代 TCP 栈,则会使用高精度定时器系统(HPTS)。如果仅使用 FreeBSD 基础 TCP 栈,则不会使用此系统。

如果 FreeBSD 内核中没有加载 HPTS,当触发 flush 操作时,发生的情况是:TCP LRO 将合并一个 struct lro_entry 条目的数据包链,将所有单个 TCP 段的用户数据连接成一个大的 TCP 段。当然,这只有在没有间隙和重叠的情况下才有效。如果发生间隙和重叠,TCP LRO 可能只能合并较小的部分。 ACK 数据的信息也会被合并,生成的这个大 TCP 段将被注入到接口层。这将减少需要处理的数据包数量,但也会导致丧失关于每个 TCP 段接收时间的信息,以及任何 IP 层 ECN 标志。根据拥塞控制和丢包恢复的情况,这可能会带来负面影响。

如果加载了 HPTS 系统,flush 操作会触发查找 TCP 端点。这些信息用于确定 TCP 端点所使用的 TCP 栈是否支持 mbuf 队列。若不支持,则会执行与 FreeBSD 基础栈相同的处理。如果 TCP 栈支持 mbuf 队列,但不支持压缩 ACK,则条目的数据包链会被复制到 TCP 端点,并且可能会触发 TCP 端点处理该数据包链。这就是 TCP BBR 栈使用时的处理方式,它支持 mbuf 队列但不支持压缩 ACK 。如果使用的是 TCP RACK 栈,它也支持压缩 ACK ,可以将多个已连续接收的 ACK 存储在一个特殊的数据结构中,从而以更节省内存的方式传递它们。请注意,当使用 mbuf 队列和压缩 ACK 时,单个数据包接收时的信息会被保留并传递到 TCP 端点。

未来发展

TCP 的精确 ECN(显式拥塞通知)目前是由互联网工程任务组(IETF)指定的一项 TCP 特性,FreeBSD 正在开发对此的支持。除了使用两个新的 TCP 选项外,它还改变了两个现有 TCP 标志的使用,并使用了一个额外的标志。这需要对 TCP LRO 代码进行修改,以便仍然能够对支持精确 ECN 的 TCP 连接聚合传入的 TCP 段。

可以改进对 VXLAN 的支持,使其能够利用 mbuf 排队机制。


RANDALL STEWARTrrs@freebsd.org)是一位拥有 40 年操作系统开发经验的开发者,自 2006 年起成为 FreeBSD 开发者。他专注于传输协议,包括 TCP 和 SCTP,但也涉猎操作系统的其他领域。目前,他是一名独立顾问。

MICHAEL TüXENtuexen@freebsd.org)是明斯特应用技术大学的教授,也是 Netflix 的兼职承包商,自 2009 年起成为 FreeBSD 源代码提交者。他的研究重点是传输协议,如 SCTP 和 TCP,它们在 IETF 的标准化过程以及在 FreeBSD 中的实现。

最后更新于

FreeBSD 中文社区 2024