译|Monitoring and Tuning the Linux Networking Stack: Receiving Data


  1. TL;DR
  2. 特别感谢
  3. 监控和调优 Linux 网络栈的一般建议
  4. 概览
  5. 详细探讨
    1. 网络设备驱动程序
      1. 初始化
        1. PCI 初始化
        2. PCI 探测
          1. PCI 初始化一瞥
        3. 更多 Linux PCI 驱动程序信息
      2. 网络设备初始化
        1. struct net_device_ops
        2. ethtool 注册
        3. 硬中断
        4. NAPI
        5. igb 驱动程序的 NAPI 初始化
      3. 启动网络设备
        1. 准备从网络接收数据
        2. 启用NAPI
        3. 注册中断处理程序
        4. 启用中断
        5. 网络设备现已启动
      4. 监控网络设备
        1. ethtool -S 使用
        2. sysfs 使用
        3. /proc/net/dev 使用
      5. 调优网络设备
        1. 检查正在使用的接收队列数量
        2. 调整接收队列的数量
        3. 调整接收队列的大小
        4. 调整接收队列的处理权重
        5. 调整网络流的接收哈希字段
        6. ntuple 过滤引导网络流
    2. 软中断
      1. 什么是软中断?
      2. ksoftirqd
      3. __do_softirq
      4. 监控
      5. /proc/softirqs
    3. Linux 网络设备子系统
      1. 网络设备子系统初始化
        1. struct softnet_data 结构初始化
        2. 软中断处理程序的初始化
      2. 数据到达
        1. 中断处理程序
        2. NAPI 和 napi_schedule
        3. 关于 CPU 和网络数据处理的说明
        4. 监控网络数据到达
          1. 硬件中断请求
        5. 调优网络数据到达
          1. 中断合并
          2. 调整 IRQ 亲和性
      3. 网络数据处理开始
        1. net_rx_action 处理循环
        2. NAPI poll 函数和 权重
        3. NAPI / 网络设备驱动程序契约
        4. 完成 net_rx_action 循环
        5. 达到限制时退出循环
        6. NAPI poll
          1. igb_poll
          2. igb_clean_rx_irq
        7. 监控网数据处理
          1. /proc/net/softnet_stat
        8. 调整网络数据处理
          1. 调整 net_rx_action 预算
      4. Generic Receive Offloading (GRO)
        1. 调优:使用 ethtool 调整 GRO 设置
      5. napi_gro_receive
        1. dev_gro_receive
      6. napi_skb_finish
    4. Receive Packet Steering (RPS)
      1. 调优:启用 RPS
    5. Receive Flow Steering (RFS)
      1. 调优:启用 RFS
    6. 硬件加速 Receive Flow Steering (aRFS)
      1. 调优:启用加速 RFS(aRFS)
    7. 使用 netif_receive_skb 向上移动网络栈。
      1. 调优:接收数据包时间戳
    8. netif_receive_skb
      1. RPS 禁用(默认设置)
      2. RPS 启用
        1. enqueue_to_backlog
        2. 流量限制
        3. 监控:监控 input_pkt_queue 已满或流量限制导致的丢弃
        4. 调优
          1. 调优:调优 netdev_max_backlog 防止丢弃
          2. 调优:调优 backlog 的 NAPI poll 权重
          3. 调优:启用流量限制并调优流量限制哈希表大小
      3. backlog 队列 NAPI 轮询器
      4. process_backlog
      5. __netif_receive_skb_core 传送数据到数据包抓取和协议层
      6. 数据包抓取传送
      7. 协议层交付
    9. 协议层注册
      1. IP 协议层
        1. ip_rcv
        2. netfilter 和 iptables
        3. ip_rcv_finish
          1. 调优:调整 IP 协议 early demux
        4. ip_local_deliver
        5. ip_local_deliver_finish
        6. 监控:IP 协议层统计信息
      2. 更高级别协议注册
      3. UDP协议层
        1. udp_rcv
        2. __udp4_lib_rcv
        3. udp_queue_rcv_skb
        4. sk_rcvqueues_full
          1. 调优:套接字接收队列内存
      4. udp_queue_rcv_skb
        1. __udp_queue_rcv_skb
        2. 监控:UDP 协议层统计信息
          1. /proc/net/snmp
          2. /proc/net/udp
      5. 排队数据到套接字
    10. 其他
      1. 时间戳
      2. 低延迟套接字的忙轮询
      3. Netpoll:支持关键环境中的联网
      4. SO_INCOMING_CPU
      5. DMA引擎
        1. 英特尔的 I/O 加速技术(IOAT)
          1. 直接缓存访问
          2. 监控 IOAT DMA 引擎
          3. 调优 IOAT DMA 引擎
  6. 结论

TL;DR

本文解释了 Linux 内核的计算机如何接收数据包,以及当数据包从网络流向用户程序时,如何监视和调优网络栈的每个组件。

更新 我们已经发布了本文的姊妹篇:监控和调优 Linux 网络栈:发送数据

更新 查看 监控和调优 Linux 网络栈图解指南:接收数据,它为下面的内容添加了一些图表。

如果不阅读内核的源代码,不深入了解到底发生了什么,就不可能调优或监控 Linux 网络栈。

希望本文能给想做这方面工作的人提供参考。

特别感谢

特别感谢 Private Internet Access 的工作人员雇用我们,结合其他网络研究进行进一步研究,并慷慨地允许以研究为基础发布这些信息。

本文基于为 Private Internet Access 所做的工作,最初以 5 部分的系列文章的形式发表。

监控和调优 Linux 网络栈的一般建议

Linux 网络栈是复杂的,没有一刀切的监控或调优解决方案。 如果您真的想调优网络栈,您别无选择,只能投入大量的时间、精力和金钱来了解网络系统的各个部分是如何交互的。

理想情况下,您应该考虑在网络栈的每一层测量数据包丢弃。 这样您就可以确定并缩小需要调优的组件的范围。

这就是我认为许多运营商偏离轨道的地方:假设一组 sysctl 设置或 /proc 值可以简单地被大规模重用。在某些情况下,也许可以,但事实证明,整个系统是如此微妙和交织在一起,如果您希望有意义的监控或调优,您必须努力深入了解系统如何运作。否则,您可以直接使用默认设置,在必要的进一步优化(以及推导这些设置所需的投资)之前,已经足够好。

本文中提供的许多示例设置仅用于说明目的,并不是对某个配置或默认设置的推荐或反对。 在调整任何设置之前,您应该围绕您需要监控的内容制定一个参考框架,以注意到有意义的变化。

通过网络连接到计算机时调整网络设置是危险的;你很容易地把自己锁在外面,或者完全关闭你的网络。 不要在生产机器上调整这些设置;相反,如果可能的话,在新机器上进行调整,再投入生产中。

概览

作为参考,您可能需要手边有一份设备数据手册。 这篇文章将研究由 igb 设备驱动程序控制的 Intel I350 以太网控制器。 您可以找到该数据手册(警告:大型 PDF)供您参考

数据包从到达到套接字接收缓冲区的流程概览:

  1. 驱动程序已加载并初始化。
  2. 数据包从网络到达 NIC。
  3. 数据包被复制(通过 DMA)到内核内存中的环形缓冲区。
  4. 产生硬件中断通知系统知道数据包到达内存。
  5. 驱动程序调用 NAPI 启动轮询循环(如果尚未运行轮询循环)。
  6. ksoftirqd 进程运行在系统的每个 CPU 上。 它们在启动时注册。 ksoftirqd 进程调用设备驱动程序在初始化期间注册的 NAPI poll 函数,从环形缓冲区收取数据包。
  7. 环形缓冲区中已写入网络数据的内存区域被取消映射。
  8. DMA 到内存的数据以 “skb” 向上传递到网络层,以进行更多处理。
  9. 如果 packet steering 启用或 NIC 具有多个接收队列,则传入的网络数据帧将分布在多个CPU 中。
  10. 网络数据帧从队列传递到协议层。
  11. 协议层处理数据。
  12. 协议层添加数据到套接字关联的接收缓冲区。

整个流程将在以下各节中详细介绍。

下面检查的协议层是IP和UDP协议层。 本文提供的许多信息也将作为其他协议层的参考。

详细探讨

本文将探讨 Linux 3.13.0 版本内核,贯穿全文提供了 GitHub 代码链接和代码片段。

准确理解 Linux 内核如何接收数据包是非常复杂的。 我们需要仔细检查和理解网络驱动程序是如何工作的,以便更加清晰理解后面的网络栈部分。

本文将介绍 igb 网络驱动程序。 此驱动程序用于相对常见的服务器 NIC,即 Intel Ethernet Controller I350。 那么,让我们从理解 igb 网络驱动程序的工作原理开始。

网络设备驱动程序

初始化

驱动程序注册一个初始化函数,当驱动程序被加载时,内核会调用该函数。 此函数使用module_init 宏注册。

igb 初始化函数(igb_init_module)及其与 module_init 的注册可以在 drivers/net/ethernet/intel/igb/igb_main.c 中找到。

两者都非常简单明了:

/**
 *  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 初始化

英特尔 I350 网卡是一种 PCI express 设备。

PCI 设备通过 PCI 配置空间 中的一系列寄存器标识自己。

当设备驱动程序被编译时,会使用一个名为 MODULE_DEVICE_TABLE 的宏(来自 include/module.h)来导出一个 PCI 设备 ID 表,标识设备驱动程序可以控制的设备。该表注册为一个结构的一部分,我们稍后将看到。

内核使用此表来确定要加载哪个设备驱动程序来控制设备。

这就是操作系统如何确定哪些设备连接到系统,以及应该使用哪个驱动程序与设备通信。

此表和 igb 驱动程序的 PCI 设备 ID 位于 drivers/net/ethernet/intel/igb/igb_main.cdrivers/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 系统中注册了一个探测函数。内核为尚未被设备驱动程序认领的设备调用此函数。一旦设备被认领,不会再就该设备询问其他驱动程序。大多数驱动程序都有大量的代码运行,以使设备做好使用准备。所做的确切事情因驱动程序而异。

要执行的一些典型操作包括:

  1. 启用 PCI 设备。
  2. 请求内存范围和 IO 端口
  3. 设置 DMA 掩码。
  4. 注册驱动程序支持的 ethtool 函数(下面将详细描述)。
  5. 启动看门狗任务(例如,e1000e 有一个看门狗任务来检查硬件是否挂起)。
  6. 其他设备相关的内容,如替代方法或处理硬件特定的状况之类。
  7. 创建、初始化和注册 struct net_device_ops 结构。此结构包含指向打开设备、发送数据到网络、设置 MAC 地址等各种函数的函数指针。
  8. 创建、初始化和注册抽象 struct net_device,表示网络设备。

让我们快速看一下 igb 驱动程序中 igb_probe 函数的一些操作。

PCI 初始化一瞥

下面的 igb_probe 函数代码执行一些基本的 PCI 配置。 来自drivers/net/ethernet/intel/igb/igb_main.c

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_BIT_MASK(64) 调用 dma_set_mask_and_coherent

调用 pci_request_selected_regions 保留内存区域,启用 PCI Express 高级错误报告(如果加载了 PCI AER 驱动程序),调用 pci_set_master 启用 DMA,并调用 pci_save_state 保存 PCI 配置空间。

更多 Linux PCI 驱动程序信息

全面解释 PCI 设备如何工作超出了本文的范围,但这个精彩的演讲这个 wiki这个来自 Linux 内核的文件都是很好的资源。

网络设备初始化

igb_probe 函数执行一些重要的网络设备初始化。除了 PCI 特定的工作外,它还执行更多通用的网络和网络设备工作:

  1. 注册 struct net_device_ops
  2. 注册 ethtool 操作。
  3. 从 NIC 获取默认 MAC 地址。
  4. 设置 net_device 特性标志。
  5. 还有更多。

让我们逐个来看看,它们很有趣。

struct net_device_ops

struct net_device_ops 包含指向许多重要操作的函数指针,网络子系统需要这些操作来控制设备。在本文的其余部分,我们将多次提到这个结构。

net_device_ops 结构被关联到 igb_probe 中的 struct net_device 上。来自 drivers/net/ethernet/intel/igb/igb_main.c

static int igb_probe(struct pci_dev *pdev, const struct pci_device_id *ent)
{
  /* ... */

  netdev->netdev_ops = &igb_netdev_ops;

