NAPI

NAPI 是 Linux 网络堆栈使用的事件处理机制。NAPI 这个名称不再特指任何事物[1]

在基本操作中,设备通过中断通知主机新事件。主机随后调度一个 NAPI 实例来处理这些事件。设备也可以通过 NAPI 轮询事件,而无需首先接收中断(忙轮询)。

NAPI 处理通常发生在软件中断上下文中,但也有选项可以使用单独的内核线程进行 NAPI 处理。

总而言之,NAPI 抽象了驱动程序对事件(数据包接收 Rx 和发送 Tx)处理的上下文和配置。

驱动程序API

NAPI 最重要的两个元素是 `struct napi_struct` 结构体及其关联的轮询方法。`struct napi_struct` 存储 NAPI 实例的状态,而该方法是驱动程序特定的事件处理程序。该方法通常会释放已发送的 Tx 数据包并处理新接收的 Rx 数据包。

控制API

netif_napi_add()netif_napi_del() 用于向系统添加/删除 NAPI 实例。这些实例附加到作为参数传递的网卡设备 (netdevice) 上(并在网卡设备注销时自动删除)。实例在禁用状态下添加。

napi_enable()napi_disable() 管理禁用状态。禁用的 NAPI 无法被调度,并且其轮询方法保证不会被调用。napi_disable() 会等待 NAPI 实例的所有权被释放。

这些控制 API 不是幂等的。控制 API 调用对于数据路径 API 的并发使用是安全的,但错误的控制 API 调用序列可能导致崩溃、死锁或竞态条件。例如,连续多次调用 napi_disable() 将导致死锁。

数据路径 API

napi_schedule() 是调度 NAPI 轮询的基本方法。驱动程序应在其中断处理程序中调用此函数(更多信息请参见调度和中断屏蔽)。成功调用 napi_schedule() 将获得 NAPI 实例的所有权。

随后,在 NAPI 被调度后,将调用驱动程序的轮询方法来处理事件/数据包。该方法接受一个 budget 参数——驱动程序可以处理任意数量的 Tx 数据包的完成,但只能处理最多 budget 数量的 Rx 数据包。Rx 处理通常昂贵得多。

换句话说,对于 Rx 处理,budget 参数限制了驱动程序在一次轮询中可以处理的数据包数量。当 budget 为 0 时,无法使用像页面池 (page pool) 或 XDP 这样的 Rx 特定 API。skb Tx 处理应无论 budget 值如何都发生,但如果该参数为 0,驱动程序则无法调用任何 XDP(或页面池)API。

警告

如果核心代码仅尝试处理 skb Tx 完成而没有 Rx 或 XDP 数据包,则 budget 参数可能为 0。

轮询方法返回已完成的工作量。如果驱动程序仍有未完成的工作(例如 budget 已用尽),轮询方法应精确返回 budget。在这种情况下,NAPI 实例将被再次服务/轮询(无需重新调度)。

如果事件处理已完成(所有未完成的数据包都已处理),轮询方法应在返回之前调用 napi_complete_done()napi_complete_done() 释放该实例的所有权。

警告

必须仔细处理完成所有事件并恰好用尽 budget 的情况。没有办法将这种(罕见)情况报告给堆栈,因此驱动程序必须要么不调用 napi_complete_done() 并等待再次被调用,要么返回 budget - 1

如果 budget 为 0,则绝不应调用 napi_complete_done()

调用序列

驱动程序不应假定调用的确切顺序。轮询方法可能在驱动程序未调度实例的情况下被调用(除非实例被禁用)。同样,即使 napi_schedule() 成功,也无法保证轮询方法一定会被调用(例如,如果实例被禁用)。

正如控制API部分所述——napi_disable() 及后续对轮询方法的调用仅等待实例所有权被释放,而不是等待轮询方法退出。这意味着驱动程序在调用 napi_complete_done() 后应避免访问任何数据结构。

调度和中断屏蔽

驱动程序在调度 NAPI 实例后应保持中断被屏蔽——直到 NAPI 轮询完成,任何进一步的中断都是不必要的。

必须显式屏蔽中断的驱动程序(与设备自动屏蔽中断不同)应使用 napi_schedule_prep()__napi_schedule() 调用。

if (napi_schedule_prep(&v->napi)) {
    mydrv_mask_rxtx_irq(v->idx);
    /* schedule after masking to avoid races */
    __napi_schedule(&v->napi);
}

只有在成功调用 napi_complete_done() 之后,中断才应被解除屏蔽。

