谷动谷力

 找回密码
 立即注册
查看: 638|回复: 0
打印 上一主题 下一主题
收起左侧

【linux内核源码解读】linux 网络栈监控及调优:数据接收

[复制链接]
跳转到指定楼层
楼主
发表于 2023-3-20 21:10:05 | 只看该作者 回帖奖励 |倒序浏览 |阅读模式
本帖最后由 sunsili 于 2023-3-20 22:16 编辑

【linux内核源码解读】linux 网络栈监控及调优:数据接收


1. 垫话

本文乃《Monitoring and Tuning the Linux Networking Stack: Receiving Data》一文的翻译,是系列文章的第一篇。上次我在朋友圈分享了一篇网络相关的文章,文章内容本身不深,解答了我这个战五渣的一些常识性疑问。一位技术前辈(我曾经的实习主管)看到后,向我勘误了文章中的常识性错误,并给我推荐了两篇前辈收藏的文章。食用后觉得营养价值颇高(虽然里面的内容确实有点老了),几乎是 end-to-end 的网络底层拉通了,于是翻译成文。感慨于前辈的技术热情和诲人不倦的精神,敬意!

2. 前言

本文解释 linux 内核如何接收 packets,以及如何对 packets 流从网络到达用户程序过程中所涉及的网络栈组件进行监控及调优。不读内核源码并深入理解其中的细节就不可能搞好网络栈的监控及调优。本文旨在为尝试做好此事的读者提供参考。

3. 网络栈监控及调优

一般建议网络栈很复杂,不存在能解决任何问题的银弹。如果你的应用对网络的性能和质量要求很高,唯一的方法就是深入去搞清楚系统内部是怎么工作的。通常来说,你可以测量网络栈在每个层上 packet 的丢包情况。借此可以将问题范围缩小,锁定需要调优的组件。大多数运维人员的三板斧是希望能通过一通无脑的 sysctl 或 /proc 设置以达到预期效果。这在有些情况下可能管用,但整个系统是如此的精密,如果你希望做有意义的监控和调优,就必须得搞清楚系统在更深层次上到底是如何运行的;否则的话,你也可以简单地使用默认设置,如果你不需要进一步优化(考虑到成本)的话,默认的设置往往够用。本文的示例配置仅作演示其可达成的效果之用,并不是对某一配置的教科书式建议。在做相应调整之前,你最好开发一个参考对比框架,可以观察调整所带来的实际变化。调整网络设置是危险的,你很可能会把自己搞断网。建议不要在生产机器上做实验,建议是可以在新机器上调整好后再将它们投入生产环境。

4. 综述

你可能需要一个设备的 data sheet 在手头做参考。本文基于 Intel I350 Ethernet controller,由 igb 设备驱动控制。data sheet 链接:www.intel.com/content/dam/www/pu ... -i350-datasheet.pdf。一个 packet 走到 socket 接收 buffer 的 high level 视角路径:
  • 加载及初始化驱动。
  • packet 通过网络到达 NIC。
  • packet 通过 DMA 被拷贝至内核内存中的 ring buffer。
  • 发起一个硬件中断,通知内核有 packet 到达内存。
  • 如果 poll loop 尚未启动,则驱动调用 NAPI 启动该  poll loop。
  • 系统的每个 CPU 上运行一个 ksoftirqd 进程,由系统 boot 阶段注册。ksoftirqd 通过设备驱动在初始化时所注册的 NAPI poll 函数,将 packets 从 ring buffer 中读出。
  • ring buffer 中写入网路数据的内存区域被 unmap(取消映射)。
  • 被 DMA 进内存的数据将作为一个 'skb' 向上给到网络栈,并做后续处理。
  • 如果使能了 packet steering 或 NIC 有多个接收队列,则收进来的网络数据帧会在多个 CPU 间分发。
  • 网络栈从队列中获取网络数据帧并处理。
  • 协议层处理数据。
  • 协议层将数据添加到 sockets 接收 buffer。
下文对该流程做详细拆解。本文基于 3.13.0 内核。linux 内核中的 packet 接收相当复杂。我们先搞清楚一个网络驱动是如何工作的,这样后续对网络栈的解构会更清晰。本文以 igb 网络驱动为分析目标。该驱动用于一个很常见的服务器 NIC:Intel Ethernet Controller I350。5. 网络设备驱动初始化驱动会注册一个初始化函数,内核在加载该驱动时会调用之。此函数通过 module_init 宏来注册。igb 初始化函数(igb_init_module)以及对其进行注册的 module_init 在 drivers/net/ethernet/intel/igb/igb_main.c(译者注:原文中所给出的源文件,背后都是有 github 链接的,篇幅起见,统一不贴 URL 了,需要的读者自行去原文取食,或者下一份本地的内核代码)。此二者皆很直观:
/**
*  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 中。

5.1 PCI 初始化Intel I350 网卡是一个 PCIe 设备。PCI 设备通过其 PCI 配置空间中的一坨寄存器来唯一标识。设备驱动编译时,会通过 MODULE_DEVICE_TABLE(include/module.h)声明一个 PCI 设备 IDs 表,表中的 ID 标识该驱动所能控制的设备。该表本身也被注册为另外一个数据结构的一部分。内核就是靠这张表来知悉应该加载哪个驱动来控制目标设备。这也是为啥 OS 可以知道接在系统上的是哪些设备,以及通过哪些驱动可以与这些设备搭上话。igb 驱动的 IDs 表以及 PCI 设备 IDs 分别在 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 device ID table 的指针。内核使用驱动注册的这些函数来拉起 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,
  /* ... */
};

5.2 PCI probe

一旦通过 PCI IDs 识别到一个设备,内核会选一个适当的驱动来控制该设备。内核中的每个 PCI 驱动向 PCI 系统注册一个 probe 函数,内核会为尚未匹配驱动的设备调用此 probe 函数。一旦设备匹配到一个驱动,就不会再询问其他驱动。大多数驱动有大量代码将设备初始化至可用状态,但它们做的具体操作互不相同。标准操作有:
  • 使能 PCI 设备。
  • 请求内存区域及 IO 端口。
  • 设置 DMA 掩码。
  • 驱动注册其所支持的 ethtool 函数。
  • 拉起所需要的 watchdog 任务(具体来说,e1000e 会起一个 watchdog 任务来检查硬件是不是 hung 了)。
  • 其他一些设备 specific 的操作,比如 walkarounds 或一些对设备特殊癖好的处理等。
  • 创建、初始化、注册 struct net_device_ops 数据结构。该数据结构包含各种函数指针,用来打开设备、发送数据、设置 MAC 地址,等等。
  • 创建、初始化、注册 high level 的 struct net_device 数据结构,其表示一个网络设备。
下面我们来看 igb 驱动 igb_probe 函数中的这几个操作。

5.3 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 初始化设备。如果设备处于 suspend 状态则该函数会唤醒之,使能内存资源,以及其他。随后,设置 DMA mask。此设备可以读写 64 bit 的内存地址,故而 dma_set_mask_and_coherent 的入参为 DMA_BIT_MASK(64)。通过 pci_request_selected_regions 预留内存区域,通过 pci_enable_pcie_error_reporting 使能 PCIe Advanced Error Reporting(在加载了 PCI AER 驱动的情况下),通过 pci_set_master 使能 DMA,通过 pci_save_state 保存 PCI 配置空间。

5.4 linux PCI

驱动细节本文不讨论“PCI 设备是怎么工作的”这种话题,相关内容可以参阅:https://bootlin.com/doc/legacy/pci-drivers/pci-drivers.pdfhttps://wiki.osdev.org/PCI,以及内核的 Documentation/PCI/pci.txt。

5.5 网络设备初始化

igb_probe 函数做了一些网络设备初始化相关的重要工作。除 PCI specific 的工作之外,其还做了一些通用的网络及网络设备相关的初始化工作。
  • 注册 struct net_device_ops。
  • 注册 ethtool 函数。
  • 从 NIC 获取默认 MAC 地址。
  • 设置 net_device feature flag。
  • 等等。
看一下我们会关注的内容。

5.5.1 struct net_device_ops