并且此 net_device_ops 结构保存的指针指向的函数也在同一个文件中设置。 来自 drivers/net/ethernet/intel/igb/igb_main.c

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_openndo_stopndo_start_xmitndo_get_stats64,它们保存了 igb 驱动程序实现的函数地址。

稍后我们将更详细地了解其中的一些内容。

ethtool 注册

ethtool 是一个命令行程序,您可以使用它来获取和设置各种驱动程序和硬件选项。在 Ubuntu 上,您可以运行 apt-get install ethtool 安装它。

ethtool 的一个常见用途是从网络设备收集详细统计信息。其他有趣的 ethtool 设置将在后面描述。

ethtool 程序使用 ioctl 系统调用与设备驱动程序通信。设备驱动程序注册一系列 ethtool 操作的函数,内核负责粘合。

当从 ethtool 发出 ioctl 调用时,内核找到驱动程序注册的 ethtool 结构,并执行已注册的函数。驱动程序的 ethtool 函数实现可以做任何事情,从更改驱动程序中的简单软件标志到向设备写入寄存器值来调整实际 NIC 硬件的工作方式。

igb 驱动程序调用 igb_set_ethtool_opsigb_probe 中注册其 ethtool 操作:

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 结构,其中 igb 驱动程序支持的 ethtool 函数设置为适当的字段。

来自 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 和legacy IRQ。这些将在稍后提及。当数据通过 DMA 写入 RAM 时,设备生成 IRQ 是很简单的,但如果大量数据帧到达,则会生成大量 IRQ。生成的 IRQ 越多,更高级任务(如用户进程)的 CPU 时间就越少。

新 API (NAPI) 被创建为一种减少网络设备在数据包到达时生成的 IRQ 数量的机制。虽然 NAPI 减少了 IRQ 的数量,但不能完全消除它们。

我们将在后面的部分看到为什么会这样。

NAPI

NAPI 与传统的收集数据方法在几个重要方面有所不同。NAPI 允许设备驱动程序注册一个 poll 函数,NAPI 子系统将调用该函数来收集数据帧。

在网络设备驱动程序中,NAPI 的预期用法如下:

  1. 驱动程序启用 NAPI,但最初处于关闭状态。
  2. 数据包到达,并由 NIC 通过 DMA 写入内存。
  3. NIC 生成 IRQ,触发驱动程序中的 IRQ 处理程序。
  4. 驱动程序使用 softirq(稍后将详细介绍)唤醒 NAPI 子系统。开始在单独的执行线程中调用驱动程序注册的 poll 函数来收集数据包。
  5. 驱动程序应禁用来自 NIC 的进一步 IRQ。这样做是为了让 NAPI 子系统在没有设备中断的情况下处理数据包。
  6. 一旦没有更多工作要做,NAPI 子系统被禁用,设备的 IRQ 被重新启用。
  7. 过程从第 2 步重新开始。

与传统方法相比,这种收集数据帧的方法减少了开销,因为可以一次处理多个数据帧,而无需处理每个数据帧一次 IRQ。

设备驱动程序实现一个 poll 函数并调用 netif_napi_add 将其注册到 NAPI。当使用 netif_napi_add 注册 NAPI poll 函数时,驱动程序还将指定 weight。大多数驱动程序硬编码一个为 64 的值。这个值及其含义将在下面更详细地描述。

通常,驱动程序在驱动程序初始化期间注册它们的 NAPI poll 函数。

igb 驱动程序的 NAPI 初始化

igb 驱动程序通过一个长调用链来实现:

  1. igb_probe 调用 igb_sw_init
  2. igb_sw_init 调用 igb_init_interrupt_scheme
  3. igb_init_interrupt_scheme 调用 igb_alloc_q_vectors
  4. igb_alloc_q_vectors 调用 igb_alloc_q_vector
  5. igb_alloc_q_vector 调用 netif_napi_add

该调用跟踪发生了一些高级的事情:

  1. 如果支持 MSI-X,则调用 pci_enable_msix 启用它。
  2. 计算并初始化各种设置;最值得注意的是设备和驱动程序发送和接收数据包的传输和接收队列的数量。
  3. 为每个创建的传输和接收队列调用一次 igb_alloc_q_vector
  4. 每次调用 igb_alloc_q_vector 都会调用 netif_napi_add 为该队列注册一个 poll 函数,当调用以收集数据包时,将传递一个 struct napi_struct 实例给 poll

让我们看一下 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);

  /* ... */

上面的代码为接收队列分配内存,并注册函数 igb_poll 到 NAPI 子系统。它提供了一个指向与此新创建的接收队列关联的 struct napi_struct 的引用(上面的 &q_vector->napi)。当 NAPI 子系统调用它来从此接收队列收集数据包时,将传递给 igb_poll

当我们探讨数据从驱动程序到网络栈的流动时,这一点很重要。

启动网络设备

我们之前看到的 net_device_ops 结构体注册了一组函数,用于启动网络设备、传输数据包、设置 MAC 地址等。

当网络设备启动时(例如,使用 ifconfig eth0 up),net_device_ops 结构体中的 ndo_open 字段所关联的函数会被调用。

ndo_open 函数通常会执行以下操作:

  1. 分配接收队列 和传输队列内存
  2. 启用 NAPI
  3. 注册中断处理程序
  4. 启用硬件中断
  5. 等等。

在 igb 驱动程序的情况下,net_device_ops 结构体中 ndo_open 字段所关联的函数被称为 igb_open

准备从网络接收数据

现在大多数网卡都使用 DMA 直接将数据写入 RAM,操作系统可以从中获取数据进行处理。大多数网卡用于此目的的数据结构类似于基于循环缓冲区(或环形缓冲区)构建的队列。

为了做到这一点,设备驱动程序必须与操作系统协作,保留一块网卡硬件可以使用的内存区域。一旦保留了这个区域,就会告知硬件其位置,传入的数据将被写入 RAM,在 RAM 中稍后将被网络子系统拾取并处理。

这看起来很简单,但如果数据包速率足够高,以至于单个 CPU 无法正确处理所有传入的数据包呢?数据结构建立在固定长度的内存区域上,因此传入的数据包将被丢弃。

这就是 接收端扩展 (RSS) 或多队列能够改善的点。

有些设备能够同时将传入的数据包写入几个不同的 RAM 区域;每个区域都是一个单独的队列。这允许操作系统从硬件层面开始,使用多个 CPU 并行处理传入的数据。并非所有网卡都支持此功能。

Intel I350 网卡支持多队列。我们可以在 igb 驱动程序中看到这一点。当 igb 驱动程序启动时,它首先调用一个名为 igb_setup_all_rx_resources 的函数。这个函数为每个 接收队列调用另一个函数 igb_setup_rx_resources,以安排设备将传入数据写入 DMA支持内存。

如果您想了解这是如何工作的,请参阅 Linux 内核的 DMA API HOWTO

事实证明,可以使用 ethtool 调整接收队列的数量和大小。调整这些值会对处理的帧数与丢弃的帧数产生明显影响。

网卡使用数据包头字段(如源、目的地、端口等)上的哈希函数来确定数据应该发送到哪个接收队列。

有些网卡允许您调整接收队列的权重,因此您可以向特定队列发送更多流量。

少部分网卡允许您调整哈希函数本身。如果您可以调整哈希函数,您可以发送某些流量到特定的接收队列进行处理,甚至在硬件层面丢弃数据包(如果需要)。

我们稍后将看看如何调整这些设置。

启用NAPI

当网络设备启动时,驱动程序通常会启用 NAPI

我们之前看到驱动程序如何向 NAPI 注册 poll 函数,但 NAPI 通常不会在设备启动之前启用。

启用 NAPI 相对简单。调用 napi_enable 翻转 struct napi_struct 的一个位,以指示它现在已启用。如上所述,虽然 NAPI 被启用,但它将处于关闭状态。

在 igb 驱动程序的情况下,当驱动程序加载或使用 ethtool 更改队列计数或大小时,会为每个已初始化的 q_vector 启用 NAPI。

来自 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 和 legacy interrupt。因此,代码因设备而异,具体取决于特定硬件支持的中断方法。

驱动程序必须确定设备支持哪种方法,并注册适当的处理程序函数,在接收到中断时执行。

有些驱动程序,如 igb 驱动程序,会尝试使用每种方法注册中断处理程序,在失败时回退到下一个未测试的方法。

MSI-X 中断是首选方法,特别是对于支持多个接收队列的网卡。这是因为每个接收队列都可以分配自己的硬件中断,然后由特定的 CPU 处理(使用 irqbalance 或修改 /proc/irq/IRQ_NUMBER/smp_affinity)。我们稍后将看到,处理中断的 CPU 将是处理数据包的 CPU。通过这种方式,从硬件中断层次到网络栈,到达的数据包可以由单独的 CPU 处理。

如果 MSI-X 不可用,MSI 仍然比 legacy interrupt 具有优势。如果设备支持它,驱动程序将使用它。阅读 这个有用的维基页面 了解更多关于 MSI 和 MSI-X 的信息。