if (budget && napi_complete_done(&v->napi, work_done)) {
  mydrv_unmask_rxtx_irq(v->idx);
  return min(work_done, budget - 1);
}

napi_schedule_irqoff()napi_schedule() 的一个变体,它利用了在中断 (IRQ) 上下文中调用所提供的保证(无需屏蔽中断)。如果中断是线程化的(例如启用了 PREEMPT_RT),napi_schedule_irqoff() 将退回到 napi_schedule()

实例到队列的映射

现代设备每个接口有多个 NAPI 实例(struct napi_struct)。对于实例如何映射到队列和中断没有严格要求。NAPI 主要是一种轮询/处理抽象,没有特定的面向用户语义。尽管如此,大多数网络设备最终都会以相当相似的方式使用 NAPI。

NAPI 实例通常与中断和队列对(队列对是单个 Rx 队列和单个 Tx 队列的集合)呈 1:1:1 对应关系。

在较少见的情况下,一个 NAPI 实例可能用于多个队列,或者 Rx 和 Tx 队列可以在单个核心上由单独的 NAPI 实例提供服务。然而,无论队列分配如何,NAPI 实例和中断之间通常仍然存在 1:1 的映射。

值得注意的是,ethtool API 使用“通道”术语,其中每个通道可以是 rxtxcombined。目前尚不清楚通道的具体构成;推荐的解释是将通道理解为服务给定类型队列的中断/NAPI。例如,配置为 1 个 rx、1 个 tx 和 1 个 combined 通道,预计将使用 3 个中断、2 个 Rx 队列和 2 个 Tx 队列。

NAPI 持久配置

驱动程序经常动态分配和释放 NAPI 实例。这导致每次重新分配 NAPI 实例时,NAPI 相关的用户配置都会丢失。netif_napi_add_config() API 通过将每个 NAPI 实例与基于驱动程序定义的索引值(例如队列号)的持久 NAPI 配置关联起来,从而防止了这种配置丢失。

使用此 API 可以实现持久的 NAPI ID(以及其他设置),这对于使用 SO_INCOMING_NAPI_ID 的用户空间程序可能很有益。其他 NAPI 配置设置请参见以下章节。

驱动程序应尽可能尝试使用 netif_napi_add_config()

用户API

用户与 NAPI 的交互取决于 NAPI 实例 ID。实例 ID 仅通过 SO_INCOMING_NAPI_ID 套接字选项对用户可见。

用户可以使用 netlink 查询设备或设备队列的 NAPI ID。这可以在用户应用程序中通过编程方式完成,或者使用内核源代码树中包含的脚本:tools/net/ynl/pyynl/cli.py

例如,使用该脚本转储设备的所有队列(这将揭示每个队列的 NAPI ID)。

$ kernel-source/tools/net/ynl/pyynl/cli.py \
          --spec Documentation/netlink/specs/netdev.yaml \
          --dump queue-get \
          --json='{"ifindex": 2}'

有关可用操作和属性的更多详细信息,请参见 Documentation/netlink/specs/netdev.yaml

软件中断合并

NAPI 默认不执行任何显式事件合并。在大多数情况下,批处理是由于设备进行的中断合并 (IRQ coalescing) 而发生的。在某些情况下,软件合并是有帮助的。

NAPI 可以配置为在所有数据包处理完毕后,启动一个重新轮询计时器,而不是立即解除硬件中断的屏蔽。网卡设备 (netdevice) 的 gro_flush_timeout sysfs 配置被重用以控制计时器的延迟,而 napi_defer_hard_irqs 则控制在 NAPI 放弃并恢复使用硬件中断之前,连续空轮询的次数。

上述参数也可以通过 netlink 经由 netdev-genl 进行每 NAPI 实例的设置。当与 netlink 配合使用并按每 NAPI 实例配置时,上述参数使用连字符而不是下划线:gro-flush-timeoutnapi-defer-hard-irqs

每 NAPI 实例配置可以在用户应用程序中通过编程方式完成,或者使用内核源代码树中包含的脚本:tools/net/ynl/pyynl/cli.py

例如,使用该脚本

$ kernel-source/tools/net/ynl/pyynl/cli.py \
          --spec Documentation/netlink/specs/netdev.yaml \
          --do napi-set \
          --json='{"id": 345,
                   "defer-hard-irqs": 111,
                   "gro-flush-timeout": 11111}'

类似地,参数 irq-suspend-timeout 可以通过 netlink 经由 netdev-genl 设置。该值没有全局的 sysfs 参数。