struct net_device_ops 包含网络子系统用来控制设备的函数指针,这些函数很重要。该数据结构在本文将贯穿始终。igb_probe 函数将 net_device_ops 数据结构注册到 struct net_device 中(drivers/net/etherne/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 数据结构包含的函数指针所指向的函数也在这个文件中:
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 驱动实现的。后面会展开对这些函数的讨论。

5.5.2 ethtool 注册

ethtool 是一个命令行程序,可以用来获取及设置一些驱动和硬件配置。Ubuntu 下通过 apt-get install ethtool 安装。ethtool 的一个常见用法是查看网络设备的统计数据。后面会讨论其他 ethtool 设置。ethtool 通过 ioctl 系统调用来与设备驱动搭上话。设备驱动会注册一系列 ethtool 操作的回调函数,由内核完成二者的牵线搭桥。
ethtool 调用 ioctl 时,内核找到对应驱动所注册的 ethtool 数据结构,并执行驱动注册的函数。驱动的 ethtool 函数可以做很多事,比如改变驱动中的一个软件 flag,或是写设备寄存器来控制 NIC 硬件的行为。
igb 驱动在 igb_probe 函数中通过 igb_set_ethtool_ops 来注册其 ethtool 操作:
static int igb_probe(struct pci_dev *pdev, const struct pci_device_id *ent)
{
  /* ... */

  igb_set_ethtool_ops(netdev);
igb 驱动的所有 ethtool 代码与 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 函数。
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 访问这些统计数据。

5.5.3 IRQs

数据帧通过 DMA 写入 RAM 后,NIC 该如何通知系统有数据要处理了呢?通常来说,NIC 会发起一个中断请求(IRQ)来告诉系统有数据了。有三种常见的 IRQs 类型:MSI-X、MSI 及 legacy IRQs,后面会展开。当数据通过 DMA 写入 RAM 时设备发起一个 IRQ,这个模型很简单,但如果有大量数据帧到来的话,会触发大量的中断。中断越多意味着 CPU 给其他任务(比如用户程序)的时间就越少。New API(NAPI)就是一种用来在收 packet 时减少网络设备中断的机制。虽然 NAPI 可以减少中断数量,但不可能完全消除中断。后面章节我们会讨论为什么会这样。

5.5.4 NAPI

NAPI 与传统的数据接收方式不同。NAPI 允许设备驱动注册一个 poll 函数,NAPI 子系统会调用此 poll 函数来接收数据帧。NAPI 的使用范式如下:
  • 驱动使能 NAPI,初始是关闭状态。
  • packet 到来时由 NIC 将其 DMA 到内存。
  • NIC 发起一个 IRQ,触发驱动中的 IRQ 处理函数。
  • 驱动通过一个软中断唤醒 NAPI 子系统,在另一个独立线程中调用驱动注册的 poll 函数来接收 packets。
  • 驱动应该禁能 NIC 的后续 IRQs,如此让 NAPI 子系统在无设备打断的情况下处理 packets。
  • 当事情处理完后,禁能 NAPI 子系统,并重新使能设备的 IRQs。
  • 再次从第 2 步开始。
相较于传统方式,NAPI 可以减少中断开销,因为可以一次处理很多数据帧,而无需每个 IRQ 处理一个数据。设备驱动负责实现 poll 函数并通过 netif_napi_add 将其注册给 NAPI。通过 netif_napi_add 注册 poll 函数时,驱动还可以为其指定权重(weight)。大多数驱动会 hardcode 一个值为 64 的权重。该值的含义下面展开讨论。通常来说,驱动会在驱动初始化阶段注册其 NAPI poll 函数。

5.5.5 igb 驱动的 NAPI 初始化igb 通过如下调用链完成 NAPI 初始化:
  • igb_probe 调用 igb_sw_init。
  • igb_sw_init 调用 igb_init_interrupt_scheme。
  • igb_init_interrupt_scheme 调用 igb_alloc_q_vectors。
  • igb_alloc_q_vectors 调用 igb_alloc_q_vector。
  • igb_alloc_q_vector 调用 netif_napi_add。
该调用链的细节:
  • 如果支持 MSI-X,则调用 pci_enable_msix 使能之。
  • 做了很多配置初始化动作;最重要的配置是,设备及驱动在收发 packets 时使用的 transmit 和 receive 队列数量。
  • 每个 transmit 和 receive 队列在创建时会调用 igb_alloc_q_vector。
  • 每一次调用 igb_alloc_q_vector 进而调用到 netif_napi_add 时,会为当前队列注册一个 poll 函数,以及一个 struct napi_struct,后者会在接收 packets 被传给 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);
  /* ... */
上面代码为 receive 队列分配内存,并将 igb_poll 函数注册到 NAPI 子系统,其提供了一个与该新创建的 RX 队列相关联的 struct napi_struct 指针(上面的 &q_vector->napi),需要从 RX 队列收数据时,NAPI 子系统将调用 igb_poll 并把该指针传给 igb_poll。我们分析数据流从驱动给到网络栈的路径时,这会是一个很重要的点。

5.6 拉起网络设备上文提到,net_device_ops 数据结构注册了一组函数用于拉起网络设备、传输 packets 以及设置 MAC 地址,等等。当网络设备被拉起时(比如通过 ifconfig eth0 up),将调用 net_device_ops 数据结构的 ndo_open 域所指向的函数。ndo_open 通常会做下面这些事:
  • 为 RX、TX 队列分配内存。
  • 使能 NAPI。
  • 注册一个中断处理函数。
  • 使能硬件中断。
  • 其他。
igb 驱动中,net_device_ops 数据结构的 ndo_open 域所指向的函数是 igb_open。

5.7 准备从网络接收数据如今你所能找到的绝大多数 NIC 都是通过 DMA 将数据直接写入 RAM,OS 从这片 RAM 中找到数据并做后续处理。大多数 NIC 对此功能的实现都是基于一个底层是环形 buffer(或 ring buffer)的队列。要这么干,设备驱动必须与 OS 一起为 NIC 硬件预留一块内存区域。此区域预留好之后,会告知硬件其之所在,如此后续接收的数据将被写入这片 RAM,网络子系统后续会从中找到并处理数据。看起来挺简单的,但请想想,如果 incoming packet 速率太高,以至于单个 CPU 处理不过来咋办?因为该数据结构是基于一个固定大小的内存区域构建的,处理不过来的 packet 会被丢弃。而这正是 Receive Side Scaling(RSS) 或者叫 multiqueue 技术所要解决的问题。有些设备支持将 incoming packets 同时写入 RAM 的不同区域;每个区域是一个独立的队列,这使得 OS 可以用多个 CPUs 来同时处理 incoming 数据,并非所有 NIC 都支持该特性。Intel I350 NIC 是支持 multiple queues 的,可以从 igb 驱动中略窥一二。igb 驱动在拉起设备时第一批做的事情中就有对 igb_setup_all_rx_resources 的调用。该函数为每个 RX 队列调用一次 igb_setup_rx_resources,igb_setup_rx_resources 中分配可 DMA 的内存,设备会将 incoming 数据写入其中。如果你对这一切是怎么工作的感兴趣,可以参阅内核 Documentation/DMA-API-HOWTO.txt。可以通过 ethtool 调整 RX 队列的数量及大小。对这些数值的调整,会显著影响被处理的帧数量以及被丢弃的帧数量。NIC 会利用 packet 的首部(比如源端、目的端与端口等)做哈希,以决定数据该被投递给哪个 RX 队列。有些 NIC 支持用户调整 RX 队列的权重,从而可以向指定队列打入更多流量。少数 NIC 支持用户调整哈希函数本身。如果可以调整哈希函数,就意味着可以将指定 flow 打给指定队列,只要你想,甚至可以在硬件层直接丢弃 packets。

5.8 使能 NAPI驱动通常会在拉起网络设备后使能 NAPI。上文提到驱动如何向 NAPI 注册 poll 函数,但在设备被拉起之前,NAPI 通常是未使能的。使能 NAPI 相对来说是很直白的。调用 napi_enable 函数,其会翻转 struct napi_struct 中的一个 bit 位,表示对 NAPI 的使能。如上文所言,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->napi));

5.9 中断注册

使能 NAPI 之后,接下来就是注册一个中断处理函数。设备发送中断的方式有很多种:MSI-X、MSI 以及传统中断。取决于硬件到底支持什么中断方式,设备之间的驱动代码会各有差异。驱动必须搞清楚设备支持什么中断方式,并注册适当的处理函数。有些驱动,比如 igd 驱动,会尝试给每个方式都注册一个中断处理函数,注册失败的话则尝试下一个。驱动会优先考虑 MSI-X 中断方式,尤其是支持多 RX 队列的 NICs。这是因为每个 RX 队列都可以被分配独立的硬件中断,这些中断会被指定的 CPU 处理(通过 irqbalance 或修改 /proc/irq/IRQ_NUMBER/smp_affinity)。

我们后面会看到,处理对应中断的 CPU 也是处理对应 packet 的 CPU。通过此方式,收到的 packets 可以被不同的 CPU 处理(从硬件层一直穿过整个网络栈)。
如果无法使用 MSI-X,因为 MSI 相较于传统中断方式仍有优势,如果设备支持 MSI 的话,驱动仍会优先使用 MSI。更多关于 MSI 和 MSI-X,参阅 https://en.wikipedia.org/wiki/Message_Signaled_Interrupts。igd 驱动中,igb_msix_ring、igb_intr_msi 和 igb_intr 分别是 MSI-X、MSI 和传统中断模式的处理函数。驱动尝试注册每种中断方式的代码在 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 注册传统中断处理函数 igb_intr。

5.10 中断使能

至此,事情都做的差不多了,剩下的事情就是使能 NIC 的中断并等数据到来。中断的使能是硬件 specific 的,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);
  /* ... */
}

5.11 网络设备已拉起


驱动还会做一些其他的事,比如起 timer、work queues,或进行其他硬件 specific 的设置。当这些都完事之后,网络设备拉起且处于可用状态。下面看看对网络设备驱动的监控及调优。

6. 网络设备监控


对网络设备的监控有不同的粒度层级。我们从最粗粒度到最细粒度逐个分析。6.1 使用 ethtool -S安装好 ethtool 后,可通过 -S 选项,后面跟你想要统计的网络设备,来看对应的统计数据。通过 ethtool -S 监控 NIC 设备的统计(比如 packet drops):
$ 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" 等标签的值,但你得阅读驱动源码方能知晓哪些值是在软件中统计的(比如,内存不足情况的计数),哪些值是直接从硬件寄存器中读来的。如果是来自寄存器的值,你应该查阅硬件的 data sheet 来获知这个值的真正含义;ethtool 中的很多标签都是带有误导性的。

6.2 使用 sysfs

sysfs 也可以提供很多统计信息,但相对 NIC 层的状态信息来说,它们会更 high level。可以通过 cat 一个文件来查看一个网络设备(比如 eth0)上被丢弃的 incoming 网络数据帧的数量。通过 sysfs 查看 high level 的 NIC 统计:
$ cat /sys/class/net/eth0/statistics/rx_dropped
这些统计值会分为多个独立的文件,比如 collisions、rx_dropped、rx_errors、rx_missed_errors 等。不幸的是,这些值到底是啥含义、什么时候会增加这些值的计数,以及这些值从哪里来,还是驱动强相关的。可能存在的情况是,有些驱动会将某些错误条件视为 drop,而其他驱动会将这些视为 miss。如果这些值对你很重要,你就必须得阅读驱动源码来搞清楚驱动到底是怎么认知这些值的。

6.3 使用 /proc/net/dev

一个更 high level 的文件是 /proc/net/dev,其为系统中的每个网络适配器提供一个 high level 的总览。通过读取 /proc/net/dev 获取 high level 的 NIC 统计:
$ 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 文件中找到对应的项,但它仍然不失为一个有用的总览。上节中的警告,在本节中依然存在:如果这些值对你很重要,你最好阅读驱动源码,以搞清楚这些值 when、where 以及 why 会累计,如此确保你对 error、drop 或 fifo 的理解与驱动一致。

7. 网络设备调优

7.1 检查正在使用的 RX 队列数量


如果你的 NIC 以及设备驱动支持 RSS/multiqueue,可以通过 ethtool 来调整 RX queues(又称为 RX channels)的数量。通过 ethtool 检查 NIC receive 队列的数量:
$ sudo ethtool -l eth0
Channel parameters for eth0:
Pre-set maximums:
RX:   0TX:   0Other:    0Combined: 8
Current hardware settings:
RX:   0TX:   0Other:    0Combined: 4
上面的输出展示了预设的最大值(由驱动和硬件强制规定的)以及当前值。注意:并非所有设备驱动都支持该操作。如果你的 NIC 不支持该操作则会看到如下错误:
$ sudo ethtool -l eth0Channel parameters for eth0:Cannot get device channel parameters: Operation not supported这表明你的驱动并未实现 ethtool get_channels 操作。这可能是因为 NIC 本身不支持调整队列数量,不支持 RSS/multiqueue,或者你的驱动尚未支持此特性。

7.2 调整 RX 队列数量


找到当前及最大的队列数量后,可以通过 sudo ethtool -L 来调整这些值。注意:有些设备及驱动只支持 combined 队列,它们是按 transmit 与 receive 成对出现的,正如上一节所示。通过 ethtool -L 将 combined NIC transmit 与 receive 队列设置为 8:
$ sudo ethtool -L eth0 combined 8如果你的设备及驱动支持对 RX 和 TX 进行单独设置,且你只想将 RX 队列数量调整为 8,你应当运行如下命令。通过 ethtool -L 将 NIC receive 队列数设置为 8:
$ sudo ethtool -L eth0 rx 8注意:在做出这些调整之后,大多数驱动会先 down 掉网络接口然后再 up 起来;该网络接口上的连接会中断。如果只是做一次设置的话,影响倒不大。7.3 调整 RX 队列大小有些 NIC 及它们的驱动还支持调整 RX 队列的大小。具体怎么实现的是硬件 specific 的,但幸运的是 ethtool 提供了一个调整大小的通用方式。增大 RX 队列的大小,会减少大量数据帧到来时的 NIC 丢包,但软件还是可能会丢包,所以还需要其他调优手法来减少或彻底杜绝丢包。通过 ethtool -g 检查当前 NIC 的队列大小:
$ 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 个 receive 和 transmit descriptors(译者注:descriptor 数就是队列的大小),而当前的设置只有 512。通过 ethtool -G 将每个 RX 队列的大小增加至 4096。
$ sudo ethtool -G eth0 rx 4096注意:在做出这些调整之后,大多数驱动会先 down 掉网络接口然后再 up 起来;该网络接口上的连接会中断。如果只是做一次设置的话,影响倒不大。7.4 调整 RX 队列权重有些 NIC 支持通过配置权重来调整网络数据在 RX 队列间的分发。对 RX 队列权重的配置需要满足如下条件:
  • 你的 NIC 支持 flow indirection。
  • 你的驱动实现了 ethtool 函数 get_rxfh_indir_size 和 get_rxfh_indir。
  • 你运行的 ethtool 版本足够新,其支持 -x 及 -X 选项来显示及设置 indirection table。