igb 驱动程序中,函数 igb_msix_ringigb_intr_msiigb_intr 分别是 MSI-X、MSI 和 legacy interrupt 模式的中断处理程序方法。

您可以在驱动程序中找到尝试每种中断方法的代码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;
}

如上面的简略代码所示,驱动程序首先尝试使用 igb_request_msix 设置 MSI-X 中断处理程序,在失败时回退到 MSI。接下来,使用 request_irq 注册 igb_intr_msi,即 MSI 中断处理程序。如果这失败了,驱动程序将回退到传统中断。再次使用 request_irq 注册 legacy interrupt 处理程序 igb_intr

这就是 igb 驱动程序如何注册一个函数,在网卡引发中断信号表明数据已到达并准备好处理时执行。

启用中断

此时,几乎所有东西都已设置好。剩下的只是启用网卡的中断并等待数据到达。启用中断是硬件特定的,但 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 使用

您可以运行 sudo apt-get install ethtool 在 Ubuntu 系统上安装 ethtool

安装完成后,您可以传递 -S 标志以及您想要获取统计信息的网络设备名称来访问统计信息。

使用 ethtool -S 监控详细的网卡设备统计信息(例如,数据包丢弃)。

$ 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 也提供了许多统计值,但它们的层级比直接提供的网卡级别统计值略高一些。

您可以使用 cat 在文件上查找丢弃的传入网络数据帧的数量,例如 eth0。

使用 sysfs 监控更高层级的网卡统计信息。

$ cat /sys/class/net/eth0/statistics/rx_dropped
2

计数器值分为 collisionsrx_droppedrx_errorsrx_missed_errors 等文件。

不幸的是,由驱动程序决定每个字段的含义,何时增加它们以及值来自哪里。您可能会注意到,有些驱动程序将某种类型的错误条件计为丢弃,但其他驱动程序可能将其计为错过。

如果这些值对您至关重要,您需要阅读驱动程序源代码,以准确了解您的驱动程序认为每个值的含义。

/proc/net/dev 使用

一个更高层级的文件是 /proc/net/dev,它为系统上的每个网络适配器提供高层级概要信息。

读取 /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 文件中找到的值的子集,但它可能作为一个有用的一般参考。

上面提到的警告在这里也适用:如果这些值对您很重要,您仍然需要阅读驱动程序源代码,以准确了解何时、何地以及为什么它们会增加,以确保您对 error、drop 或 fifo 的理解与你的驱动程序相同。

调优网络设备

检查正在使用的接收队列数量

如果您的网卡和系统上加载的设备驱动程序支持 RSS / 多队列,您通常可以使用 ethtool 调整接收队列(也称为接收通道)的数量。

使用 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 的 get_channels 操作。这可能是因为网卡不支持调整队列数量,不支持 RSS / 多队列,或者您的驱动程序尚未更新以处理此功能。

调整接收队列的数量

一旦您找到了当前和最大队列数,您可以使用 sudo ethtool -L 调整这些值。

注意: 一些设备及其驱动程序仅支持组合队列,用于传输和接收,如上一节中的示例。

使用 ethtool -L 设置组合网卡传输和接收队列为 8。

$ sudo ethtool -L eth0 combined 8

如果您的设备和驱动程序支持单独设置接收队列和传输队列,并且您只想更改接收队列数为 8,则可以运行:

使用 ethtool -L 设置 NIC 接收队列数为 8。

$ sudo ethtool -L eth0 rx 8

注意: 对于大多数驱动程序,这些更改将使接口下线,然后重新启动;与此接口的连接将中断。对于一次性更改,这可能并不重要。

调整接收队列的大小

一些网卡及其驱动程序也支持调整接收队列的大小。具体如何操作取决于硬件,但幸运的是,ethtool 为用户提供了一种通用的调整大小的方法。在接收大量数据帧的时期,增加接收队列的大小可以帮助防止网卡丢失网络数据。不过,数据仍然可能在软件中丢失,并且需要其他调整来减少或完全消除丢失。

使用 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 个。

使用 ethtool -G 增加每个接收队列的大小到 4096。

$ sudo ethtool -G eth0 rx 4096

注意: 对于大多数驱动程序,这些更改将使接口下线,然后重新启动;与此接口的连接将中断。对于一次性更改,这可能并不重要。

调整接收队列的处理权重

一些网卡支持设置权重来调整网络数据在接收队列之间的分配。

如果满足以下条件,您可以进行配置:

  • 网卡支持流量引导。
  • 驱动程序实现了 ethtool 函数 get_rxfh_indir_sizeget_rxfh_indir
  • 运行的 ethtool 版本足够新,支持命令行选项 -x-X 分别显示和设置引导表。

使用 ethtool -x 检查 RX 流量引导表。

$ 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。

示例:在前两个接收队列之间均匀扩散处理

$ sudo ethtool -X eth0 equal 2

如果你想设置自定义权重来改变命中特定接收队列(以及CPU)的数据包数量,你也可以在命令行中指定这些权重:

使用 ethtool -X 设置自定义收队队列权重

$ sudo ethtool -X eth0 weight 6 2

上述命令指定接收队列 0 的权重为 6,接收队列 1 权重为 2,使得推送更多的数据到队列 0 处理。

一些网卡还允许您调整哈希算法中使用的字段,我们接下来会看到。

调整网络流的接收哈希字段

您可以使用 ethtool 来调整计算 RSS 时使用的哈希字段。

使用 ethtool -n 检查 UDP 接收流哈希所用的字段。

$ 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 源地址和目标地址。让我们添加源端口和目标端口:

使用 ethtool -N 设置 UDP 接收流哈希字段。

$ sudo ethtool -N eth0 rx-flow-hash udp4 sdfn

sdfn 字符串有点神秘;请查看 ethtool 手册页获取每个字母的解释。

调整哈希的字段很有用,但是,对于更精细地控制哪些流将由哪个接收队列处理, ntuple 过滤更有用。

ntuple 过滤引导网络流

一些网卡支持一种称为 “ntuple 过滤” 的功能。此功能允许用户通过 ethtool 指定一组参数,在硬件中过滤传入的网络数据并将其排队到特定的接收队列。例如,用户可以指定目标为特定端口的 TCP 数据包应发送到接收队列 1。

在英特尔网卡上,此功能通常称为 Intel Ethernet Flow Director。其他网卡供应商可能为此功能提供其他营销名称。

正如我们稍后将看到的,ntuple 过滤是另一种称为加速接收流引导 (aRFS) 的功能的关键组成部分,如果您的网卡支持它,则使用 ntuple 更容易。aRFS 将在后面介绍。

如果系统的运行要求最大化数据局部性,以期在处理网络数据时提高 CPU 缓存命中率,那么此功能可能很有用。例如,考虑在端口 80 上运行的 Web 服务器的以下配置:

  • 在端口 80 上运行的 Web 服务器被固定在 CPU 2 上运行。
  • 接收队列的 IRQ 被分配给 CPU 2 处理。
  • 目标为端口 80 的 TCP 流量使用 ntuple 过滤到 CPU 2。
  • 所有传入端口 80 的流量都由 CPU 2 处理,从数据到达用户程序开始。
  • 需要仔细监控系统,包括缓存命中率和网络堆栈延迟,以确定有效性。

如前所述,可以使用 ethtool 配置 ntuple 过滤,但首先,您需要确保在您的设备上启用了此功能。

使用 ethtool -k 检查是否启用了 ntuple 过滤。

$ sudo ethtool -k eth0
Offload parameters for eth0:
...
ntuple-filters: off
receive-hashing: on

正如所见,在这个设备上 ntuple-filters 被禁用。

使用 ethtool -K 启用 ntuple 过滤

$ sudo ethtool -K eth0 ntuple on

一旦你启用了 ntuple 过滤,或者验证它已经启用,你可以使用 ethtool 检查现有的ntuple 规则:

使用 ethtool -u 检查现有的 ntuple 过滤

$ sudo ethtool -u eth0
40 RX rings available
Total 0 rules

如您所见,此设备没有 ntuple 过滤规则。您可以在 ethtool 命令行上指定它来添加规则。让我们添加一个规则,定向目标端口为 80 的所有 TCP 流量到接收队列 2:

添加 ntuple 过滤器,发送目标端口为 80 的 TCP 流量到接收队列 2。

$ sudo ethtool -U eth0 flow-type tcp4 dst-port 80 action 2

您还可以使用 ntuple 过滤在硬件级别丢弃特定流的数据包。这对于缓解来自特定 IP 地址的大量传入流量很有用。有关配置 ntuple 过滤规则的更多信息,请参阅 ethtool 手册页。

您通常可以检查 ethtool -S [设备名称] 输出的值来获取有关 ntuple 规则成功(或失败)的统计信息。例如,在英特尔网卡上,统计信息 fdir_matchfdir_miss 计算您的 ntuple 过滤规则的匹配和未命中次数。请查阅您的设备驱动程序源代码和设备数据表以追查统计计数器(如果可用)。

软中断

在研究网络栈之前,我们需要稍微了解一下 Linux 内核名为软中断的东西。

什么是软中断?

Linux 内核中的软中断系统是一种在驱动程序中实现的中断处理程序上下文之外执行代码的机制。这个系统很重要,因为在中断处理程序的全部或部分执行期间,硬件中断可能被禁用。中断被禁用的时间越长,错过事件的机会就越大。因此,推迟任何长时间运行的操作到中断处理程序之外是很重要的,以便它能尽快完成并重新启用来自设备的中断。

内核中还有其他机制推迟工作,但对于网络栈,我们将探讨 softirq。

可以将 softirq 系统想象为一系列内核线程(每个 CPU 一个),它们运行已为不同 softirq 事件注册的处理程序函数。如果您曾经查看过 top 并在内核线程列表中看到 ksoftirqd/0,那么您正在查看在 CPU 0 上运行的 softirq 内核线程。

内核子系统(如网络)可以执行 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_runrun_ksoftirqd

作为类似于事件循环的一部分,这两个函数都是从 kernel/smpboot.c 中调用的。

kernel/smpboot.c 中的代码首先调用 ksoftirqd_should_run,确定是否有未决的软中断,如果有未决的软中断,则执行 run_ksoftirqdrun_ksoftirqd 在调用 __do_softirq 之前进行了一些小的簿记工作。

__do_softirq

__do_softirq 函数做了一些有趣的事情:

  • 确定哪个软中断处于未决状态
  • 出于统计目的,记录软中断时间
  • 增加软中断执行统计
  • 执行未决软中断的软中断处理程序(已调用 open_softirq 注册)。

因此,当您查看 CPU 使用率图表并看到 softirqsi 时,您现在知道这是在测量延迟工作上下文中的 CPU 使用量。

监控

/proc/softirqs

