这篇博文解释了运行 Linux 内核的计算机如何接收数据包,以及如何在数据包从网络流向用户程序时监控和调整网络堆栈的每个组件。
如果不阅读内核源代码并深入了解正在发生的事情,就不可能调整或监控 Linux 网络堆栈。
希望这篇博文能够为任何想要这样做的人提供参考。
网络堆栈非常复杂,没有一种万能的解决方案。如果网络的性能和健康对您或您的企业至关重要,那么您别无选择,只能投入大量时间、精力和金钱来了解系统各个部分如何交互。
理想情况下,你应该考虑测量网络堆栈每一层的数据包丢失情况。这样你就可以确定并缩小需要调整的组件的范围。
我认为,这就是许多操作员偏离轨道的地方:他们假设一组 sysctl 设置或值可以简单地被全部重复使用。在某些情况下,也许如此,但事实证明,整个系统是如此微妙和交织在一起,如果您希望进行有意义的监控或调整,您必须努力了解系统在深层次上的运作方式。否则,您可以简单地使用默认设置,这应该足够好,直到需要进一步优化(以及推断这些设置所需的投资)。
本博文中提供的许多示例设置仅用于说明目的,并不建议或反对某种配置或默认设置。在调整任何设置之前,您应该围绕需要监控的内容制定一个参考框架,以注意到有意义的变化。
在通过网络连接到计算机时调整网络设置非常危险;您很容易将自己锁定或完全切断网络。请勿在生产机器上调整这些设置;相反,如果可能的话,请在新机器上进行调整并将其轮换到生产中。
概述
数据包从到达套接字接收缓冲区所采用的高级路径如下:
- 驱动程序已加载并初始化。
- 数据包从网络到达 NIC。
- 数据包被复制(通过 DMA)到内核内存中的环形缓冲区。
- 生成硬件中断是为了让系统知道数据包在内存中。
- 如果尚未运行,驱动程序将调用NAPI来启动轮询循环。
ksoftirqd进程在系统的每个 CPU 上运行。它们在启动时注册。进程通过调用设备驱动程序在初始化期间注册的NAPI 函数从环形缓冲区中提取数据包。ksoftirqdpoll- 环形缓冲区中已写入网络数据的内存区域未映射。
- 通过 DMA 进入内存的数据将作为“skb”传递到网络层以进行进一步处理。
- 如果启用了数据包转向或 NIC 具有多个接收队列,则传入的网络数据帧将分布在多个 CPU 之间。
- 网络数据帧从队列交给协议层。
- 协议层处理数据。
- 数据被添加到通过协议层连接到套接字的接收缓冲区中。
以下章节将详细讨论整个流程。
下面讨论的协议层是 IP 和 UDP 协议层。本文提供的大部分信息也可供其他协议层参考。
详细了解
准确理解 Linux 内核如何接收数据包非常复杂。我们需要仔细研究并了解网络驱动程序的工作原理,以便稍后更清楚地了解网络堆栈的各个部分。
这篇博文将介绍网络驱动程序。此驱动程序用于相对常见的服务器 NIC,即英特尔以太网控制器 I350。因此,让我们首先了解网络驱动程序的工作原理。
网络设备驱动程序
初始化
驱动程序注册一个初始化函数,该函数在加载驱动程序时由内核调用。该函数是使用宏来注册的。 module_init
初始化函数()及其注册可以在drivers/net/ethernet/intel/igb/igb_main.c中找到。 igb igb_init_module module_init
两者都相当简单:
/**
* igb_init_module - Driver Registration Routine
*
* igb_init_module is the first routine called when the driver is
* loaded. All it does is register with the PCI subsystem.
**/
static int __init igb_init_module(void)
{
int ret;
pr_info("%s - version %s\n", igb_driver_string, igb_driver_version);
pr_info("%s\n", igb_copyright);
/* ... */
ret = pci_register_driver(&igb_driver);
return ret;
}
module_init(igb_init_module);
初始化设备的大部分工作是通过调用进行的,我们接下来会看到。 pci_register_driver
PCI 初始化
Intel I350 网卡是PCI 快速设备。
PCI 设备使用PCI 配置空间中的一系列寄存器来识别自己。
编译设备驱动程序时,将使用名为(from ) 的宏来导出 PCI 设备 ID 表,该表标识设备驱动程序可以控制的设备。该表还注册为结构的一部分,我们稍后会看到。 MODULE_DEVICE_TABLE include/module.h
内核使用这个表来决定加载哪一个设备驱动程序来控制该设备。
这样,操作系统就可以确定哪些设备连接到了系统,以及应该使用哪个驱动程序与该设备通信。
该表和驱动程序的 PCI 设备 ID可分别在和中找到: igb drivers/net/ethernet/intel/igb/igb_main.c drivers/net/ethernet/intel/igb/e1000_hw.h
static DEFINE_PCI_DEVICE_TABLE(igb_pci_tbl) = {
{ PCI_VDEVICE(INTEL, E1000_DEV_ID_I354_BACKPLANE_1GBPS) },
{ PCI_VDEVICE(INTEL, E1000_DEV_ID_I354_SGMII) },
{ PCI_VDEVICE(INTEL, E1000_DEV_ID_I354_BACKPLANE_2_5GBPS) },
{ PCI_VDEVICE(INTEL, E1000_DEV_ID_I211_COPPER), board_82575 },
{ PCI_VDEVICE(INTEL, E1000_DEV_ID_I210_COPPER), board_82575 },
{ PCI_VDEVICE(INTEL, E1000_DEV_ID_I210_FIBER), board_82575 },
{ PCI_VDEVICE(INTEL, E1000_DEV_ID_I210_SERDES), board_82575 },
{ PCI_VDEVICE(INTEL, E1000_DEV_ID_I210_SGMII), board_82575 },
{ PCI_VDEVICE(INTEL, E1000_DEV_ID_I210_COPPER_FLASHLESS), board_82575 },
{ PCI_VDEVICE(INTEL, E1000_DEV_ID_I210_SERDES_FLASHLESS), board_82575 },
/* ... */
};
MODULE_DEVICE_TABLE(pci, igb_pci_tbl);
正如上一节所见,在驱动程序的初始化函数中被调用。 pci_register_driver
此函数注册一个指针结构。大多数指针都是函数指针,但也注册了 PCI 设备 ID 表。内核使用驱动程序注册的函数来启动 PCI 设备。
从: drivers/net/ethernet/intel/igb/igb_main.c
static struct pci_driver igb_driver = {
.name = igb_driver_name,
.id_table = igb_pci_tbl,
.probe = igb_probe,
.remove = igb_remove,
/* ... */
};
PCI 探针
一旦设备通过其 PCI ID 被识别,内核便可以选择适当的驱动程序来控制该设备。每个 PCI 驱动程序都会在内核中向 PCI 系统注册一个探测函数。内核会为尚未被设备驱动程序认领的设备调用此函数。一旦设备被认领,其他驱动程序将不会被询问该设备。大多数驱动程序都运行了大量代码以使设备可供使用。具体执行的操作因驱动程序而异。
要执行的一些典型操作包括:
- 启用 PCI 设备。
- 请求内存范围和IO 端口。
- 设置DMA掩码。
- 驱动程序支持的 ethtool(下面有更详细的描述)功能已注册。
- 任何需要的看门狗任务(例如,e1000e 有一个看门狗任务来检查硬件是否挂起)。
- 其他设备特定的东西,如解决方法或处理硬件特定的怪癖或类似的东西。
- 创建、初始化和注册结构。此结构包含指向打开设备、向网络发送数据、设置 MAC 地址等所需的各种函数的函数指针。
struct net_device_ops - 代表网络设备的高层的创建、初始化和注册。
struct net_device
我们来快速看一下函数中驱动程序中的一些操作。 igb igb_probe
了解 PCI 初始化
该函数中的以下代码执行一些基本的 PCI 配置。来自drivers/net/ethernet/intel/igb/igb_main.c: igb_probe
err = pci_enable_device_mem(pdev);
/* ... */
err = dma_set_mask_and_coherent(&pdev->dev, DMA_BIT_MASK(64));
/* ... */
err = pci_request_selected_regions(pdev, pci_select_bars(pdev,
IORESOURCE_MEM),
igb_driver_name);
pci_enable_pcie_error_reporting(pdev);
pci_set_master(pdev);
pci_save_state(pdev);
首先,使用 初始化设备。这将唤醒处于挂起状态的设备、启用内存资源等。 pci_enable_device_mem
接下来,将设置DMA掩码。此设备可以读写 64 位内存地址,因此被调用。 dma_set_mask_and_coherent DMA_BIT_MASK(64)
通过调用 可以保留内存区域,启用PCI Express 高级错误报告(如果已加载 PCI AER 驱动程序),通过调用 可以启用 DMA ,并且通过调用 可以保存 PCI 配置空间。 pci_request_selected_regions pci_set_master pci_save_state
呼。
更多 Linux PCI 驱动程序信息
深入解释 PCI 设备的工作原理超出了本文的范围,但这个精彩的演讲、这个 wiki和这个来自 Linux 内核的文本文件都是很好的资源。
网络设备初始化
该函数执行一些重要的网络设备初始化。除了 PCI 特定的工作外,它还将执行更多常规网络和网络设备工作: igb_probe
- 已注册。
struct net_device_ops ethtool操作已注册。- 默认 MAC 地址是从 NIC 获取的。
net_device功能标志已设置。- 还有更多。
让我们逐一看一下这些内容,因为它们稍后会很有趣。
struct net_device_ops
包含指向网络子系统控制设备所需的许多重要操作的函数指针。我们将在本文的其余部分多次提到此结构。 struct net_device_ops
此结构附加到。来自drivers/net/ethernet/intel/igb/igb_main.c ) net_device_ops struct net_device igb_probe
static int igb_probe(struct pci_dev *pdev, const struct pci_device_id *ent)
{
/* ... */
netdev->netdev_ops = &igb_netdev_ops;
并且此结构保存指针指向的函数在同一个文件中设置。来自drivers/net/ethernet/intel/igb/igb_main.c: net_device_ops
static const struct net_device_ops igb_netdev_ops = {
.ndo_open = igb_open,
.ndo_stop = igb_close,
.ndo_start_xmit = igb_xmit_frame,
.ndo_get_stats64 = igb_get_stats64,
.ndo_set_rx_mode = igb_set_rx_mode,
.ndo_set_mac_address = igb_set_mac,
.ndo_change_mtu = igb_change_mtu,
.ndo_do_ioctl = igb_ioctl,
/* ... */
如您所见,其中有几个有趣的字段,如、、和,它们保存着驱动程序实现的函数的地址。 struct ndo_open ndo_stop ndo_start_xmit ndo_get_stats64 igb
稍后我们将更详细地讨论其中的一些内容。
ethtool 登记
ethtool 是一个命令行程序,可用于获取和设置各种驱动程序和硬件选项。您可以通过运行在 Ubuntu 上安装它。 apt-get install ethtool
的常见用途是收集网络设备的详细统计数据。其他感兴趣的设置将在后面介绍。 ethtool ethtool
程序使用系统调用与设备驱动程序对话。设备驱动程序注册一系列为操作而运行的函数,而内核则提供粘合剂。 ethtool ioctl ethtool
当从 进行调用时,内核会找到相应驱动程序注册的结构并执行注册的函数。驱动程序的函数实现可以执行任何操作,从更改驱动程序中的简单软件标志到通过将寄存器值写入设备来调整实际 NIC 硬件的工作方式。 ioctl ethtool ethtool ethtool
驱动程序通过调用以下命令来注册其操作: igb ethtool igb_probe igb_set_ethtool_ops
static int igb_probe(struct pci_dev *pdev, const struct pci_device_id *ent)
{
/* ... */
igb_set_ethtool_ops(netdev);
所有驱动程序的代码均可与函数一起在文件中找到。 igb ethtool drivers/net/ethernet/intel/igb/igb_ethtool.c igb_set_ethtool_ops
从: drivers/net/ethernet/intel/igb/igb_ethtool.c
void igb_set_ethtool_ops(struct net_device *netdev)
{
SET_ETHTOOL_OPS(netdev, &igb_ethtool_ops);
}
在其上方,您可以找到将驱动程序支持的功能设置为相应字段的结构。 igb_ethtool_ops ethtool igb
从: drivers/net/ethernet/intel/igb/igb_ethtool.c
static const struct ethtool_ops igb_ethtool_ops = {
.get_settings = igb_get_settings,
.set_settings = igb_set_settings,
.get_drvinfo = igb_get_drvinfo,
.get_regs_len = igb_get_regs_len,
.get_regs = igb_get_regs,
/* ... */
哪些功能相关以及哪些功能应该实现取决于各个驱动程序。遗憾的是,并非所有驱动程序都实现所有功能。 ethtool ethtool
一个有趣的功能是,它(如果实现的话)会生成详细的统计计数器,可以通过驱动程序中的软件或通过设备本身进行跟踪。 ethtool get_ethtool_stats
下面的监控部分将显示如何使用来访问这些详细的统计数据。 ethtool
中断请求
当数据帧通过DMA写入 RAM 时,NIC 如何告知系统其余部分数据已准备好处理?
传统上,NIC 会生成中断请求 (IRQ)来表示数据已到达。IRQ 有三种常见类型:MSI-X、MSI 和传统 IRQ。稍后会介绍这些类型。当数据通过 DMA 写入 RAM 时,设备生成 IRQ 很简单,但如果大量数据帧到达,则会导致生成大量 IRQ。生成的 IRQ 越多,可用于高级任务(如用户进程)的 CPU 时间就越少。
新API (NAPI)是一种减少数据包到达时网络设备产生的 IRQ 数量的机制。虽然 NAPI 减少了 IRQ 数量,但无法完全消除它们。
在后面的章节中,我们将确切地了解为什么会这样。
NAPI
NAPI 与传统的数据收集方法在几个重要方面有所不同。NAPI 允许设备驱动程序注册一个函数,NAPI 子系统将调用该函数来收集数据帧。 poll
NAPI 在网络设备驱动程序中的预期用途如下:
- NAPI 由驱动程序启用,但最初处于关闭位置。
- 数据包到达并由 NIC 通过 DMA 传输到内存。
- NIC 生成 IRQ,并触发驱动程序中的 IRQ 处理程序。
- 驱动程序使用软中断唤醒 NAPI 子系统(稍后会详细介绍)。这将通过在单独的执行线程中调用驱动程序的注册函数来开始收集数据包。
poll - 驱动程序应禁用 NIC 的更多 IRQ。这样做是为了让 NAPI 子系统能够处理数据包,而不会受到设备中断。
- 一旦没有其他工作要做,NAPI 子系统将被禁用,并且设备的 IRQ 将被重新启用。
- 该过程从步骤 2 开始。
这种收集数据帧的方法与传统方法相比减少了开销,因为可以一次使用许多数据帧,而不必一次处理一个 IRQ 中的每个数据帧。
设备驱动程序实现一个函数并通过调用 将其注册到 NAPI 。当使用 注册 NAPI函数时,驱动程序还将指定。大多数驱动程序都会硬编码 的值。此值及其含义将在下面更详细地描述。 poll netif_napi_add poll netif_napi_add weight 64
通常,驱动程序在驱动程序初始化期间注册其 NAPI 函数。 poll
驱动程序中的 NAPI 初始化 igb
驱动程序通过一个长的调用链来实现这一点: igb
igb_probe呼叫。igb_sw_initigb_sw_init呼叫。igb_init_interrupt_schemeigb_init_interrupt_scheme呼叫。igb_alloc_q_vectorsigb_alloc_q_vectors呼叫。igb_alloc_q_vectorigb_alloc_q_vector呼叫。netif_napi_add
此调用跟踪导致了一些高级事件的发生:
- 如果支持MSI-X,则通过调用 来启用它。
pci_enable_msix - 计算并初始化各种设置;最值得注意的是设备和驱动程序用于发送和接收数据包的传输和接收队列的数量。
igb_alloc_q_vector对于每个将要创建的传输和接收队列都会调用一次。- 每次调用都会为该队列注册一个函数,并且在调用来收集数据包时会传递该函数的一个实例。
igb_alloc_q_vectornetif_napi_addpollstruct napi_structpoll
让我们看一下回调及其私有数据是如何注册的。 igb_alloc_q_vector poll
来自drivers/net/ethernet/intel/igb/igb_main.c:
static int igb_alloc_q_vector(struct igb_adapter *adapter,
int v_count, int v_idx,
int txr_count, int txr_idx,
int rxr_count, int rxr_idx)
{
/* ... */
/* allocate q_vector and rings */
q_vector = kzalloc(size, GFP_KERNEL);
if (!q_vector)
return -ENOMEM;
/* initialize NAPI */
netif_napi_add(adapter->netdev, &q_vector->napi, igb_poll, 64);
/* ... */
上述代码是为接收队列分配内存并将函数注册到 NAPI 子系统。它提供了与此新建的 RX 队列(见上文)关联的引用。当需要从此 RX 队列中收集数据包时,NAPI 子系统会调用此引用并将其传递进去。 igb_poll struct napi_struct &q_vector->napi igb_poll
当我们检查从驱动程序到网络堆栈的数据流时,这一点很重要。
启动网络设备
回想一下我们之前看到的结构,它注册了一组用于启动网络设备、传输数据包、设置 MAC 地址等的功能。 net_device_ops
当启动网络设备时(例如,使用),将调用附加到结构字段的函数。 ifconfig eth0 up ndo_open net_device_ops
该函数通常会做如下的事情: ndo_open
- 分配 RX 和 TX 队列内存
- 启用 NAPI
- 注册中断处理程序
- 启用硬件中断
- 和更多。
对于驱动程序来说,附加到结构字段的函数被称为。 igb ndo_open net_device_ops igb_open
准备从网络接收数据
如今,大多数 NIC 都会使用DMA将数据直接写入 RAM,操作系统可以在 RAM 中检索数据进行处理。大多数 NIC 为此使用的数据结构类似于建立在循环缓冲区(或环形缓冲区)上的队列。
为了实现这一点,设备驱动程序必须与操作系统协作,保留一块内存区域,供 NIC 硬件使用。保留此区域后,硬件会获知其位置,传入数据将写入 RAM,随后由网络子系统拾取并处理。
这看起来很简单,但如果数据包速率太高,单个 CPU 无法正确处理所有传入数据包,该怎么办?数据结构建立在固定长度的内存区域上,因此传入的数据包将被丢弃。
这时,所谓的接收方扩展 (RSS)或多队列就可以提供帮助。
某些设备能够同时将传入的数据包写入 RAM 的几个不同区域;每个区域都是一个单独的队列。这允许操作系统使用多个 CPU 并行处理传入数据,从硬件级别开始。并非所有 NIC 都支持此功能。
Intel I350 NIC 确实支持多个队列。我们可以在驱动程序中看到这一点的证据。驱动程序启动时做的第一件事之一就是调用一个名为的函数。此函数为每个 RX 队列调用一次另一个函数,以安排设备将写入传入数据的 DMA 内存。 igb igb igb_setup_all_rx_resources igb_setup_rx_resources
如果您好奇其具体工作原理,请参阅Linux 内核的 DMA API HOWTO。
事实证明,可以使用 来调整 RX 队列的数量和大小。调整这些值会对处理的帧数与丢弃的帧数产生明显的影响。 ethtool
NIC 对数据包头字段(如源、目标、端口等)使用哈希函数来确定应将数据定向到哪个 RX 队列。
一些 NIC 允许您调整 RX 队列的权重,这样您就可以向特定队列发送更多流量。
更少的 NIC 允许您调整此哈希函数本身。如果您可以调整哈希函数,则可以将某些流发送到特定的 RX 队列进行处理,甚至可以在硬件级别丢弃数据包(如果需要)。
我们将很快了解如何调整这些设置。
启用 NAPI
当启动网络设备时,驱动程序通常会启用NAPI。
我们之前看到了驱动程序如何向 NAPI 注册功能,但 NAPI 通常在设备启动后才会启用。 poll
启用 NAPI 相对简单。调用将翻转其中的一位以指示它现在已启用。如上所述,当 NAPI 被启用时,它将处于关闭状态。 napi_enable struct napi_struct
对于驱动程序,当驱动程序加载时或队列计数或大小发生变化时,会为每个初始化的驱动程序启用 NAPI 。 igb q_vector ethtool
来自drivers/net/ethernet/intel/igb/igb_main.c:
for (i = 0; i < adapter->num_q_vectors; i++)
napi_enable(&(adapter->q_vector[i]->napi));
注册中断处理程序
启用 NAPI 后,下一步是注册中断处理程序。设备可以使用不同的方法来发出中断信号:MSI-X、MSI 和传统中断。因此,代码因设备而异,具体取决于特定硬件支持的中断方法。
驱动程序必须确定设备支持哪种方法,并注册在收到中断时执行的适当的处理程序函数。
某些驱动程序(例如驱动程序)将尝试使用每种方法注册一个中断处理程序,如果失败则返回到下一个未经测试的方法。 igb
MSI-X 中断是首选方法,尤其是对于支持多个 RX 队列的 NIC。这是因为每个 RX 队列都可以分配自己的硬件中断,然后可以由特定 CPU 处理(使用或通过修改)。我们很快就会看到,处理中断的 CPU 将是处理数据包的 CPU。这样,到达的数据包可以由从硬件中断级别到网络堆栈的单独 CPU 处理。 irqbalance /proc/irq/IRQ_NUMBER/smp_affinity
如果 MSI-X 不可用,MSI 仍然比传统中断更具优势,如果设备支持,驱动程序将使用它。阅读这个有用的 wiki 页面,了解有关 MSI 和 MSI-X 的更多信息。
在驱动程序中,函数、分别是 MSI-X、MSI 和传统中断模式的中断处理程序方法。 igb igb_msix_ring igb_intr_msi igb_intr
您可以在drivers/net/ethernet/intel/igb/igb_main.c中找到尝试每种中断方法的驱动程序代码:
static int igb_request_irq(struct igb_adapter *adapter)
{
struct net_device *netdev = adapter->netdev;
struct pci_dev *pdev = adapter->pdev;
int err = 0;
if (adapter->msix_entries) {
err = igb_request_msix(adapter);
if (!err)
goto request_done;
/* fall back to MSI */
/* ... */
}
/* ... */
if (adapter->flags & IGB_FLAG_HAS_MSI) {
err = request_irq(pdev->irq, igb_intr_msi, 0,
netdev->name, adapter);
if (!err)
goto request_done;
/* fall back to legacy interrupts */
/* ... */
}
err = request_irq(pdev->irq, igb_intr, IRQF_SHARED,
netdev->name, adapter);
if (err)
dev_err(&pdev->dev, "Error %d getting interrupt\n", err);
request_done:
return err;
}
如您在上面的缩写代码中看到的,驱动程序首先尝试使用 设置 MSI-X 中断处理程序,如果失败则返回到 MSI。接下来,用于注册MSI 中断处理程序。如果失败,驱动程序将返回到旧式中断。再次用于注册旧式中断处理程序。 igb_request_msix request_irq igb_intr_msi request_irq igb_intr
这就是驱动程序注册一个函数的方式,当 NIC 发出中断信号表示数据已到达并准备好处理时,该函数将被执行。 igb
启用中断
此时,几乎所有设置都已完成。剩下要做的就是启用 NIC 的中断并等待数据到达。启用中断是特定于硬件的,但驱动程序通过调用名为 的辅助函数来执行此操作。 igb __igb_open igb_irq_enable
通过写入寄存器来启用此设备的中断:
static void igb_irq_enable(struct igb_adapter *adapter)
{
/* ... */
wr32(E1000_IMS, IMS_ENABLE_MASK | E1000_IMS_DRSTA);
wr32(E1000_IAM, IMS_ENABLE_MASK | E1000_IMS_DRSTA);
/* ... */
}
网络设备现已启动
驱动程序可能还会执行一些其他操作,例如启动计时器、工作队列或其他特定于硬件的设置。完成这些操作后,网络设备即可启动并可供使用。
让我们看一下网络设备驱动程序的监控和调整设置。
监控网络设备
监控网络设备的方法有很多种,每种方法的精细程度和复杂程度都不同。让我们从最精细的开始,逐步过渡到最不精细的。
使用 ethtool -S
您可以通过运行以下命令在 Ubuntu 系统上安装:。 ethtool sudo apt-get install ethtool
一旦安装完成,您可以通过传递标志以及您想要统计的网络设备名称来访问统计信息。 -S
使用“ethtool -S”监控详细的 NIC 设备统计信息(例如,数据包丢失)。
$ sudo ethtool -S eth0
NIC statistics:
rx_packets: 597028087
tx_packets: 5924278060
rx_bytes: 112643393747
tx_bytes: 990080156714
rx_broadcast: 96
tx_broadcast: 116
rx_multicast: 20294528
....
监控这些数据可能很困难。这些数据很容易获取,但字段值没有标准化。不同的驱动程序,甚至_同一_驱动程序的不同版本可能会产生具有相同含义的不同字段名称。
您应该查找标签中带有“drop”、“buffer”、“miss”等的值。接下来,您必须读取驱动程序源代码。您将能够确定哪些值完全由软件计算(例如,当没有内存时递增),哪些值直接通过寄存器读取来自硬件。对于寄存器值,您应该查阅硬件的数据表以确定计数器的真正含义;通过给出的许多标签可能会产生误导。 ethtool
使用 sysfs
sysfs 也提供了很多统计值,但是它们比直接提供的 NIC 级别统计数据略高一些。
您可以使用文件来查找丢失的传入网络数据帧的数量,例如 eth0 。 cat
使用 sysfs 监控更高级别的 NIC 统计数据。
$ cat /sys/class/net/eth0/statistics/rx_dropped
2
计数器值将被分成诸如、、、、等文件。 collisions rx_dropped rx_errors rx_missed_errors
不幸的是,每个字段的含义、何时增加它们以及这些值来自何处都由驱动程序决定。您可能会注意到,有些驱动程序将某种类型的错误情况算作丢弃,但其他驱动程序可能将其算作未命中。
如果这些值对您来说很重要,您将需要阅读驱动程序源代码以准确了解驱动程序认为每个值的含义。
使用 /proc/net/dev
更高级别的文件为系统上的每个网络适配器提供高级摘要信息。 /proc/net/dev
通过阅读来监控高级 NIC 统计数据。 /proc/net/dev
$ cat /proc/net/dev
Inter-| Receive | Transmit
face | bytes packets errs drop fifo frame compressed multicast | bytes packets errs drop fifo colls carrier compressed
eth0: 110346752214 597737500 0 2 0 0 0 20963860 990024805984 6066582604 0 0 0 0 0 0
lo: 428349463836 1579868535 0 0 0 0 0 0 428349463836 1579868535 0 0 0 0 0 0
该文件显示了您将在上述 sysfs 文件中找到的值的子集,但它可以作为有用的一般参考。
上面提到的警告也适用于此:如果这些值对您很重要,您仍然需要阅读驱动程序源代码以准确了解它们何时、何地和为何增加,以确保您对错误、丢弃或 fifo 的理解与您的驱动程序相同。
调整网络设备
检查正在使用的 RX 队列数量
如果您的 NIC 和系统上加载的设备驱动程序支持 RSS/多队列,您通常可以使用 来调整 RX 队列(也称为 RX 通道)的数量。 ethtool
使用以下命令检查 NIC 接收队列的数量 ethtool
$ sudo ethtool -l eth0
Channel parameters for eth0:
Pre-set maximums:
RX: 0
TX: 0
Other: 0
Combined: 8
Current hardware settings:
RX: 0
TX: 0
Other: 0
Combined: 4
此输出显示预设的最大值(由驱动程序和硬件强制执行)和当前设置。
如果您的 NIC 不支持此操作,则会出现错误。
$ sudo ethtool -l eth0
Channel parameters for eth0:
Cannot get device channel parameters
: Operation not supported
这意味着您的驱动程序尚未实现 ethtool操作。这可能是因为 NIC 不支持调整队列数量、不支持 RSS/多队列,或者您的驱动程序尚未更新以处理此功能。 get_channels
调整 RX 队列数量
一旦找到当前和最大队列数,您就可以使用 调整值。 sudo ethtool -L
注意: 某些设备及其驱动程序仅支持用于发送和接收的组合队列,如上一节中的示例。
将组合 NIC 传输和接收队列设置为 8,使用 ethtool -L
$ sudo ethtool -L eth0 combined 8
如果您的设备和驱动程序支持 RX 和 TX 的单独设置,并且您只想将 RX 队列数更改为 8,则可以运行:
使用 将 NIC 接收队列数设置为 8 。 ethtool -L
$ sudo ethtool -L eth0 rx 8
注意: 对于大多数驱动程序来说,进行这些更改将关闭接口然后重新打开;与此接口的连接将中断。不过,对于一次性更改来说,这可能无关紧要。
调整 RX 队列的大小
一些 NIC 及其驱动程序还支持调整 RX 队列的大小。具体如何工作取决于硬件,但幸运的是,它为用户提供了一种调整大小的通用方法。增加 RX 队列的大小有助于防止在接收大量数据帧期间 NIC 出现网络数据丢失。不过,软件中仍可能会出现数据丢失,需要进行其他调整才能减少或完全消除数据丢失。 ethtool
使用以下方法检查当前 NIC 队列大小 ethtool -g
$ sudo ethtool -g eth0
Ring parameters for eth0:
Pre-set maximums:
RX: 4096
RX Mini: 0
RX Jumbo: 0
TX: 4096
Current hardware settings:
RX: 512
RX Mini: 0
RX Jumbo: 0
TX: 512
以上输出表明硬件支持最多 4096 个接收和发送描述符,但是当前仅使用 512 个。
将每个 RX 队列的大小增加到 4096 ethtool -G
$ sudo ethtool -G eth0 rx 4096
注意: 对于大多数驱动程序来说,进行这些更改将关闭接口然后重新打开;与此接口的连接将中断。不过,对于一次性更改来说,这可能无关紧要。
调整 RX 队列的处理权重
一些 NIC 支持通过设置权重来调整 RX 队列之间的网络数据分配的能力。
如果存在以下情况,则可以进行此项配置:
- 您的 NIC 支持流间接。
- 您的驱动程序实现了功能和。
ethtoolget_rxfh_indir_sizeget_rxfh_indir - 您正在运行一个足够新的版本,该版本支持命令行选项并分别显示和设置间接表。
ethtool-x-X
使用以下方法检查 RX 流间接表 ethtool -x
$ sudo ethtool -x eth0
RX flow hash indirection table for eth3 with 2 RX ring(s):
0: 0 1 0 1 0 1 0 1
8: 0 1 0 1 0 1 0 1
16: 0 1 0 1 0 1 0 1
24: 0 1 0 1 0 1 0 1
此输出在左侧显示数据包哈希值,其中列出了接收队列 0 和 1。因此,哈希值为 2 的数据包将被传送到接收队列 0,而哈希值为 3 的数据包将被传送到接收队列 1。
示例:在前 2 个 RX 队列之间均匀分布处理
$ sudo ethtool -X eth0 equal 2
如果您想要设置自定义权重来改变到达特定接收队列(以及 CPU)的数据包数量,您也可以在命令行上指定这些权重:
使用以下方式设置自定义 RX 队列权重 ethtool -X
$ sudo ethtool -X eth0 weight 6 2
上述命令为 rx 队列 0 指定权重为 6,为 rx 队列 1 指定权重为 2,从而将更多数据推送到队列 0 进行处理。
正如我们现在看到的,一些 NIC 还允许您调整哈希算法中使用的字段。
调整网络流的 rx 哈希字段
您可以使用它来调整计算 RSS 哈希时要使用的字段。 ethtool
检查哪些字段用于 UDP RX 流哈希。 ethtool -n
$ sudo ethtool -n eth0 rx-flow-hash udp4
UDP over IPV4 flows use these fields for computing Hash flow key:
IP SA
IP DA
对于 eth0,用于计算 UDP 流哈希的字段是 IPv4 源地址和目标地址。让我们包括源端口和目标端口:
使用 设置 UDP RX 流哈希字段。 ethtool -N
$ sudo ethtool -N eth0 rx-flow-hash udp4 sdfn
该字符串有点神秘;请查看手册页以了解每个字母的解释。 sdfn ethtool
调整字段以进行哈希处理很有用,但过滤对于更细粒度地控制哪些流将由哪个 RX 队列处理更有用。 ntuple
用于控制网络流量的二元组过滤
某些 NIC 支持称为“ntuple 过滤”的功能。此功能允许用户(通过)指定一组参数,用于过滤硬件中的传入网络数据并将其排队到特定的 RX 队列。例如,用户可以指定发往特定端口的 TCP 数据包应发送到 RX 队列 1。 ethtool
在 Intel NIC 上,此功能通常称为Intel Ethernet Flow Director。其他 NIC 供应商可能对该功能有其他营销名称。
我们稍后会看到,ntuple 过滤是另一个称为加速接收流控制 (aRFS) 的功能的重要组成部分,如果您的 NIC 支持此功能,则可以使用 ntuple 变得更加容易。aRFS 将在后面介绍。
如果系统的运行要求涉及最大化数据局部性,以期在处理网络数据时提高 CPU 缓存命中率,则此功能非常有用。例如,考虑在端口 80 上运行的 Web 服务器的以下配置:
- 在端口 80 上运行的 Web 服务器固定在 CPU 2 上运行。
- RX 队列的 IRQ 被分配由 CPU 2 处理。
- 发往端口 80 的 TCP 流量被 ntuple 过滤并发送到 CPU 2。
- 从数据到达用户程序开始,所有进入端口 80 的流量都由 CPU 2 处理。
- 需要仔细监控系统,包括缓存命中率和网络堆栈延迟,以确定有效性。
如上所述,可以使用 配置 ntuple 过滤,但首先,您需要确保您的设备已启用此功能。 ethtool
检查 ntuple 过滤器是否启用 ethtool -k
$ sudo ethtool -k eth0
Offload parameters for eth0:
...
ntuple-filters: off
receive-hashing: on
如您所见,此设备上设置为关闭。 ntuple-filters
启用 ntuple 过滤器 ethtool -K
$ sudo ethtool -K eth0 ntuple on
一旦启用了 ntuple 过滤器,或验证它已启用,您就可以使用以下命令检查现有的 ntuple 规则: ethtool
使用以下方法检查现有的 ntuple 过滤器 ethtool -u
$ sudo ethtool -u eth0
40 RX rings available
Total 0 rules
如您所见,此设备没有 ntuple 过滤规则。您可以通过在命令行中指定来添加规则。让我们添加一条规则,将所有目标端口为 80 的 TCP 流量定向到 RX 队列 2: ethtool
添加 ntuple 过滤器,将目标端口为 80 的 TCP 流发送到 RX 队列 2
$ sudo ethtool -U eth0 flow-type tcp4 dst-port 80 action 2
您还可以使用 ntuple 过滤在硬件级别丢弃特定流的数据包。这对于缓解来自特定 IP 地址的大量传入流量非常有用。有关配置 ntuple 过滤规则的更多信息,请参阅手册页。 ethtool
通常,您可以通过检查 的输出值来获取有关 ntuple 规则成功(或失败)的统计信息。例如,在 Intel NIC 上,统计信息和计算 ntuple 过滤规则的匹配和未命中次数。查阅设备驱动程序源和设备数据表以跟踪统计计数器(如果可用)。 ethtool -S [device name] fdir_match fdir_miss
软中断
在检查网络堆栈之前,我们需要稍微绕道检查一下 Linux 内核中称为 SoftIRQ 的东西。
什么是软中断?
Linux 内核中的软中断系统是一种在驱动程序中实现的中断处理程序上下文之外执行代码的机制。该系统非常重要,因为在执行中断处理程序的全部或部分期间可能会禁用硬件中断。禁用中断的时间越长,错过事件的可能性就越大。因此,重要的是推迟中断处理程序之外的任何长时间运行的操作,以便它能够尽快完成并重新启用来自设备的中断。
还有其他机制可用于延迟内核中的工作,但出于网络堆栈的目的,我们将研究软中断。
可以将软中断系统想象为一系列内核线程(每个 CPU 一个),这些线程运行已为不同软中断事件注册的处理程序函数。如果您曾经查看过 top 并查看过内核线程列表,那么您看到的是在 CPU 0 上运行的软中断内核线程。 ksoftirqd/0
内核子系统(如网络)可以通过执行该函数来注册软中断处理程序。稍后我们将看到网络系统如何注册其软中断处理程序。现在,让我们进一步了解软中断的工作原理。 open_softirq
ksoftirqd
由于软中断对于延迟设备驱动程序的工作非常重要,您可能会想象该过程是在内核生命周期的早期产生的,这是正确的。 ksoftirqd
查看kernel/softirq.c中的代码可以发现系统是如何初始化的: ksoftirqd
static struct smp_hotplug_thread softirq_threads = {
.store = &ksoftirqd,
.thread_should_run = ksoftirqd_should_run,
.thread_fn = run_ksoftirqd,
.thread_comm = "ksoftirqd/%u",
};
static __init int spawn_ksoftirqd(void)
{
register_cpu_notifier(&cpu_nfb);
BUG_ON(smpboot_register_percpu_thread(&softirq_threads));
return 0;
}
early_initcall(spawn_ksoftirqd);
从上面的定义可以看出,有两个函数指针被注册:和。 struct smp_hotplug_thread ksoftirqd_should_run run_ksoftirqd
这两个函数都是从kernel/smpboot.c调用的,作为类似于事件循环的一部分。
中的代码首先调用来确定是否有任何待处理的软中断,如果有,则执行。在调用 之前, 会进行一些简单的记录。 kernel/smpboot.c ksoftirqd_should_run run_ksoftirqd run_ksoftirqd __do_softirq
__do_softirq
这个函数做了一些有趣的事情: __do_softirq
- 确定哪个软中断处于挂起状态
- softirq 时间用于统计目的
- softirq 执行统计信息增加
- 挂起的软中断 (通过调用 注册) 的软中断处理程序被执行。
open_softirq
因此,当您查看 CPU 使用率图表并看到或现在知道这是在测量延迟工作环境中发生的 CPU 使用量时。 softirq si
监控
/proc/softirqs
系统会增加统计计数器,可以从监控这些统计数据中读取,让您了解生成各种事件的软中断的速率。 softirq /proc/softirqs
通过阅读检查 softIRQ 统计信息。 /proc/softirqs
$ cat /proc/softirqs
CPU0 CPU1 CPU2 CPU3
HI: 0 0 0 0
TIMER: 2831512516 1337085411 1103326083 1423923272
NET_TX: 15774435 779806 733217 749512
NET_RX: 1671622615 1257853535 2088429526 2674732223
BLOCK: 1800253852 1466177 1791366 634534
BLOCK_IOPOLL: 0 0 0 0
TASKLET: 25 0 0 0
SCHED: 2642378225 1711756029 629040543 682215771
HRTIMER: 2547911 2046898 1558136 1521176
RCU: 2056528783 4231862865 3545088730 844379888
此文件可以让您了解您的网络接收(NET_RX)处理当前在 CPU 上的分布情况。如果分布不均匀,您会看到某些 CPU 的计数值比其他 CPU 大。这是一个指标,表明您可能能够从下面描述的接收数据包控制/接收流控制中受益。在监控性能时,请谨慎使用此文件:在网络活动高峰期间,您预计会看到速率增量增加,但情况并非总是如此。事实证明,这有点微妙,因为网络堆栈中还有其他调整旋钮可以影响软中断触发的速率,我们很快就会看到。 NET_RX NET_RX
但是,您应该意识到这一点,以便在您调整其他调节旋钮时您知道要检查并期望看到变化。 /proc/softirqs
现在,让我们转到网络堆栈并追踪从上到下如何接收网络数据。
Linux 网络设备子系统
现在我们已经了解了网络驱动程序和软中断的工作原理,让我们看看 Linux 网络设备子系统是如何初始化的。然后,我们可以从数据包到达开始跟踪其路径。
网络设备子系统初始化
网络设备(netdev)子系统在函数中初始化。在这个初始化函数中发生了很多有趣的事情。 net_dev_init
结构初始化 struct softnet_data
net_dev_init 为系统上的每个 CPU创建一组结构。这些结构将保存指向处理网络数据的几个重要内容的指针: struct softnet_data
稍后,随着我们逐步推进,我们将对每个问题进行更详细的检查。
软中断处理程序的初始化
net_dev_init 注册一个传输和接收软中断处理程序,用于处理传入或传出的网络数据。此代码非常简单:
static int __init net_dev_init(void)
{
/* ... */
open_softirq(NET_TX_SOFTIRQ, net_tx_action);
open_softirq(NET_RX_SOFTIRQ, net_rx_action);
/* ... */
}
我们很快就会看到驱动程序的中断处理程序如何“引发”(或触发)注册到软中断的函数。 net_rx_action NET_RX_SOFTIRQ
数据到达
终于,网络数据到达了!
假设 RX 队列有足够的可用描述符,数据包将通过 DMA 写入 RAM。然后设备会触发分配给它的中断(或者在 MSI-X 的情况下,触发与数据包到达的 RX 队列绑定的中断)。
中断处理程序
一般而言,中断发生时运行的中断处理程序应尝试将尽可能多的处理推迟到中断上下文之外进行。这一点至关重要,因为在处理中断时,其他中断可能会被阻止。
让我们看一下 MSI-X 中断处理程序的源代码;它将真正帮助说明中断处理程序尽可能少做工作的想法。
来自drivers/net/ethernet/intel/igb/igb_main.c:
static irqreturn_t igb_msix_ring(int irq, void *data)
{
struct igb_q_vector *q_vector = data;
/* Write the ITR value calculated from the previous interrupt. */
igb_write_itr(q_vector);
napi_schedule(&q_vector->napi);
return IRQ_HANDLED;
}
这个中断处理程序非常短,在返回之前执行2个非常快速的操作。
首先,此函数调用只是更新硬件特定寄存器。在这种情况下,更新的寄存器是用于跟踪硬件中断到达速率的寄存器。 igb_write_itr
此寄存器与称为“中断节流”(也称为“中断合并”)的硬件功能一起使用,可用于调整向 CPU 发送中断的速度。我们很快就会看到如何提供一种机制来调整 IRQ 触发的速率。 ethtool
其次,调用 ,如果 NAPI 处理循环尚未激活,则将其唤醒。请注意,NAPI 处理循环在软中断中执行;NAPI 处理循环不会从中断处理程序执行。如果 NAPI 处理循环尚未激活,则中断处理程序只会使其开始执行。 napi_schedule
实际代码准确展示了其工作原理非常重要;它将指导我们理解如何在多 CPU 系统上处理网络数据。
NAPI 和 napi_schedule
让我们弄清楚硬件中断处理程序的调用是如何工作的。 napi_schedule
请记住,NAPI 专门用于收集网络数据,而无需 NIC 发出中断信号来表示数据已准备好进行处理。如前所述,NAPI循环通过接收硬件中断进行引导。换句话说:NAPI 已启用但处于关闭状态,直到第一个数据包到达,此时 NIC 发出 IRQ 并启动 NAPI。我们很快就会看到,还有其他几种情况,其中 NAPI 可以被禁用,并且需要发出硬件中断才能再次启动。 poll
当驱动程序中的中断处理程序调用时,将启动 NAPI 轮询循环。它实际上只是一个在头文件中定义的包装函数,它向下调用。 napi_schedule napi_schedule __napi_schedule
/**
* __napi_schedule - schedule for receive
* @n: entry to schedule
*
* The entry's receive function will be scheduled to run
*/
void __napi_schedule(struct napi_struct *n)
{
unsigned long flags;
local_irq_save(flags);
____napi_schedule(&__get_cpu_var(softnet_data), n);
local_irq_restore(flags);
}
EXPORT_SYMBOL(__napi_schedule);
此代码用于获取注册到当前 CPU 的结构。此结构和从驱动程序传递的结构被传递到。哇,下划线好多啊 ;) __get_cpu_var softnet_data softnet_data struct napi_struct ____napi_schedule
让我们看一下net/core/dev.c中的: ____napi_schedule
/* Called with irq disabled */
static inline void ____napi_schedule(struct softnet_data *sd,
struct napi_struct *napi)
{
list_add_tail(&napi->poll_list, &sd->poll_list);
__raise_softirq_irqoff(NET_RX_SOFTIRQ);
}
这段代码做了两件重要的事情:
- 从设备驱动程序的中断处理程序中传上来的代码被添加到附加到与当前 CPU 关联的结构中。
struct napi_structpoll_listsoftnet_data __raise_softirq_irqoff用于“引发”(或触发)NET_RX_SOFTIRQ 软中断。这将导致执行在网络设备子系统初始化期间注册的(如果当前未执行)。net_rx_action
我们很快就会看到,softirq 处理函数将调用 NAPI函数来收集数据包。 net_rx_action poll
关于 CPU 和网络数据处理的说明
请注意,到目前为止我们看到的所有将工作从硬件中断处理程序推迟到软中断的代码都是使用与当前 CPU 相关的结构。
虽然驱动程序的 IRQ 处理程序本身执行的工作很少,但软中断处理程序将与驱动程序的 IRQ 处理程序在同一个 CPU 上执行。
这就是为什么设置处理特定 IRQ 的 CPU 很重要:该 CPU 不仅用于执行驱动程序中的中断处理程序,而且还用于通过 NAPI 在软中断中收集数据包。
我们稍后会看到,诸如接收数据包引导之类的功能可以将部分工作分配给网络堆栈上层的其他 CPU。
监控网络数据到达
硬件中断请求
注意: 监控硬件 IRQ 并不能全面反映数据包处理状况。许多驱动程序在 NAPI 运行时会关闭硬件 IRQ,这一点我们稍后会看到。这是整个监控解决方案的一个重要部分。
通过阅读检查硬件中断统计信息。 /proc/interrupts
$ cat /proc/interrupts
CPU0 CPU1 CPU2 CPU3
0: 46 0 0 0 IR-IO-APIC-edge timer
1: 3 0 0 0 IR-IO-APIC-edge i8042
30: 3361234770 0 0 0 IR-IO-APIC-fasteoi aacraid
64: 0 0 0 0 DMAR_MSI-edge dmar0
65: 1 0 0 0 IR-PCI-MSI-edge eth0
66: 863649703 0 0 0 IR-PCI-MSI-edge eth0-TxRx-0
67: 986285573 0 0 0 IR-PCI-MSI-edge eth0-TxRx-1
68: 45 0 0 0 IR-PCI-MSI-edge eth0-TxRx-2
69: 394 0 0 0 IR-PCI-MSI-edge eth0-TxRx-3
NMI: 9729927 4008190 3068645 3375402 Non-maskable interrupts
LOC: 2913290785 1585321306 1495872829 1803524526 Local timer interrupts
您可以监视统计数据,以查看数据包到达时硬件中断的数量和速率如何变化,并确保 NIC 的每个 RX 队列都由适当的 CPU 处理。我们很快就会看到,这个数字只告诉我们发生了多少次硬件中断,但它_不一定_是了解已接收或处理了多少数据的良好指标,因为许多驱动程序会根据与 NAPI 子系统的合同禁用 NIC IRQ。此外,使用中断合并也会影响从此文件收集的统计数据。监视此文件可以帮助您确定所选的中断合并设置是否真正有效。 /proc/interrupts
为了更全面地了解您的网络处理健康状况,您需要监控(如上所述)和我们将在下面介绍的其他文件。 /proc/softirqs /proc
调整网络数据到达
中断合并
中断合并 是一种防止设备向 CPU 发出中断的方法,直到特定量的工作或特定数量的事件处于待处理状态。
这可以帮助防止中断风暴,并有助于提高吞吐量或延迟,具体取决于所使用的设置。产生的中断越少,吞吐量就越高,延迟就越高,CPU 使用率就越低。产生的中断越多,结果就越相反:延迟越低,吞吐量越低,但 CPU 使用率也会增加。
从历史上看,早期版本的、和其他驱动程序都支持名为 的参数。在较新的驱动程序中,此参数已被通用函数取代。 igb e1000 InterruptThrottleRate ethtool
使用 获取当前 IRQ 合并设置。 ethtool -c
$ sudo ethtool -c eth0
Coalesce parameters for eth0:
Adaptive RX: off TX: off
stats-block-usecs: 0
sample-interval: 0
pkt-rate-low: 0
pkt-rate-high: 0
...
ethtool 提供用于设置各种合并设置的通用接口。但请记住,并非每个设备或驱动程序都支持所有设置。您应该检查驱动程序文档或驱动程序源代码,以确定哪些设置受支持或不受支持。根据 ethtool 文档:“驱动程序未实现的任何设置都会导致这些值被忽略。”
一些驱动程序支持的一个有趣选项是“自适应 RX/TX IRQ 合并”。此选项通常由硬件实现。驱动程序通常需要做一些工作来通知 NIC 此功能已启用,并进行一些记录(如上面的驱动程序代码所示)。 igb
启用自适应 RX/TX IRQ 合并的结果是,中断传送将被调整以在数据包速率低时改善延迟,并在数据包速率高时提高吞吐量。
启用自适应 RX IRQ 合并 ethtool -C
$ sudo ethtool -C eth0 adaptive-rx on
您还可以使用来设置多个选项。一些更常见的选项包括: ethtool -C
rx-usecs:数据包到达后,延迟 RX 中断多少微秒。rx-frames:RX 中断前接收的最大数据帧数。rx-usecs-irq:当主机正在处理中断时,延迟 RX 中断多少微秒。rx-frames-irq:系统处理中断时,在生成 RX 中断之前要接收的最大数据帧数。
还有很多很多。
提醒 您,您的硬件和驱动程序可能仅支持上面列出的部分选项。您应该查阅驱动程序源代码和硬件数据表,了解有关受支持的合并选项的更多信息。
不幸的是,除了头文件之外,您可以设置的选项在任何地方都没有得到很好的记录。检查include/uapi/linux/ethtool.h的源代码以找到每个选项的说明(但不一定是您的驱动程序和 NIC)。 ethtool
注意: 虽然中断合并乍一看似乎是一种非常有用的优化,但在尝试优化时,网络堆栈内部的其余部分也会发挥作用。中断合并在某些情况下很有用,但您应该确保网络堆栈的其余部分也经过了适当的调整。仅仅修改合并设置本身可能只会带来很小的好处。
调整 IRQ 亲和性
如果您的 NIC 支持 RSS/多队列或者您正在尝试优化数据局部性,您可能希望使用一组特定的 CPU 来处理 NIC 生成的中断。
通过设置特定的 CPU,您可以划分哪些 CPU 将用于处理哪些 IRQ。这些更改可能会影响上层的运行方式,正如我们在网络堆栈中看到的那样。
如果您决定调整 IRQ 亲和性,则应首先检查是否正在运行守护程序。此守护程序会尝试自动将 IRQ 平衡到 CPU,并且可能会覆盖您的设置。如果您正在运行,则应禁用或结合使用以告知它不应触及您想要自行分配的一组 IRQ 和 CPU。 irqbalance irqbalance irqbalance --banirq IRQBALANCE_BANNED_CPUS irqbalance
接下来,您应该检查文件中是否存在您的 NIC 的每个网络 RX 队列的 IRQ 编号列表。 /proc/interrupts
最后,您可以通过修改每个 IRQ 编号来调整每个 IRQ 将由哪个 CPU 处理。 /proc/irq/IRQ_NUMBER/smp_affinity
您只需向此文件写入一个十六进制位掩码即可指示内核应使用哪个 CPU 来处理 IRQ。
示例:将 IRQ 8 的 IRQ 亲和性设置为 CPU 0
$ sudo bash -c 'echo 1 > /proc/irq/8/smp_affinity'
网络数据处理开始
一旦软中断代码确定软中断处于挂起状态,就开始处理并执行,网络数据处理就开始了。 net_rx_action
让我们看一下处理循环的各个部分,以了解它是如何工作的、哪些部分是可调的以及可以监控什么。 net_rx_action
net_rx_action 处理循环
net_rx_action 开始处理来自设备通过 DMA 传输到内存中的数据包。
该函数遍历当前 CPU 排队的 NAPI 结构列表,从队列中取出每个结构并对其进行操作。
处理循环限制了已注册的 NAPI 函数可以消耗的工作量和执行时间。它通过两种方式实现这一点: poll
- 通过跟踪工作(可以调整),以及
budget - 检查已用时间
while (!list_empty(&sd->poll_list)) {
struct napi_struct *n;
int work, weight;
/* If softirq window is exhausted then punt.
* Allow this to run for 2 jiffies since which will allow
* an average latency of 1.5/HZ.
*/
if (unlikely(budget <= 0 || time_after_eq(jiffies, time_limit)))
goto softnet_break;
这就是内核防止数据包处理消耗整个 CPU 的方法。以上是注册到此 CPU 的每个可用 NAPI 结构将花费的总可用预算。 budget
这是多队列网卡应仔细调整 IRQ 亲和性的另一个原因。回想一下,处理设备 IRQ 的 CPU 将是软中断处理程序执行的 CPU,因此,也将是上述循环和预算计算运行的 CPU。
具有多个 NIC(每个 NIC 具有多个队列)的系统最终可能会出现多个 NAPI 结构注册到同一 CPU 的情况。同一 CPU 上所有 NAPI 结构的数据处理都花费相同的时间。 budget
如果没有足够的 CPU 来分配 NIC 的 IRQ,您可以考虑增加预算,以便每个 CPU 处理更多的数据包。增加预算将增加 CPU 使用率(特别是在或其他程序中),但应该会减少延迟,因为数据将得到更迅速的处理。 net_rx_action budget sitime si top
NAPI函数和 poll weight
回想一下网络设备驱动程序用于注册的功能。正如我们在本文前面看到的,驱动程序有一段这样的代码: netif_napi_add poll igb
/* initialize NAPI */
netif_napi_add(adapter->netdev, &q_vector->napi, igb_poll, 64);
这将注册一个 NAPI 结构,其硬编码权重为 64。现在我们将看到如何在处理循环中使用它。 net_rx_action
weight = n->weight;
work = 0;
if (test_bit(NAPI_STATE_SCHED, &n->state)) {
work = n->poll(n, weight);
trace_napi_poll(n);
}
WARN_ON_ONCE(work > weight);
budget -= work;
64 此代码获取注册到 NAPI 结构(在上面的驱动程序代码中)的权重,并将其传递给同样注册到 NAPI 结构(在上面的代码中)的函数。 poll igb_poll
该函数返回已处理的数据帧数。该数量在上面保存为,然后从总体中减去。 poll work budget
因此,假设:
- 您正在使用驱动程序的权重(在 Linux 3.13.0 中,所有驱动程序都使用此值进行硬编码),并且
64 - 您已将默认设置为
budget300
当出现以下任一情况时,您的系统将停止处理数据:
- 该函数最多被调用 5 次(如果没有数据需要处理,则调用次数会更少,我们接下来会看到),或者
igb_poll - 至少已过去 2 个 jiffy 时间。
NAPI / 网络设备驱动程序契约
关于 NAPI 子系统和设备驱动程序之间的合同尚未提及的一个重要信息是关闭 NAPI 的要求。
本合同该部分内容如下:
- 如果驱动程序的功能消耗了其全部权重(已硬编码为),则它不得修改 NAPI 状态。循环将接管。
poll64net_rx_action - 如果驱动程序的功能未消耗其全部权重,则必须禁用 NAPI。下次收到 IRQ 并且驱动程序的 IRQ 处理程序调用 时,NAPI 将重新启用。
pollnapi_schedule
现在,我们将看看如何处理该合同的第一部分。接下来,检查该函数,我们将看看如何处理该合同的第二部分。 net_rx_action poll
完成循环 net_rx_action
处理循环以最后一段代码结束,该代码处理上一节中解释的 NAPI 契约的第一部分。来自net/core/dev.c: net_rx_action
/* Drivers must not modify the NAPI state if they
* consume the entire weight. In such cases this code
* still "owns" the NAPI instance and therefore can
* move the instance around on the list at-will.
*/
if (unlikely(work == weight)) {
if (unlikely(napi_disable_pending(n))) {
local_irq_enable();
napi_complete(n);
local_irq_disable();
} else {
if (n->gro_list) {
/* flush too old packets
* If HZ < 1000, flush all packets.
*/
local_irq_enable();
napi_gro_flush(n, HZ >= 1000);
local_irq_disable();
}
list_move_tail(&n->poll_list, &sd->poll_list);
}
}
如果整个作品被消费,有两种情况需要处理: net_rx_action
- 网络设备应该关闭(例如因为用户运行了),
ifconfig eth0 down - 如果设备_未_关闭,请检查是否有通用接收卸载 (GRO) 列表。如果计时器滴答率>= 1000,则所有最近更新的 GRO 网络流都将被刷新。我们稍后会详细探讨 GRO。将 NAPI 结构移至此 CPU 的列表末尾,以便循环的下一次迭代将注册下一个 NAPI 结构。
这就是数据包处理循环如何调用驱动程序的注册函数来处理数据包。我们很快就会看到,该函数将收集网络数据并将其发送到堆栈上进行处理。 poll poll
达到限制时退出循环
当出现以下任一情况时,循环将退出: net_rx_action
- 该 CPU 注册的轮询列表不再有 NAPI 结构 (
!list_empty(&sd->poll_list)),或者 - 剩余预算 <= 0,或
- 已达到 2 jiffies 的时间限制
下面是我们之前看到的代码:
/* If softirq window is exhausted then punt.
* Allow this to run for 2 jiffies since which will allow
* an average latency of 1.5/HZ.
*/
if (unlikely(budget <= 0 || time_after_eq(jiffies, time_limit)))
goto softnet_break;
如果你跟着标签走,你会偶然发现一些有趣的东西。来自net/core/dev.c: softnet_break
softnet_break:
sd->time_squeeze++;
__raise_softirq_irqoff(NET_RX_SOFTIRQ);
goto out;
该结构的一些统计数据已递增,并且软中断已关闭。该字段衡量的是需要完成更多工作但预算已耗尽或时间限制已达到才能完成的次数。这是一个非常有用的计数器,可用于了解网络处理中的瓶颈。我们很快就会看到如何监视此值。已禁用以释放处理时间用于其他任务。这是有道理的,因为只有在可以完成更多工作时才会执行这段小代码,但我们不想独占 CPU。 struct softnet_data NET_RX_SOFTIRQ time_squeeze net_rx_action NET_RX_SOFTIRQ
然后执行被转移到标签。如果没有更多的 NAPI 结构需要处理,执行也可以转移到标签,换句话说,预算多于网络活动,并且所有驱动程序都关闭了 NAPI,没有其他事情要做。 out out net_rx_action
该部分在从 返回之前执行一件重要的事情:它调用。如果启用了接收数据包转向,则此函数将发挥重要作用;它唤醒远程 CPU 以开始处理网络数据。 out net_rx_action net_rps_action_and_irq_enable
稍后我们将详细了解 RPS 的工作原理。现在,让我们看看如何监控处理循环的运行状况,并继续了解 NAPI函数的内部工作原理,以便我们能够进一步了解网络堆栈。 net_rx_action poll
NAPI 民意调查
回想一下前面的部分,设备驱动程序为设备分配了一块内存区域,用于对传入的数据包执行 DMA。正如驱动程序负责分配这些区域一样,驱动程序也负责取消映射这些区域、收集数据并将其发送到网络堆栈。
让我们看一下驾驶员是如何做到这一点的,以了解其在实践中是如何运作的。 igb
igb_poll
终于,我们可以检查一下我们的朋友了。事实证明的代码看似简单。让我们来看看。来自drivers/net/ethernet/intel/igb/igb_main.c: igb_poll igb_poll
/**
* igb_poll - NAPI Rx polling callback
* @napi: napi polling structure
* @budget: count of how many packets we should handle
**/
static int igb_poll(struct napi_struct *napi, int budget)
{
struct igb_q_vector *q_vector = container_of(napi,
struct igb_q_vector,
napi);
bool clean_complete = true;
#ifdef CONFIG_IGB_DCA
if (q_vector->adapter->flags & IGB_FLAG_DCA_ENABLED)
igb_update_dca(q_vector);
#endif
/* ... */
if (q_vector->rx.ring)
clean_complete &= igb_clean_rx_irq(q_vector, budget);
/* If all work not completed, return budget and keep polling */
if (!clean_complete)
return budget;
/* If not enough Rx work done, exit the polling mode */
napi_complete(napi);
igb_ring_irq_enable(q_vector);
return 0;
}
这段代码做了一些有趣的事情:
- 如果内核中启用了直接缓存访问 (DCA)支持,则 CPU 缓存会预热,这样对 RX 环的访问就会到达 CPU 缓存。您可以在本博文末尾的附加内容部分中阅读有关 DCA 的更多信息。
- 接下来,被称为“下一步”,它将完成繁重的工作,我们接下来会看到。
igb_clean_rx_irq - 接下来,检查 以确定是否还有更多工作可以完成。如果有,则返回(请记住,这是硬编码为)。正如我们之前看到的,将把这个 NAPI 结构移动到轮询列表的末尾。
clean_completebudget64net_rx_action - 否则,驱动程序通过调用 关闭 NAPI ,然后通过调用 重新启用中断。下一个中断到来时,将重新启用 NAPI。
napi_completeigb_ring_irq_enable
让我们看看如何将网络数据发送到堆栈上。 igb_clean_rx_irq
igb_clean_rx_irq
该函数是一个循环,每次处理一个数据包,直到达到或没有其他数据需要处理。 igb_clean_rx_irq budget
此函数中的循环做了几件重要的事情:
- 分配额外的缓冲区用于接收数据,因为已使用的缓冲区已被清除。每次添加额外的缓冲区 (16)。
IGB_RX_BUFFER_WRITE - 从 RX 队列中获取缓冲区并将其存储在结构中。
skb - 检查缓冲区是否为“数据包结束”缓冲区。如果是,则继续处理。否则,继续从 RX 队列中获取其他缓冲区,并将它们添加到。如果接收到的数据帧大于缓冲区大小,则必须这样做。
skb - 验证数据的布局和标题是否正确。
- 已处理的字节数统计计数器增加了。
skb->len - 设置 skb 的哈希、校验和、时间戳、VLAN ID 和协议字段。哈希、校验和、时间戳和 VLAN ID 由硬件提供。如果硬件发出校验和错误信号,则统计数据将递增。如果校验和成功且数据是 UDP 或 TCP 数据,则标记为。如果校验和失败,则由协议堆栈来处理此数据包。协议通过调用来计算并存储在结构中。
csum_errorskbCHECKSUM_UNNECESSARYeth_type_transskb - 通过调用 将构造函数传递到网络堆栈。
skbnapi_gro_receive - 已处理的数据包数量统计计数器会增加。
- 循环持续,直到处理的数据包数量达到预算。
一旦循环终止,该函数就会为 rx 数据包和处理的字节分配统计计数器。
在继续介绍网络堆栈之前,现在该绕两个弯路了。首先,让我们看看如何监视和调整网络子系统的软中断。接下来,让我们讨论通用接收卸载 (GRO)。之后,当我们进入时,网络堆栈的其余部分将更有意义。 napi_gro_receive
监控网络数据处理
/proc/net/softnet_stat
如上一节所述,当退出循环并可以完成其他工作但达到或软中断的时间限制时,将增加统计数据。此统计数据作为与 CPU 相关的的一部分进行跟踪。 net_rx_action net_rx_action budget struct softnet_data
这些统计数据输出到 proc 中的一个文件:遗憾的是,该文件的文档非常少。proc 文件中的字段没有标签,并且可能会因内核版本不同而有所变化。 /proc/net/softnet_stat
在 Linux 3.13.0 中,你可以通过阅读内核源代码来找到哪些值映射到哪个字段。从net/core/net-procfs.c中: /proc/net/softnet_stat
seq_printf(seq,
"%08x %08x %08x %08x %08x %08x %08x %08x %08x %08x %08x\n",
sd->processed, sd->dropped, sd->time_squeeze, 0,
0, 0, 0, 0, /* was fastroute */
sd->cpu_collision, sd->received_rps, flow_limit_count);
其中许多统计数据的名称令人困惑,并且会在您可能意想不到的地方增加。在检查网络堆栈时,将解释每个统计数据何时何地增加。由于在中看到了统计数据,我认为现在记录此文件是有意义的。 squeeze_time net_rx_action
通过读取来监控网络数据处理统计数据。 /proc/net/softnet_stat
$ cat /proc/net/softnet_stat
6dcad223 00000000 00000001 00000000 00000000 00000000 00000000 00000000 00000000 00000000
6f0e1565 00000000 00000002 00000000 00000000 00000000 00000000 00000000 00000000 00000000
660774ec 00000000 00000003 00000000 00000000 00000000 00000000 00000000 00000000 00000000
61c99331 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000
6794b1b3 00000000 00000005 00000000 00000000 00000000 00000000 00000000 00000000 00000000
6488cb92 00000000 00000001 00000000 00000000 00000000 00000000 00000000 00000000 00000000
重要细节如下: /proc/net/softnet_stat
- 每一行对应一个结构,每个 CPU 有一个结构。
/proc/net/softnet_statstruct softnet_data - 这些值由一个空格分隔,并以十六进制显示
- 第一个值是处理的网络帧数。如果您使用以太网绑定,则该值可能大于接收的网络帧总数。在某些情况下,以太网绑定驱动程序将触发重新处理网络数据,这会使同一数据包的计数增加不止一次。
sd->processedsd->processed - 第二个值是由于处理队列中没有空间而丢弃的网络帧数。稍后将详细介绍。
sd->dropped - 第三个值是(如我们所见)由于预算耗尽或时间限制达到而导致循环终止的次数,但本可以完成更多工作。如前所述,增加 可以帮助减少这种情况。
sd->time_squeezenet_rx_actionbudget - 接下来的 5 个值始终为 0。
- 第九个值是传输数据包时尝试获取设备锁时发生碰撞的次数。本文是关于接收的,因此下面不会看到这个统计数据。
sd->cpu_collision - 第十个值是此 CPU 通过处理器间中断被唤醒来处理数据包的次数
sd->received_rps - 最后一个值是达到流量限制的次数。流量限制是可选的接收数据包控制功能,稍后将对其进行介绍。
flow_limit_count
如果您决定监视此文件并绘制结果图,则必须非常小心,确保这些字段的顺序没有改变,并且每个字段的含义都保留了下来。您需要阅读内核源代码来验证这一点。
调整网络数据处理
调整预算 net_rx_action
您可以通过设置名为 的 sysctl 值来调整预算,该预算决定了在注册到 CPU 的所有 NAPI 结构中可以花费多少数据包处理时间。 net_rx_action net.core.netdev_budget
示例:将整体数据包处理预算设置为 600。
$ sudo sysctl -w net.core.netdev_budget=600
您可能还希望将此设置写入您的文件,以便更改在重新启动后仍然存在。 /etc/sysctl.conf
Linux 3.13.0 上的默认值是 300。
通用接收卸载 (GRO)
通用接收卸载 (GRO) 是硬件优化(称为大型接收卸载 (LRO))的软件实现。
这两种方法背后的主要思想是,通过将“足够相似”的数据包组合在一起来减少传递到网络堆栈的数据包数量,从而降低 CPU 使用率。例如,假设正在发生大型文件传输,并且大多数数据包都包含文件中的数据块。传入的数据包可以组合成一个具有巨大有效负载的数据包,而不是一次将小数据包发送到堆栈。然后可以将该数据包传递到堆栈。这允许协议层处理单个数据包的标头,同时向用户程序传递更大的数据块。
当然,这种优化的问题在于信息丢失。如果一个数据包设置了一些重要的选项或标志,那么当该数据包合并到另一个数据包中时,该选项或标志可能会丢失。这正是大多数人不使用或鼓励使用 LRO 的原因。一般来说,LRO 实现对合并数据包的规则非常宽松。
GRO 是作为软件中 LRO 的一种实现而引入的,但对于数据包的合并有更严格的规则。
顺便说一句:如果您曾经使用过并看到传入数据包大小异常大,这很可能是因为您的系统启用了 GRO。您很快就会看到,数据包捕获分接头在 GRO 发生后插入到堆栈的更上层。 tcpdump
调整:使用以下方法调整 GRO 设置 ethtool
您可以使用它来检查 GRO 是否已启用并调整设置。 ethtool
用于检查您的 GRO 设置。 ethtool -k
$ ethtool -k eth0 | grep generic-receive-offload
generic-receive-offload: on
正如您所见,在此系统上我已设置为开启。 generic-receive-offload
用于启用(或禁用)GRO。 ethtool -K
$ sudo ethtool -K eth0 gro on
注意: 对于大多数驱动程序来说,进行这些更改将关闭接口然后重新打开;与此接口的连接将中断。不过,对于一次性更改来说,这可能无关紧要。
napi_gro_receive
该函数负责处理 GRO 的网络数据(如果系统启用了 GRO)并将数据向上发送到协议层。大部分逻辑由名为 的函数处理。 napi_gro_receive dev_gro_receive
dev_gro_receive
此函数首先检查 GRO 是否已启用,如果已启用,则准备执行 GRO。如果启用了 GRO,则遍历 GRO 卸载过滤器列表,以允许更高级别的协议栈对正在考虑用于 GRO 的数据块进行操作。这样做是为了让协议层可以让网络设备层知道此数据包是否是当前正在接收卸载的网络流的一部分,并处理 GRO 应发生的任何协议特定问题。例如,TCP 协议将需要决定是否/何时确认正在合并到现有数据包中的数据包。
以下是实现该功能的代码: net/core/dev.c
list_for_each_entry_rcu(ptype, head, list) {
if (ptype->type != type || !ptype->callbacks.gro_receive)
continue;
skb_set_network_header(skb, skb_gro_offset(skb));
skb_reset_mac_len(skb);
NAPI_GRO_CB(skb)->same_flow = 0;
NAPI_GRO_CB(skb)->flush = 0;
NAPI_GRO_CB(skb)->free = 0;
pp = ptype->callbacks.gro_receive(&napi->gro_list, skb);
break;
}
如果协议层指示是时候刷新 GRO 数据包了,则下一步将处理该问题。这通过调用 来实现,它会调用协议层的回调,然后通过调用 将数据包传递到堆栈上。 napi_gro_complete gro_complete netif_receive_skb
以下是实现该功能的代码: net/core/dev.c
if (pp) {
struct sk_buff *nskb = *pp;
*pp = nskb->next;
nskb->next = NULL;
napi_gro_complete(nskb);
napi->gro_count--;
}
接下来,如果协议层将此数据包合并到现有流,则只需返回,因为没有其他操作可做。 napi_gro_receive
如果数据包未合并且系统中的 GRO 流少于(8) 个,则会在该 CPU 的 NAPI 结构中添加一个新条目。 MAX_GRO_SKBS gro_list
以下是实现该功能的代码: net/core/dev.c
if (NAPI_GRO_CB(skb)->flush || napi->gro_count >= MAX_GRO_SKBS)
goto normal;
napi->gro_count++;
NAPI_GRO_CB(skb)->count = 1;
NAPI_GRO_CB(skb)->age = jiffies;
skb_shinfo(skb)->gso_size = skb_gro_len(skb);
skb->next = napi->gro_list;
napi->gro_list = skb;
ret = GRO_HELD;
这就是 Linux 网络堆栈中的 GRO 系统的工作方式。
napi_skb_finish
一旦完成,就会被调用,要么因为数据包已被合并而释放不需要的数据结构,要么调用将数据传递到网络堆栈(因为已经有流被 GRO)。 dev_gro_receive napi_skb_finish netif_receive_skb MAX_GRO_SKBS
接下来,我们来看看数据是如何被传递到协议层的。在研究这一点之前,我们需要先了解一下接收数据包控制 (RPS)。 netif_receive_skb
接收数据包控制 (RPS)
回想一下我们之前讨论过网络设备驱动程序如何注册 NAPI函数。每个轮询器实例都在软中断上下文中执行,每个 CPU 都有一个软中断。进一步回想一下,驱动程序的 IRQ 处理程序运行的 CPU 将唤醒其软中断处理循环来处理数据包。 poll NAPI
换句话说:单个 CPU 处理硬件中断并轮询数据包以处理传入数据。
一些 NIC(如 Intel I350)在硬件级别支持多个队列。这意味着传入的数据包可以通过 DMA 传输到每个队列的单独内存区域,并且使用单独的 NAPI 结构来管理轮询该区域。因此,多个 CPU 将处理来自设备的中断并处理数据包。
此功能通常称为接收方缩放 (RSS)。
接收数据包控制 (RPS) 是 RSS 的软件实现。由于它是在软件中实现的,这意味着它可以为任何 NIC 启用,即使是只有一个 RX 队列的 NIC。但是,由于它是在软件中实现的,这意味着 RPS 只能在从 DMA 内存区域获取数据包后才能进入流中。
这意味着您不会注意到处理 IRQ 或 NAPI 循环所花费的 CPU 时间的减少,但您可以在收集数据包后分配处理数据包的负载,从而减少网络堆栈的 CPU 时间。 poll
RPS 的工作原理是为传入数据生成哈希值,以确定哪个 CPU 应该处理数据。然后,数据被排队到每个 CPU 的接收网络积压队列中等待处理。处理器间中断 (IPI)被传送到拥有积压队列的 CPU。如果它当前没有处理积压队列上的数据,这有助于启动积压队列处理。包含每个结构收到 IPI的次数(字段)。 /proc/net/softnet_stat softnet_data received_rps
因此,要么继续将网络数据发送到网络堆栈,要么将其交给 RPS 在另一个 CPU 上进行处理。 netif_receive_skb
调整:启用 RPS
为了使 RPS 正常工作,必须在内核配置中启用它(在 Ubuntu 内核 3.13.0 中启用它),并使用位掩码描述哪些 CPU 应该处理给定接口和 RX 队列的数据包。
您可以在内核文档中找到有关这些位掩码的一些文档。
简而言之,需要修改的位掩码位于:
/sys/class/net/DEVICE_NAME/queues/QUEUE/rps_cpus
因此,对于接收队列 0,您需要修改文件:使用十六进制数指示哪些 CPU 应该处理来自接收队列 0 的数据包。正如文档指出的那样,在某些配置中 RPS 可能是不必要的。 eth0 /sys/class/net/eth0/queues/rx-0/rps_cpus eth0
注意: 启用 RPS 将数据包处理分发到之前未处理数据包的 CPU 将导致该 CPU 的“NET_RX”软中断数量增加,以及 CPU 使用率图中的“si”或“sitime”增加。您可以比较软中断和 CPU 使用率图表的前后情况,以确认 RPS 是否按您的喜好正确配置。
接收流控制 (RFS)
接收流控制 (RFS) 与 RPS 结合使用。RPS 尝试在多个 CPU 之间分配传入数据包负载,但不会考虑任何数据局部性问题以最大化 CPU 缓存命中率。您可以使用 RFS 将同一流的数据包定向到同一 CPU 进行处理,从而帮助提高缓存命中率。
调整:启用 RFS
为了使 RFS 正常工作,您必须启用并配置 RPS。
RFS 跟踪所有流的全局哈希表,并且可以通过设置 sysctl 来调整该哈希表的大小。 net.core.rps_sock_flow_entries
通过设置来增加 RFS 套接字流哈希的大小。 sysctl
$ sudo sysctl -w net.core.rps_sock_flow_entries=32768
接下来,您还可以通过将此值写入为每个 RX 队列命名的 sysfs 文件来设置每个 RX 队列的流数。 rps_flow_cnt
示例:将 eth0 上 RX 队列 0 的流数增加到 2048。
$ sudo bash -c 'echo 2048 > /sys/class/net/eth0/queues/rx-0/rps_flow_cnt'
硬件加速接收流控制 (aRFS)
可以使用硬件加速来加速 RFS;NIC 和内核可以协同工作来确定哪些流应该在哪些 CPU 上处理。要使用此功能,NIC 和驱动程序必须支持此功能。
请参阅您的 NIC 数据表以确定是否支持此功能。如果您的 NIC 驱动程序公开了一个名为 的函数,则该驱动程序支持加速 RFS。 ndo_rx_flow_steer
调整:启用加速 RFS (aRFS)
假设您的 NIC 和驱动程序支持它,您可以通过启用和配置一组内容来启用加速 RFS:
- 已启用并配置 RPS。
- 已启用并配置 RFS。
- 您的内核已在编译时启用。Ubuntu 内核 3.13.0 已启用。
CONFIG_RFS_ACCEL - 按照前面所述为设备启用 ntuple 支持。您可以使用来验证设备是否已启用 ntuple 支持。
ethtool - 配置您的 IRQ 设置以确保每个 RX 队列都由您所需的网络处理 CPU 之一处理。
一旦完成上述配置,加速 RFS 将用于自动将数据移动到与处理该流数据的 CPU 核心绑定的 RX 队列,您无需为每个流手动指定 ntuple 过滤规则。
使用以下方式提升网络堆栈 netif_receive_skb
从我们上次提到的 开始,它有几个调用的地方。最常见的两个(也是我们已经看过的两个): netif_receive_skb
napi_skb_finish如果数据包不会合并到现有的 GRO 流中,或者napi_gro_complete如果协议层指示是时候刷新流量了,或者
**提醒:**及其后代都在软中断处理循环的上下文中运行,你会看到这里花费的时间被计算为或者使用类似的工具。 netif_receive_skb sitime si top
netif_receive_skb 首先检查一个值,以确定用户是否已请求在数据包到达积压队列之前或之后接收时间戳。如果启用此设置,则在数据到达 RPS(以及 CPU 的关联积压队列)之前,现在就为数据加盖时间戳。如果禁用此设置,则在数据到达队列后为其加盖时间戳。如果启用了 RPS,这可用于在多个 CPU 之间分配时间戳负载,但会因此引入一些延迟。 sysctl
调整:RX 数据包时间戳
您可以通过调整名为 sysctl 的参数来调整接收数据包后添加时间戳的时间: net.core.netdev_tstamp_prequeue
通过调整禁用 RX 数据包的时间戳 sysctl
$ sudo sysctl -w net.core.netdev_tstamp_prequeue=0
默认值为 1。请参阅上一节以了解此设置的具体含义。
netif_receive_skb
处理完时间戳后,根据是否启用 RPS,操作会有所不同。我们先从更简单的路径开始:禁用 RPS。 netif_receive_skb
无 RPS(默认设置)
如果未启用 RPS,则会调用它进行一些簿记,然后调用将数据移近协议栈。 __netif_receive_skb __netif_receive_skb_core
我们将确切地了解其工作原理,但首先让我们看看启用 RPS 的代码路径如何工作,因为该代码也将调用。 __netif_receive_skb_core __netif_receive_skb_core
启用 RPS 后
如果启用了 RPS,在处理完上述时间戳选项后,将执行一些计算来确定应使用哪个 CPU 的积压队列。这是通过使用函数完成的。来自net/core/dev.c: netif_receive_skb get_rps_cpu
cpu = get_rps_cpu(skb->dev, skb, &rflow);
if (cpu >= 0) {
ret = enqueue_to_backlog(skb, cpu, &rflow->last_qtail);
rcu_read_unlock();
return ret;
}
get_rps_cpu 将考虑如上所述的 RFS 和 aRFS 设置,以确保数据通过调用排队到所需的 CPU 积压队列中。 enqueue_to_backlog
enqueue_to_backlog
此函数首先获取指向远程 CPU结构的指针,该结构包含指向 的指针。接下来,检查远程 CPU的队列长度。来自net/core/dev.c: softnet_data input_pkt_queue input_pkt_queue
qlen = skb_queue_len(&sd->input_pkt_queue);
if (qlen <= netdev_max_backlog && !skb_flow_limit(skb, qlen)) {
首先将的长度与 进行比较。如果队列长度超过此值,则丢弃数据。同样,检查流量限制,如果已超过限制,则丢弃数据。在这两种情况下,结构上的丢弃计数都会增加。请注意,这是数据要排队到的 CPU 的结构。阅读上面关于 的部分,了解如何获取丢弃计数以用于监控目的。 input_pkt_queue netdev_max_backlog softnet_data softnet_data /proc/net/softnet_stat
enqueue_to_backlog 在很多地方都没有被调用。它被调用用于启用 RPS 的数据包处理,也来自。大多数驱动程序不应该使用,而应该使用。如果您不使用 RPS 并且您的驱动程序不使用,则增加积压不会对您的系统产生任何明显的影响,因为它没有被使用。 netif_rx netif_rx netif_receive_skb netif_rx
注意:您需要检查所使用的驱动程序。如果它调用并且您没有使用 RPS,则增加将不会带来任何性能改进,因为没有数据会到达。 netif_receive_skb netdev_max_backlog input_pkt_queue
假设足够小,并且流量限制(关于这一点,下文会详细介绍)尚未达到(或已禁用),则可以将数据排队。这里的逻辑有点搞笑,但可以总结为: input_pkt_queue
- 如果队列为空:检查远程 CPU 上是否已启动 NAPI。如果没有,检查是否有 IPI 排队等待发送。如果没有,则排队一个并通过调用 启动 NAPI 处理循环。继续排队数据。
____napi_schedule - 如果队列不为空,或者前面描述的操作已完成,则将数据入队。
代码在使用时有点棘手,所以请仔细阅读。来自net/core/dev.c: goto
if (skb_queue_len(&sd->input_pkt_queue)) {
enqueue:
__skb_queue_tail(&sd->input_pkt_queue, skb);
input_queue_tail_incr_save(sd, qtail);
rps_unlock(sd);
local_irq_restore(flags);
return NET_RX_SUCCESS;
}
/* Schedule NAPI for backlog device
* We can use non atomic operation since we own the queue lock
*/
if (!__test_and_set_bit(NAPI_STATE_SCHED, &sd->backlog.state)) {
if (!rps_ipi_queued(sd))
____napi_schedule(sd, &sd->backlog);
}
goto enqueue;
流量限制
RPS 会在多个 CPU 之间分配数据包处理负载,但单个大型流可能会独占 CPU 处理时间,导致小型流无法处理。流量限制功能可用于将每个流排队到积压队列的数据包数量限制为一定数量。这可以帮助确保小型流得到处理,即使有更大的流正在推送数据包。
上面的net/core/dev.c中的 if 语句通过调用以下命令检查流量限制: skb_flow_limit
if (qlen <= netdev_max_backlog && !skb_flow_limit(skb, qlen)) {
此代码检查队列中是否还有空间,以及是否未达到流量限制。默认情况下,流量限制是禁用的。为了启用流量限制,您必须指定一个位图(类似于 RPS 的位图)。
监控:监控因满载或流量限制导致的丢失 input_pkt_queue
请参阅上文有关监控的部分。该字段是一个计数器,每次数据被丢弃而不是排队到 CPU 时,该计数器都会递增。 /proc/net/softnet_stat dropped input_pkt_queue
调优
调整:调整以防止掉落 netdev_max_backlog
在调整此调整值之前,请参阅上一节中的注释。
如果您使用 RPS 或您的司机呼叫 ,您可以通过增加 来帮助防止掉线。 enqueue_to_backlog netdev_max_backlog netif_rx
示例:使用 将积压增加到 3000 。 sysctl
$ sudo sysctl -w net.core.netdev_max_backlog=3000
默认值为 1000。
调优:调整 backlog循环的 NAPI 权重 poll
您可以通过设置 sysctl 来调整 backlog 的 NAPI 轮询器的权重。调整此值决定了 backlog 循环可以消耗多少总体预算(请参阅上面有关调整的部分): net.core.dev_weight poll net.core.netdev_budget
示例:使用 增加 NAPI积压处理循环。 poll sysctl
$ sudo sysctl -w net.core.dev_weight=600
默认值为 64。
请记住,backlog 处理在类似于设备驱动程序的注册函数的 softirq 上下文中运行,并且会受到总体和时间限制的限制,如前面章节所述。 poll budget
调整:启用流量限制并调整流量限制哈希表大小
使用 设置流量限制表的大小。 sysctl
$ sudo sysctl -w net.core.flow_limit_table_len=8192
默认值为 4096。
此更改仅影响新分配的流哈希表。因此,如果您想增加表大小,则应在启用流限制之前进行此操作。
要启用流量限制,您应该指定类似于 RPS 位掩码的位掩码,它指示哪些 CPU 启用了流量限制。 /proc/sys/net/core/flow_limit_cpu_bitmap
积压队列 NAPI 轮询器
每个 CPU 的积压队列插入 NAPI 的方式与设备驱动程序相同。提供了一个用于处理来自软中断上下文的数据包的函数。还提供了一个,就像设备驱动程序一样。 poll weight
此 NAPI 结构在网络系统初始化期间提供。来自: net_dev_init net/core/dev.c
sd->backlog.poll = process_backlog;
sd->backlog.weight = weight_p;
sd->backlog.gro_list = NULL;
sd->backlog.gro_count = 0;
积压 NAPI 结构与设备驱动程序 NAPI 结构的不同之处在于,该参数是可调整的,而驱动程序将其 NAPI 权重硬编码为 64。我们将在下面的调整部分看到如何使用来调整权重。 weight sysctl
process_backlog
该函数是一个循环,一直运行直到其权重(如上一节所述)被消耗或积压中不再剩余数据。 process_backlog
积压队列中的每条数据都会从积压队列中移除并传递给。数据命中后的代码路径与上面针对 RPS 禁用情况的解释相同。即,在调用将网络数据传递到协议层之前进行一些记录。 __netif_receive_skb __netif_receive_skb __netif_receive_skb __netif_receive_skb_core
process_backlog 遵循与设备驱动程序相同的与 NAPI 的约定,即:如果总权重不使用,则禁用 NAPI。轮询器通过调用from重新启动,如上所述。 ____napi_schedule enqueue_to_backlog
该函数返回已完成的工作量,该工作量(如上所述)将从预算中减去(如上所述,使用 进行调整)。 net_rx_action net.core.netdev_budget
__netif_receive_skb_core 将数据传送至数据包分接头和协议层
__netif_receive_skb_core 执行将数据传送到协议栈的繁重工作。在执行此操作之前,它会检查是否已安装任何数据包分接头来捕获所有传入数据包。执行此操作的一个例子是地址系列,通常通过libpcap 库使用。 AF_PACKET
如果存在这样的分接头,数据将首先传送到那里,然后再传送到协议层。
包裹分接递送
如果安装了数据包分接头(通常通过 libpcap),则数据包将通过net/core/dev.c中的以下代码传送到那里:
list_for_each_entry_rcu(ptype, &ptype_all, list) {
if (!ptype->dev || ptype->dev == skb->dev) {
if (pt_prev)
ret = deliver_skb(skb, pt_prev, orig_dev);
pt_prev = ptype;
}
}
如果你对数据通过 pcap 的路径感兴趣,请阅读net/packet/af_packet.c。
协议层交付
一旦满足了 tap 的要求,就会将数据传送到协议层。它通过从数据中获取协议字段并遍历为该协议类型注册的传送函数列表来实现这一点。 __netif_receive_skb_core
这可以在net/core/dev.c中看到: __netif_receive_skb_core
type = skb->protocol;
list_for_each_entry_rcu(ptype,
&ptype_base[ntohs(type) & PTYPE_HASH_MASK], list) {
if (ptype->type == type &&
(ptype->dev == null_or_dev || ptype->dev == skb->dev ||
ptype->dev == orig_dev)) {
if (pt_prev)
ret = deliver_skb(skb, pt_prev, orig_dev);
pt_prev = ptype;
}
}
上面的标识符在net/core/dev.c中定义为列表的哈希表: ptype_base
struct list_head ptype_base[PTYPE_HASH_SIZE] __read_mostly;
每个协议层都会向哈希表中给定槽位的列表中添加一个过滤器,该过滤器使用名为的辅助函数进行计算: ptype_head
static inline struct list_head *ptype_head(const struct packet_type *pt)
{
if (pt->type == htons(ETH_P_ALL))
return &ptype_all;
else
return &ptype_base[ntohs(pt->type) & PTYPE_HASH_MASK];
}
通过调用 可以将过滤器添加到列表中。这就是协议层如何注册自己以根据其协议类型进行网络数据传输的方式。 dev_add_pack
现在您知道网络数据如何从 NIC 到达协议层。
协议层注册
现在我们知道了数据是如何从网络设备子系统传送到协议栈的,让我们看看协议层是如何注册自己的。
这篇博文将研究 IP 协议栈,因为它是一种常用的协议并且与大多数读者相关。
IP协议层
IP 协议层将自身插入哈希表,以便数据从前面章节描述的网络设备层传送给它。 ptype_base
这发生在net/ipv4/af_inet.c中的函数中: inet_init
dev_add_pack(&ip_packet_type);
这将注册在net/ipv4/af_inet.c中定义的 IP 数据包类型结构:
static struct packet_type ip_packet_type __read_mostly = {
.type = cpu_to_be16(ETH_P_IP),
.func = ip_rcv,
};
__netif_receive_skb_core 调用(如上一节所示),后者调用(在本例中为)。 deliver_skb func ip_rcv
ip_rcv
从高层次来看,该函数非常简单。有几个完整性检查来确保数据有效。统计计数器也得到了提升。 ip_rcv
ip_rcv 最后将数据包传递给netfilter 。这样做是为了让任何应在 IP 协议层匹配的iptables规则能够在数据包继续传输之前对其进行检查。 ip_rcv_finish
我们可以在net/ipv4/ip_input.c的末尾看到将数据交给 netfilter 的代码: ip_rcv
return NF_HOOK(NFPROTO_IPV4, NF_INET_PRE_ROUTING, skb, dev, NULL, ip_rcv_finish);
netfilter 和 iptables
为了简洁起见(以及我的 RSI),我决定跳过对 netfilter、iptables 和 conntrack 的深入研究。
简而言之,它将检查是否安装了任何过滤器,并尝试将执行返回到 IP 协议层,以避免深入到 netfilter 以及在其下方挂钩的任何内容,如 iptables 和 conntrack。 NF_HOOK_THRESH
请记住:如果您有大量或非常复杂的 netfilter 或 iptables 规则,这些规则将在 softirq 上下文中执行,并可能导致网络堆栈出现延迟。不过,如果您需要安装一组特定的规则,这可能是不可避免的。
ip_rcv_finish
一旦 net filter 有机会查看数据并决定如何处理它,就会调用它。当然,这只发生在 netfilter 没有丢弃数据的情况下。 ip_rcv_finish
ip_rcv_finish 首先进行优化。为了将数据包传送到正确的位置,需要从路由系统中获取一个。为了获得一个,代码首先尝试从该数据要发送到的更高级别协议中调用该函数。 dst_entry early_demux
该例程是一种优化,它通过检查是否缓存在套接字结构中来尝试找到传送数据包所需的信息。 early_demux dst_entry dst_entry
if (sysctl_ip_early_demux && !skb_dst(skb) && skb->sk == NULL) {
const struct net_protocol *ipprot;
int protocol = iph->protocol;
ipprot = rcu_dereference(inet_protos[protocol]);
if (ipprot && ipprot->early_demux) {
ipprot->early_demux(skb);
/* must reload iph, skb->head might have changed */
iph = ip_hdr(skb);
}
}
如上所示,此代码由 sysctl 保护。默认情况下启用。下一节包含有关如何禁用它以及您可能需要禁用的原因的信息。 sysctl_ip_early_demux early_demux
如果启用了优化并且没有缓存条目(因为这是第一个到达的数据包),则该数据包将被交给内核中的路由系统,在那里进行计算和分配。 dst_entry
一旦路由层完成,统计计数器就会更新,并且函数通过调用来结束,这反过来又调用路由系统附加的数据包结构上的输入函数指针。 dst_input(skb) dst_entry
如果数据包的最终目的地是本地系统,则路由系统将把该函数附加到数据包结构中的输入函数指针。 ip_local_deliver dst_entry
调整:调整 IP 协议早期解复用
通过设置来禁用优化。 early_demux sysctl
$ sudo sysctl -w net.ipv4.ip_early_demux=0
默认值为1;已启用。 early_demux
添加此 sysctl 是因为某些用户发现优化后吞吐量在某些情况下下降了约 5% 。 early_demux
ip_local_deliver
回想一下我们如何在 IP 协议层看到以下模式:
- 打电话来做一些初步的簿记。
ip_rcv - 数据包被交给 netfilter 进行处理,并带有一个指向处理完成时要执行的回调的指针。
ip_rcv_finish是完成处理并继续将数据包推送到网络堆栈的回调。
ip_local_deliver 具有相同的模式。来自net/ipv4/ip_input.c:
/*
* Deliver IP Packets to the higher protocol layers.
*/
int ip_local_deliver(struct sk_buff *skb)
{
/*
* Reassemble IP fragments.
*/
if (ip_is_fragment(ip_hdr(skb))) {
if (ip_defrag(skb, IP_DEFRAG_LOCAL_DELIVER))
return 0;
}
return NF_HOOK(NFPROTO_IPV4, NF_INET_LOCAL_IN, skb, skb->dev, NULL,
ip_local_deliver_finish);
}
一旦 netfilter 有机会查看数据,就会被调用,假设数据没有首先被 netfilter 丢弃。 ip_local_deliver_finish
ip_local_deliver_finish
ip_local_deliver_finish 从数据包中获取协议,查找为该协议注册的结构,并调用结构中指向的函数。 net_protocol handler net_protocol
这会将数据包传递给更高级别的协议层。
监控:IP协议层统计
通过阅读来监控详细的 IP 协议统计数据。 /proc/net/snmp
$ cat /proc/net/snmp
Ip: Forwarding DefaultTTL InReceives InHdrErrors InAddrErrors ForwDatagrams InUnknownProtos InDiscards InDelivers OutRequests OutDiscards OutNoRoutes ReasmTimeout ReasmReqds ReasmOKs ReasmFails FragOKs FragFails FragCreates
Ip: 1 64 25922988125 0 0 15771700 0 0 25898327616 22789396404 12987882 51
1 10129840 2196520 1 0 0 0
...
该文件包含多个协议层的统计信息。IP 协议层首先出现。第一行包含下一行中每个对应值的空格分隔名称。
在 IP 协议层,您会发现统计计数器被提升。这些计数器由 C 枚举引用。所有有效的枚举值及其对应的字段名称都可以在include/uapi/linux/snmp.h中找到: /proc/net/snmp
enum
{
IPSTATS_MIB_NUM = 0,
/* frequently written fields in fast path, kept in same cache line */
IPSTATS_MIB_INPKTS, /* InReceives */
IPSTATS_MIB_INOCTETS, /* InOctets */
IPSTATS_MIB_INDELIVERS, /* InDelivers */
IPSTATS_MIB_OUTFORWDATAGRAMS, /* OutForwDatagrams */
IPSTATS_MIB_OUTPKTS, /* OutRequests */
IPSTATS_MIB_OUTOCTETS, /* OutOctets */
/* ... */
通过阅读来监控扩展 IP 协议统计信息。 /proc/net/netstat
$ cat /proc/net/netstat | grep IpExt
IpExt: InNoRoutes InTruncatedPkts InMcastPkts OutMcastPkts InBcastPkts OutBcastPkts InOctets OutOctets InMcastOctets OutMcastOctets InBcastOctets OutBcastOctets InCsumErrors InNoECTPkts InECT0Pktsu InCEPkts
IpExt: 0 0 0 0 277959 0 14568040307695 32991309088496 0 0 58649349 0 0 0 0 0
格式类似于,只是行以 为前缀。 /proc/net/snmp IpExt
一些有趣的统计数据:
InReceives:任何数据完整性检查之前到达的 IP 数据包总数。ip_rcvInHdrErrors:标头损坏的 IP 数据包总数。标头太短、太长、不存在、IP 协议版本号错误等。InAddrErrors:主机不可达的 IP 数据包总数。ForwDatagrams:已转发的 IP 数据包总数。InUnknownProtos:标头中指定了未知或不受支持的协议的 IP 数据包总数。InDiscards:数据包修剪时,由于内存分配失败或校验和失败而丢弃的 IP 数据包总数。InDelivers:成功传送到更高协议层的 IP 数据包总数。请记住,即使 IP 层不会丢弃数据,这些协议层也可能会丢弃数据。InCsumErrors:校验和错误的 IP 数据包总数。
请注意,这些指标中的每一个指标都在 IP 层中非常具体的位置递增。代码会不时地移动,重复计算错误或其他会计错误可能会潜入其中。如果这些统计数据对您很重要,强烈建议您阅读对您来说很重要的指标的 IP 协议层源代码,以便您了解它们何时递增(以及何时不递增)。
更高级别协议注册
这篇博文将研究 UDP,但 TCP 协议处理程序的注册方式和注册时间与 UDP 协议处理程序相同。
在 中,可以找到包含用于将 UDP、TCP 和 ICMP 协议连接到 IP 协议层的处理函数的结构定义。来自net/ipv4/af_inet.c: net/ipv4/af_inet.c
static const struct net_protocol tcp_protocol = {
.early_demux = tcp_v4_early_demux,
.handler = tcp_v4_rcv,
.err_handler = tcp_v4_err,
.no_policy = 1,
.netns_ok = 1,
};
static const struct net_protocol udp_protocol = {
.early_demux = udp_v4_early_demux,
.handler = udp_rcv,
.err_handler = udp_err,
.no_policy = 1,
.netns_ok = 1,
};
static const struct net_protocol icmp_protocol = {
.handler = icmp_rcv,
.err_handler = icmp_err,
.no_policy = 1,
.netns_ok = 1,
};
这些结构在 inet 地址系列的初始化代码中注册。来自net/ipv4/af_inet.c:
/*
* Add all the base protocols.
*/
if (inet_add_protocol(&icmp_protocol, IPPROTO_ICMP) < 0)
pr_crit("%s: Cannot add ICMP protocol\n", __func__);
if (inet_add_protocol(&udp_protocol, IPPROTO_UDP) < 0)
pr_crit("%s: Cannot add UDP protocol\n", __func__);
if (inet_add_protocol(&tcp_protocol, IPPROTO_TCP) < 0)
pr_crit("%s: Cannot add TCP protocol\n", __func__);
我们将研究 UDP 协议层。如上所示,UDP 的函数名为。 handler udp_rcv
这是 UDP 层的入口点,IP 层在此传递数据。让我们继续我们的旅程。
UDP 协议层
UDP协议层的代码可以在net/ipv4/udp.c中找到。
udp_rcv
该函数的代码只有一行,直接调用来处理接收数据报。 udp_rcv __udp4_lib_rcv
__udp4_lib_rcv
该函数将检查数据包是否有效,并获取 UDP 报头、UDP 数据报长度、源地址和目标地址。接下来是一些额外的完整性检查和校验和验证。 __udp4_lib_rcv
回想一下,在 IP 协议层中,我们看到在将数据包交给上层协议(在我们的例子中是 UDP)之前,会执行一项优化以将数据包附加到该数据包上。 dst_entry
如果找到套接字和相应的内容,则会将数据包排队到套接字: dst_entry __udp4_lib_rcv
sk = skb_steal_sock(skb);
if (sk) {
struct dst_entry *dst = skb_dst(skb);
int ret;
if (unlikely(sk->sk_rx_dst != dst))
udp_sk_rx_dst_set(sk, dst);
ret = udp_queue_rcv_skb(sk, skb);
sock_put(sk);
/* a return value > 0 means to resubmit the input, but
* it wants the return to be -protocol, or 0
*/
if (ret > 0)
return -ret;
return 0;
} else {
如果没有从 early_demux 操作附加套接字,则现在将通过调用来查找接收套接字。 __udp4_lib_lookup_skb
在上述两种情况下,数据报都将排队到套接字:
ret = udp_queue_rcv_skb(sk, skb);
sock_put(sk);
如果没有找到套接字,数据报将被丢弃:
/* No socket. Drop packet silently, if checksum is wrong */
if (udp_lib_checksum_complete(skb))
goto csum_error;
UDP_INC_STATS_BH(net, UDP_MIB_NOPORTS, proto == IPPROTO_UDPLITE);
icmp_send(skb, ICMP_DEST_UNREACH, ICMP_PORT_UNREACH, 0);
/*
* Hmm. We got an UDP packet to a port to which we
* don't wanna listen. Ignore it.
*/
kfree_skb(skb);
return 0;
udp_queue_rcv_skb
该函数的初始部分如下:
- 确定与数据报关联的套接字是否为封装套接字。如果是,则将数据包向上传递到该层的处理函数,然后再继续。
- 确定数据报是否是 UDP-Lite 数据报并进行一些完整性检查。
- 验证数据报的 UDP 校验和,如果校验和不合格则丢弃该数据报。
最后,我们到达接收队列逻辑,它首先检查套接字的接收队列是否已满。来自: net/ipv4/udp.c
if (sk_rcvqueues_full(sk, skb, sk->sk_rcvbuf))
goto drop;
sk_rcvqueues_full
该函数检查套接字的积压长度和套接字的长度,以确定总和是否大于套接字的长度(在上面的代码片段中): sk_rcvqueues_full sk_rmem_alloc sk_rcvbuf sk->sk_rcvbuf
/*
* Take into account size of receive queue and backlog queue
* Do not take into account this skb truesize,
* to allow even a single big packet to come.
*/
static inline bool sk_rcvqueues_full(const struct sock *sk, const struct sk_buff *skb,
unsigned int limit)
{
unsigned int qsize = sk->sk_backlog.len + atomic_read(&sk->sk_rmem_alloc);
return qsize > limit;
}
调整这些值有点棘手,因为有很多东西可以调整。
调优:套接字接收队列内存
该值(上面称为限制) 可以增加到 sysctl所设置的任何值。 sk->sk_rcvbuf sk_rcvqueues_full net.core.rmem_max
通过设置来增加最大接收缓冲区大小。 sysctl
$ sudo sysctl -w net.core.rmem_max=8388608
sk->sk_rcvbuf 从该值开始,也可以通过设置 sysctl 进行调整,如下所示: net.core.rmem_default
通过设置来调整默认的初始接收缓冲区大小。 sysctl
$ sudo sysctl -w net.core.rmem_default=8388608
您还可以通过从应用程序调用并传递来设置大小。您可以设置的最大值是。 sk->sk_rcvbuf setsockopt SO_RCVBUF setsockopt net.core.rmem_max
但是,您可以通过调用和传递来覆盖该限制,但运行该应用程序的用户将需要该功能。 net.core.rmem_max setsockopt SO_RCVBUFFORCE CAP_NET_ADMIN
该值通过调用设置数据报所有者套接字的函数来增加。稍后我们将在 UDP 层中看到该函数的调用。 sk->sk_rmem_alloc skb_set_owner_r
通过调用来增加,我们接下来会看到。 sk->sk_backlog.len sk_add_backlog
udp_queue_rcv_skb
一旦确认队列未满,就可以继续排队数据报。来自net/ipv4/udp.c:
bh_lock_sock(sk);
if (!sock_owned_by_user(sk))
rc = __udp_queue_rcv_skb(sk, skb);
else if (sk_add_backlog(sk, skb, sk->sk_rcvbuf)) {
bh_unlock_sock(sk);
goto drop;
}
bh_unlock_sock(sk);
return rc;
第一步是确定套接字当前是否有来自用户空间程序的任何系统调用。如果没有**,**则可以通过调用将数据报添加到接收队列。如果有,则通过调用将数据报排队到积压队列中。 __udp_queue_rcv_skb sk_add_backlog
当套接字系统调用通过内核中的调用释放套接字时,积压的数据报将被添加到接收队列中。 release_sock
__udp_queue_rcv_skb
该函数通过调用将数据报添加到接收队列,如果无法将数据报添加到套接字的接收队列,则增加统计计数器。 __udp_queue_rcv_skb sock_queue_rcv_skb
rc = sock_queue_rcv_skb(sk, skb);
if (rc < 0) {
int is_udplite = IS_UDPLITE(sk);
/* Note that an ENOMEM error is charged twice */
if (rc == -ENOMEM)
UDP_INC_STATS_BH(sock_net(sk), UDP_MIB_RCVBUFERRORS,is_udplite);
UDP_INC_STATS_BH(sock_net(sk), UDP_MIB_INERRORS, is_udplite);
kfree_skb(skb);
trace_udp_fail_queue_rcv_skb(rc, sk);
return -1;
}
监控:UDP协议层统计
获取 UDP 协议统计信息的两个非常有用的文件是:
/proc/net/snmp/proc/net/udp
/proc/net/snmp
通过阅读来监控详细的 UDP 协议统计数据。 /proc/net/snmp
$ cat /proc/net/snmp | grep Udp\:
Udp: InDatagrams NoPorts InErrors OutDatagrams RcvbufErrors SndbufErrors
Udp: 16314 0 0 17161 0 0
与此文件中有关 IP 协议的详细统计信息非常相似,您需要阅读协议层源代码才能准确确定这些值何时何地增加。
InDatagrams:当用户空间程序使用它来读取数据报时,该值会增加。当 UDP 数据包被封装并发送回去进行处理时,该值也会增加。recvmsgNoPorts:当 UDP 数据包到达没有程序监听的端口时,该值会增加。InErrors:在几种情况下会增加:接收队列中没有内存、出现错误校验和以及添加数据报失败。sk_add_backlogOutDatagrams:当 UDP 数据包毫无错误地传递到 IP 协议层进行发送时,该值会增加。RcvbufErrors:当报告没有可用内存时增加;如果大于或等于就会发生这种情况。sock_queue_rcv_skbsk->sk_rmem_allocsk->sk_rcvbufSndbufErrors:如果尝试发送数据包时 IP 协议层报告错误且未设置错误队列,则增加。如果没有可用的发送队列空间或内核内存,也会增加。InCsumErrors:检测到 UDP 校验和失败时增加。请注意,在我找到的所有情况下,都与 同时增加。因此,-应该产生接收端内存相关错误的数量。InCsumErrorsInErrorsInErrorsInCsumErros
/proc/net/udp
通过读取来监控 UDP 套接字统计信息 /proc/net/udp
$ cat /proc/net/udp
sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode ref pointer drops
515: 00000000:B346 00000000:0000 07 00000000:00000000 00:00000000 00000000 104 0 7518 2 0000000000000000 0
558: 00000000:0371 00000000:0000 07 00000000:00000000 00:00000000 00000000 0 0 7408 2 0000000000000000 0
588: 0100007F:038F 00000000:0000 07 00000000:00000000 00:00000000 00000000 0 0 7511 2 0000000000000000 0
769: 00000000:0044 00000000:0000 07 00000000:00000000 00:00000000 00000000 0 0 7673 2 0000000000000000 0
812: 00000000:006F 00000000:0000 07 00000000:00000000 00:00000000 00000000 0 0 7407 2 0000000000000000 0
第一行描述了下面各行中的每个字段:
sl:套接字的内核哈希槽local_address:十六进制的套接字本地地址及端口号,以 分隔。:rem_address:十六进制的套接字远程地址和端口号,以 分隔。:st:套接字的状态。奇怪的是,UDP 协议层似乎使用了一些 TCP 套接字状态。在上面的示例中,是。7TCP_CLOSEtx_queue:内核中为传出 UDP 数据报分配的内存量。rx_queue:内核中为传入的 UDP 数据报分配的内存量。tr,,:这些字段不被UDP协议层使用。tm->whenretrnsmtuid:创建此套接字的用户的有效用户ID。timeout:UDP协议层未使用。inode:此套接字对应的 inode 编号。您可以使用它来帮助您确定哪个用户进程打开了此套接字。检查,它将包含指向 的符号链接。/proc/[pid]/fdsocket[:inode]ref:套接字的当前引用计数。pointer:内核中的内存地址。struct sockdrops:与此套接字相关的数据报丢弃数。请注意,这不包括与发送数据报(在 corked UDP 套接字或其他方式上)相关的任何丢弃;自本博客文章所检查的内核版本起,此值仅在接收路径中递增。
输出此内容的代码可以在 中找到。 net/ipv4/udp.c
将数据排队到套接字
通过调用 将网络数据排队到套接字中。此函数在将数据报添加到队列之前会执行以下几项操作: sock_queue_rcv
- 检查套接字分配的内存,以确定它是否超出了接收缓冲区大小。如果是,则增加套接字的丢弃计数。
- 接下来用于处理已应用于套接字的任何伯克利数据包过滤器 (Berkeley Packet Filter) 过滤器。
sk_filter sk_rmem_schedule运行以确保有足够的接收缓冲区空间来接受该数据报。- 接下来,通过调用 将数据报的大小加载到套接字中。这将增加。
skb_set_owner_rsk->sk_rmem_alloc - 通过调用 将数据添加到队列中。
__skb_queue_tail - 最后,任何等待数据到达套接字的进程都会通过调用通知处理程序函数来通知。
sk_data_ready
这就是数据如何到达系统并穿过网络堆栈直到到达套接字并准备好被用户程序读取。
还有一些额外的事情值得一提,但在其他地方似乎不太正确。
时间戳
如上文所述,网络堆栈可以收集传入数据的时间戳。与 RPS 结合使用时,有一些 sysctl 值控制何时/如何收集时间戳;有关 RPS、时间戳以及网络堆栈中接收时间戳的具体位置的更多信息,请参阅上文。一些 NIC 甚至还支持硬件中的时间戳。
如果您想尝试确定内核网络堆栈在接收数据包时增加了多少延迟,这是一个有用的功能。
关于时间戳的内核文档非常出色,甚至还包含一个示例程序和 Makefile,您可以查看!。
确定您的驱动程序和设备支持哪种时间戳模式。 ethtool -T
$ sudo ethtool -T eth0
Time stamping parameters for eth0:
Capabilities:
software-transmit (SOF_TIMESTAMPING_TX_SOFTWARE)
software-receive (SOF_TIMESTAMPING_RX_SOFTWARE)
software-system-clock (SOF_TIMESTAMPING_SOFTWARE)
PTP Hardware Clock: none
Hardware Transmit Timestamp Modes: none
Hardware Receive Filter Modes: none
不幸的是,这个 NIC 不支持硬件接收时间戳,但是在这个系统上仍然可以使用软件时间戳来帮助我确定内核给我的数据包接收路径增加了多少延迟。
忙于轮询低延迟套接字
可以使用一个名为的套接字选项,当阻塞接收完成并且没有数据时,它将导致内核忙于轮询新数据。 SO_BUSY_POLL
重要提示:要使此选项起作用,您的设备驱动程序必须支持它。Linux 内核 3.13.0 的驱动程序不支持此选项。但是,驱动程序支持。如果您的驱动程序将函数设置为其结构的字段(上述博客文章中提到),则它支持。 igb ixgbe ndo_busy_poll struct net_device_ops SO_BUSY_POLL
英特尔提供了一篇出色的论文,解释了其工作原理和如何使用它。
当对单个套接字使用此套接字选项时,您应传递一个时间值(以微秒为单位),作为忙于轮询设备驱动程序接收队列中的新数据的时间量。当您在设置此值后向此套接字发出阻塞读取时,内核将忙于轮询新数据。
您还可以将 sysctl 值设置为以微秒为单位的时间值,以表示调用或忙轮询等待新数据到达的时间。 net.core.busy_poll poll select
此选项可以减少延迟,但会增加CPU使用率和功耗。
Netpoll:在关键情况下支持网络
Linux 内核提供了一种在内核崩溃时使用设备驱动程序在 NIC 上发送和接收数据的方法。此方法的 API 称为 Netpoll,它被一些程序使用,但最值得注意的是:kgdb、netconsole。
大多数驱动程序都支持 Netpoll;您的驱动程序需要实现该功能并将其附加到探测期间注册的(如上所示)。 ndo_poll_controller struct net_device_ops
当网络设备子系统对传入或传出数据执行操作时,首先检查 netpoll 系统以确定数据包是否发往 netpoll。
例如,我们可以在from中看到以下代码: __netif_receive_skb_core net/dev/core.c
static int __netif_receive_skb_core(struct sk_buff *skb, bool pfmemalloc)
{
/* ... */
/* if we've gotten here through NAPI, check netpoll */
if (netpoll_receive_skb(skb))
goto out;
/* ... */
}
Netpoll 检查在大多数处理传输或接收网络数据的 Linux 网络设备子系统代码的早期发生。
Netpoll API 的使用者可以通过调用来注册结构。该结构具有用于附加接收钩子的函数指针,并且 API 导出用于发送数据的函数。 struct netpoll netpoll_setup struct netpoll
如果您有兴趣使用 Netpoll API,您应该看看驱动程序、Netpoll API 头文件“include/linux/netpoll.h ”和这个精彩的演讲。 netconsole
SO_INCOMING_CPU
该标志直到 Linux 3.19 才被添加,但它非常有用,应该包含在此博客文章中。 SO_INCOMING_CPU
您可以使用该选项来确定哪个 CPU 正在处理特定套接字的网络数据包。然后,您的应用程序可以使用此信息将套接字交给在所需 CPU 上运行的线程,以帮助提高数据局部性和 CPU 缓存命中率。 getsockopt SO_INCOMING_CPU
邮件列表消息介绍提供了一个简短的示例架构,其中此选项很有用。 SO_INCOMING_CPU
DMA 引擎
DMA引擎是一种硬件,允许 CPU 卸载大型复制操作。这样,CPU 就可以腾出时间去执行其他任务,而内存复制则由硬件完成。启用 DMA 引擎并运行利用它的代码,应该可以降低 CPU 的使用率。
Linux 内核有一个通用的 DMA 引擎接口,DMA 引擎驱动程序作者可以将其插入。您可以在内核源代码文档中阅读有关 Linux DMA 引擎接口的更多信息。
虽然内核支持一些 DMA 引擎,但我们将讨论一个非常常见的引擎:Intel IOAT DMA 引擎。
英特尔的 I/O 加速技术 (IOAT)
许多服务器都包含英特尔 I/O AT 捆绑包,它由一系列性能变化组成。
这些变化之一是包含硬件 DMA 引擎。您可以检查输出以确定模块是否正在加载以及是否找到了支持的硬件。 dmesg ioatdma
DMA 卸载引擎在几个地方使用,最显著的是 TCP 堆栈。
Linux 2.6.18 中包含了对 Intel IOAT DMA 引擎的支持,但由于一些不幸的数据损坏错误,在 3.13.11.10 中被禁用。
使用 3.13.11.10 之前内核的用户可能默认在其服务器上使用该模块。也许这个问题将在未来的内核版本中得到修复。 ioatdma
直接缓存访问 (DCA)
Intel I/O AT 捆绑包中包含的另一个有趣的功能是直接缓存访问 (DCA)。
此功能允许网络设备(通过其驱动程序)将网络数据直接放置在 CPU 缓存中。具体如何工作取决于驱动程序。对于驱动程序,您可以检查函数的代码以及代码。驱动程序通过将寄存器值写入 NIC 来使用 DCA。 igb igb_update_dca igb_update_rx_dca igb
要使用 DCA,您需要确保在 BIOS 中启用了 DCA、模块已加载并且网卡和驱动程序都支持 DCA。 dca
监控 IOAT DMA 引擎
如果您不顾上述数据损坏的风险而使用该模块,则可以通过检查中的某些条目来监控它。 ioatdma sysfs
监视DMA 通道的卸载操作总数。 memcpy
$ cat /sys/class/dma/dma0chan0/memcpy_count
123205655
类似地,要获取此 DMA 通道卸载的字节数,您可以运行如下命令:
监视 DMA 通道传输的总字节数。
$ cat /sys/class/dma/dma0chan0/bytes_transferred
131791916307
调整 IOAT DMA 引擎
IOAT DMA 引擎仅在数据包大小超过特定阈值时使用。该阈值称为。进行此检查是因为对于小型副本,设置和使用 DMA 引擎的开销不值得加速传输。 copybreak
使用 调整 DMA 引擎复制中断。 sysctl
$ sudo sysctl -w net.ipv4.tcp_dma_copybreak=2048
默认值为 4096。
结论
Linux 网络堆栈非常复杂。
如果不深入了解到底发生了什么,就不可能监控或调整它(或任何其他复杂的软件)。通常,在互联网上,您可能会偶然发现一个包含一组 sysctl 值的样本,这些值应该被复制并粘贴到您的计算机上。这可能不是优化网络堆栈的最佳方法。 sysctl.conf
监控网络堆栈需要仔细核算每一层的网络数据。从驱动程序开始,然后逐步向上。这样,您就可以确定发生丢包和错误的具体位置,然后调整设置以确定如何减少您看到的错误。