通过 ethtool -x 来检查 RX flow indirection table:
$ sudo ethtool -x eth0
RX flow hash indirection table for eth3 with 2 RX ring(s):
0: 0 1 0 1 0 1 0 18: 0 1 0 1 0 1 0 116: 0 1 0 1 0 1 0 124: 0 1 0 1 0 1 0 1该输出的左侧展示的是队列 0 及队列 1 的 packet 哈希值。故而,如果一个 packet 哈希为 2,则其将被投递给 receive 队列 0,而哈希为 3 的 packet 将被投递给 receive 队列 1。

举个例子:在前两个 RX 队列间公平地投递流量:
$ sudo ethtool -X eth0 equal 2如果你想通过指定权重来改变投递给指定 receive 队列(以及对应 CPUs)的 packet 数量,也可以通过指定对应参数来完成。通过 ethtool -X 指定 RX 队列权重:
$ sudo ethtool -X eth0 weight 6 2
上述命令为 RX 队列 0 指定权重 6,为 RX 队列 1 指定权重 2,如此会向队列 0 打入更多流量。

7.5 调整

网络流的 RX 哈希域有些 NIC 还支持调整哈希算法所用到的域,可以通过 ethtool 调整 RSS 在计算哈希时所用到的域。通过 ethtool -n 检查 UDP RX flow 哈希会用到的域。
$ sudo ethtool -n eth0 rx-flow-hash udp4UDP over IPV4 flows use these fields for computing Hash flow key:IP SAIP DA对于 eth0,UDP flow 的哈希计算会用到的域有:IPv4 的源端地址、目的端地址。我们将源端和目的端的端口也纳入进来。通过 ethtool -N 设置 UDP RX flow 哈希域:
$ sudo ethtool -N eth0 rx-flow-hash udp4 sdfnsdfn 字符串本质是个 bit 序列,每个字母的具体含义自行查阅 ethtool man。调整哈希的域往往很有用,但如果你想更细粒度地控制哪条流交由哪个 RX 队列来处理,ntuple filtering 会更适合。

7.6 通过 ntuple filtering 控制网络流 steering有些 NIC 支持所谓的 "ntuple filtering" 特性,该特性允许用户通过 ethtool 指定参数,以对硬件中的 incoming 网络数据进行过滤,并将它们投递给指定的 RX 队列。举例来说,用户可以指定特定端口的 TCP 报文被投递给 RX 队列 1。Intel NICs 上这个特性一般叫 "Intel Ethernet Flow Director"。其他 NIC vendors 可能会有他们自己的命名。后面会看到,ntuple filtering 是另外一个叫 Accelerated Receive Flow Steering(aRFS) 特性的关键组件,如果你的 NIC 支持 aRFS 的话,通过 aRFS 会让 ntuple 用起来更简单。aRFS 的内容后面再说。如果你想在处理网络数据时,最大化数据的 locality 进而提升 CPU cache 命中率,那么这个特性会是一个好帮手。假设一个运行在 80 端口上的 webserver 配置如下:
  • webserver 被 pin 在 CPU 2。
  • RX 队列的 IRQs 绑定在 CPU 2。
  • 80 端口的 TCP 流量被 ntuple 过滤给 CPU 2。
  • 80 端口的 incoming 流量会由 CPU 2 处理并给到用户程序。
  • 若要对系统的性能进行评估,需要对系统做精细的监控,比如 cache 命中率、网络栈延迟等。
如上文所言,可以通过 ethtool 配置 ntuple filtering,但首先得确认你的设备是否开启了这个特性。通过 ethtool -k 查看 ntuple filtering 是否开启:
$ sudo ethtool -k eth0Offload parameters for eth0:...ntuple-filters: offreceive-hashing: on如你所见,这台设备上的 ntuple-filters 是关闭的。通过 ethtool -K 使能 ntuple filtering:
$ sudo ethtool -u eth0
40 RX rings available
Total 0 rules
如你所见,此设备尚无 ntuple filter 规则,可以通过 ethtool 指定规则。我们添加一条规则,指定 80 端口的 TCP 流量分发给 RX 队列 2:
$ sudo ethtool -U eth0 flow-type tcp4 dst-port 80 action 2
还可以使用 ntuple filtering 从硬件层丢弃掉指定 flow 的 packets,这可以缓解来自指定 IP 的大流量。ntuple filter 规则的更多配置信息,参考 ethtool man。通过 ethtool -S [device name],可以检查 ntuple 规则命中或未命中的统计数据。具体来说,Intel NICs 上,fdir_match 与 fdir_miss 可以计算你的 ntuple filtering 规则命中或未命中的次数。关于统计数据的更多细节,参阅设备驱动源码以及设备 data sheet。

7.7 软中断
在深入网络栈之前,我们先了解一下 linux 内核软中断。

7.7.1 软中断是什么linux
内核的软中断系统是一个在中断上下文之外执行由驱动所实现的代码的机制。软中断很重要,因为在一个中断处理函数执行过程中可能会关硬件中断(译者注:这里不是指不让设备发中断,而是让 CPU 不处理中断,又分为两种情况,一是中断控制器不投递中断,二是中断控制器虽然投递中断但 CPU 不响应)。关中断的时间越长,就越容易错过事件的处理。所以需要将比较耗时的中断处理逻辑放到(推迟到)中断上下文之外去执行,如此中断处理函数可以尽快结束并重新使能设备中断。内核有很多推迟运行的机制,对于网络栈来说我们主要关注软中断机制。软中断系统可以理解为一坨内核线程(每个 CPU 一个),这些线程里面运行的是为不同软中断事件而注册的处理函数。打开 top,ksoftirqd/0 内核线程就是运行在 CPU 0 上的软中断内核线程。诸如网络之类的内核子系统可以通过 open_softirq 函数注册一个软中断处理函数。后面我们会看到网络系统是怎么注册它的软中断处理函数的,但我们先稍微研究一下软中断是怎么工作的。

7.7.2 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 中被调用的,作为一种类似于事件 loop 的一部分。kernel/smpboot.c 中的代码先调用 ksoftirqd_should_run 来判断是否有 pending 的软中断待处理,如果有的话则执行 run_ksoftirqd。run_ksoftirqd 会在调用 __do_softirq 之前做一些记录工作。

7.7.3 __do_softirq
__do_softirq 函数做了如下事情:
  • 判断是否有 pending 的软中断。
  • 软中断的时间统计。
  • 软中断的执行统计。
  • 为 pending 的软中断执行软中断处理函数(此处理函数通过 open_softirq 注册)。
所以,如果你在 CPU 利用率图表中看到 softirq 或 si,应该明白这是推迟工作上下文中的 CPU 利用率。7.7.4 监控 /proc/softirqssoftirq 的统计数据在 /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     634534BLOCK_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 是否发生变化。接下来我们继续研究网络栈,并追踪一个网络数据是怎么自下而上传递的。

8. linux 网络设备子系统

现在我们看看网络驱动和软中断到底是怎么工作的,以及 linux 网络设备子系统是怎么初始化的,然后我们会追踪一个 packet 的投递路径。

8.1 网络设备子系统初始化
网络设备(netdev)子系统是在 net_dev_init 函数中初始化的。这个初始化函数中做的事情很有意思。
8.1.1 struct softnet_data 数据结构初始化
net_dev_init 为系统中的每个 CPU 创建一组 struct softnet_data 数据结构。这些数据结构中包含网络数据处理时所需的重要信息:
  • 注册到此 CPU 上的 NAPI 数据结构列表。
  • 数据处理的 backlog(译者注:就是队列)。
  • 处理权重。
  • receive offload 数据结构列表。
  • receive packed steering 配置。
  • 其他。
随着对网络栈的深入,会逐步展开这些细节。

8.1.2 软中断处理函数初始化

net_dev_init 会注册一个 transmit 和一个 receive 软中断处理函数,它们会在处理 incoming 或 outgoing 网络数据时被调用。代码很直白:
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 函数。8.2 数据到达终于,网络数据来了!假设 RX 队列有足够的可用 descriptors,packet 被 DMA 写入 RAM。设备随后触发分配给它的中断(如果是 MSI-X 场景,中断是与 packet 所到达的 RX 队列绑定的,译者注:意思就是 packet 被送到哪个 CPU 的 RX 队列上,就向哪个 CPU 触发 MSI-X 中断)。8.2.1 中断处理通常来说,中断处理函数应该尽可能将任务推迟到中断上下文之外去处理。这非常重要,因为一个中断在被处理时,其他中断是被阻塞的。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 简单地更新一个硬件 specific 寄存器。该代码所更新的寄存器,是用来记录硬件中断速率的。这个寄存器可以与一个叫 "Interrupt Throttling" 又称 "Interrupt Coalescing" 的硬件特性结合使用,从而限制向 CPU 投递中断的速率。我们后面会看到如何利用 ethtool 调整 IRQs 的触发速率。然后,通过 napi_schedule 唤醒 NAPI processing loop(如果 NAPI loop 并未激活的话)。注意,NAPI processing loop 是在软中断上下文中执行的,而并非在中断处理函数中执行。中断处理函数只是让 NAPI loop 在未激活状态时投入运行而已。阅读相关代码非常重要,可以让我们搞清楚多 CPU 系统是如何处理网络数据的。8.2.2 NAPI 与 napi_schedule我们来看一下硬件中断处理函数中所调用的 napi_schedule 到底干了些啥。NAPI 的作用是为了在网络数据收取时 NIC 无需再发出通知中断。如上文所言,NAPI poll loop 会在收到一个硬件中断后被拉起。换句话说:NAPI 是使能但未开启的,直到 NIC 接收到第一个 packet 并发起一个 IRQ 之后 NAPI 被开启。后面会说到,在其他一些情况下,NAPI 可以被禁能,且需要一个硬件中断来重新开启之。驱动的中断处理函数调用 napi_schedule 来开启 NAPI poll。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。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);
}

这段代码干了两件事:
  • 从设备驱动的中断处理函数传过来的 struct napi_struct,会被加入到当前 CPU softnet_data 数据结构的 poll_list 上。
  • 通过 __raise_softirq_irqoff 触发一个 NET_RX_SOFTIRQ 软中断,这将进一步调用到网络设备子系统初始化时所注册的 net_rx_action(如果它尚未被执行的话)。