softirq 系统增加统计计数器,可以从 /proc/softirqs 读取。监控这些统计数据可以让您了解各种事件的软中断产生的速率。

读取 /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 大。这是一个指示器,表明您可能会从下面描述的 Receive Packet Steering / Receive Flow Steering 中受益。在监控性能时要小心使用这个文件:在网络活动高峰期,您可能会期望看到 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_SOFTIRQ 软中断的 net_rx_action 函数。

数据到达

终于,网络数据到达了!

假设接收队列有足够的可用描述符,数据包将通过 DMA 写入 RAM。然后设备引发分配给它的中断(或者在 MSI-X 的情况下,与数据包到达的接收队列绑定的中断)。

中断处理程序

通常,当中断被引发时,运行的中断处理程序应该尽量推迟尽可能多的处理到中断上下文之外发生。这至关重要,因为在处理中断时,其他中断可能会被阻塞。

让我们看一下 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 的速度。我们很快就会看到 ethtool 如何提供一种调整 IRQ 触发速率的机制。

其次,调用 napi_schedule,如果 NAPI 处理循环尚未激活,则唤醒它。请注意,NAPI 处理循环在软中断中执行;NAPI 处理循环不从中断处理程序执行。中断处理程序只是使其开始执行(如果尚未执行)。

显示如何工作的实际代码非常重要;它将指导我们了解如何在多 CPU 系统上处理网络数据。

NAPI 和 napi_schedule

让我们弄清楚硬件中断处理程序中的 napi_schedule 调用是如何工作的。

请记住,NAPI 的存在是为了在不需要来自 NIC 的中断来信号数据准备好处理的情况下收集网络数据。如前所述,NAPI poll 循环是接收硬件中断引导的。换句话说:NAPI 已启用,但关闭,直到第一个数据包到达时,NIC 引发硬件中断并启动 NAPI。正如我们很快就会看到的那样,还有一些其他情况,其中 NAPI 可能被禁用,并且需要引发硬件中断才能再次启动。

当驱动程序中的中断处理程序调用 napi_schedule 时,将启动 NAPI 轮询循环。napi_schedule 实际上只是一个在头文件中定义的包装函数,它调用 __napi_schedule

来自 net/core/dev.c

/**
 * __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);

这段代码使用 __get_cpu_var 获取注册到当前 CPU 的 softnet_data 结构。这个 softnet_data 结构和从驱动程序传递上来的 struct napi_struct 结构被传递到 ____napi_schedule。哇,这是很多下划线 ;)

让我们看一下 ____napi_schedule,来自 net/core/dev.c

/* 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);
}

这段代码做了两件重要的事情:

  1. 从设备驱动程序的中断处理程序代码传递上来的 struct napi_struct,被添加到与当前 CPU 关联的 softnet_data 结构的 poll_list 中。
  2. 使用 __raise_softirq_irqoff 来“引发”(或触发)NET_RX_SOFTIRQ 软中断。将执行(如果当前未执行)在网络设备子系统初始化期间注册的 net_rx_action

正如我们很快就会看到的那样,软中断处理函数 net_rx_action 将调用 NAPI poll 函数来收集数据包。

关于 CPU 和网络数据处理的说明

请注意,我们迄今为止看到的所有推迟硬件中断处理程序中的工作到 softirq 的代码,都使用了与当前 CPU 相关联的结构。

虽然驱动程序的硬中断处理程序本身所做的工作非常少,但软中断处理程序在与驱动程序的硬中断处理程序相同的 CPU 上执行。

这就是为什么设置硬中断处理的 CPU 处理很重要:该 CPU 不仅执行驱动程序中的中断处理程序,而且在 NAPI 以软中断方式收集数据包时也将使用相同的 CPU。

正如我们稍后将看到的,像 Receive Packet Steering 的功能可以将一些工作分配给网络栈更高层级的其他 CPU。

监控网络数据到达
硬件中断请求

注意: 监视硬件中断并不能全面了解数据包处理的健康状况。 许多驱动程序在 NAPI 运行时关闭硬件中断,我们将在后面看到。 它是整个监控解决方案的重要组成部分。

读取 /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

您可以监控 /proc/interrupts 中的统计信息,以查看随着数据包到达而硬件中断的数量和速率如何变化,并确保您的 NIC 的每个接收队列都由适当的 CPU 处理。正如我们不久将看到的,这个数字只告诉我们发生了多少次硬件中断,但它并不一定是了解接收或处理了多少数据的好指标,因为许多驱动程序会作为与 NAPI 子系统协作的一部分禁用 NIC 硬中断。此外,使用中断合并也会影响从此文件收集的统计信息。监控此文件可以帮助您确定所选的中断合并设置是否真正起作用。

要获得更完整的网络处理健康状况图像,您需要监控 /proc/softirqs(如上所述)以及我们将在下面介绍的 /proc 中的其他文件。

调优网络数据到达
中断合并

中断合并 是一种防止设备向 CPU 发出中断的方法,直到有特定数量的工作或事件处于挂起状态。

这可以帮助防止中断风暴,并可以根据使用的设置帮助提高吞吐量或延迟。产生的中断较少会导致吞吐量更高,延迟增加,CPU 使用率降低。产生的中断较多会导致相反的结果:延迟降低,吞吐量降低,但 CPU 使用率增加。

历史上,早期版本的 igbe1000 和其他驱动程序都包含对名为 InterruptThrottleRate 的参数的支持。在较新的驱动程序中,此参数已替换为通用的 ethtool 函数。

使用 ethtool -c 获取当前的 IRQ 合并设置。

$ 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 文档:“驱动程序未实现的任何内容都会导致这些值被静默忽略。”

一些驱动程序支持的一个有趣选项是“自适应接收/传输硬中断合并”。此选项通常在硬件中实现。驱动程序通常需要做一些工作来通知 NIC 启用了此功能,并进行一些簿记(如上面的 igb 驱动程序代码所示)。

启用自适应接收/传输硬中断合并的结果是,在数据包速率低时调整中断传递以改善延迟,并在数据包速率高时提高吞吐量。

使用 ethtool -C 启用自适应接收硬中断合并。

$ sudo ethtool -C eth0 adaptive-rx on

你可以使用 ethtool -C 来设置多个选项。一些常见的选项包括:

  • rx-usecs:在数据包到达后,延迟多少微秒才触发接收中断。
  • rx-frames:在触发接收中断之前,最多接收多少个数据帧。
  • rx-usecs-irq:当主机正在处理中断时,延迟多少微秒才触发接收中断。
  • rx-frames-irq:当系统正在处理中断时,在触发接收中断之前,最多接收多少个数据帧。

还有更多选项。

提醒,你的硬件和驱动程序可能只支持上述选项的一个子集。你应该查阅驱动程序源代码和硬件数据表,以获取有关支持的合并选项的更多信息。

不幸的是,除了头文件之外,你可以设置的选项并没有在其他地方得到很好的记录。查看 include/uapi/linux/ethtool.h 的源代码,以找到 ethtool 支持的每个选项的解释(但不一定是你的驱动程序和 NIC)。

注意:虽然中断合并看起来是一个非常有用的优化,但在尝试优化时,网络栈的其他内部也会受到影响。在某些情况下,中断合并可能很有用,但你应该确保网络栈的其他部分也调整得当。仅仅修改合并设置本身可能带来的好处微不足道。

调整 IRQ 亲和性

如果你的网卡支持 RSS/多队列,或者你想优化数据本地性,你可能希望使用特定的 CPU 来处理网卡产生的中断。

设置特定的 CPU 可以让你划分哪些 CPU 处理哪些 IRQ。这些更改可能会影响上层操作,正如在网络栈中看到的那样。

如果你决定调整 IRQ 亲和性,你应该首先检查是否运行了 irqbalance 守护程序。这个守护程序试图自动平衡 IRQ 到 CPU 上,它可能会覆盖你的设置。如果你正在运行 irqbalance,你应该禁用 irqbalance 或使用 --banirqIRQBALANCE_BANNED_CPUS 结合使用,让 irqbalance 知道它不应该触碰你想要自己分配的 IRQ 和 CPU 集合。

接下来,你应该检查文件 /proc/interrupts,查看网卡每个网络 RX 队列的 IRQ 编号列表。

最后,你可以修改每个 IRQ 编号的 /proc/irq/IRQ_NUMBER/smp_affinity 来调整每个 IRQ 将由哪些 CPU 处理。

你只需写入十六进制位掩码到此文件,以指示内核应使用哪些 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 函数所能消耗的工作量和执行时间。它通过两种方式实现这一点:

  1. 跟踪工作 budget(可以调整),以及
  2. 检查运行时间

来自 net/core/dev.c

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 的方法。上面提到的 budget 是在这个 CPU 上注册的所有可用 NAPI 结构花费的总预算。

这也是多队列网卡应该仔细调整 IRQ 亲和性的另一个原因。回想一下,处理设备的 IRQ 的 CPU 将是执行软中断处理程序的 CPU,因此也将是上述循环和预算计算运行的 CPU。

具有多个网卡,每个网卡都有多个队列的系统可能会出现多个 NAPI 结构注册到同一个 CPU 的情况。同一 CPU 上所有 NAPI 结构的数据处理都从同一个 budget 中扣减。

如果您没有足够的 CPU 来分布您的网卡的 IRQ,您可以考虑增加 net_rx_actionbudget,以允许每个 CPU 处理更多的数据包。增加预算将增加 CPU 使用率(具体来说是 sitimetop 或其他程序中的 si),但减少延迟,因为数据处理更及时。

注意: 无论如何分配预算,CPU 仍然受到 2 个 jiffies 的时间限制。

NAPI poll 函数和 权重

回想一下,网络设备驱动程序使用 netif_napi_add 来注册 poll 函数。正如我们在本文前面看到的那样,igb 驱动程序有一段类似这样的代码:

/* initialize NAPI */
netif_napi_add(adapter->netdev, &q_vector->napi, igb_poll, 64);

这行代码注册了具有硬编码权重 64 的 NAPI 结构。 现在我们将看到如何在 net_rx_action 处理循环中使用它。

来自 net/core/dev.c

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;

这段代码获取了注册到 NAPI 结构的权重(上面驱动程序代码中的 64)并传递其给也注册到 NAPI 结构的 poll 函数(上面代码中的 igb_poll)。

poll 函数返回处理的数据帧数。这个数量被保存为上面的 work,然后从总 budget 中扣减。

因此,假设:

  1. 您使用来自驱动程序的权重 64(在 Linux 3.13.0 中,所有驱动程序都使用这个值硬编码),并且
  2. 您设置 budget 为默认值 300

当满足以下任一条件时,您的系统将停止处理数据:

  1. 最多调用了 5 次 igb_poll 函数(如果没有数据要处理,我们接下来会看到次数更少),或者
  2. 至少经过了 2 个 jiffies 的时间。