irq-suspend-timeout 用于确定应用程序可以完全暂停中断 (IRQs) 的时长。它与 SO_PREFER_BUSY_POLL 结合使用,后者可以通过 EPIOCSPARAMS ioctl 在每 epoll 上下文的基础上设置。

忙轮询

忙轮询允许用户进程在设备中断触发之前检查传入的数据包。与任何忙轮询一样,它以 CPU 周期为代价换取更低的延迟(NAPI 忙轮询的生产用途不为人所熟知)。

忙轮询可以通过在选定的套接字上设置 SO_BUSY_POLL,或者使用全局的 net.core.busy_pollnet.core.busy_read sysctls 来启用。还存在一个用于 NAPI 忙轮询的 io_uring API。

基于 epoll 的忙轮询

可以直接从对 epoll_wait 的调用中触发数据包处理。为了使用此功能,用户应用程序必须确保添加到 epoll 上下文的所有文件描述符都具有相同的 NAPI ID。

如果应用程序使用专用的接受器线程,则可以通过 SO_INCOMING_NAPI_ID 获取传入连接的 NAPI ID,然后将该文件描述符分发给工作线程。工作线程会将该文件描述符添加到其 epoll 上下文中。这将确保每个工作线程都有一个包含相同 NAPI ID 文件描述符的 epoll 上下文。

或者,如果应用程序使用 SO_REUSEPORT,则可以插入 bpf 或 ebpf 程序,将传入连接分发给线程,从而使每个线程仅获得具有相同 NAPI ID 的传入连接。必须注意仔细处理系统可能具有多个网卡 (NIC) 的情况。

为了启用忙轮询,有两种选择:

  1. /proc/sys/net/core/busy_poll 可以设置为一个微秒 (useconds) 时间,以进行忙循环等待事件。这是一个系统范围的设置,将导致所有基于 epoll 的应用程序在调用 epoll_wait 时进行忙轮询。这可能不理想,因为许多应用程序可能不需要忙轮询。

  2. 使用较新内核的应用程序可以在 epoll 上下文文件描述符上发出 ioctl,以设置 (EPIOCSPARAMS) 或获取 (EPIOCGPARAMS) struct epoll_params: 结构体,用户程序可以定义如下:

struct epoll_params {
    uint32_t busy_poll_usecs;
    uint16_t busy_poll_budget;
    uint8_t prefer_busy_poll;

    /* pad the struct to a multiple of 64bits */
    uint8_t __pad;
};

中断缓解

虽然忙轮询应由低延迟应用程序使用,但类似的机制也可用于中断 (IRQ) 缓解。

非常高的每秒请求量应用程序(特别是路由/转发应用程序以及使用 AF_XDP 套接字的应用程序)可能不希望在完成处理请求或一批数据包之前被中断。

此类应用程序可以向内核承诺,它们将定期执行忙轮询操作,并且驱动程序应使设备中断 (IRQs) 永久屏蔽。此模式通过使用 SO_PREFER_BUSY_POLL 套接字选项启用。为了避免系统异常行为,如果 gro_flush_timeout 经过且没有任何忙轮询调用,则该承诺将被撤销。对于基于 epoll 的忙轮询应用程序,可以将 struct epoll_paramsprefer_busy_poll 字段设置为 1,并发出 EPIOCSPARAMS ioctl 以启用此模式。更多详细信息请参见上一节。

NAPI 用于忙轮询的预算低于默认值(考虑到正常忙轮询的低延迟意图,这是有道理的)。然而,中断缓解 (IRQ mitigation) 的情况并非如此,因此可以使用 SO_BUSY_POLL_BUDGET 套接字选项调整预算。对于基于 epoll 的忙轮询应用程序,可以在 struct epoll_params 中将 busy_poll_budget 字段调整到所需值,并使用 EPIOCSPARAMS ioctl 在特定的 epoll 上下文中设置。更多详细信息请参见上一节。

值得注意的是,为 gro_flush_timeout 选择一个较大的值将延迟中断 (IRQs),以实现更好的批处理,但在系统未完全负载时会引入延迟。为 gro_flush_timeout 选择一个较小的值可能会导致尝试通过设备中断和软中断处理进行忙轮询的用户应用程序受到干扰。应仔细权衡这些利弊来选择此值。基于 epoll 的忙轮询应用程序可以通过为 maxevents 选择适当的值来缓解用户处理的量。

用户可能希望考虑另一种方法——中断暂停 (IRQ suspension),以帮助应对这些权衡。