简而言之,软中断处理函数 net_rx_action 会调用 NAPI 的 poll 函数来收包。8.2.3 关于 CPU 及网络数据处理注意,截止目前我们所看到的代码,是通过本 CPU 相关的数据结构,实现将数据处理从硬件中断上下文推迟到软中断上下文中执行的。驱动的 IRQ 处理函数本身做的事情很少,同 CPU 上的软中断处理函数帮助做了驱动 IRQ 处理函数的工作。这也是为啥指定 IRQ 的处理 CPU 很重要:此 CPU 不仅执行驱动的中断处理函数,其还负责在软中断中通过 NAPI 来收包。如我们马上会看到的,诸如 Receive Packet Steering 之类的技术,可以将网络栈的部分工作分发给其他 CPUs。

9. 监控网络数据到达

9.1 硬件中断请求
注意:对硬件 IRQs 的监控并不能全面地获知包处理的健康情况,如后面所看到,大多数驱动在运行 NAPI 时会关闭硬件 IRQs。通过 /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 统计信息,可以看到 packets 收取时硬件中断的数量及速率的变化,如此确保 NIC 的每个 RX 队列被交由适当的 CPU 来处理。如后面所见,这些数字只能告诉我们发生了多少硬件中断,其并不能直接反应收到或处理了多少数据,因为大多数驱动在调用 NAPI 时会关闭 NIC IRQs。并且,interrupt coalescing 也会影响此文件的中断统计。通过观察此文件,可以搞清楚你的 interrupt coalescing 设置是否真的生效了。想要完整地搞清楚网络处理的健康情况,你应该再看看 /proc/softirqs(上文提到过)以及后面会提到的一些其他 /proc 文件。

10. 网络数据到达调优

10.1 interrupt coalescing

interrupt coalescing 机制可以在 pending work 或 events 的数量达到指定阈值时,阻止设备继续向 CPU 发送中断。这可以防止 interrupt storms,取决于具体配置情况,其会提高吞吐率或延迟。中断越少,吞吐率越高,延迟越高,CPU 利用率越低。中断越多,吞吐率越低,延迟越低,CPU 利用率越高。早期版本的 igb,e1000,以及其他驱动支持 InterruptThrottleRate 参数。现代驱动中此参数被一个通用的 ethtool 函数替代。通过 ethtool -c 获取当前 IRQ coalescing 设置:
$ sudo ethtool -c eth0Coalesce parameters for eth0:Adaptive RX: off  TX: off
stats-block-usecs: 0
sample-interval: 0
pkt-rate-low: 0
pkt-rate-high: 0
...ethtool 提供通用接口来配置 coalescing。但并非所有设备及驱动都支持所有的 coalescing 配置,你应该检查驱动文档或源码来确认到底支持哪些配置。根据 ethtool 文档,驱动未实现的值都将被忽略。有些驱动支持所谓的 "adaptive RX/TX IRQ coalescing" 选项,该选项通常实现在硬件中。驱动通常需要知会 NIC 使能此特性,并做一些记录工作(参考上面的 igb 驱动代码)。使能 adaptive RX/TX IRQ coalescing 的效果是,当 packet 速率很低时,中断的触发会被调整为降低延迟,当 packet 速率很高时,中断的触发会被调整为提高吞吐率。通过 ethtool -C 使能 adaptive RX/TX IRQ coalescing:
$ sudo ethtool -C eth0 adaptive-rx on还可以使用 ethtool -C 做一些选项配置。一些通用选项有:
  • rx-usecs:一个 packet 到达后将 RX 中断推迟多少微秒。
  • rx-frames:最多在收到多少个数据帧后触发 RX 中断。
  • rx-usecs-irq:系统正在处理中断时,将 RX 中断推迟多少微秒。
  • rx-frames-irq:系统正在处理中断时,最多在收到多少个数据帧后触发 RX 中断。
除此之外,还有很多选项。再次重申,你的硬件及驱动可能只支持上述选项中的一部分。具体支持哪些 coalescing 选项,请参阅驱动源码和硬件 data sheet。不幸的是,你可以配置的选项除了头文件之外没有其他地方是有详细注解的。ethtool 所支持的所有选项的注解参考 include/uapi/linux/ethtool.h。注意:虽然乍看上去 interrupt coalescing 是很有用的优化,但网络栈内部在尝试做优化时也会做中断的打包。interrupt coalescing 在有些场合下有用,但你需要确保网络栈也做了合理的调优。仅是简单地修改 coalescing 配置收益往往有限。10.2 调整 IRQ 亲和性如果你的 NIC 支持 RSS/multiqueue,或者你尝试优化数据的 locality,你可能需要指定一组 CPU 来处理 NIC 中断。配置指定 CPUs,可以让不同 IRQs 在不同 CPUs 上处理。如我们已经看到的,这会影响网络栈上层的操作。如果你确定要调整 IRQ 亲和性:首先,应该检查 irqbalance 守护进程是否处于运作状态。该守护进程会尝试自动在 CPUs 之间均衡 IRQs,并会覆盖掉你的设置。如果你正在运行 irqbalance,可以关闭之,或通过 --banirq 选项加 IRQBALANCE_BANNED_CPUS 参数来让 irqbalance 知道它不可以操作哪些 IRQs 及 CPUs(这些你会自己手动操作)。其次,通过 /proc/interrupts 确认 NIC 每个 RX 队列的 IRQ 号。最后,通过修改每个 IRQ 的 /proc/irq/IRQ_NUMBER/smp_affinity 来设置这些 IRQs 应该被哪些 CPUs 处理。通过写入十六进制的 bitmask 来告知内核,此 IRQ 应由哪些 CPUs 处理。示例:将 IRQ 8 的亲和性指定为 CPU 0:
$ sudo bash -c 'echo 1 > /proc/irq/8/smp_affinity'11. 网络数据处理的开始一旦软中断代码获知有 pending 的软中断,其开始处理,并执行 net_rx_action,网络数据处理自此开始。我们管窥一下 net_rx_action processing loop 来搞清楚它是怎么工作的,以及哪些部分可以调优、哪些部分可以监控。

11.1 net_rx_action processing loop

net_rx_action 从内存开始做 packets 处理,内存中有设备 DMA 传入的 packets。此函数遍历当前 CPU 上的 NAPI 数据结构链表,取出每个数据结构并对其进行操作。processing loop 会限制可由 NAPI poll 函数处理的 work 数量以及其可消耗的执行时间。通过如下两个手法做到:
  • 记录 work 的 budget(budget 可被调整)。
  • 校验流逝的时间。
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;由此内核可以避免 packet 处理占据所有 CPU 时间。上面的 budget 是注册到此 CPU 上的所有 NAPI 数据结构可消费的 budget 总数。multiqueue NICs 需要对 IRQ 亲和性做调优还有另外一个重要原因:正因为处理 IRQ 的 CPU 也是执行软中断处理函数的 CPU,故而,此 CPU 也是执行上面 loop 以及做 budget 计算的 CPU。有多个 NICs,每个 NIC 又有多队列的系统,可能会出现多个 NAPI 数据结构注册到同一个 CPU 上的情况,同一 CPU 上的所有 NAPI 数据处理,消费的是同一个 budget。如果你没有足够的 CPUs 来分发 NIC IRQs,可以考虑增加 net_rx_action budget,以此让每个 CPU 可以处理更多的 packet。增加 budget 会增加 CPU 利用率(尤其是 sitime 或 top 中的 si 或其他程序),但可以降低延迟,因为数据可以更快得到处理。注意:无论赋予多少 budget,CPU 仍然有 2 jiffies 的时间限制。

11.2 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 processing loop 是怎么使用该权重的。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 数据结构中的权重(weight,上面代码给的是 64),并将其传给同样是注册进 NAPI 数据结构(上文代码注册的是 igb_poll)中的 poll 函数。poll 函数返回的是处理的数据帧数量。这个数量就是上文提到的所记录的 work,其会被从总 budget 中减去。所以,假设:
  • 你的驱动使用的是权重 64(linux 3.13.0 中所有驱动皆硬编码为该值)。
  • 你的 budget 默认设置为 300。
出现以下任意情况,系统会停止数据处理:
  • igb_poll 函数最多被调用 5 次(如下文所见,没有数据处理的情况下会更少)。
  • 至少消耗了 2 jiffies 的时间。
译者注:权重就是一个 poll 所能消费的 budget 比例。11.3 NAPI/网络设备驱动的逻辑约定NAPI 子系统及设备驱动之间关于“停止 NAPI”有如下逻辑约定:
  • 如果一个驱动的 poll 函数消费掉了它的所有权重(硬编码为 64),则其必须不能修改 NAPI 的状态(译者注:意思就是此情况下 poll 函数不能停止 NAPI)。此时逻辑会重新回到 net_rx_action loop 中(译者注:这个读一下 net_rx_action 源码就知道了,其调用了 napi_poll,如果 work == weight 也就是驱动消费掉了全部的权重,则逻辑直接就从 napi_poll 中返回到 net_rx_action 中)。
  • 如果一个驱动的 poll 函数没有消费掉它的所有权重,其必须禁能 NAPI。下一个 IRQ 到来时 NAPI 会重新使能,设备的 IRQ 处理函数会调用 napi_schedule。

我们马上会看 net_rx_action 如何处理第一个约定,随后会看 poll 函数如何处理第二个约定。11.4 结束 net_rx_action loopnet_rx_action processing loop 的最后部分代码是处理上一节所述的第一个约定。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);  }}如果所有权重都消费完(译者注:work == weight),net_rx_action 会处理如下两种场景:
  • 如果网络设备应该停机(比如用户运行了 ifconfig eth0 down),则执行之。
  • 否则,检查是否有 generic receive offload(GRO)列表。如果 time tick rate >= 1000(译者注:系统 tick 频率,也就是 HZ,感觉源代码中的注释 "If HZ < 1000, flush all packets" 应该是笔误了?实际代码是 HZ >= 1000),所有最近被更新过的 GRO 网络流都会被 flush,下文会深入 GRO 细节。将当前 NAPI 数据结构移动到该 CPU 列表的尾端,如此下个迭代中会选中下一个被注册的 NAPI 数据结构(译者注:就是 round robin)。
以上就是 packet processing loop 如何调用驱动所注册的 poll 函数来处理 packets。如后面会看到的,poll 函数会收取网络数据,并向上给到网络栈处理。11.5 适时退出 loopnet_rx_action loop 会在以下任意条件满足时退出:
  • 此 CPU 上 poll list 再无注册的 NAPI 数据结构(!list_empty(&sd->poll_list))。
  • 剩余 budget <= 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 数据结构中做一些数据统计,并关闭软中断 NET_RX_SOFTIRQ。time_squeeze 域记录的是 net_rx_action 明明还有活干,但遇到 budget 耗尽或时间限制已到的次数,该指标在分析网络处理瓶颈时灰常灰常重要,后面会讲怎么监控这个指标。NET_RX_SOFTIRQ 禁能后其他 tasks 方有时间得到执行,这很好理解,因为这段代码只在明明还有任务需要处理时才会执行,但我们并不想一直霸占整个 CPU。
代码的执行流随后跳转到 out 标签。跳转到 out 标签的还有一种场景,就是没有 NAPI 数据结构可处理了,换句话说,budget 数量比网络任务要多,且所有驱动都关停了 NAPI,进而导致 net_rx_action 无事可做。在从 net_rx_action 返回之前,out 标签还做了一件很重要的事:其调用 net_rps_action_and_irq_enable。Receive Packet Steering 使能的情况下此函数承担着重要使命:唤醒远程 CPUs 来处理网络数据。后面会分析 RPS 的工作原理。现在我们看看怎么监控 net_rx_action processing loop 的健康度,并进一步分析 NAPI poll 函数的底层原理,如此进一步展开对网络栈的分析。