NAPI / 网络设备驱动程序契约

关于 NAPI 子系统和设备驱动程序之间的契约,有一个重要的信息尚未提及,那就是关闭 NAPI 的要求。

这部分契约如下:

  • 如果驱动程序的 poll 函数消耗了其全部权重(硬编码为 64),则它不得修改 NAPI 状态。net_rx_action 循环将接管。
  • 如果驱动程序的 poll 函数消耗其全部权重,则必须禁用 NAPI。下次收到 IRQ 并且驱动程序的 IRQ 处理程序调用 napi_schedule 时,NAPI 将重新启用。

我们现在将看到 net_rx_action 如何处理该契约的第一部分。接下来,我们检查 poll 函数,我们将看到如何处理该契约的第二部分。

完成 net_rx_action 循环

net_rx_action 处理循环以最后一段代码结束,该代码处理前一节中解释的 NAPI 合约的第一部分。 来自 net/core/dev.c

/* 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 会处理两种情况:

  1. 网络设备应该关闭(例如,因为用户运行了 ifconfig eth0 down),
  2. 如果设备未关闭,请检查是否存在 generic receive offload(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;

如果跟随 softnet_break 标签,你会偶然发现一些有趣的东西。 来自 net/core/dev.c

softnet_break:
  sd->time_squeeze++;
  __raise_softirq_irqoff(NET_RX_SOFTIRQ);
  goto out;

struct softnet_data 结构会增加一些统计数据,然后关闭 softirq NET_RX_SOFTIRQtime_squeeze 字段是衡量 net_rx_action 有更多工作要做,但预算耗尽或时间限制到达之前无法完成的次数。这是一个极其有用的计数器,理解网络处理中的瓶颈。我们稍后将看到如何监控这个值。禁用 NET_RX_SOFTIRQ 以释放处理时间给其他任务。这是有道理的,因为这段小代码只在有更多工作可以完成时执行,但我们不希望垄断 CPU。

然后执行转移到 out 标签。如果没有更多的 NAPI 结构要处理,执行也可以到达 out 标签,换句话说,预算比网络活动多,所有驱动程序都关闭了 NAPI,而且 net_rx_action 没有剩下任何事情要做。

在从 net_rx_action 返回之前,out 部分做了一件重要的事情:它调用了 net_rps_action_and_irq_enable。如果启用了 Receive Packet Steering,此函数具有重要作用;它唤醒远程 CPU 开始处理网络数据。

我们稍后将了解更多关于 RPS 的工作原理。现在,让我们看看如何监控 net_rx_action 处理循环的健康状况,并继续深入了解 NAPI poll 函数的内部工作原理,以便我们能够沿着网络栈向上。

NAPI poll

在前面的章节中,我们提到设备驱动程序会为设备分配一块内存区域,用于对传入数据包进行 DMA。正如驱动程序负责分配这些区域一样,它也负责取消映射这些区域,收集数据并发送其到网络栈。

让我们看看 igb 驱动程序是如何做到这一点的,以便了解这在实践中是如何运作的。

igb_poll

最后,我们终于可以探讨我们的朋友 igb_polligb_poll 看起来很简单。 我们来看看。 来自 drivers/net/ethernet/intel/igb/igb_main.c

/**
 *  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 缓存,以便对接收环的访问能够命中 CPU 缓存。您可以在本博客文章末尾的额外部分中了解更多关于 DCA 的信息。
  • 接下来,调用 igb_clean_rx_irq 进行繁重的工作,我们接下来会看到。
  • 然后,检查 clean_complete 以确定是否还有更多的工作可以完成。如果是这样,返回 budget(记住,这是硬编码为 64 的)。正如我们前面看到的,net_rx_action 会移动此 NAPI 结构到轮询列表的末尾。
  • 否则,驱动程序调用 napi_complete 关闭 NAPI,并调用 igb_ring_irq_enable 重新启用中断。下一个到达的中断将重新启用 NAPI。

让我们看看 igb_clean_rx_irq 如何发送网络数据到栈。

igb_clean_rx_irq

igb_clean_rx_irq 函数是一个循环,每次处理一个数据包,直到用尽 budget 或没有更多数据需要处理为止。

这个函数中的循环做了一些重要的事情:

  1. 在清理使用过的缓冲区时,为接收数据分配额外的缓冲区。每次添加 IGB_RX_BUFFER_WRITE(16)个额外的缓冲区。
  2. 从接收队列中获取一个缓冲区并存储在 skb 结构中。
  3. 检查缓冲区是否为“数据包结束”缓冲区。如果是,则继续处理。否则,继续从接收队列中获取额外的缓冲区,并添加到 skb 中。如果接收到的数据帧大于缓冲区大小,则需要这样做。
  4. 验证数据的布局和头部是否正确。
  5. 已处理字节数统计计数器增加 skb->len
  6. 设置 skb 的哈希、校验和、时间戳、VLAN id 和协议字段。哈希、校验和、时间戳和 VLAN id 由硬件提供。如果硬件发出校验和错误信号,则增加 csum_error 统计量。如果校验和成功且数据为 UDP 或 TCP 数据,则标记 skbCHECKSUM_UNNECESSARY。如果校验和失败,则协议栈负责处理此数据包。协议调用 eth_type_trans 计算并存储在 skb 结构中。
  7. 调用 napi_gro_receive 传递构建的 skb 到网络栈。
  8. 增加已处理数据包数量统计计数器。
  9. 循环继续,直到处理的数据包数量达到预算。

循环结束后,函数为接收数据包和已处理字节数分配统计计数器。

在继续上行网络栈之前,现在是时候先兵分两路了。首先,让我们看看如何监控和调整网络子系统的 softirqs。接下来,让我们谈谈通用接收卸载 (GRO)。之后,当我们进入 napi_gro_receive 时,网络栈的其余部分将更有意义。

监控网数据处理
/proc/net/softnet_stat

如前一节所述,在退出 net_rx_action 循环并且可以完成更多工作,但 softirq 的 budget 或时间限制被触发时,net_rx_action 会增加一个统计量。这个统计量作为与 CPU 关联的 struct softnet_data 的一部分进行跟踪。

这些统计数据输出到 proc 中的一个文件:/proc/net/softnet_stat,不幸的是,关于这个文件的文档非常少。proc 文件中的字段没有标记,并且可能在内核版本之间发生变化。

在 Linux 3.13.0 中,您可以阅读内核源代码来查找哪些值映射到 /proc/net/softnet_stat 中的哪个字段。从 net/core/net-procfs.c

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);

这些统计数据中包含许多令人困惑的名称,并且在您可能未预期的地方增加。在探讨网络栈时,将提供每个统计数据何时以及在哪里增加的解释。由于在 net_rx_action 中看到了 squeeze_time 统计量,我认为现在记录这个文件是有意义的。

读取 /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 的重要细节:

  • 每一行 /proc/net/softnet_stat 对应一个 struct softnet_data 结构,每个 CPU 都有一个。
  • 值之间用一个空格分隔,并以十六进制显示
  • 第一个值,sd->processed,是处理的网络帧数。如果您使用以太网绑定,这可能会超过接收到的网络帧总数。有些情况下,以太网绑定驱动程序会触发网络数据重新处理,同一数据包将使 sd->processed 计数增加不止一次。
  • 第二个值,sd->dropped,是因处理队列没有空间而丢弃的网络帧数。稍后再谈。
  • 第三个值,sd->time_squeeze,(如我们所见)是 net_rx_action 循环因消耗预算或达到时间限制而终止的次数,但仍然可以完成更多工作。如前所述,增加 budget 可以帮助减少这种情况。
  • 接下来的 5 个值始终为 0。
  • 第九个值,sd->cpu_collision,是在发送数据包尝试获取设备锁时发生冲突的次数。本文讨论的是接收,因此下面不会看到这个统计量。
  • 第十个值,sd->received_rps,是唤醒此 CPU 通过 处理器间中断 处理数据包的次数。
  • 最后一个值,flow_limit_count,是达到流量限制的次数。流量限制是可选的 Receive Packet Steering 功能,稍后会探讨到该特性。

如果您决定监控此文件并绘制结果图表,则必须非常小心这些字段的顺序是否发生了变化,并且每个字段的含义是否得到了保留。您需要阅读内核源代码来验证这一点。

调整网络数据处理
调整 net_rx_action 预算

您可以调整 net_rx_action 预算,设置名为 net.core.netdev_budget 的 sysctl 值来确定注册到 CPU 的所有 NAPI 结构数据包处理可以消耗多少。

示例:设置总体数据包处理预算为 600。

$ sudo sysctl -w net.core.netdev_budget=600

您可能还希望写入此设置到 /etc/sysctl.conf 文件,以便在重启前后保持更改。

Linux 3.13.0上的默认值是 300。

Generic Receive Offloading (GRO)

Generic Receive Offloading (GRO) 是 Large Receive Offloading (LRO) 硬件优化的软件实现。

这两种方法的主要思想是,将“足够相似”的数据包组合在一起,减少传递到网络栈的数据包数量,从而减少 CPU 使用率。例如,想象一种情况,正在进行大文件传输,大多数数据包都包含文件中的数据块。与其一次发送一个小数据包到栈,不如将传入的数据包组合成一个具有巨大有效负载的数据包。然后传递该数据包到栈。这样可以让协议层处理单个数据包的头部,同时传递更大的数据块给用户程序。

当然,这种优化的问题是信息丢失。如果一个数据包设置了某些重要选项或标志,则如果该数据包与另一个数据包合并,则该选项或标志可能会丢失。这正是为什么大多数人不使用或鼓励使用 LRO 的原因。一般来说,对于合并数据包,LRO 实现的规则非常宽松。

GRO 作为 LRO 的软件实现被引入,但对于哪些数据包可以合并有更严格的规则。

顺便说一句:如果您曾经使用过 tcpdump 并看到过不切实际的大型传入数据包大小,那么很可能是因为您的系统启用了 GRO。正如您很快就会看到的那样,在 GRO 已经发生之后,数据包抓取被插入栈中。

调优:使用 ethtool 调整 GRO 设置

您可以使用 ethtool 检查是否启用了 GRO,也可以调整设置。

使用 ethtool -k 检查您的 GRO 设置。

$ ethtool -k eth0 | grep generic-receive-offload
generic-receive-offload: on

如您所见,在这个系统上,我设置 generic-receive-offload 为启用。

使用 ethtool -K 启用(或禁用)GRO。

$ sudo ethtool -K eth0 gro on

注意: 对于大多数驱动程序来说,进行这些更改将使接口关闭,然后再将其重新打开;到该接口的连接将被中断。 不过,这对于一次性的改变来说可能并不重要。

napi_gro_receive

函数 napi_gro_receive 处理 GRO 的网络数据(如果系统启用了GRO),并向上发送数据到协议层。 这个逻辑的大部分是在一个名为 dev_gro_receive 的函数中。

dev_gro_receive

这个函数首先检查是否启用了 GRO,如果是,则准备进行 GRO。在启用 GRO 的情况下,遍历 GRO 卸载过滤器列表,以便高层协议栈对正在考虑进行 GRO 的数据进行操作。这样做是为了使得协议层让网络设备层知道此数据包是否属于当前正在接收卸载的 网络流,并处理应该为 GRO 发生的任何协议相关内容。例如,TCP 协议需要决定是否/何时对正在合并到现有数据包中的数据包进行 ACK。

下面是来自 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 流少 于MAX_GRO_SKBS(8),则会向该CPU的NAPI结构上的 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

一旦 dev_gro_receive 执行完毕,就会调用 napi_skb_finish,它要么释放不需要的数据结构(因为数据包已经被合并),要么调用 netif_receive_skb 向上传递数据到网络栈(因为已经有 MAX_GRO_SKBS 流被 GRO)。

接下来,是时候让 netif_receive_skb 看看数据是如何传递到协议层的了。 在对此进行探讨之前,我们首先需要了解一下 Receive Packet Steering (RPS)。

Receive Packet Steering (RPS)

回想一下我们之前讨论的网络设备驱动程序注册 NAPI poll 函数的过程。 每个 NAPI 轮询器实例在软中断的上下文中执行,每个 CPU 有一个软中断。 进一步回想一下,驱动程序的 IRQ 处理程序运行的 CPU 将唤醒其 softirq 处理循环来处理数据包。

换句话说:单个 CPU 处理硬件中断并轮询数据包以处理输入数据。

某些 NIC(如Intel I350)在硬件级别支持多个队列。 这意味着传入的数据包可以被 DMA 到每个队列的单独的内存区域,并且还具有单独的 NAPI 结构来管理轮询该区域。 因此,多个 CPU 将处理来自设备的中断,并且还处理数据包。

该特征通常被称为 Receive Side Scaling (RSS)。

Receive Packet Steering (RPS) 是 RSS 的软件实现。 由于它是在软件中实现的,这意味着它可以为任何 NIC 启用,即使是只有单个接收队列的 NIC。 然而,由于它是在软件中,这意味着 RPS 只能在已经从 DMA 存储器区域收取数据包之后进入流。

这意味着您不会注意到处理 IRQ 或 NAPI poll 循环所花费的 CPU 时间减少,但您可以在收集数据包后分布处理数据包的负载,并减少网络栈上的 CPU 时间。

RPS 的工作原理是为传入数据生成一个散列,以确定哪个 CPU 应该处理数据。 然后排队数据到每 CPU 接收网络积压中以进行处理。 处理器间中断(IPI)被传送到拥有积压的 CPU。 如果当前没有处理积压工作中的数据,这有助于启动积压工作处理。 /proc/net/softnet_stat 包含每个 softnet_data 结构体接收 IPI(received_rps字段)的次数计数。

因此,netif_receive_skb 将继续向网络栈发送网络数据,或者将其移交给 RPS 以在不同的 CPU 上进行处理。

调优:启用 RPS

要使 RPS 工作,必须在内核配置中启用它(Ubuntu 内核 3.13.0 是启用的),并使用位掩码描述哪些 CPU 应该处理给定接口和接收队列的数据包。

您可以在内核文档中找到有关这些位掩码的一些文档。

简而言之,要修改的位掩码位于:

/sys/class/net/DEVICE_NAME/queues/QUEUE/rps_cpus

因此,对于 eth0 和 接收队列 0,你将修改 /sys/class/net/eth0/queues/rx-0/rps_cpus 文件,其中十六进制数指示哪些 CPU 应处理来自 eth0 的接收队列 0 的数据包。 正如文档指出的,在某些配置中可能不需要 RPS。

注: 启用 RPS 将数据包处理分配给以前未处理数据包的 CPU,将导致该 CPU 的 NET_RX 软中断数增加,以及 CPU 使用率图中的 sisitime 增加。 您可以比较软中断和 CPU 使用率图表的前后对比,以确认 RPS 配置是否符合您的喜好。

Receive Flow Steering (RFS)

Receive flow steering (RFS) 与 RPS 配合使用。 RPS 尝试在多个 CPU 之间分配传入数据包负载,但不考虑任何数据局部性问题以最大化 CPU 缓存命中率。 您可以使用 RFS 定向同一个流的数据包到同一个 CPU 进行处理,从而帮助提高缓存命中率。

调优:启用 RFS

要使 RFS 工作,您必须启用并配置 RPS。

RFS 跟踪所有流的全局哈希表,并且可以设置 net.core.rps_sock_flow_entries sysctl 来调整该哈希表的大小。

设置 sysctl 增加 RFS 套接字流哈希的大小。

$ sudo sysctl -w net.core.rps_sock_flow_entries=32768

接下来,您还可以设置每个接收队列的流数,方法是写入此值每个接收队列的名为rps_flow_cnt 的 sysfs 文件。

示例:增加 eth0 上接收队列 0 的流数到 2048。

$ sudo bash -c 'echo 2048 > /sys/class/net/eth0/queues/rx-0/rps_flow_cnt'

硬件加速 Receive Flow Steering (aRFS)

RFS 可以使用硬件加速来加速;NIC 和内核可以一起工作以确定哪些流应该在哪些 CPU 上被处理。 要使用此功能,NIC 和驱动程序必须支持此功能。

请参阅您的网卡数据手册以确定是否支持此功能。 如果您的 NIC 驱动程序公开了一个名为 ndo_rx_flow_steer 的函数,则该驱动程序支持加速 RFS。

调优:启用加速 RFS(aRFS)

假设您的 NIC 和驱动程序支持它,您可以启用和配置一组内容来启用加速 RFS:

  1. 启用并配置 RPS。
  2. 启用并配置 RFS。
  3. x在编译内核时启用 CONFIG_RFS_ACCEL。 Ubuntu kernel 3.13.0 启用
  4. 如前所述,为设备启用 ntuple 支持。 您可以使用 ethtool 来验证是否为设备启用了ntuple 支持。
  5. 配置 IRQ 设置以确保每个接收队列由所需的网络处理 CPU 之一处理。

一旦配置了上述内容,加速 RFS 自动移动数据到与处理该流数据的CPU核心绑定的接收队列,并且您不需要为每个流手动指定 ntuple 过滤规则。

使用 netif_receive_skb 向上移动网络栈。

接着我们上次讲到的 netif_receive_skb,它从几个地方调用。最常见的两个(也是我们已经看过的两个):

  • 如果数据包不会合并到现有的 GRO 流中,则为 napi_skb_finish,或者
  • 如果协议层指示现在是刷新流的时候,则为 napi_gro_complete,或者

提醒: netif_receive_skb 及其后代在 softirq 处理循环的上下文中运行,使用像 top 这样的工具,您将看到这里花费的时间计入 sitimesi

netif_receive_skb 首先检查一个 sysctl 值,以确定用户是否在数据包进入积压队列之前或之后请求接收时间戳。如果启用了此设置,则在数据进入 RPS(和 CPU 的关联积压队列)之前对数据进行时间戳。如果禁用了此设置,则在进入队列后进行时间戳。如果启用了 RPS,则可以使用此功能在多个 CPU 之间分配时间戳的负载,但会因此引入一些延迟。

调优:接收数据包时间戳

您可以调整一个名为 net.core.netdev_tstamp_prequeue 的 sysctl 来调优接收到数据包后的时间戳:

调整 sysctl 禁用接收数据包的时间戳

$ sudo sysctl -w net.core.netdev_tstamp_prequeue=0

默认值为 1。 请参阅上一节的解释,以了解此设置的确切含义。

netif_receive_skb

处理完时间戳后,netif_receive_skb 的操作方式会因 RPS 是否启用而不同。 让我们先从更简单的路径开始:RPS 已禁用。

RPS 禁用(默认设置)

如果未启用 RPS,则调用 __netif_receive_skb,它执行一些簿记工作,然后调用 __netif_receive_skb_core 移动数据到协议栈附近。

我们将看到 __netif_receive_skb_core 的工作原理,但首先让我们看看启用 RPS 的代码路径如何工作,因为该代码也将调用 __netif_receive_skb_core

RPS 启用

如果启用了 RPS,在处理上述提到的时间戳选项之后,netif_receive_skb 将执行一些计算,以确定应使用哪个 CPU 的积压队列。这是使用 get_rps_cpu 函数完成的。来自 net/core/dev.c

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 设置,以确保调用 enqueue_to_backlog 排队数据到所需的 CPU 的 backlog。

enqueue_to_backlog

此函数首先获取指向远程 CPU 的 softnet_data 结构指针,该结构包含指向input_pkt_queue 的指针。 接下来,检查远程 CPU 的 input_pkt_queue。 来自 net/core/dev.c

qlen = skb_queue_len(&sd->input_pkt_queue);
if (qlen <= netdev_max_backlog && !skb_flow_limit(skb, qlen)) {

首先比较 input_pkt_queue 的长度与 netdev_max_backlog 。如果队列长度大于此值,则丢弃数据。同样,检查流量限制,如果超过了流量限制,则丢弃数据。在这两种情况下,都会增加 softnet_data 结构的丢弃计数。请注意,这是数据将要排队到的 CPU 的 softnet_data 结构。阅读上面关于 /proc/net/softnet_stat 的部分,基于监控的目的了解如何获取丢弃计数。

enqueue_to_backlog 在很少地方调用。它用于已启用 RPS 的数据包处理,也从 netif_rx 调用。大多数驱动程序都 不应 使用 netif_rx,而应使用 netif_receive_skb。如果您没有使用 RPS 并且您的驱动程序没有使用 netif_rx,则增加积压不会对您的系统产生任何明显影响,因为它不会被使用。

注意:您需要检查正在使用的驱动程序。如果它调用了 netif_receive_skb 并且您 没有 使用 RPS,则增加 netdev_max_backlog 将不会产生任何性能改进,因为没有任何数据会进入 input_pkt_queue

假设 input_pkt_queue 足够小且未达到流量限制(接下来会详细介绍),则可以排队数据。这里的逻辑有点奇怪,但可以总结为:

  • 如果队列为空:检查远程 CPU 上是否已启动 NAPI。如果没有,则检查是否已排队发送 IPI。如果没有,则排队一个并调用 ____napi_schedule 启动 NAPI 处理循环。继续排队数据。
  • 如果队列不为空,或者前面描述的操作已完成,则将数据入队。

这段代码使用了 goto,所以要仔细阅读。 来自 net/core/dev.c

  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 已满或流量限制导致的丢弃

请参阅上面有关监视/proc/net/softnet_statdropped字段是一个计数器,每次数据被丢弃而不是排队到CPU的 input_pkt_queue 时,它都会递增。

调优
调优:调优 netdev_max_backlog 防止丢弃

在调整此调优值之前,请参阅上一节中的注释。

如果使用 RPS 或驱动程序调用 netif_rx,则可以增加 netdev_max_backlog 来帮助防止 enqueue_to_backlog 的丢弃。

示例:使用 sysctl 增加 backlog 到 3000。

$ sudo sysctl -w net.core.netdev_max_backlog=3000

默认值为 1000。

调优:调优 backlog 的 NAPI poll 权重

您可以设置 net.core.dev_weight sysctl 来调整积压的 NAPI 轮询器的权重。调整此值可以确定积压 poll 循环可以消耗的总预算的多少(请参阅上面调整 net.core.netdev_budget 的部分):

示例:使用 sysctl 增加 NAPI poll 积压处理循环。

$ 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。

此更改仅影响新分配的流哈希表。 因此,如果您想增加表的大小,应该在启用流量限制之前进行。

要启用 流量限制,您应该在 /proc/sys/net/core/flow_limit_cpu_bitmap 中指定一个位掩码,该位掩码类似于 RPS 位掩码,指示哪些 CPU 启用了流量限制。

backlog 队列 NAPI 轮询器

每个 CPU 的 backlog 队列插入 NAPI 的方式与设备驱动程序相同。提供了一个 poll 函数,从 softirq 上下文处理数据包。就像设备驱动程序一样,还提供了一个 weight

这个 NAPI 结构在初始化网络系统时提供。来自 net/core/dev.c 中的 net_dev_init

sd->backlog.poll = process_backlog;
sd->backlog.weight = weight_p;
sd->backlog.gro_list = NULL;
sd->backlog.gro_count = 0;

backlog NAPI 结构与设备驱动程序 NAPI 结构的不同之处在于 weight 参数是可调整的,其中驱动程序编码其 NAPI 权重硬为 64。 我们将在下面的调优部分看到如何使用 sysctl 调整权重。

process_backlog

process_backlog 函数是一个循环,直到其权重(如前一节所述)被消耗完或 backlog 中没有更多数据为止。

backlog 队列中的每个数据都从 backlog 队列中移除,并传递给 __netif_receive_skb。一旦数据进入 __netif_receive_skb,代码路径与上面解释的 RPS 禁用情况相同。也就是说,在调用 __netif_receive_skb_core 传递网络数据到协议层之前,__netif_receive_skb 会进行一些簿记工作。

process_backlog 遵循与设备驱动程序相同的 NAPI 契约,即:如果不使用总权重,则禁用 NAPI。通过上面描述的 enqueue_to_backlog 中对 ____napi_schedule 的调用,轮询器重新启动。

该函数返回完成的工作量,net_rx_action(上面描述)将从预算中扣减该工作量(使用上面描述的 net.core.netdev_budget 进行调整)。

__netif_receive_skb_core 传送数据到数据包抓取和协议层

__netif_receive_skb_core 执行传递数据到协议栈的繁重工作。 在此之前,它检查是否安装了捕获传入数据包的数据包抓取。 AF_PACKET 地址族就是一个这样的例子,它通常通过 libpcap库使用。

如果存在这样的抓取,则首先传送数据到那里,然后传送到下一个协议层。

数据包抓取传送

如果安装了一个数据包抓取(通常通过 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

协议层交付

一旦满足抓取,__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;
        }
}

上面的 ptype_base 标识符被定义为  net/core/dev.c 中链表组成的散列表:

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.cinet_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(如上一节所示),deliver_skb 调用func(在本例中为 ip_rcv)。

ip_rcv

从高层次来看,ip_rcv 函数非常简单。有几个完整性检查以确保数据有效。统计计数器也会增加。

ip_rcv 通过 netfilter 传递数据包给 ip_rcv_finish 结束。这样做是为了让任何应该在 IP 协议层匹配的 iptables 规则在数据包继续之前查看数据包。

我们可以在 net/ipv4/ip_input.c 中的 ip_rcv 结尾处看到将数据交给 netfilter 的代码:

return NF_HOOK(NFPROTO_IPV4, NF_INET_PRE_ROUTING, skb, dev, NULL, ip_rcv_finish);
netfilter 和 iptables

为了简洁(和我的 RSI),我决定跳过对 netfilter、iptables 和 conntrack 的深入研究。

简而言之,NF_HOOK_THRESH 会检查是否安装了过滤器,并尝试返回执行到 IP 协议层,以避免深入到 netfilter 和 iptables 和 conntrack 等下面的任何钩子。

请记住:如果您有许多或非常复杂的 netfilter 或 iptables 规则,那么这些规则将在 softirq 上下文中执行,并可能产生网络堆栈中的延迟。不过,如果您需要安装特定的规则集,这可能是不可避免的。

ip_rcv_finish

一旦 net filter 有机会查看数据并决定如何处理它,就会调用 ip_rcv_finish。 当然,只有当数据没有被 netfilter 丢弃时才会发生这种情况。

ip_rcv_finish 以一个优化开始。为了传递数据包到适当的位置,来自路由系统的dst_entry 需要到位。 为了获得一个 dst_entry,代码最初尝试从该数据的目的地的更高级别协议调用 early_demux 函数。

early_demux 流程是一种优化,它试图检查 dst_entry 是否缓存在套接字结构上,来找到传递数据包所需的 dst_entry

下面是 net/ipv4/ip_input.c 中的内容:

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_ip_early_demux 的保护。默认情况下,early_demux 是启用的。下一节将介绍如何禁用它以及为什么要禁用它。

如果启用了优化并且没有缓存条目(因为这是第一个到达的数据包),则移交该数据包给内核中的路由系统,在那里将计算并分配 dst_entry

路由层完成后,更新统计计数器,并调用 dst_input(skb) 结束函数,该函数又调用了由路由系统关联的数据包的 dst_entry 结构上的输入函数指针。

如果数据包的最终目的地是本地系统,则路由系统将在数据包的 dst_entry 结构上的输入函数指针中关联 ip_local_deliver 函数。

调优:调整 IP 协议 early demux

设置 sysctl 禁用 early_demux 优化。

$ sudo sysctl -w net.ipv4.ip_early_demux=0

默认值为1;启用 early_demux

添加此 sysctl 是因为一些用户发现在某些情况下使用 early_demux 优化会使吞吐量降低约 5%

ip_local_deliver

回想一下在 IP 协议层中看到的以下模式:

  1. 调用 ip_rcv 做一些初始簿记。
  2. 移交数据包给 netfilter 进行处理,并带有一个指针,指向处理完成时要执行的回调。
  3. 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 结构,并调用 net_protocol 结构中 handler 指向的函数。

这向上传递数据包到更高级别的协议层。

监控:IP 协议层统计信息

读取 /proc/net/snmp 监控详细的 IP 协议统计信息。

$ 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 枚举类型。 /proc/net/snmp 所有有效的枚举值和它们对应的字段名称可以在 include/uapi/linux/snmp.h 中找到:

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 */

  /* ... */

