监控和调整 Linux 网络堆栈:发送数据 (2)

72 阅读34分钟

监控和调整 Linux 网络堆栈:发送数据

监控和调整 Linux 网络堆栈:发送数据 (1) juejin.cn/spost/73826…

这篇博文解释了运行 Linux 内核的计算机如何发送数据包,以及如何在数据包从用户程序流向网络硬件时监控和调整网络堆栈的每个组件。

正如我们之前的文章中提到的,Linux 网络堆栈非常复杂,没有一种万能的解决方案可以用于监控或调优。如果你真的想调优网络堆栈,你别无选择,只能投入大量的时间、精力和金钱来了解网络系统各个部分是如何交互的。

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

在通过网络连接到计算机时调整网络设置非常危险;您很容易将自己锁定或完全切断网络。请勿在生产机器上调整这些设置;相反,如果可能的话,请在新机器上进行调整并将其轮换到生产中。

概述

网络数据从用户程序到网络设备的高级路径如下:

  1. 数据是使用系统调用(如、等)写入的。 `
  2. 数据通过套接字子系统传递到套接字协议系列的系统(在我们的例子中为)。 
  3. 协议族通过协议层传递数据,协议层(在许多情况下)将数据排列成数据包。
  4. 数据通过路由层,沿途填充目标和邻居缓存(如果它们是冷的)。如果需要查找以太网地址,这可能会生成 ARP 流量。
  5. 数据包穿过协议层后到达设备无关层。
  6. 使用 XPS(如果启用)或哈希函数来选择输出队列。
  7. 调用设备驱动程序的传输函数。
  8. 然后,数据被传递到连接到输出设备的队列规则 (qdisc)。
  9. 如果可以的话,qdisc 将直接传输数据,或者将其排队以便在软中断期间发送。 
  10. 最终,数据从 qdisc 传递给驱动程序。
  11. 驱动程序创建所需的 DMA 映射,以便设备可以从 RAM 读取数据。
  12. 驱动程序向设备发出信号,表示数据已准备好传输。
  13. 该设备从 RAM 中获取数据并传输。
  14. 一旦传输完成,设备就会发出中断来表示传输完成。
  15. 驱动程序注册的用于传输完成的 IRQ 处理程序运行。对于许多设备,此处理程序只是触发 NAPI 轮询循环通过软中断开始运行。 
  16. 轮询函数通过 softIRQ 运行,并调用驱动程序来取消映射 DMA 区域并释放数据包数据。

详细了解

让我们首先检查协议系列如何在内核中注册以及如何被套接字子系统使用,然后我们就可以继续接收数据。

网络设备驱动程序

我们的旅程即将结束。关于数据包传输,有一个重要的概念需要理解。大多数设备和驱动程序将数据包传输视为一个两步过程:

  1. 数据被正确排列,设备被触发以 DMA 方式从 RAM 中获取数据并将其写入网络
  2. 传输完成后,设备将发出中断,以便驱动程序可以取消缓冲区映射、释放内存或以其他方式清除其状态。

第二阶段通常称为“传输完成”阶段。我们将研究这两个阶段,但首先从第一阶段开始:传输阶段。

我们看到调用(持有锁)来传输数据,所以让我们首先检查驱动程序如何注册,然后深入了解该函数的工作原理。 dev_hard_start_xmit  ndo_start_xmit  ndo_start_xmit 

正如之前的博客文章一样,我们将检查驱动程序。   igb 

驾驶员运营登记

驱动程序为各种操作实现了一系列功能,例如:

  • 正在发送数据(ndo_start_xmit
  • 获取统计信息(ndo_get_stats64
  • 搬运装置( ) ioctls ndo_do_ioctl
  • 和更多。

这些函数以结构体形式导出为一系列函数指针。让我们看一下驱动程序源代码中这些操作的结构定义: igb 

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,

				/* ... more fields ... */
};

该结构在函数中注册: igb_probe 

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

        netdev->netdev_ops = &igb_netdev_ops;

				/* ... more code ... */
}

正如我们在上一节中看到的,更高层的代码将获取对设备结构的引用并调用相应的函数。如果您想了解有关 PCI 设备究竟是如何启动以及何时/何地调用的更多信息,请查看我们另一篇博客文章中的驱动程序初始化部分。 netdev_ops  igb_probe   

使用传输数据 ndo_start_xmit

网络堆栈的较高层使用该结构来调用驱动程序以执行各种操作。如前所述,qdisc 代码调用将数据向下传递给驱动程序进行传输。对于大多数硬件设备,该函数在持有锁时被调用,如上所示。 net_device_ops  ndo_start_xmit  ndo_start_xmit 

在设备驱动程序中,注册到的函数称为,因此让我们从开始了解该驱动程序如何传输数据。继续执行./drivers/net/ethernet/intel/igb/igb_main.c,并记住在执行以下代码的整个过程中都会保持锁定: igb  ndo_start_xmit  igb_xmit_frame igb_xmit_frame   

netdev_tx_t igb_xmit_frame_ring(struct sk_buff *skb,
                                struct igb_ring *tx_ring)
{
        struct igb_tx_buffer *first;
        int tso;
        u32 tx_flags = 0;
        u16 count = TXD_USE_COUNT(skb_headlen(skb));
        __be16 protocol = vlan_get_protocol(skb);
        u8 hdr_len = 0;

        /* need: 1 descriptor per page * PAGE_SIZE/IGB_MAX_DATA_PER_TXD,
         *       + 1 desc for skb_headlen/IGB_MAX_DATA_PER_TXD,
         *       + 2 desc gap to keep tail from touching head,
         *       + 1 desc for context descriptor,
         * otherwise try next time
         */
        if (NETDEV_FRAG_PAGE_MAX_SIZE > IGB_MAX_DATA_PER_TXD) {
                unsigned short f;
                for (f = 0; f < skb_shinfo(skb)->nr_frags; f++)
                        count += TXD_USE_COUNT(skb_shinfo(skb)->frags[f].size);
        } else {
                count += skb_shinfo(skb)->nr_frags;
        }

该函数首先确定使用宏来确定需要多少个传输描述符来传输传入的数据。该值初始化为适合 skb 的描述符数量。然后对其进行调整以考虑需要传输的任何其他片段。 TXD_USER_COUNT  count 

        if (igb_maybe_stop_tx(tx_ring, count + 3)) {
                /* this is a hard error */
                return NETDEV_TX_BUSY;
        }

然后,驱动程序调用一个内部函数,该函数将检查所需的描述符数量,以确保传输队列有足够的可用描述符。如果没有,则返回此处。正如我们之前在 qdisc 代码中看到的那样,这将导致 qdisc 将数据重新排队,以便稍后重试。 igb_maybe_stop_tx  NETDEV_TX_BUSY 

        /* record the location of the first descriptor for this packet */
        first = &tx_ring->tx_buffer_info[tx_ring->next_to_use];
        first->skb = skb;
        first->bytecount = skb->len;
        first->gso_segs = 1;

然后,代码获取传输队列中下一个可用缓冲区信息的引用。此结构将跟踪稍后设置缓冲区描述符所需的信息。对数据包及其大小的引用被复制到缓冲区信息结构中。

 skb_tx_timestamp(skb);

上述代码首先调用,用于获取基于软件的传输时间戳。应用程序可以使用传输时间戳来确定数据包通过网络堆栈传输路径所需的时间。 skb_tx_timestamp 

一些设备还支持为硬件传输的数据包生成时间戳。这允许系统将时间戳卸载到设备上,并允许程序员获得更准确的时间戳,因为它将更接近硬件实际传输的时间。我们现在来看看这个代码:

        if (unlikely(skb_shinfo(skb)->tx_flags & SKBTX_HW_TSTAMP)) {
                struct igb_adapter *adapter = netdev_priv(tx_ring->netdev);

                if (!(adapter->ptp_tx_skb)) {
                        skb_shinfo(skb)->tx_flags |= SKBTX_IN_PROGRESS;
                        tx_flags |= IGB_TX_FLAGS_TSTAMP;

                        adapter->ptp_tx_skb = skb_get(skb);
                        adapter->ptp_tx_start = jiffies;
                        if (adapter->hw.mac.type == e1000_82576)
                                schedule_work(&adapter->ptp_tx_work);
                }
        }

一些网络设备可以使用精确时间协议在硬件中为数据包添加时间戳。当用户请求硬件时间戳时,驱动程序代码会在这里处理该问题。 

上面的语句检查标志。此标志表示用户请求了硬件时间戳。如果用户请求了硬件时间戳,代码接下来将检查是否设置了。一次只能给一个数据包加时间戳,因此这里引用了正在加时间戳的数据包,并在 skb 上设置了标志。更新了标志。变量稍后将被复制到缓冲区信息结构中。 if  SKBTX_HW_TSTAMP  ptp_tx_skb  SKBTX_IN_PROGRESS  tx_flags  IGB_TX_FLAGS_TSTAMP  tx_flags 

引用 skb,将当前 jiffies 计数复制到。驱动程序中的其他代码将使用此值来确保 TX 硬件时间戳不会挂起。最后,如果这是以太网硬件适配器,则使用此函数来踢出工作队列。 ptp_tx_start schedule_work    82576 

        if (vlan_tx_tag_present(skb)) {
                tx_flags |= IGB_TX_FLAGS_VLAN;
                tx_flags |= (vlan_tx_tag_get(skb) << IGB_TX_FLAGS_VLAN_SHIFT);
        }

上面的代码会检查skb 的字段是否被设置。如果被设置,则启用该标志并存储 vlan ID。 vlan_tci  IGB_TX_FLAGS_VLAN 

        /* record initial flags and protocol */
        first->tx_flags = tx_flags;
        first->protocol = protocol;

标志和协议被记录到缓冲区信息结构中。

        tso = igb_tso(tx_ring, first, &hdr_len);
        if (tso < 0)
                goto out_drop;
        else if (!tso)
                igb_tx_csum(tx_ring, first);

接下来,驱动程序调用其内部函数。此函数将确定 skb 是否需要分片。如果需要,缓冲区信息引用 ( ) 将更新其标志,以向硬件指示需要TSO 。 igb_tso``first  

igb_tso 如果 TSO 是不必要的,则返回,否则返回。如果返回,则将被调用来处理启用校验和卸载(如果需要且此协议支持)。该函数将检查 skb 的属性并翻转缓冲区信息中的某些标志位以表示需要校验和卸载。 0  1  0  igb_tx_csum  igb_tx_csum  first 

 igb_tx_map(tx_ring, first, hdr_len);

调用此函数来准备设备要使用的传输数据。接下来我们将详细研究此函数。 igb_tx_map 

        /* Make sure there is space in the ring for the next send. */
        igb_maybe_stop_tx(tx_ring, DESC_NEEDED);

        return NETDEV_TX_OK;

传输完成后,驱动程序会检查以确保有足够的空间用于另一次传输。如果没有,则关闭队列。无论哪种情况,都会返回到更高层(qdisc 代码)。 NETDEV_TX_OK 

out_drop:
        igb_unmap_and_free_tx_resource(tx_ring, first);

        return NETDEV_TX_OK;
}

最后,一些错误处理代码。只有遇到某种错误时才会触发此代码。用于清理数据。在这种情况下也会返回。传输未成功,但驱动程序释放了相关资源,因此无需执行任何操作。请注意,此驱动程序在这种情况下不会增加数据包丢失,但它可能应该这样做。 igb_tso  igb_unmap_and_free_tx_resource  NETDEV_TX_OK 

igb_tx_map

该函数处理将 skb 数据映射到 RAM 的 DMA 区域的细节。它还会更新设备上传输队列的尾指针,这会触发设备“唤醒”,从 RAM 中获取数据并开始传输数据。 igb_tx_map 

让我们简单看一下这个函数的工作原理:  

static void igb_tx_map(struct igb_ring *tx_ring,
                       struct igb_tx_buffer *first,
                       const u8 hdr_len)
{
        struct sk_buff *skb = first->skb;

				/* ... other variables ... */

        u32 tx_flags = first->tx_flags;
        u32 cmd_type = igb_tx_cmd_type(skb, tx_flags);
        u16 i = tx_ring->next_to_use;

        tx_desc = IGB_TX_DESC(tx_ring, i);

        igb_tx_olinfo_status(tx_ring, tx_desc, tx_flags, skb->len - hdr_len);

        size = skb_headlen(skb);
        data_len = skb->data_len;

        dma = dma_map_single(tx_ring->dev, skb->data, size, DMA_TO_DEVICE);

上面的代码做了几件事:

  1. 声明一组变量并初始化它们。
  2. 使用宏来确定获取对下一个可用描述符的引用。 IGB_TX_DESC 
  3. igb_tx_olinfo_status 将更新并将它们复制到描述符中()。 tx_flags tx_desc
  4. 捕获大小和数据长度以便以后使用。
  5. dma_map_single 用于构造获取 的 DMA 地址所需的任何内存映射。这样做是为了让设备可以从内存中读取数据包数据。 skb->data

接下来是驱动程序中非常密集的循环,用于为 skb 的每个片段生成有效映射。具体如何发生的细节并不特别重要,但值得一提:

  • 驱动程序对一组数据包片段进行迭代。
  • 当前描述符中已填充数据的DMA地址。
  • 如果片段的大小大于单个 IGB 描述符可以传输的大小,则会构建多个描述符来指向可 DMA 区域的块,直到描述符指向整个片段。
  • 描述符迭代器被碰撞。
  • 剩余长度减少。
  • 当出现以下任一情况时循环终止:没有剩余的片段或整个数据长度已被消耗。

下面提供了循环的代码,供参考上述描述。这应该进一步向读者说明,尽可能避免碎片化是一个好主意。需要在堆栈的每一层(包括驱动程序)运行大量额外的代码来处理它。

        tx_buffer = first;

        for (frag = &skb_shinfo(skb)->frags[0];; frag++) {
                if (dma_mapping_error(tx_ring->dev, dma))
                        goto dma_error;

                /* record length, and DMA address */
                dma_unmap_len_set(tx_buffer, len, size);
                dma_unmap_addr_set(tx_buffer, dma, dma);

                tx_desc->read.buffer_addr = cpu_to_le64(dma);

                while (unlikely(size > IGB_MAX_DATA_PER_TXD)) {
                        tx_desc->read.cmd_type_len =
                                cpu_to_le32(cmd_type ^ IGB_MAX_DATA_PER_TXD);

                        i++;
                        tx_desc++;
                        if (i == tx_ring->count) {
                                tx_desc = IGB_TX_DESC(tx_ring, 0);
                                i = 0;
                        }
                        tx_desc->read.olinfo_status = 0;

                        dma += IGB_MAX_DATA_PER_TXD;
                        size -= IGB_MAX_DATA_PER_TXD;

                        tx_desc->read.buffer_addr = cpu_to_le64(dma);
                }

                if (likely(!data_len))
                        break;

                tx_desc->read.cmd_type_len = cpu_to_le32(cmd_type ^ size);

                i++;
                tx_desc++;
                if (i == tx_ring->count) {
                        tx_desc = IGB_TX_DESC(tx_ring, 0);
                        i = 0;
                }
                tx_desc->read.olinfo_status = 0;

                size = skb_frag_size(frag);
                data_len -= size;

                dma = skb_frag_dma_map(tx_ring->dev, frag, 0,
                                       size, DMA_TO_DEVICE);

                tx_buffer = &tx_ring->tx_buffer_info[i];
        }

一旦构建了所有必要的描述符并且所有 skb 的数据都映射到可 DMA 地址,驱动程序就会继续执行其最后步骤以触发传输:

        /* write last descriptor with RS and EOP bits */
        cmd_type |= size | IGB_TXD_DCMD;
        tx_desc->read.cmd_type_len = cpu_to_le32(cmd_type);

写入终止描述符是为了向设备指示它是最后一个描述符。

        netdev_tx_sent_queue(txring_txq(tx_ring), first->bytecount);

        /* set the timestamp */
        first->time_stamp = jiffies;

该函数被调用时传入要添加到此传输队列的字节数。该函数是字节查询限制功能的一部分,稍后我们将更详细地介绍该功能。当前 jiffies 存储在第一个缓冲区信息结构中。 netdev_tx_sent_queue 

接下来,有些棘手的事情:

        /* Force memory writes to complete before letting h/w know there
         * are new descriptors to fetch.  (Only applicable for weak-ordered
         * memory model archs, such as IA-64).
         *
         * We also need this memory barrier to make certain all of the
         * status bits have been updated before next_to_watch is written.
         */
        wmb();

        /* set next_to_watch value indicating a packet is present */
        first->next_to_watch = tx_desc;

        i++;
        if (i == tx_ring->count)
                i = 0;

        tx_ring->next_to_use = i;

        writel(i, tx_ring->tail);

        /* we need this if more than one processor can write to our tail
         * at a time, it synchronizes IO on IA64/Altix systems
         */
        mmiowb();

        return;

上面的代码做了一些重要的事情:

1.首先使用被调用的函数强制完成内存写入。这作为适合 CPU 平台的特殊指令执行,通常称为“写入屏障”。这在某些 CPU 架构上很重要,因为如果我们触发设备启动 DMA,而没有确保所有用于更新内部状态的内存写入都已完成,则设备可能会从状态不一致的 RAM 中读取数据。本文和本讲座深入探讨了内存排序的细节。 wmb     

  1. 字段已设置。它将在稍后的完成阶段使用。 next_to_watch 
  2. 计数器被增加,并且传输队列的字段被更新为下一个可用的描述符。 next_to_use 
  3. 传输队列的尾部使用函数进行更新。将“long”写入内存映射的 I/O地址。在本例中,地址为(即硬件地址),要写入的值为。此写入操作会触发设备,让其知道有更多数据已准备好从 RAM 进行 DMA 传输并写入网络。 writel  writel    tx_ring->tail  i
  4. 最后,调用该函数。该函数将执行适合 CPU 架构的指令,使内存映射写入操作有序进行。它也是一个写入屏障,但用于内存映射 I/O 写入。 mmiowb 

如果您想了解有关、的更多信息以及何时使用它们,您可以阅读Linux内核中包含的一些有关内存屏障的优秀文档。   wmb mmiowb

最后,代码包含一些错误处理。仅当尝试将 skb 数据地址映射到 DMA 可用地址时从 DMA API 返回错误时,才会执行此代码。

dma_error:
        dev_err(tx_ring->dev, "TX DMA map failed\n");

        /* clear dma mappings for failed tx_buffer_info map */
        for (;;) {
                tx_buffer = &tx_ring->tx_buffer_info[i];
                igb_unmap_and_free_tx_resource(tx_ring, tx_buffer);
                if (tx_buffer == first)
                        break;
                if (i == 0)
                        i = tx_ring->count;
                i--;
        }

        tx_ring->next_to_use = i;

在继续讨论传输完成之前,让我们先检查一下上面忽略的内容:动态队列限制。

动态队列限制 (DQL)

正如您在这篇文章中看到的,网络数据在越来越接近要传输的设备时,会花费大量时间停留在各个阶段的队列中。随着队列大小的增加,数据包停留在队列中的时间会更长,也就是说,数据包传输延迟会随着队列大小的增加而增加。

解决此问题的一种方法是使用背压。动态队列限制 (DQL) 系统是一种机制,设备驱动程序可以使用该机制向网络系统施加背压,以防止在设备无法传输时排队等待传输过多数据,

要使用此系统,网络设备驱动程序需要在其传输和完成例程期间进行一些简单的 API 调用。DQL 系统内部将使用一种算法来确定何时传输了足够的数据。一旦达到此限制,传输队列将被暂时禁用。这种队列禁用会对网络系统产生背压。当 DQL 系统确定足够的数据已完成传输时,队列将自动重新启用。

查看有关 DQL 系统的这组精彩的幻灯片,了解一些性能数据和 DQL 内部算法的解释。  

我们刚刚看到的代码中调用的函数是 DQL API 的一部分。当数据排队到设备进行传输时,将调用此函数。传输完成后,驱动程序将调用。在内部,这两个函数都将调用 DQL 库(位于./lib/dynamic_queue_limits.c./include/linux/dynamic_queue_limits.h中)来确定是否应禁用、重新启用或保持原样。 netdev_tx_sent_queue  netdev_tx_completed_queue   

DQL 在 sysfs 中导出统计数据和调优旋钮。调优 DQL 应该不是必需的;算法会随时间调整其参数。不过,为了完整起见,我们稍后将了解如何监视和调优 DQL。

传输完成

一旦设备传输完数据,它就会生成一个中断来表示传输已完成。然后,设备驱动程序可以安排一些需要长时间运行的工作来完成,例如取消映射内存区域和释放数据。具体如何工作取决于设备。对于驱动程序(及其相关设备),传输完成和数据包接收会触发相同的 IRQ。这意味着对于驱动程序来说,它用于处理传输完成传入数据包接收。 igb  igb  NET_RX  ** 

让我重申一下,以强调这一点的重要性:您的设备在接收数据包时可能会发出与数据包传输已完成时相同的中断。如果是这样,则软中断会运行以处理传入数据包和传输完成。 NET_RX  ** 

由于两个操作共享同一个 IRQ,因此只能注册一个 IRQ 处理函数,并且它必须处理两种可能的情况。回想一下接收网络数据时的以下流程:

  1. 网络数据已接收。
  2. 网络设备发出 IRQ。
  3. 设备驱动程序的 IRQ 处理程序执行,清除 IRQ 并确保安排运行softIRQ(如果尚未运行)。此处触发的 softIRQ 就是 softIRQ 。   NET_RX 
  4. softIRQ 本质上作为单独的内核线程执行。它运行并实现NAPI轮询循环。  
  5. NAPI 轮询循环只是一段代码,只要有足够的预算,它就会循环执行收集数据包。
  6. 每次处理一个数据包时,预算就会减少,直到没有更多数据包需要处理、预算达到 0 或者时间片已过期。

驱动程序(和驱动程序 [问候,tyler])中的上述步骤 5在处理传入数据之前处理 TX 完成。请记住,根据驱动程序的实现,TX 完成和传入数据的处理函数可能共享相同的处理预算。和驱动程序分别跟踪 TX 完成和传入数据包预算,因此处理 TX 完成不一定会耗尽 RX 预算。 igb  ixgbe  igb  ixgbe 

也就是说,整个 NAPI 轮询循环在硬编码的时间片内运行。这意味着,如果您有大量 TX 完成处理需要处理,TX 完成可能比处理传入数据占用更多的时间片。对于那些在高负载环境中运行网络硬件的人来说,这可能是一个重要的考虑因素。 

让我们看看在实际操作中驾驶员是如何遇到这种情况的。 igb 

传输完成 IRQ

本文不会重新陈述Linux 内核接收端网络博客文章中已经涵盖的信息,而是按顺序列出步骤并链接到接收端博客文章中的相应部分,直到传输完成。 

那么,让我们从头开始:

  1. 网络设备已启动。 
  2. IRQ 处理程序已注册
  3. 用户程序将数据发送到网络套接字。数据在网络堆栈中传输,直到设备从内存中获取并传输。
  4. 设备完成数据传输并发出 IRQ 来表示传输完成。
  5. 驱动程序的IRQ 处理程序执行来处理中断。 
  6. IRQ 处理程序调用来响应 IRQ。 napi_schedule 
  7. NAPI代码触发软中断执行。   NET_RX 
  8. sofitrq函数,开始执行。 NET_RX  net_rx_action 
  9. 该函数调用驱动程序注册的NAPI轮询函数。 net_rx_action  
  10. 执行NAPI轮询函数。 igb_poll 

轮询函数是代码分离和处理传入数据包和传输完成的地方。让我们深入研究此函数的代码,看看发生了什么。 igb_poll 

igb_poll

让我们看一下代码(来自./drivers/net/ethernet/intel/igb/igb_main.c): igb_poll  

/**
 *  igb_poll - NAPI Rx polling callback
 *  @napi: napi polling structure
 *  @budget: count of how many packets we should handle
 **/
static int igb_poll(struct napi_struct *napi, int budget)
{
        struct igb_q_vector *q_vector = container_of(napi,
                                                     struct igb_q_vector,
                                                     napi);
        bool clean_complete = true;

#ifdef CONFIG_IGB_DCA
        if (q_vector->adapter->flags & IGB_FLAG_DCA_ENABLED)
                igb_update_dca(q_vector);
#endif
        if (q_vector->tx.ring)
                clean_complete = igb_clean_tx_irq(q_vector);

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

此函数按顺序执行一些操作:

  1. 如果内核中启用了直接缓存访问 (DCA)支持,则 CPU 缓存会预热,这样对 RX 环的访问就会到达 CPU 缓存。您可以在接收端网络帖子的Extras 部分中阅读有关 DCA 的更多信息。    
  2. igb_clean_tx_irq 被调用来执行传输完成操作。
  3. igb_clean_rx_irq 接下来被调用来执行传入的数据包处理。
  4. 最后,检查是否还有更多工作可以完成。如果是,则返回。如果发生这种情况,将把这个 NAPI 结构移到轮询列表的末尾,以便稍后再次处理。 clean_complete  budget  net_rx_action 

要了解更多有关其工作原理的信息,请阅读上一篇博文的这一部分。 igb_clean_rx_irq   

这篇博文主要关注传输端,因此我们将继续研究上述工作原理。 igb_clean_tx_irq 

igb_clean_tx_irq

查看./drivers/net/ethernet/intel/igb/igb_main.c中该函数的源代码。 

它有点长,所以我们将其分成几个部分并逐一讲解:

static bool igb_clean_tx_irq(struct igb_q_vector *q_vector)
{
        struct igb_adapter *adapter = q_vector->adapter;
        struct igb_ring *tx_ring = q_vector->tx.ring;
        struct igb_tx_buffer *tx_buffer;
        union e1000_adv_tx_desc *tx_desc;
        unsigned int total_bytes = 0, total_packets = 0;
        unsigned int budget = q_vector->tx.work_limit;
        unsigned int i = tx_ring->next_to_clean;

        if (test_bit(__IGB_DOWN, &adapter->state))
                return true;

该函数首先初始化一些有用的变量。需要注意的一个重要变量是。如上所示,它被初始化为此队列的。在驱动程序中,它被初始化为硬编码值(128)。 budget budget  tx.work_limit igb  tx.work_limit  IGB_DEFAULT_TX_WORK 

值得注意的是,虽然我们现在看到的 TX 完成代码与接收处理在同一个软中断中运行,但驱动程序中的 TX 和 RX 函数并不共享处理预算。由于整个轮询函数在同一个时间片内运行,因此单次运行该函数不可能阻止传入数据包处理或传输完成。只要调用,两者就会得到处理。 NET_RX  igb   igb_poll  igb_poll 

接下来,上面的代码片段通过检查网络设备是否已关闭来结束。如果是,则返回并退出。 true  igb_clean_tx_irq

        tx_buffer = &tx_ring->tx_buffer_info[i];
        tx_desc = IGB_TX_DESC(tx_ring, i);
        i -= tx_ring->count;
  1. 该变量被初始化为位于位置的传输缓冲区信息结构(该变量本身被初始化为)。 tx_buffer  tx_ring->next_to_clean  0
  2. 获取对相关描述符的引用并将其存储在中。 tx_desc
  3. 计数器会根据传输队列的大小而减少。这个值可以调整(我们将在调整部分看到),但初始化为(256)。 i  IGB_DEFAULT_TXD 

接下来,循环开始。它包含一些有用的注释来解释每一步发生的事情:

        do {
                union e1000_adv_tx_desc *eop_desc = tx_buffer->next_to_watch;

                /* if next_to_watch is not set then there is no work pending */
                if (!eop_desc)
                        break;

                /* prevent any other reads prior to eop_desc */
                read_barrier_depends();

                /* if DD is not set pending work has not been completed */
                if (!(eop_desc->wb.status & cpu_to_le32(E1000_TXD_STAT_DD)))
                        break;

                /* clear next_to_watch to prevent false hangs */
                tx_buffer->next_to_watch = NULL;

                /* update the statistics for this packet */
                total_bytes += tx_buffer->bytecount;
                total_packets += tx_buffer->gso_segs;

                /* free the skb */
                dev_kfree_skb_any(tx_buffer->skb);

                /* unmap skb header data */
                dma_unmap_single(tx_ring->dev,
                                 dma_unmap_addr(tx_buffer, dma),
                                 dma_unmap_len(tx_buffer, len),
                                 DMA_TO_DEVICE);

                /* clear tx_buffer data */
                tx_buffer->skb = NULL;
                dma_unmap_len_set(tx_buffer, len, 0);
  1. 首先设置为缓冲区的字段。这是我们之前看到的传输代码中设置的。 eop_desc  next_to_watch 
  2. 如果(eop = 数据包结束) 为,则没有待处理的工作。 eop_desc  NULL
  3. 调用该函数,它将执行适合该 CPU 架构的 CPU 指令,以防止读取通过该屏障重新排序。 read_barrier_depends 
  4. 接下来,检查数据包描述符末尾的状态位。如果该位未设置,则表示传输尚未完成,因此请退出循环。 eop_desc E1000_TXD_STAT_DD 
  5. 清除。驱动程序中的看门狗定时器将监视此字段以确定传输是否挂起。清除此字段将阻止看门狗触发。 tx_buffer->next_to_watch
  6. 统计计数器会根据发送的总字节数和数据包数进行更新。一旦处理完所有描述符,这些内容将被复制到驱动程序读取的统计计数器中。
  7. skb 已被释放。
  8. dma_unmap_single 用于取消映射 skb 数据区域。
  9. 设置为并且取消映射。 tx_buffer->skb  NULL  tx_buffer 

接下来,在上面的循环内部启动另一个循环:

                /* clear last DMA location and unmap remaining buffers */
                while (tx_desc != eop_desc) {
                        tx_buffer++;
                        tx_desc++;
                        i++;
                        if (unlikely(!i)) {
                                i -= tx_ring->count;
                                tx_buffer = tx_ring->tx_buffer_info;
                                tx_desc = IGB_TX_DESC(tx_ring, 0);
                        }

                        /* unmap any remaining paged data */
                        if (dma_unmap_len(tx_buffer, len)) {
                                dma_unmap_page(tx_ring->dev,
                                               dma_unmap_addr(tx_buffer, dma),
                                               dma_unmap_len(tx_buffer, len),
                                               DMA_TO_DEVICE);
                                dma_unmap_len_set(tx_buffer, len, 0);
                        }
                }

此内循环将循环遍历每个传输描述符,直到到达。此代码将取消映射任何附加描述符所引用的数据。 tx_desc  eop_desc

外层循环继续:

                /* move us one more past the eop_desc for start of next pkt */
                tx_buffer++;
                tx_desc++;
                i++;
                if (unlikely(!i)) {
                        i -= tx_ring->count;
                        tx_buffer = tx_ring->tx_buffer_info;
                        tx_desc = IGB_TX_DESC(tx_ring, 0);
                }

                /* issue prefetch for next Tx descriptor */
                prefetch(tx_desc);

                /* update budget accounting */
                budget--;
        } while (likely(budget));

外循环增加迭代器并减少值。检查循环不变量以确定循环是否应继续。 budget 

        netdev_tx_completed_queue(txring_txq(tx_ring),
                                  total_packets, total_bytes);
        i += tx_ring->count;
        tx_ring->next_to_clean = i;
        u64_stats_update_begin(&tx_ring->tx_syncp);
        tx_ring->tx_stats.bytes += total_bytes;
        tx_ring->tx_stats.packets += total_packets;
        u64_stats_update_end(&tx_ring->tx_syncp);
        q_vector->tx.total_bytes += total_bytes;
        q_vector->tx.total_packets += total_packets;

此代码:

  1. 调用,这是上面解释的 DQL API 的一部分。如果处理了足够多的完成,这将有可能重新启用传输队列。 netdev_tx_completed_queue
  2. 统计数据被添加到适当的位置,以便用户可以访问它们,我们稍后会看到。

代码继续执行,首先检查标志是否已设置。每次运行定时器回调时,看门狗定时器都会设置此标志,以强制定期检查传输队列。如果该标志现在恰好处于打开状态,则代码将继续执行并检查传输队列是否已挂起: IGB_RING_FLAG_TX_DETECT_HANG 

        if (test_bit(IGB_RING_FLAG_TX_DETECT_HANG, &tx_ring->flags)) {
                struct e1000_hw *hw = &adapter->hw;

                /* Detect a transmit hang in hardware, this serializes the
                 * check with the clearing of time_stamp and movement of i
                 */
                clear_bit(IGB_RING_FLAG_TX_DETECT_HANG, &tx_ring->flags);
                if (tx_buffer->next_to_watch &&
                    time_after(jiffies, tx_buffer->time_stamp +
                               (adapter->tx_timeout_factor * HZ)) &&
                    !(rd32(E1000_STATUS) & E1000_STATUS_TXOFF)) {

                        /* detected Tx unit hang */
                        dev_err(tx_ring->dev,
                                "Detected Tx Unit Hang\n"
                                "  Tx Queue             <%d>\n"
                                "  TDH                  <%x>\n"
                                "  TDT                  <%x>\n"
                                "  next_to_use          <%x>\n"
                                "  next_to_clean        <%x>\n"
                                "buffer_info[next_to_clean]\n"
                                "  time_stamp           <%lx>\n"
                                "  next_to_watch        <%p>\n"
                                "  jiffies              <%lx>\n"
                                "  desc.status          <%x>\n",
                                tx_ring->queue_index,
                                rd32(E1000_TDH(tx_ring->reg_idx)),
                                readl(tx_ring->tail),
                                tx_ring->next_to_use,
                                tx_ring->next_to_clean,
                                tx_buffer->time_stamp,
                                tx_buffer->next_to_watch,
                                jiffies,
                                tx_buffer->next_to_watch->wb.status);
                        netif_stop_subqueue(tx_ring->netdev,
                                            tx_ring->queue_index);

                        /* we are about to reset, no point in enabling stuff */
                        return true;
                }

上述语句检查: if 

  • tx_buffer->next_to_watch 已设置,并且
  • 当前值大于传输路径上记录的值,并添加了超时因子,并且 jiffies  time_stamp  tx_buffer 
  • 设备的传输状态寄存器未设置为。 E1000_STATUS_TXOFF

如果这三个测试全部为真,则会打印一条错误,表明检测到挂起。用于关闭队列并返回。 netif_stop_subqueue  true 

让我们继续阅读代码,看看如果没有传输挂起检查,或者有传输挂起检查但没有检测到挂起,会发生什么:

#define TX_WAKE_THRESHOLD (DESC_NEEDED * 2)
        if (unlikely(total_packets &&
            netif_carrier_ok(tx_ring->netdev) &&
            igb_desc_unused(tx_ring) >= TX_WAKE_THRESHOLD)) {
                /* Make sure that anybody stopping the queue after this
                 * sees the new next_to_clean.
                 */
                smp_mb();
                if (__netif_subqueue_stopped(tx_ring->netdev,
                                             tx_ring->queue_index) &&
                    !(test_bit(__IGB_DOWN, &adapter->state))) {
                        netif_wake_subqueue(tx_ring->netdev,
                                            tx_ring->queue_index);

                        u64_stats_update_begin(&tx_ring->tx_syncp);
                        tx_ring->tx_stats.restart_queue++;
                        u64_stats_update_end(&tx_ring->tx_syncp);
                }
        }

        return !!budget;

在上面的代码中,如果之前已禁用传输队列,驱动程序将重新启动传输队列。它首先检查:

  • 一些数据包被处理以完成(total_packets 非零),并且
  • netif_carrier_ok 确保设备没有被摔落,并且
  • 传输队列中未使用的描述符数量大于或等于。此阈值似乎在我的 x86_64 系统上。 TX_WAKE_THRESHOLD 42 

如果所有条件都满足,则使用写屏障(smp_mb)。接下来检查另一组条件:

  • 如果队列停止,并且
  • 设备未关闭

然后调用以唤醒传输队列并向更高层发出信号,表示它们可以再次排队数据。统计计数器将递增。接下来我们将了解如何读取此值。 netif_wake_subqueue  restart_queue 

最后返回一个布尔值。如果还有未使用的预算,则返回,否则返回。检查此值以确定返回什么。 true  false igb_poll  net_rx_action

igb_poll 返回值

该函数具有以下代码来确定返回什么: igb_poll  net_rx_action

        if (q_vector->tx.ring)
                clean_complete = igb_clean_tx_irq(q_vector);

        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;

换句话说,如果:

  • igb_clean_tx_irq 清除所有传输完成,且未耗尽其传输完成预算,并且
  • igb_clean_rx_irq 清除所有传入数据包,且不会耗尽其数据包处理预算

然后,将返回整个预算金额(对于包括在内的大多数驱动程序,该金额是硬编码的)。如果 RX 或 TX 处理无法完成(因为还有更多工作要做),则 NAPI 将被禁用,并调用并返回: 64  igb napi_complete  0 

        /* If not enough Rx work done, exit the polling mode */
        napi_complete(napi);
        igb_ring_irq_enable(q_vector);

        return 0;
}

监控网络设备

监控网络设备的方法有很多种,每种方法的精细程度和复杂程度都不同。让我们从最精细的开始,逐步过渡到最不精细的。

使用 ethtool -S

您可以通过运行以下命令在 Ubuntu 系统上安装:。 ethtool  sudo apt-get install ethtool

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

使用“ethtool -S”监控详细的 NIC 设备统计信息(例如传输错误)。

$ sudo ethtool -S eth0
NIC statistics:
     rx_packets: 597028087
     tx_packets: 5924278060
     rx_bytes: 112643393747
     tx_bytes: 990080156714
     rx_broadcast: 96
     tx_broadcast: 116
     rx_multicast: 20294528
     ....

监控这些数据可能很困难。这些数据很容易获取,但字段值没有标准化。不同的驱动程序,甚至同一驱动程序的不同版本可能会产生具有相同含义的不同字段名称。 ** 

您应该查找标签中带有“drop”、“buffer”、“miss”、“errors”等的值。接下来,您必须读取驱动程序源代码。您将能够确定哪些值完全由软件决定(例如,当没有内存时递增),哪些值直接通过寄存器读取来自硬件。对于寄存器值,您应该查阅硬件的数据表以确定计数器的真正含义;通过给出的许多标签可能会产生误导。 ethtool 

使用 sysfs

sysfs 也提供了很多统计值,但是它们比直接提供的 NIC 级别统计数据略高一些。

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

使用 sysfs 监控更高级别的 NIC 统计数据。

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

计数器值将被分成诸如、、、、等文件。 tx_aborted_errors tx_carrier_errors tx_compressed tx_dropped

不幸的是,每个字段的含义、何时增加它们以及这些值来自何处都由驱动程序决定。您可能会注意到,有些驱动程序将某种类型的错误情况算作丢弃,但其他驱动程序可能将其算作未命中。

如果这些值对您至关重要,您将需要阅读驱动程序源和设备数据表,以准确了解驱动程序认为这些值的含义。

使用 /proc/net/dev

更高级别的文件为系统上的每个网络适配器提供高级摘要信息。 /proc/net/dev 

通过阅读来监控高级 NIC 统计数据。 /proc/net/dev

$ cat /proc/net/dev
Inter-|   Receive                                                |  Transmit
 face |bytes    packets errs drop fifo frame compressed multicast|bytes    packets errs drop fifo colls carrier compressed
  eth0: 110346752214 597737500    0    2    0     0          0  20963860 990024805984 6066582604    0    0    0     0       0          0
    lo: 428349463836 1579868535    0    0    0     0          0         0 428349463836 1579868535    0    0    0     0       0          0

该文件显示了您将在上述 sysfs 文件中找到的值的子集,但它可以作为有用的一般参考。

上面提到的警告也适用于此:如果这些值对您很重要,您仍然需要阅读驱动程序源代码以准确了解它们何时、何地和为何增加,以确保您对错误、丢弃或 fifo 的理解与您的驱动程序相同。

监控动态队列限制

您可以通过读取以下文件来监控网络设备的动态队列限制:。 /sys/class/net/NIC/queues/tx-QUEUE_NUMBER/byte_queue_limits/

用您的设备名称(、、等)和传输队列号(、、、等)替换。 NIC eth0 eth1 tx-QUEUE_NUMBER tx-0 tx-1 tx-2

其中一些文件是:

  • hold_time:初始化为(1 赫兹)。如果队列已满,则最大大小会减少。 HZ  hold_time
  • inflight:此值等于(排队的数据包数量 - 已完成的数据包数量)。这是当前正在传输但尚未完成处理的数据包数量。
  • limit_max:一个硬编码值,设置为(在我的 x86_64 系统上)。 DQL_MAX_LIMIT 1879048192 
  • limit_min:硬编码值,设置为。 0
  • limit:和之间的值,表示当前可以排队的最大对象数。 limit_min  limit_max 

在修改任何这些值之前,强烈建议阅读这些演示幻灯片以深入了解该算法。  

通过读取来监控飞行中的数据包传输。 /sys/class/net/eth0/queues/tx-0/byte_queue_limits/inflight

$ cat /sys/class/net/eth0/queues/tx-0/byte_queue_limits/inflight
350

调整网络设备

检查正在使用的 TX 队列数量

如果您的 NIC 和系统上加载的设备驱动程序支持多个传输队列,则通常可以使用 来调整 TX 队列(也称为 TX 通道)的数量。 ethtool

使用以下命令检查 NIC 传输队列的数量 ethtool

$ sudo ethtool -l eth0
Channel parameters for eth0:
Pre-set maximums:
RX:   0
TX:   0
Other:    0
Combined: 8
Current hardware settings:
RX:   0
TX:   0
Other:    0
Combined: 4

此输出显示预设的最大值(由驱动程序和硬件强制执行)和当前设置。

如果您的 NIC 不支持此操作,则会出现错误。

$ sudo ethtool -l eth0
Channel parameters for eth0:
Cannot get device channel parameters
: Operation not supported

这意味着您的驱动程序尚未实现 ethtool操作。这可能是因为 NIC 不支持调整队列数量、不支持多个传输队列,或者您的驱动程序尚未更新以处理此功能。 get_channels 

调整使用的 TX 队列数量

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

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

将组合 NIC 传输和接收队列设置为 8,使用 ethtool -L

$ sudo ethtool -L eth0 combined 8

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

使用 将 NIC 传输队列数设置为 8 。 ethtool -L

$ sudo ethtool -L eth0 tx 8

注意:  对于大多数驱动程序来说,进行这些更改将关闭接口然后重新打开;与此接口的连接将中断。不过,对于一次性更改来说,这可能无关紧要。

调整 TX 队列的大小

一些 NIC 及其驱动程序还支持调整 TX 队列的大小。具体如何工作取决于硬件,但幸运的是,它为用户提供了一种调整大小的通用方法。增加 TX 的大小可能不会带来很大的不同,因为 DQL 用于防止更高层的网络代码有时排队更多数据。不过,您可能希望将 TX 队列增加到最大大小,然后让 DQL 为您解决所有其他问题: ethtool 

使用以下方法检查当前 NIC 队列大小 ethtool -g

$ sudo ethtool -g eth0
Ring parameters for eth0:
Pre-set maximums:
RX:   4096
RX Mini:  0
RX Jumbo: 0
TX:   4096
Current hardware settings:
RX:   512
RX Mini:  0
RX Jumbo: 0
TX:   512

以上输出表明硬件支持最多 4096 个接收和发送描述符,但是当前仅使用 512 个。

将每个 TX 队列的大小增加到 4096 ethtool -G

$ sudo ethtool -G eth0 tx 4096

注意:  对于大多数驱动程序来说,进行这些更改将关闭接口然后重新打开;与此接口的连接将中断。不过,对于一次性更改来说,这可能无关紧要。

结束

结束!现在您已经了解了 Linux 上数据包传输的工作原理:从用户程序到设备驱动程序再返回。

还有一些额外的事情值得一提,但在其他地方似乎不太正确。

减少 ARP 流量(MSG_CONFIRM

、和系统调用都带有一个参数。如果您从应用程序将标志传递给这些系统调用,它将导致发送路径上的内核中的函数更新邻居结构的时间戳。这样做的结果是邻居结构不会被垃圾收集。这可以防止生成额外的 ARP 流量,因为邻居缓存条目将保持更长时间的温暖。 send sendto sendmsg  flags  MSG_CONFIRM  dst_neigh_output 

UDP Corking

我们在整个 UDP 协议栈中广泛研究了 UDP corking。如果您想在应用程序中使用它,可以通过调用并将level 设置为、 optname 设置为和设置为 来启用 UDP corking 。 setsockopt  IPPROTO_UDP UDP_CORK optval  1

时间戳

如上文所述,网络堆栈可以收集传出数据的时间戳。请参阅上述网络堆栈演练,了解软件中传输时间戳发生的位置。一些 NIC 甚至还支持硬件中的时间戳。

如果您想尝试确定内核网络堆栈在发送数据包时增加了多少延迟,这是一个有用的功能。

关于时间戳的内核文档非常出色,甚至还包含一个示例程序和 Makefile,您可以查看!。   

确定您的驱动程序和设备支持哪种时间戳模式。 ethtool -T

$ sudo ethtool -T eth0
Time stamping parameters for eth0:
Capabilities:
  software-transmit     (SOF_TIMESTAMPING_TX_SOFTWARE)
  software-receive      (SOF_TIMESTAMPING_RX_SOFTWARE)
  software-system-clock (SOF_TIMESTAMPING_SOFTWARE)
PTP Hardware Clock: none
Hardware Transmit Timestamp Modes: none
Hardware Receive Filter Modes: none

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

结论

Linux 网络堆栈非常复杂。

正如我们上面看到的,即使像 这样的简单的东西也不能保证它能按我们期望的那样工作。尽管名称中是 ,但传输完成仍然在此软中断中处理。 

这凸显了我认为问题的核心:除非你仔细阅读并理解网络堆栈的工作原理,否则优化和监控网络堆栈是不可能的。你无法监控你不深入理解的代码。