11.6 NAPI poll

上文提到,设备驱动会分配一片内存区域,设备会将 incoming packets DMA 到这片区域。正因为驱动负责这些区域的分配,其也负责这些区域的 unmap、数据收取,以及将数据向上给到网络栈。下面通过 igb 驱动来看一下这些工作具体是怎么完成的。

11.6.1 igb_poll

终于可以分析咱们可爱的 igb_poll。igb_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;}
这段代码干了下面这些有意思的事:
  • 如果内核使能了 Direct Cache Access(DCA)支持,则对 CPU cache 进行预热,如此访问 RX ring 时会命中 CPU cache。DCA 的更多细节参阅本文最后的番外章节。
  • 随后,调用 igb_clean_rx_irq 来干点脏活,后面会说。
  • 随后,通过 clean_complete 来检查是否还有工作要处理。如果还有工作待处理的话,则返回 budget(请牢记,这里硬编码了 64)。如上文所述,net_rx_action 里面会将此 NAPI 数据结构移到 poll list 的尾端。
  • 否则(没有更多工作需要处理),驱动通过 napi_complete 关停 NAPI 并通过 igb_ring_irq_enable 重新使能中断。下一个中断来时将重新使能 NAPI。
下面我们看看 igb_clean_rx_irq 是怎么把数据向上给到网络栈的。11.6.2 igb_clean_rx_irqigb_clean_rx_irq 函数里面是一个 loop,其每次处理一个 packet,直到 budget 消耗殆尽或没有更多数据需要处理。此函数的 loop 做了下面这些重要的事情:
  • 当清除掉 used buffer 后,分配额外的 buffer 来接收数据。每次增加 IGB_RX_BUFFER_WRITE(16)个 buffer。
  • 从 RX 队列中获取一个 buffer,并将其存到一个 skb 数据结构中。
  • 校验此 buffer 是否是一个 "End Of Packet" buffer。如果是,则继续处理。否则,继续从 RX 队列中获取 buffer 并将它们添加至 skb。这个校验很重要,因为有些数据帧的大小会超过 buffer 的大小。
  • 校验数据的 layout 及首部是否合法。
  • 将处理字节数统计值加上 skb->len。
  • 设置 skb 的 hash、checksum、timestamp、VLAN id 及 protocol 域。hash、checksum、timestamp 及 VLAN id 是由硬件提供的。如果硬件发出 checksum 错误,则增加 csum_error 统计值。如果 checksum 校验成功,且数据是 UDP 或 TCP 数据,则将 skb 标记为 CHECHSUM_UNNECESSARY。如果 checksum 校验失败,则由协议栈来处理此 packet。通过 eth_type_trans 获取具体协议,并将其存到 skb 数据结构中。
  • 调用 napi_gro_receive 将构造好的 skb 向上递交给网络栈。
  • 增加被处理 packets 的统计值。
  • loop 处理继续,直到 packets 数量达到 budget 限制。
一旦 loop 终止,此函数会更新 RX 队列及所处理字节数的统计值。在深入研究网络栈之前,我们先了解另外两个知识。其一,研究一下如何对网络子系统的软中断做监控及调优,其二,研究一下 Generic Receive Offloading(GRO)。在那之后,我们顺着 napi_gro_receive 进入网络栈方能看得更清楚。

12. 网络数据处理监控


12.1 /proc/net/softnet_stat

如上文所述,net_rx_action 会在还有工作需要处理但却不得不(软中断的 budget 不足或时间限制已到)退出 net_rx_action loop 时做数据统计。这会作为相应 CPU struct softnet_data 统计数据的一部分。这些统计数据可以通过 /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 一个。
  • 每一行的值用空格切分,16 进制呈现。
  • 第 1 个值 sd->processed:处理的网络帧数量。如果你使用了 ethernet bonding,这个值可能会比所有收到的网络帧数量还要大。有些场景下,ethernet bonding 的驱动会让网络数据被重新处理,这会导致同一 packet 多次增加 sd->processed。
  • 第 2 个值 sd->dropped:处理队列因空间不足而导致的网络帧丢包数量。后面细说。
  • 第 3 个值 sd->time_squeeze:如上文所述,因为 budget 消耗殆尽或时间限制已到(但实际上仍有工作需要处理)而导致的 net_rx_action loop 终止的次数。如上文所述,增大 budget 可以减少该值。
  • 其后 5 个值始终为 0。
  • 第 9 个值 sd->cpu_collision:发送 packets 时尝试拿设备锁时发生 collision 的次数。本文讨论的是数据收,所以下文不讨论此统计值。
  • 第 10 个值 sd->received_rps:此 CPU 被 Inter-processor Interrupt(IPI)唤醒来处理 packets 的次数。
  • 最后一个值 flow_limit_count:flow limit 达到阈值上限的次数。flow limiting 是 Receive Packet Steering 的一个可选特性,后面会说。
如果要监控此文件,你最好阅读一下内核代码以确认这些域的顺序及含义没有发生变化。13. 网络数据处理调优13.1 调优:net_rx_action budget通过设置 net.core.netdev_budget 的 sysctl 值,可以调整 net_rx_action budget,如上文所言,该值决定了注册至指定 CPU 上的所有 NAPI 数据结构,在处理 packet 时所能消耗的 budget。举例:将总的 packet 处理 budget 设置为 600:
$ sudo sysctl -w net.core.netdev_budget=600你还可以将修改固化到 /etc/sysctl.conf 文件,这样即使机器重启修改依然生效。linux 3.13.0 的默认值是 300。

14. Generic Receive Offloading(GRO)

Generic Receive Offloading(GRO)是一个名曰 Large Receive Offloading(LRO)的硬件优化的软件实现。此二者方法背后的主要思想是,将“足够相似”的 packets 进行组合(combining),如此减少送到网络栈中的 packets 数量,从而降低 CPU 利用率。举个具体的例子,设想传输一个大文件,大多数 packets 包含文件中的块数据。相较于每次将小的 packets 送给网络栈,更好的做法是先将这些 incoming packets 组合为一个 payload 贼大的 packet,再将此 packet 给到网络栈。这在将更大块的数据投递给用户程序时,允许协议层变更一个 packet 的首部。该优化存在的一个显然问题是信息丢失。如果一个 packet 有一些重要的选项或 flag,当此 packet 与其他 packet 合并时,这些选项或 flag 可能会丢失。这也是为啥大多数人不使用或不鼓励使用 LRO。通常来说,LRO 实现所涉及的 packet 合并规则并不明确。GRO 可视为 LRO 的软件实现,但在哪些包可以合并的规则上更为严格。顺便提一下,如果你用过 tcpdump 并观察到过奇大无比的 incoming packet,那大概率是因为你的系统使能了 GRO。如下文所述,在做了 GRO 之后,会在网络栈上插入 packet capture taps(译者注:意思就是使能 GRO 的时候,会在网络栈的处理流程上插入一个钩子,这个钩子称为 tap)。

14.1 调优:通过 ethtool 调整 GRO 设置
你可以通过 ethtool 检查 GRO 是否使能,并调整设置。使用 ethtool -k 检查 GRO 设置:
$ ethtool -k eth0 | grep generic-receive-offloadgeneric-receive-offload: on如你所见,我的系统上 generic-receive-offload 已使能。使用 ethtool -K 使能或禁能 GRO。
$ sudo ethtool -K eth0 gro on

注意:在做出这些调整之后,大多数驱动会先 down 掉网络接口然后再 up 起来;该网络接口上的连接会中断。如果只是做一次设置的话,影响倒不大。

14.2 napi_gro_receive

napi_gro_receive 函数做 GRO(使能了的情况下)的网络数据处理,并将数据投递给网络栈上方的协议层。此逻辑中的大部分是在 dev_gro_receive 函数中处理的。

14.3 dev_gro_receive

此函数从检查 GRO 是否使能开始,如果使能则准备做 GRO。
GRO 使能的情况下,会遍历一个 GRO offload filters 列表,以使更高层的协议栈可以处理准备 GRO 的数据。这使得协议层可以让网络设备层知道该 packet 是否是一条当前被 receive offloaded 的网络流的一部分,并让网络设备层处理 GRO 某个特定协议相关的逻辑。举例来说,TCP 协议需要知道是否或何时为一个要合并的 packet 发送 ACK。
译者注:filter 是高层协议注册给底层网络设备层的,网络设备层通过调用协议层的这个 filter 钩子,获知当前 packet 是否是要被 GRO 的。
下面 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;
}

如果协议层表示是时候该 flush 被 GRO 的 packet 了,则调用 napi_gro_complete 来干这事,进而调用到协议层的 gro_complete,并通过 netif_receive_skb 将 packet 向上给到网络栈。译者注:所谓的 GRO flush,就是把已完成合并的最后的总 packet,给到网络栈。没 flush 之前相当于都还在 buffer 中。下面 net/core/dev.c 代码是干这事的:
if (pp) {
  struct sk_buff *nskb = *pp;  
  *pp = nskb->next;
  nskb->next = NULL;
  napi_gro_complete(nskb);
  napi->gro_count--;
}

接下来,如果协议层将此 packet 合并至一个已有 flow 中,则 napi_gro_receive 将简单地返回无事可做。如果此 packet 没有被合并,且系统中有少于 MAX_GRO_SKBS(8)的 GRO flows,则向此 CPU 的 NAPI 数据结构的 gro_list 上加入一个新的 entry。译者注:gro_list 就相当于还没 flush 之前的 buffer。MAX_GRO_SKBS 相当于此 buffer 最多能有多少个。下面 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 的工作原理。

14.4 napi_skb_finish

napi_skb_finish一旦 dev_gro_receive 执行完毕,会接着调用 napi_skb_finish,此函数要么释放掉一些不再需要的数据结构(因为一个 packet 已经被合并了),要么调用 netif_receive_skb 将数据向上送进网络栈(因为已经有 MAX_GRO_SKBS 条流被 GRO 了)。
接下来,就需要看看 netif_receive_skb 是怎么把数据给到协议层的。但在此之前,我们先瞅瞅 Receive Packet Steering(RPS)。

15. Receive Packet Steering(RPS)

回想一下上文提到的,网络设备驱动是怎么注册一个 NAPI poll 函数的。每个 NAPI poll 实例都会在一个 per CPU 的软中断上下文中执行。运行驱动 IRQ 处理函数的 CPU 会唤醒其软中断 processing loop 来处理 packets。换句话说:一个 CPU 处理硬件中断并 poll packets 处理 incoming 数据。有些 NICs(比如 Intel I350)在硬件层支持多队列。这意味着不同队列的 incoming packets 可以被 DMA 到不同的内存区域,并可以由不同的 NAPI 数据结构来对此区域进行 polling。因此会有多个 CPUs 响应设备中断并处理 packets。这个特性通常称为 Receive Side Scaling(RSS)。Receive Packet Steering(RPS)是 RSS 的软件实现。因为其是软件实现的,这意味着其可在任意 NIC 上使能,即使这些 NICs 只有一个 RX 队列。然而,也正因为其是软件实现的,RPS 只能在 packet 已经从 DMA 内存区域被收取后介入。这意味着,你不要指望它可以减少花在中断处理或 NAPI poll loop 上的 CPU 时间,但你可以在 packet 被收取(从 DMA 区域)后将 packet 处理的负载均衡地分发出去,从而减少上层网络栈所消耗的 CPU 时间。RPS 会为 incoming 数据生成一个哈希,并由此决定应该由哪个 CPU 来处理数据。这个数据随后被入队到 per-CPU 的网络接收 backlog(译者注:就是队列)中以待后续处理。随后向 backlog 所属 CPU 发起一个 Inter-processor Interrupt (IPI),如果 CPU 当前并未在处理 backlog 上的数据,则启动 backlog 处理。/proc/net/softnet_stat 有一个 received_rps 计数域,记录每个 softnet_data 数据结构接收到的 IPI 次数。因此,netif_receive_skb 要么将网络数据投递给上层网络栈,要么将其交给 RPS 以在另外一个 CPU 上处理。