读取 /proc/net/netstat 监控扩展 IP 协议统计信息。

$ 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_rcv 的 IP 数据包总数,未进行任何数据完整性检查。
  • InHdrErrors:头部损坏的 IP 数据包总数。头部过短、过长、不存在、IP 协议版本号错误等。
  • InAddrErrors:主机不可达的 IP 数据包总数。
  • ForwDatagrams:已转发的 IP 数据包总数。
  • InUnknownProtos:头部中指定了未知或不支持协议的 IP 数据包总数。
  • InDiscards:由于内存分配失败而丢弃的 IP 数据包或校验和失败修剪的数据包总数。
  • InDelivers:成功传递到更高协议层的 IP 数据包总数。请注意,即使 IP 层没有丢弃数据,更高协议层也可能丢弃数据。
  • InCsumErrors:校验和错误的 IP 数据包总数。

请注意,这些值都是在 IP 层的特定位置增加的。代码有时会移动,可能会出现双重计数错误或其他统计错误。如果这些统计数据对您很重要,强烈建议您阅读 IP 协议层源代码,了解您重要的指标何时增加(或不增加)。

更高级别协议注册

本文将研究 UDP,但 TCP 协议处理程序的注册方式和时间与 UDP 协议处理程序相同。

net/ipv4/af_inet.c 中,可以找到包含将 UDP、TCP 和 ICMP 协议连接到 IP 协议层的处理程序函数的结构定义。来自 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