中断暂停

中断暂停是一种机制,其中设备中断 (IRQs) 在 epoll 触发 NAPI 数据包处理时被屏蔽。

当应用程序调用 epoll_wait 成功检索事件时,内核将延迟中断暂停计时器。如果内核在忙轮询期间未检索到任何事件(例如,因为网络流量水平下降),则中断暂停将被禁用,并启用上述的中断缓解策略。

这允许用户平衡 CPU 消耗与网络处理效率。

要使用此机制:

  1. 每个 NAPI 实例的配置参数 irq-suspend-timeout 应设置为应用程序可以暂停其中断 (IRQs) 的最长时间(以纳秒为单位)。这可以通过 netlink 完成,如上所述。此超时用作一种安全机制,以在应用程序停滞时重新启动中断驱动程序的处理。应选择此值,使其涵盖用户应用程序从其对 epoll_wait 的调用中处理数据所需的时间,并注意应用程序可以通过在调用 epoll_wait 时设置 max_events 来控制检索的数据量。

  2. sysfs 参数或每个 NAPI 实例的配置参数 gro_flush_timeoutnapi_defer_hard_irqs 可以设置为较低的值。它们将在忙轮询未发现数据后用于延迟中断 (IRQs)。

  3. prefer_busy_poll 标志必须设置为 true。这可以使用 EPIOCSPARAMS ioctl 完成,如上所述。

  4. 应用程序使用 epoll 来触发 NAPI 数据包处理,如上所述。

如上所述,只要后续对 epoll_wait 的调用向用户空间返回事件,irq-suspend-timeout 就会被延迟,并且中断 (IRQs) 将被禁用。这允许应用程序在不受干扰的情况下处理数据。

一旦对 epoll_wait 的调用没有找到事件,中断暂停 (IRQ suspension) 将自动禁用,并且 gro_flush_timeoutnapi_defer_hard_irqs 缓解机制将接管。

预计 irq-suspend-timeout 将设置为远大于 gro_flush_timeout 的值,因为 irq-suspend-timeout 应在一次用户空间处理周期内暂停中断 (IRQs)。

虽然使用 napi_defer_hard_irqsgro_flush_timeout 来使用中断暂停 (IRQ suspension) 并非严格必要,但强烈建议使用它们。

中断暂停 (IRQ suspension) 会导致系统在轮询模式和中断驱动的数据包传输之间交替。在忙碌期间,irq-suspend-timeout 会覆盖 gro_flush_timeout 并使系统保持忙轮询,但当 epoll 未发现事件时,gro_flush_timeoutnapi_defer_hard_irqs 的设置将决定下一步。

网络处理和数据包传输本质上有三种可能的循环:

  1. 硬中断 (hardirq) -> 软中断 (softirq) -> NAPI 轮询;基本中断传输

  2. 计时器 -> 软中断 (softirq) -> NAPI 轮询;延迟中断处理

  3. epoll -> 忙轮询 -> NAPI 轮询;忙循环

如果设置了 gro_flush_timeoutnapi_defer_hard_irqs,循环 2 可以从循环 1 接管控制。

如果设置了 gro_flush_timeoutnapi_defer_hard_irqs,则循环 2 和循环 3 会互相“争夺”控制权。

在忙碌期间,irq-suspend-timeout 在循环 2 中用作计时器,这本质上使网络处理倾向于循环 3。

如果未设置 gro_flush_timeoutnapi_defer_hard_irqs,则循环 3 无法从循环 1 接管控制。

因此,建议设置 gro_flush_timeoutnapi_defer_hard_irqs,因为否则设置 irq-suspend-timeout 可能没有任何可察觉的效果。

线程化 NAPI

线程化 NAPI 是一种操作模式,它使用专用的内核线程而不是软件中断 (IRQ) 上下文进行 NAPI 处理。该配置是按网卡设备 (netdevice) 进行的,并将影响该设备的所有 NAPI 实例。每个 NAPI 实例将生成一个单独的线程(命名为 napi/${ifc-name}-${napi-id})。

建议将每个内核线程绑定到单个 CPU,与处理中断的 CPU 相同。请注意,中断 (IRQs) 和 NAPI 实例之间的映射可能并非简单直观(并且取决于驱动程序)。NAPI 实例 ID 将以与内核线程的进程 ID 相反的顺序分配。

线程化 NAPI 通过向网卡设备 (netdev) 的 sysfs 目录中的 threaded 文件写入 0/1 来控制。

脚注