15.1 调优:使能 RPS要使用 RPS,必须在内核配置(kernel 3.13.0 Ubuntu)中使能之,并提供一个 bitmask 来描述由哪些 CPU 对指定网络接口及 RX 队列进行处理。这些 bitmasks 的更多细节参考内核文档 Documentation/networking/scaling.txt。简而言之,通过 /sys/class/net/DEVICE_NAME/queues/QUEUE/rps_cpus 来修改这些 bitmasks。
对于 eth0 的 receive 队列 0,你可以将 /sys/class/net/eth0/queues/rx-0/rps_cpus 修改为一个十六进制值,这个值表示 eth0 的 receive 队列 0 该由哪些 CPUs 处理。如内核文档 Documentation/networking/scaling.txt 所指出的,有些配置下不需要 RPS。
注意:如果此前并未在处理 packets 的 CPUs,被使能作为 RPS 的 packet 处理 CPU,会导致此 CPU 的 "NET_RX" 软中断数增加,CPU 利用率中的 "si" 或 "sitime" 也会增加。通过对比前后软中断和 CPU 利用率,可以确认 RPS 是否配置正确。

16. Receive Flow Steering(RFS)

receive flow steering(RFS)是与 RPS 配合使用的。RPS 只是负责在多个 CPUs 之间投递 incoming packet,但并不会关注数据的 locality 以最大化 CPU 的 cache 命中率。这时候你就需要通过 RFS 将相同 flow 的 packets 投递给同一个 CPU 处理来提升 cache 命中率。

16.1 调优:使能 RFS你得先使能、配置 RPS,从而使用 RFS。RFS 会为所有的 flow 建立一个全局哈希表,可以通过配置 net.core.rps_sock_flow_entries sysctl 来调整此哈希表的大小。设置 sysctl 来调大 RFS socket flow 哈希的大小:
$ sudo sysctl -w net.core.rps_sock_flow_entries=32768

其次,你还可以通过写入每个 RX 队列的 rps_flow_cnt sysfs 文件,来设置每个 RX 队列的 flows 数量。举个例子:将 eth0 的 RX 队列 0 的 flow 数调整为 2048:
$ sudo bash -c 'echo 2048 > /sys/class/net/eth0/queues/rx-0/rps_flow_cnt'

17. 硬件 accelerated Receive Flow Steering(aRFS)借助硬件可以加速 RFS;NIC 可以和内核一起协同决定哪些 flows 应当交由哪些 CPUs 处理。要使能此特性,需要 NIC 和驱动的支持。可以查阅你的 NIC data sheet 来确认是否支持此特性。如果你的 NIC 驱动支持一个叫 ndo_rx_flow_steer 的函数,则驱动支持 accelerated RFS。

17.1 调优:使能 accelerated RFS(aRFS)假设你的 NIC 和驱动支持 aRFS,则按如下操作使能 accelerated RFS:
  • 使能及配置 RPS。
  • 使能及配置 RFS。
  • 内核编译期使能 CONFIG_RFS_ACCEL。Ubuntu kernel 3.13.0 是使能了的。
  • 如上文所述,使能设备的 ntuple 支持。你可以使用 ethtool 来确认设备是否使能了 ntuple 支持。
  • 配置 IRQ 设置,确保每个 RX 队列由所预期的 CPU 处理。
一旦上述配置完毕,accelerated RFS 会自动将数据投递给该 flow 所绑定 CPU core 的 RX 队列,你无需再手动地为每条 flow 指定一个 ntuple filter 规则。18. 通过 netif_receive_skb 将数据向上给到网络栈接着上文的 netif_receive_skb 继续,此函数会在多个地方调用。两个最常见(也是我们上文看过的)的地方:
  • napi_skb_finish:当 packet 不会被合并到一个 GRO flow 时。
  • napi_gro_complete:如果协议层反馈需要 flush flow 时。
提醒:netif_receive_skb 及其被调用者是在软中断上下文中执行的,通过 top 之类的工具可以看到其所消耗的时间(sitime 或 si)。netif_receive_skb 先检查一个 sysctl 值,以确定用户是否配置了一个 packet 在进出 backlog 队列时需要记录 receive timestamp。如果使能了此配置,则此时(在进入 RPS 以及相应 CPU 的 backlog 队列之前)会对数据做 timestamp。如果未使能此配置,则在数据进入队列后再做 timestamp。如此在使能 RPS 的情况下,可以将 timestamp 的开销分发给多个 CPUs,但同时也会引入一些 timestamp 上的延迟。18.1 调优:RX packet timestamping通过调整 net.core.netdev_tstamp_prequeue sysctl 可以配置 packets 在被接收后应当何时做 timestamp。调整该 sysctl 可禁能 RX packets timestamping:
$ sudo sysctl -w net.core.netdev_tstamp_prequeue=0
该值默认是 1。具体配置是啥含义,请参考上面章节的解释。

19. netif_receive_skb处理完 timestamp 后,netif_receive_skb 会根据是否使能 RPS 做下一步操作。咱们先看禁能 RPS 的场景。

19.1 禁能 RPS 场景(默认配置)如果禁能了 RPS,会调用 __netif_receive_sk 做一些记录工作,并调用 __netif_receive_skb_core 将数据进一步给到协议栈。下文会详解 __netif_receive_skb_core 是怎么工作的。在此之前,我们先看一下使能 RPS 情况下的代码流程,这些代码也会调用到 __netif_receive_skb_core。

19.2 使能 RPS 场景如果使能了 RPS,在上文提到的 timestamp 处理完后,netif_queue_skb 会计算 packet 应该被投递给哪个 CPU 的 backlog 队列。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。19.3 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 做比较。如果队列的长度大于这个值,则丢弃数据。同样的,会对 flow limit 进行校验,如果达到限制则丢弃数据。这二者情况导致的丢包数量,都会被记录在 softnet_data 数据结构中。注意,此 softnet_data 所属 CPU 就是数据将要入队列的 CPU。
具体丢包数据的细节,参阅上文关于 /proc/net/softnet_stat 的描述。调用 enqueue_to_backlog 的地方并不多。使能 RPS 情况下的 packet 处理及 netif_rx 中调用了此函数。大多数驱动应该使用 netif_receive_skb 而不是 netif_rx。如果你并没有启用 RPS,且你的驱动没有使用 netif_rx,则调整 backlog 大小并不会产生任何影响。
注意:你应该检查一下你的驱动,如果你并未启用 RPS,但是驱动调用的是 netif_receive_skb,调整 netdev_max_backlog 并不会带来性能提升,因为没有数据会进入 input_pkt_queue。如果 input_pkt_queue 足够小,且并未达到 flow limit(下文会详述)限制(或禁能了 flow limit),则数据可以入队。这里涉及的逻辑很有意思,总结下来就是:
  • 如果队列是空的:检查 NAPI 是否已在远端 CPU 上拉起。如果没有,检查队列中是否有待发送的 IPI。如果没有,入队一个数据,并通过 ____napi_schedule 拉起 NAPI processing loop。继续入队数据。
  • 如果队列非空,或上面的操作已完成,则将数据入队。
这段代码因为使用了 goto,略显 tricky,阅读的时候要仔细一点。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;

20. flow limitsRPS 在多个 CPUs 之间分配 packet 处理负载,但如果有一个很大的 flow,其会霸占 CPU 处理时间并导致其他小的 flow 饥饿。flow limits 特性就是用来限制每条 flow 其所能入队到 backlog 的 packets 数量。这可以确保即使有更大的 flow 正在入队数据,较小的 flow 也能得到处理。上文 net/core/dev.c 代码中的 if 语句通过 skb_flow_limit 来校验 flow limit:
if (qlen <= netdev_max_backlog && !skb_flow_limit(skb, qlen))
该代码检查队列是否还有空间,以及是否达到 flow limit 限制。默认情况下 flow limits 是禁能的。如果要使能 flow limits,你需要指定一个 bitmap(类似 RPS 的 bitmap)。

20.1 监控:监控因 input_pkt_queue 满或 flow limit 的丢包参考上文关于监控 /proc/net/softnet_stat 的章节。每当数据未能入队一个 CPU 的 input_pkt_queue 时,dropped 域会增加。

20.2 调优:调整 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。

20.3 调优:调整 backlog poll loop 的 NAPI 权重通过修改 net.core.dev_weight sysctl 调整 backlog NAPI poll 的权重。该值决定了 backlog poll loop 可以消费的总 budget 数(参考上文 net.core.netdev_budget)。举个例子:通过 sysctl 调整 NAPI poll backlog processing loop 的权重:
$ sudo sysctl -w net.core.dev_weight=600
该值默认为 64。
请注意,类似设备驱动注册的 poll 函数,backlog processing 也是运行在软中断上下文中的,如上文所述,其也受限于总的 budget 及时间限制。