IP 层在此处理数据,这是进入 UDP 层的入口点。 让我们继续旅程。

UDP协议层

UDP 协议层的代码可以在以下文件中找到:net/ipv4/udp. c.

udp_rcv

udp_rcv 函数的代码只有一行,它直接调用 __udp4_lib_rcv 来接收数据报。

__udp4_lib_rcv

__udp4_lib_rcv 函数检查以确保数据包有效,并获取 UDP 报头、UDP 数据报长度、源地址和目标地址。 接下来,是一些附加的完整性检查和校验和验证。

回想一下,在前面的 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

此函数的初始部分如下所示:

  1. 确定与数据报关联的套接字是否是封装套接字。 如果是,在继续之前传递数据包到该层的处理函数。
  2. 确定数据报是否为 UDP-Lite 数据报,并执行一些完整性检查。
  3. 验证数据报的 UDP 校验和,如果校验和失败,则丢弃数据报。

最后,我们到达接收队列逻辑,它首先检查套接字的接收队列是否已满。 来自 net/ipv4/udp.c

if (sk_rcvqueues_full(sk, skb, sk->sk_rcvbuf))
  goto drop;
sk_rcvqueues_full

sk_rcvqueues_full 函数检查套接字的 backlog 长度和套接字的 sk_rmem_alloc,以确定总和是否大于套接字的 sk_rcvbufsk->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;
}

调优这些值有点棘手,因为有很多东西可以调整。

调优:套接字接收队列内存

sksk->sk_rcvbuf(在上面的sk_rcvqueues_full中称为limit)值可以增加到 sysctlnet.core.rmem_max 设置的值。

设置 sysctl 增加最大接收缓冲区大小。

$ sudo sysctl -w net.core.rmem_max=8388608

sk->sk_rcvbufnet.core.rmem_default 值开始,也可以设置 sysctl 来调整,如下所示:

设置 sysctl 来调整默认的初始接收缓冲区大小。

$ sudo sysctl -w net.core.rmem_default=8388608

您可以在应用程序中调用 setsockopt 并传递 SO_RCVBUF 来设置 sk->sk_rcvbuf 的大小。您可以使用 setsockopt 设置的最大值为 net.core.rmem_max

但是,您可以调用 setsockopt 并传递 SO_RCVBUFFORCE 来覆盖 net.core.rmem_max 的限制,但运行应用程序的用户需要具有 CAP_NET_ADMIN 权限。

当调用 skb_set_owner_r 设置数据报的所有者套接字时,会增加 sk->sk_rmem_alloc 的值。我们稍后将在 UDP 层中看到这个调用。

当调用 sk_add_backlog 时,会增加 sk->sk_backlog.len 的值,我们接下来将看到这个调用。

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 排队数据报到 backlog。

当套接字系统调用调用内核中的 release_sock 释放套接字时,backlog 上的数据报被添加到接收队列。

__udp_queue_rcv_skb

__udp_queue_rcv_skb 函数调用 sock_queue_rcv_skb 添加数据报到接收队列,如果数据报无法添加到套接字的接收队列,则会增加统计计数器。

来自 net/ipv4/udp.c

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

读取 /proc/net/snmp 监控详细的 UDP 协议统计信息。

$ cat /proc/net/snmp | grep Udp\:
Udp: InDatagrams NoPorts InErrors OutDatagrams RcvbufErrors SndbufErrors
Udp: 16314 0 0 17161 0 0

与此文件中 I P协议的详细统计信息非常相似,您需要阅读协议层源文件,以准确确定这些值在何时何地递增。

  • InDatagrams:当用户程序使用 recvmsg 读取数据报时递增。当 UDP 数据包被封装并发送回来进行处理时也会递增。
  • NoPorts:当 UDP 数据包到达目标端口,但没有程序在监听时递增。
  • InErrors:在几种情况下递增:接收队列中没有内存,检测到校验和错误,以及如果 sk_add_backlog 未能添加数据报。
  • OutDatagrams:当 UDP 数据包无错误地传递给 IP 协议层发送时递增。
  • RcvbufErrors:当 sock_queue_rcv_skb 报告没有可用内存时递增;如果 sk->sk_rmem_alloc 大于或等于 sk->sk_rcvbuf 时会发生这种情况。
  • SndbufErrors:如果 IP 协议层在尝试发送数据包时报告错误且未设置错误队列,则递增。如果没有可用的发送队列空间或内核内存也会递增。
  • InCsumErrors:当检测到 UDP 校验和失败时递增。请注意,在我能找到的所有情况中,InCsumErrors 都与 InErrors 同时递增。因此,InErrors - InCsumErros 应该得出接收端内存相关错误的计数。
/proc/net/udp

读取 /proc/net/udp 监控 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_CLOSE
  • tx_queue:内核为传出 UDP 数据报分配的内存量。
  • rx_queue:内核为传入 UDP 数据报分配的内存量。
  • trtm->whenretrnsmt:这些字段未被 UDP 协议层使用。
  • uid:创建此套接字的用户的有效用户 ID。
  • timeout:未被 UDP 协议层使用。
  • inode:与此套接字对应的 inode 编号。您可以使用它来帮助您确定哪个用户进程打开了此套接字。检查 /proc/[pid]/fd,其中包含指向 socket[:inode] 的符号链接。
  • ref:套接字的当前引用计数。
  • pointer:内核中 struct sock 的内存地址。
  • drops:与此套接字关联的数据报丢弃数。 请注意,这不包括任何与发送数据报有关的丢弃(在 corked 的 UDP 套接字上,或其他);在本博客考察的内核版本中,只在接收路径中增加。

可以在 net/ipv4/udp.c 中找到输出此内容的代码。

排队数据到套接字

网络数据调用 sock_queue_rcv 排队到套接字。在添加数据报到队列之前,此函数会执行一些操作:

  1. 检查套接字的分配内存,以确定它是否超过了接收缓冲区大小。如果是,则增加套接字的丢弃计数。
  2. 接下来使用 sk_filter 处理已应用于套接字的 Berkeley Packet Filter 过滤器。
  3. 运行 sk_rmem_schedule,以确保有足够的接收缓冲区空间来接受此数据报。
  4. 接下来调用 skb_set_owner_r 将数据报的大小计入套接字。这会增加 sk->sk_rmem_alloc
  5. 调用 __skb_queue_tail 添加数据到队列中。
  6. 最后,调用 sk_data_ready 通知处理程序函数通知任何等待套接字中数据到达的进程。

这就是数据如何到达系统并遍历网络堆栈,直到它到达套接字并准备好被用户程序读取。

其他

有一些额外的事情值得一提,值得一提的是,似乎不太正确的其他任何地方。

时间戳

正如上面的博客文章中提到的,网络栈可以收集传出数据的时间戳。 请参阅上面的网络栈演练,了解软件中的传输时间戳发生的位置。 一些 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

不幸的是,这个网卡不支持硬件接收时间戳,但是软件时间戳仍然可以在这个系统上使用,以帮助我确定内核给我的数据包接收路径增加了多少延迟。

低延迟套接字的忙轮询

可以使用名为 SO_BUSY_POLL 的套接字选项,当执行阻塞接收且没有数据时,它会导致内核忙碌轮询新数据。

重要提示:要使此选项正常工作,您的设备驱动程序必须支持它。Linux 内核 3.13.0 的 igb 驱动程序不支持此选项。然而,ixgbe 驱动程序支持。如果您的驱动程序在其 struct net_device_ops 结构(在上面的博客文章中提到)的 ndo_busy_poll 字段中设置了一个函数,则它支持 SO_BUSY_POLL

Intel 提供了一篇很棒的论文,解释了这是如何工作的以及如何使用它。

当为单个套接字使用此套接字选项时,您应该传递一个以微秒为单位的时间值,作为在设备驱动程序的接收队列中忙碌轮询新数据的时间。在设置此值后,当您对此套接字发出阻塞读取时,内核将忙碌轮询新数据。

您还可以设置 sysctl 值 net.core.busy_poll 为以微秒为单位的时间值,表示使用 pollselect 的调用应忙碌轮询等待新数据到达的时间。

此选项可以减少延迟,但会增加 CPU 使用率和功耗。

Netpoll:支持关键环境中的联网

Linux 内核提供了一种方法,可以在内核崩溃时使用设备驱动程序在 NIC 上发送和接收数据。这个 API 被称为 Netpoll,它被一些东西使用,但最值得注意的是:kgdbnetconsole

大多数驱动程序都支持 Netpoll;您的驱动程序需要实现 ndo_poll_controller 函数,并将其关联到探测期间注册的 struct net_device_ops(如上所示)。

当网络设备子系统对传入或传出数据执行操作时,首先检查 netpoll 系统以确定数据包是否目标为 netpoll。

例如,我们可以在 __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 的使用者可以调用 netpoll_setup 来注册 struct netpoll 结构。struct netpoll 结构具有关联接收钩子的函数指针,API 导出了一个发送数据的函数。

如果您对使用 Netpoll API 感兴趣,您应该查看 netconsole 驱动程序、Netpoll API 头文件 include/linux/netpoll.h这个优秀的演讲

SO_INCOMING_CPU

SO_INCOMING_CPU 标志直到 Linux 3.19 才被添加,但它非常有用,应该包含在此博客文章中。

您可以使用 getsockoptSO_INCOMING_CPU 选项来确定哪个 CPU 处理特定套接字的网络数据包。然后,您的应用程序可以使用此信息将套接字交给在所需 CPU 上运行的线程,以帮助增加数据局部性和 CPU 缓存命中。

引入 SO_INCOMING_CPU邮件列表消息提供了一个简短的示例架构,其中此选项很有用。

DMA引擎

DMA 引擎是一种硬件,它允许 CPU 卸载大型复制操作。这使得 CPU 使用硬件完成内存复制时可以执行其他任务。启用 DMA 引擎并运行利用它的代码,应该会降低 CPU 使用率。

Linux 内核具有通用的 DMA 引擎接口,DMA 引擎驱动程序作者可以插入。您可以在 内核源代码文档 中了解更多关于 Linux DMA 引擎接口的信息。

尽管内核支持一些 DMA 引擎,但我们将讨论一种非常常见的特定 DMA 引擎:Intel IOAT DMA 引擎

英特尔的 I/O 加速技术(IOAT)

许多服务器都包含 Intel I/O AT 组件包,它由一系列性能更改组成。

其中一个更改是包含硬件 DMA 引擎。您可以检查 dmesg 输出中的 ioatdma,以确定模块是否正在加载并且是否找到了支持的硬件。

DMA 卸载引擎在几个地方使用,最值得注意的是在 TCP 栈中。

对 Intel IOAT DMA 引擎的支持包含在 Linux 2.6.18 中,但由于一些不幸的 数据损坏错误,它在 3.13.11.10 中被禁用。

在 3.13.11.10 之前的内核上的用户可能默认在其服务器上使用 ioatdma 模块。也许这将在未来的内核版本中得到修复。

直接缓存访问

Intel I/O AT 组件包 一起包含的另一个有趣功能是直接缓存访问 (DCA)。

此功能允许网络设备(通过其驱动程序)直接放置网络数据到 CPU 缓存中。具体如何实现这一点是特定于驱动程序的。对于 igb 驱动程序,您可以检查 函数 igb_update_dca 的代码,以及 igb_update_rx_dca 的代码igb 驱动程序向 NIC 写入寄存器值来使用 DCA。

要使用 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 引擎仅在数据包大小高于某个阈值时使用。 这个阈值被称为 copybreak。 之所以进行此检查,是因为对于小型副本,设置和使用 DMA 引擎的开销不值得加速传输。

使用 sysctl 调整 DMA 引擎 copybreak

$ sudo sysctl -w net.ipv4.tcp_dma_copybreak=2048

默认值为 4096。

结论

Linux 网络堆栈非常复杂。

如果不深入了解究竟发生了什么,就不可能监控或调优它(或任何其他复杂的软件)。通常,在互联网的荒野中,您可能会偶然发现一个包含一组 sysctl 值的示例 sysctl.conf,复制并粘贴到您的计算机上。这可能不是优化您的网络堆栈的最佳方法。

监控网络堆栈需要在每一层仔细核算网络数据。从驱动程序开始,然后向上进行。这样您就可以确定丢弃和错误发生在哪里,然后调整设置以确定如何减少您看到的错误。

不幸的是,没有简单的出路。

原文: Monitoring and Tuning the Linux Networking Stack: Receiving Data

本文作者 : cyningsun
本文地址https://www.cyningsun.com/04-24-2023/monitoring-and-tuning-the-linux-networking-stack-recv-cn.html
版权声明 :本博客所有文章除特别声明外,均采用 CC BY-NC-ND 3.0 CN 许可协议。转载请注明出处!

# Network