20.4 调优:使能 flow limits 及调优 flow limit 哈希表大小通过 sysctl 获取 flow limit 表的大小:
$ sudo sysctl -w net.core.flow_limit_table_len=8192
该值默认为 4096。该值只会影响新分配的 flow 哈希表。所以,如果你想增大哈希表的大小,你应该在使能 flow limits 之前干这事。类似 RPS bitmask,你可以在 /proc/sys/net/core/flow_limit_cpu_bitmap 指定一个 bitmask,表明要使能哪些 CPU 的 flow limit。21. backlog 队列 NAPI poll向 NAPI 中插入 per-CPU backlog 队列的方式与设备驱动相同。需要提供一个 poll 函数,在软中断上下文中处理 packets。与设备驱动一样,还需要提供权重(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;

与设备驱动的 NAPI 数据结构的不同之处在于,此 weight 参数是可调整的,但驱动代码是硬编码为 64。后面的调优章节我们会讲如何通过 sysctl 调整该 weight 值。

21.1 process_backlogprocess_backlog 函数是一个 loop,一直运行直到其 weight(上一章节)消费完或 backlog 上再无数据。backlog 队列上的数据将从 backlog 队列传给 __netif_receive_skb。代码执行到 __netif_receive_skb 后,剩下的与禁能 RPS 场景下一致。也即,__netif_receive_skb 做一些记录,并随后调用 __netif_receive_skb_core 将数据给到协议层。process_backlog 与设备驱动的 NAPI 遵循相同约束,即如果没有用掉总 weight 的话,NAPI 会被禁能。如上文所述,enqueue_to_backlog 中会调用 ____napi_schedule 重新拉起 poll。此函数返回处理的工作数,如上文所述,net_rx_action 会从 budget(如上文,其值可通过 net.core.netdev_budget 调整)中减去处理的工作数。

22. __netif_receive_skb_core 将数据投递给 packet taps 及协议层__netif_receive_skb_core 会将数据给到协议栈。首先,其会检查是否存在处理所有 incoming packets 的 packet taps。一个具体的例子是 AF_PACKET 地址族,通常通过 libpcap 库使用。如果存在这样的 tap,数据会先给到它,并随后给到协议层。

22.1 packet tap 投递如果安装了 packet tap(通常通过 libpcap),如下代码会将 packet 投递到那里。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。

22.2 协议层投递数据给到 taps 后,__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;
每个协议层会向该哈希表指定槽位的列表中注册一个 filter,通过 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 将一个 filter 添加至指定列表。这就是协议层如何注册自己以进行网络数据投递。至此你已经知道网络数据是怎么从 NIC 到达协议层的。

22.3 协议层注册现在我们知道数据是怎么从网络设备子系统到达协议栈的,现在看一下协议层是怎么注册它们自己的。本文以广泛使用的 IP 协议栈为例。

22.3.1 IP 协议层IP 协议层会将自己注册进 ptype_base 哈希表,如此网络设备层方能将数据给过来。net/ipv4/af_inet.c 中的 inet_init 函数完成注册:
dev_add_pack(&ip_packet_type);inet_init 注册了定义在 net/ipv4/af_inet.c 中的 IP packet type 数据结构:
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 又会调用到 packet_type 中的 func(本文场景下,ip_rcv)。22.3.2 ip_rcvip_rcv 函数的实现很 straight-forward。其进行了若干完整性校验,以确保数据是合法的,同时会更新统计数据。ip_rcv 最后会通过 netfilter 的方式将 packet 给到 ip_rcv_finish,如此 packet 在被继续处理之前,会先尝试匹配 IP 协议层上的 iptables 规则并看一眼 packet。net/ipv4/ip_input.c 中 ip_rcv 的最后将数据给到 netfilter:
return NF_HOOK(NFPROTO_IPV4, NF_INET_PRE_ROUTING, skb, dev, NULL, ip_rcv_finish);

22.3.3 netfilter 与 iptables简单起见,我们不展开对 netfilter、iptables 及 conntrack 的探讨。简而言之,NF_HOOK_THRESH 会检查是否有 filter 安装,并一直执行到返回 IP 协议层(我们避免深入 netfilter 以及其所 hook 的诸如 iptables 及 conntrack 之类的东东)。注意:如果你有很多非常复杂的 netfilter 或 iptables 规则,这些规则会在软中断上下文中运行,并会给网络栈带来延迟。如果你必须要安装一些规则集,这些是不可避免的。

22.3.4 ip_rcv_finish一旦 netfilter 有机会看一眼此数据并决定对其做点什么,会调用 ip_rcv_finish。显然,这只会在数据未被 netfilter 丢弃的情况下发生。ip_rcv_finish 从一个优化开始。为了将 packet 投递到适当的地方,需要有来自路由系统的一个 dst_entry。代码一开始会尝试调用该 packet 的更高目标协议层的 early_demux 函数,以获取一个 dst_entry。early_demux 函数是一个优化,其通过检查 dst_entry 是否缓存在 socket 数据结构上,尝试找到投递 packet 所需的 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 sysctl,默认情况下 early_demux 是使能的。下一节会讨论如何禁能之,以及哪些场景下需要禁能。如果此优化使能,并且没有缓存的 entry(比如这是第一个到达的 packet),packet 会被给到内核的路由系统,在那里计算并赋值 dst_entry。路由层完成后,会更新统计数据,并在 ip_rcv_finish 函数的结尾调用 dst_input(skb),该函数进一步调用到此 packet 的 dst_entry(路由系统中会为二者建立联系)数据结构中的 input 函数指针。如果 packet 的最终目的地是本地系统,路由系统会将 packet dst_entry 数据结构中的 input 函数指针赋为 ip_local_deliver 函数。

22.3.5 调优:调整 IP 协议 early demux设置 sysctl 禁能 early_demux 优化:
$ sudo sysctl -w net.ipv4.ip_early_demux=0
该值默认为 1,也即默认使能 early_demux。之所以存在此 sysctl,是因为有些场景下 early_demux 会导致 5% 的吞吐率下降。

22.3.6 ip_local_deliver回想一下 IP 协议层中的如下流程:
  • 调用 ip_rcv 做一些记录。
  • 伴随一个在处理结束时被调用的回调指针,packet 被递交给 netfilter 处理。
  • ip_rcv_finish 就是 netfilter 处理结束时的回调,其后续将 packet 给到网络栈。
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。

22.3.7 ip_local_deliver_finish

ip_local_deliver_finish 从 packet 中获取协议信息,查询此协议所注册的 net_protocol 数据结构,并调用此 net_protocol 数据结构中的 handler 函数。handler 函数将 packet 给到更高的协议层。

22.3.8 监控: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 FragCreatesIp:                   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 */
  /* ... */

扩展的 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

除掉每一行的前缀是 IpExt 之外,格式类似 /proc/net/snmp。一些有意思的统计:
  • InReceives:到达 ip_rcv 并在做数据完整性校验之前,总的 IP packets 数量(译者注:意思就是不管后续有没有被丢包,只要是走到 IP 层的包都算上)。
  • InHdrErrors:非法首部的 IP packets 总数。首部太短、太长、不存在以及 IP 协议版本错误,等等。
  • InAddrErrors:host 无法到达的 IP packets 总数(译者注:应该是路由错误)。
  • ForwDatagrams:被转发的 IP packets 总数。
  • InUnknowProtos:首部中协议未知或未支持的 IP packets 总数。
  • InDiscards:在做数据 trimming 时,由于内存分配失败或 checksum 失败而丢弃的 IP packets 总数。
  • InDelivers:被成功投递到更高协议层的 IP packets 总数。注意,一个 packet 即使 IP 层并未丢弃,上层协议依然可能会丢弃。
  • InCsumErrors:checksum 错误的 IP packets 总数。
注意,这些值都只会在 IP 层的指定地方被统计。代码流程在不断变化,可能会出现重复计数错误或其他统计 bug。如果这些统计数据对你非常重要,你最好读一下 IP 协议层源码,以搞清楚这些统计值到底会在哪些地方被计数。22.4 高层协议注册本文以 UDP 为解构对象,但 TCP 协议 handler 的注册与 UDP 是一样的。net/ipv4/af_inet.c 中的数据结构定义,包含了 UDP、TCP 以及 ICMP 协议的 handler 函数(这些 handler 被 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 address family 的初始化代码中注册了这些数据结构。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 层的入口。

22.4.1 UDP 协议层UDP 协议层的代码在 net/ipv4/udp.c 中。

22.4.2 udp_rcvudp_rcv 函数的代码只有一行,其直接调用 __udp4_lib_rcv 处理数据报。

22.4.3 __udp4_lib_rcv__udp4_lib_rcv 首先校验 packet 是否合法,并获取 UDP 首部、UDP 数据报长度、源地址以及目标地址。然后再做一些额外的完整性及 checksum 校验。回想一下之前的 IP 协议层,其中有为 packet 与一个 dst_entry 建立联系的优化(在 packet 被递交给更高层协议之前,本文场景下也就是 UDP)。如果找到一个 socket 及其对应 dst_entry,__udp4_lib_rcv 会将 packet 入队到此 socket。
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 操作中并未建立 socket 联系,则通过 __udp4_lib_lookup_skb 查找接收 socket。上述两种场景下,数据报皆会被入队到对应 socket:
ret = udp_queue_rcv_skb(sk, skb);sock_put(sk);如果未找到 socket,则丢弃此数据报:
/* 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;22.4.4 udp_queue_rcv_skb此函数的部分逻辑如下:
  • 确认此数据报所关联的 socket 是否是一个 encapsulation socket。如果是,则将此 packet 给到上层协议的 handler 函数。
  • 确认此数据报是否是一个 UDP-Lite 数据报,并做一些完整性校验。
  • 校验数据报的 UDP checksum,如果 checksum 失败则丢弃。
最后,来到 socket 接收队列逻辑,其先检查此 socket 的接收队列是否已满。net/ipv4/udp.c:
if (sk_rcvqueues_full(sk, skb, sk->sk_rcvbuf))  goto drop;22.4.5 sk_rcvqueues_fullsk_rcvqueues_full 检查 socket 的 backlog 长度以及 socket 的 sk_rmem_alloc,确认二者之和是否大于 socket 的 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;}对这些值的调优有点 tricky,因为有很多可以调整的。22.4.6 调优:socket 接收队列内存sk->sk_rcvbuf(也就是上面 sk_rcvqueues_full 代码中的 limit)的值可以通过 sysctl net.core.rmem_max 调整。设置 sysctl 增大接收 buffer 的大小:
$ sudo sysctl -w net.core.rmem_max=8388608sk->sk_rcvbuf 的默认值是 net.core.rmem_default,该值也可以通过 sysctl 调整。设置 sysctl 来调整接收 buffer 大小的初始默认值:
$ 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 capability。调用 skb_set_owner_r 设置数据报的 owner socket 时,会增加 sk->sk_rmem_alloc。这个后面在 UDP 层中会讲。调用 sk_add_backlog 会增大 sk->sk_backlog.len,后面会说。22.4.7 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;

第一步是先确认用户态程序是否有正在操作此 socket 的系统调用。如果没有,则通过 __udp_queue_rcv_skb 将数据报添加至接收队列。如果有,则通过 sk_add_backlog 将数据报入队到 backlog。当 socket 系统调用释放此 socket(内核调用 release_sock)时,backlog 中的数据报会被添加至接收队列。

22.4.8 __udp_queue_rcv_skb__udp_queue_rcv_skb 函数通过调用 sock_queue_rcv_skb 将数据报添加至接收队列,如果此数据报无法被添加到此 socket 的接收队列,会做相应的计数统计。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;}22.4.9 监控:UDP 协议层统计UDP 协议层统计的两个非常有用的文件是:
  • /proc/net/snmp
  • /proc/net/udp
读取 /proc/net/snmp 获取详细的  UDP 协议统计:
$ cat /proc/net/snmp | grep Udp\:
Udp: InDatagrams NoPorts InErrors OutDatagrams RcvbufErrors SndbufErrors
Udp: 16314 0 0 17161 0 0
非常类似 IP 协议的统计,你需要阅读协议层源码来确定这些值会在哪些地方发生变化:
  • InDatagrams:当用户态程序通过 recvmsg 读数据报时,该值增加。当一个 UDP packet 是 encapsulated 并被发回处理时,也会增加。
  • NoPorts:当 UDP packets 的目标端口没有程序在监听时,该值增加。
  • InErrors:如下场景下该值增加:接收队列中内存不足时,校验到一个错误的 checksum 时,sk_add_backlog 未能成功添加数据报时。
  • OutDatagrams:未发生错误的情况下,UDP packet 给到 IP 协议层发送时,该值增加。
  • RcvbufErrors:sock_queue_rcv_skb 上报内存不足时(sk->sk_rmem_alloc 大于等于 sk->sk_rcvbuf),该值增加。
  • SndbufErrors:在尝试发送 packet 时 IP 协议层报错,且并未设置错误队列时,该值增加。如果发送队列空间或内核内存不足时,该值亦会增加。
  • InCsumErrors:检测到 UDP checksum 失败时,该值增加。注意,我所观察到的所有场景下,InCsumErrors 的增加次数与 InErrors 相同。因此,InErrors 减去 InCsumErrors,可以得出接收侧内存相关错误的次数。

22.4.9.1 /proc/net/udp读取 /proc/net/udp 获取 UDP socket 统计数据:
$ 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:socket 的内核哈希槽位。
  • local_address:十六进制的 socket 本地地址及端口,以冒号切分。
  • rem_address:十六进制的 socket 远端地址及端口,以冒号切分。
  • st:socket 状态。奇怪的是,UDP 协议似乎使用了一些 TCP 的 socket 状态。如上面的例子所示,7 是 TCP_CLOSE。
  • tx_queue:内核为 outgoing UDP 数据报所分配的内存。
  • rx_queue:内核为 incoming UDP 数据报所分配的内存。
  • tr,tm->when,retrnsmt:这几个字段 UDP 协议未使用。
  • uid:创建此 socket 的用户的有效 id。
  • timeout:UDP 协议未使用。
  • inode:此 socket 对应的 inode 号。借助该值,你可以定位打开此 socket 的是哪个用户进程。检查 /proc/[pid]/fd,里面会有到 socket[:inode] 的符号链接。
  • ref:socket 的当前引用计数。
  • pointer:内核中 struct sock 的内存地址。
  • drops:此 socket 所丢弃的数据报数量。注意:此数据并不会包含数据报发送路径下产生的丢弃(UDP sockets 阻塞或其他场景)。本文内核版本下,该值只会在接收路径下才会增加。
输出这些信息的代码在 net/ipv4/udp.c。22.5 数据入队到 socket通过 sock_queue_rcv 将网络数据入队到一个 socket。将数据包入队之前此函数做了如下事情:
  • 校验 socket 所分配的内存,确认是否超过接收 buffer 的大小。如果是,则此 socket 的 drop 数量增加。
  • 接着,调用 sk_filter 来处理应用在此 socket 上的 Berkeley Packet Filter filters。
  • 调用 sk_rmem_schedule 确保有足够的 receive buffer 空间来接收此数据报。
  • 接着,调用 skb_set_owner_r 将此数据报的大小增加至此 socket。这会增加 sk->sk_rmem_alloc。
  • 调用 __skb_queue_tail 将数据添加至队列。
  • 最后,通过 sk_data_ready 通知 handler 函数(译者注:是 socket 的一个回调函数指针,参考内核 struct sock),通知等在此 socket 数据到达上的处理流程。
这就是一个数据在到达系统后,如何穿过网络栈并走到 socket 中,等待用户程序读取的过程。23. 番外还有一些额外的事情值得一提,但在其他地方似乎不太正确。

23.1 timestamping如上文所述,网络栈可以记录 incoming 数据的时间戳。配合 RPS 使用的情况下,可以通过一些 sysctl 值控制何时以及如何记录时间戳。关于 RPS 的更多信息,以及具体是在哪里做网络栈的接收时间戳记录,参考上文相关章节。有些 NICs 甚至还支持在硬件中做时间戳记录。如果你想知道内核网络栈在对 packet 的接收中到底引入了多少延迟,这将是一个很有用的特性。内核文档 Documentation/networking/timestamping.txt 写的很好,里面甚至还有一个示例程序以及 Makefile。使用 ethtool -T 确认驱动及设备的 timestamp 模式:
$ 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 不支持 hardware receive timestamping,但我仍然可以在该系统上使用软件时间戳来帮助确定内核在 packet 接收路径上引入了多少延迟。

23.2 低延迟 socket 的 busy polling通过 SO_BUSY_POLL socket 选项,当有 blocking receive(阻塞式数据收取)且此时并没有数据时,内核会为收取新数据而进入 busy poll。重要提示:要让此选项工作,你的设备驱动必须对其进行支持。linux kernel 3.13.0 的 igb 驱动尚不支持此选项,但是 ixgbe 驱动是支持的。如果你的驱动为其 struct net_device_ops 数据结构的 ndo_busy_poll 域设置了函数,则其支持 SO_BUSY_POLL 。Intel 的一篇伟大的论文讲述了它怎么工作以及怎么使用:https://caxapa.ru/thumbs/793343/ ... ements_for_Low-.pdf。当为某个 socket 使用此 socket 选项时,你应该传入一个微秒为单位的值,表示可以对设备驱动的接收队列的新数据做 busy poll 的时间长度。在配置该值后,如果你对此 socket 发起一个 blocking receive,内核会为新的数据做 busy poll。你还可以将 net.core.busy_poll 的 sysctl 值设置为一个微秒为单位的时间值,表示 poll 或 select 为等待新数据所做的 busy poll 应该持续多久。译者注:没有深入研究,这里大概的意思就是,原先阻塞式接口在操作 socket 时(典型如数据读,如 poll 或 select。本文没提,但 read 应该也支持?),如果此时没有数据,会导致当前进程进入睡眠,后续再来数据时要走中断唤醒那条路,相对来说延迟会很大。busy polling 的思想就是将原来的“等不到数据就睡眠”逻辑,改为“等不到数据的时候忙等不睡眠”。

23.3 Netpoll:极端情况下的网络支持linux 内核支持在内核 crash 的情况下,设备驱动还可以在一个 NIC 上做数据的收发。干这事得需要借助称为 Netpoll 的 API,其用到的地方很多,最值得注意的是:kgdb、netconsole。大多数驱动支持 Netpoll;你的驱动需要实现 ndo_poll_controller 函数,并将其添加至 struct net_device_ops(如上文所言,其会在 probe 时注册)。当网络设备子系统做 incoming 或 outgoing 数据处理时,会先检查 netpoll 系统,以确认 packet 的目标是否是 netpoll。具体来说,我们可以在 net/dev/core.c 中看到如下的 __netif_receive_skb_core 代码:
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;
  /* ... */
}

大多网络数据收发相关的 linux 网络设备子系统代码中,会在一开始就对 Netpoll 进行检查。Netpoll API 的使用者可以通过 netpoll_setup 注册 struct netpoll 数据结构。struct netpoll 数据结构有一个函数指针可用于添加 receive hooks,API 还提供了一个函数用来数据发送。如果你对使用 Netpoll API 感兴趣,可以看一看 netconsole 驱动(drivers/net/netconsole.c),Netpoll API 的头文件(include/linux/netpoll.h),以及 https://people.redhat.com/~jmoyer/netpoll-linux_kongress-2005.pdf

23.4 sSO_INCOMING_CPUSO_INCOMING_CPU flag 直到 linux 3.19 才支持,但它非常有用,所以本文也简单说一下。你可以使用 SO_INCOMING_CPU 选项调用 getsockopt 来获知是哪个 CPU 在处理某个指定 socket 的网络 packets。你的程序可以借此信息,将 socket 的处理线程绑定到指定 CPU 上,以此提升数据 locality 以及 CPU cache 命中率。这组 mailing list 消息(https://patchwork.ozlabs.org/pro ... am.corp.google.com/)引入 SO_INCOMING_CPU,并提供了此选项能起作用的体系结构示例。

23.5 DMA 引擎DMA 引擎是硬件的一部分,可以让 CPU offload 掉大的拷贝操作。通过将内存拷贝交给硬件,解放 CPU 的算力以处理其他工作。使能 DMA 并运行使用 DMA 的代码,可以降低 CPU 利用率。linux 内核有一个通用的 DMA 引擎接口,DMA 驱动的作者可以利用该接口安装自己的 DMA 引擎。关于 linux DMA 引擎接口,参阅 Documentation/dmaengine.txt。内核支持了不少 DMA 引擎,我们着重讨论 Intel IOAT DMA engine。

23.5.1 Intel 的 IO Acceleration Technology(IOAT)很多服务器都有 Intel I/O AT 包,其由一组可以提升性能的组件构成。其中一个组件是硬件 DMA 引擎。你可以检查 ioatdma 的 dmesg 输出,以确认此模块是否被加载,以及其是否找到可被支持的硬件。DMA offload 引擎在很多地方都被用到,最值得注意的是在 TCP 协议栈中。linux 2.6.18 中包含了对 Intel IOAT DMA 引擎的支持,但在 3.13.11.10 中因为 data corruption bugs(77873803363c9e831fc1d1e6895c084279090c22)而被关闭。3.13.11.10 之前版本内核的用户在他们服务器上应该默认使用的是 ioatdma 模块。后续版本的内核应该会修复掉这个问题。

23.5.2 Direct Cache Access(DCA)Intel I/O AT 包中另一个有意思的特性是 Direct Cache Access(DCA)。该特性允许网络设备通过它们的驱动将网络数据直接给到 CPU Cache。该功能的具体实现是驱动 specific 的。对于 igb 驱动,你可以阅读 igb_update_dca 以及 igb_update_rx_dca 代码(二者皆位于 drivers/net/ethernet/intel/igb/igb_main.c)。igb 通过向 NIC 写入一个寄存器来使用 DCA。要使用 DCA,你需要确保 BIOS 中使能了 DCA,并加载了 dca 模块,以及你的网卡与驱动都支持 DCA。23.5.3 监控 IOAT DMA 引擎如果你无视上面说的 data corruption 风险,使用了 ioatdma 模块,可以通过 sysfs 中的一些 entry 来监控之。监控一个 DMA 通道被 offload 的 memcpy 操作总数:
$ cat /sys/class/dma/dma0chan0/memcpy_count
123205655
类似的,获取此 DMA 通道被 offload 的字节数:
$ cat /sys/class/dma/dma0chan0/bytes_transferred
131791916307

23.5.4 调优 IOAT DMA 引擎只有在 packet 大小超过一定阈值时,IOAT DMA 引擎才会被用到,这个阈值被称为 copybreak。之所以做这个检查是因为对于小的拷贝,传输的加速收益还抵不上 DMA 引擎配置及使用的开销。通过 sysctl 调整 DMA 引擎的 copybreak:
$ sudo sysctl -w net.ipv4.tcp_dma_copybreak=2048
该值默认为 4096。

24. 总结

linux 网络栈贼复杂。如果你不深入理解底层到底是怎么搞的,就无从谈起对网络栈的监控与调优。有时候你可能会在网上找到一个示例 sysctl.conf,其中包含一组可以用在你计算机上的 sysctl 值。但这大概率不是网络栈优化的最佳手法。对网络栈的监控需要对每一层的网络数据做仔细的计算。从驱动开始一路向上,按照这条路径,在你知道哪些地方会明确出现丢包或错误的情况下,调整对应的设置,并观察这些设置是如何减少错误的。这一切说起来简单,不幸的是,事实上很难。

回复

使用道具 举报

您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

QQ|Archiver|手机版|深圳市光明谷科技有限公司|光明谷商城|Sunshine Silicon Corpporation ( 粤ICP备14060730号|Sitemap

GMT+8, 2024-4-26 18:40 , Processed in 0.132380 second(s), 39 queries .

Powered by Discuz! X3.2 Licensed

© 2001-2013 Comsenz Inc.

快速回复 返回顶部 返回列表