纯真的骆驼 · 贵州省农信社往年面试试题_贵州公务员考试网_ ...· 昨天 · |
聪明的小狗 · 关于指挥家莫斯特,你需要了解的三件事 - 新芭网· 1 月前 · |
聪明伶俐的领带 · 国外城镇化:“德国模式”值得借鉴· 2 月前 · |
乐观的签字笔 · 【回忆与思考·张维为】改革开放的试验田· 2 月前 · |
不羁的感冒药 · 我不信你看完这篇文章能忍住不入坑黑胶唱片| ...· 2 月前 · |
函数调用 linux系统 中断处理 软中断 |
https://colobu.com/2019/12/09/monitoring-tuning-linux-networking-stack-receiving-data/ |
强健的猕猴桃
1 年前 |
这是一个关于Linux网络监控和调优系列文章的一篇,详尽的介绍了网络知识的各个方面,得到了广泛的转发和称赞,也被翻译成了多个中文版。本文转载的是携程的ArthurChiao翻译的版本,翻译通顺,还很有心的加上了章节,更方便阅读。他还翻译很多有价值的文章,读者可以到他的 网站 上阅读。
英文原文: Monitoring and Tuning the Linux Networking Stack: Receiving Data
中文翻译: [译] Linux 网络栈监控和调优:接收数据(2016)
这让我不禁想起前两年很火的话题: “当你在浏览器中访问一个网址的时候,背后发生了什么?”,有多种多样的回答,将细节掰的很细,但是我还没有看到一篇能将网络连接和传输介绍的像本文这么细的文章。
太长不读(TL; DR)
本文介绍了 Linux 内核是如何
收包
(receive packets)的,包是怎样从网络栈到达用
户空间程序的,以及如何
监控
(monitoring)和
调优
(tuning)这一路径上的各个
网络栈组件。
这篇文章的姊妹篇 Monitoring and Tuning the Linux Networking Stack: Sending Data 。
这篇文章的图文注释版 the Illustrated Guide to Monitoring and Tuning the Linux Networking Stack: Receiving Data 。
想对 Linux 网络栈进行监控或调优,必须对它的行为(what exactly is happening)和原理有深入的理解,而这是离不开读内核源码的。希望本文可以给那些正准备投身于此的人提供一份参考。
特别感谢 Private Internet Access 的各位同僚。公司雇佣我们做一些包括本文主题在内的网络研究,并非常慷慨地允许我们将研究成果以文章的形式发表。
本文基于在 Private Internet Access 时的研究成果,最开始以 5 篇连载 的形式出现。
网络栈很复杂,没有一种通用的方式适用于所有场景。如果网络的性能和健康(performance and health)对你或你的业务非常关键,那你没有别的选择,只能花大量的时间、精力和金钱去深入理解系统的各个部分之间是如何交互的。
理想情况下,你应该考虑在网络栈的各层测量丢包状况,这样就可以缩小范围,确定哪个组件需要调优。
然而,这也是一些网络管理员开始走偏的地方
:他们想当然地认为通过一波
sysctl
或
/proc
操作可以解决问题,并且这些配置适用于所有场景。在某些场景下,可能确实如此;但是,整个系统是如此细微而精巧地交织在一起,如果想做有意义的监控和调优,你必须得努力在更深层次搞清系统是如何工作的。否则,你虽然可以使用默认配置,并在相当长的时间内运行良好,但终会到某个时间点,你不得不(投时间、精力和金钱研究这些配置,然后)做优化。
本文中的一些示例配置仅为了方便理解(效果),并不作为任何特定配置或默认配置的建议。在做任何配置改动之前,你应该有一个能够对系统进行监控的框架,以看变更是否带来预期的效果。
对远程连接上的机器进行网络变更是相当危险的,机器很可能失联。另外,不要在生产环境直接调整这些配置;如果可能的话,在新机器上改配置,然后将机器灰度上线到生产。
本文将拿
Intel I350
网卡的
igb
驱动作为参考,网卡的 data sheet 这里可以下载
PDF
(警告:文件很大)。
从比较高的层次看,一个数据包从被网卡接收到进入 socket 接收队列的整个过程如下:
ksoftirqd
进程调用 NAPI 的
poll
函数从 ring buffer 收包(
poll
函数是网卡驱动在初始化阶段注册的;每个 CPU 上都运行着一个
ksoftirqd
进程,在系统启动期间就注册了)
skb
的形式被送至更上层处理
接下来会详细介绍这个过程。
协议层分析我们将会关注 IP 和 UDP 层,其他协议层可参考这个过程。
本文基于 Linux 3.13。
准确地理解 Linux 内核的收包过程是一件非常有挑战性的事情。我们需要仔细研究网卡驱动的工作原理,才能对网络栈的相应部分有更加清晰的理解。
本文将拿
ibg
驱动作为例子,它是常见的 Intel I350 网卡的驱动。先来看网卡驱动是如何工作的。
驱动会使用
module_init
向内核注册一个初始化函数,当驱动被加载时,内核会调用这个函数。
这个初始化函数(
igb_init_module
)的代码见
drivers/net/ethernet/intel/igb/igb_main.c
.
过程非常简单直接:
|
|
初始化的大部分工作在
pci_register_driver
里面完成,下面来细看。
Intel I350 网卡是
PCI express
设备。
PCI 设备通过
PCI Configuration
Space
里面的寄存器识别自己。
当设备驱动编译时,
MODULE_DEVICE_TABLE
宏(定义在
include/module.h
)
会导出一个
PCI 设备 ID 列表
(a table of PCI device IDs),驱动据此识别它可以控制的设备,内核也会依据这个列表对不同设备加载相应驱动。
igb
驱动的设备表和 PCI 设备 ID 分别见:
drivers/net/ethernet/intel/igb/igb_main.c
和
drivers/net/ethernet/intel/igb/e1000_hw.h
。
|
|
前面提到,驱动初始化的时候会调用
pci_register_driver
,这个函数会将该驱动的各种回调方法注册到一个
struct pci_driver
变量,
drivers/net/ethernet/intel/igb/igb_main.c
:
|
|
通过 PCI ID 识别设备后,内核就会为它选择合适的驱动。每个 PCI 驱动注册了一个
probe()
方法,内核会对每个设备依次调用其驱动的
probe
方法,一旦找到一个合适的驱动,就不会再为这个设备尝试其他驱动。
很多驱动都需要大量代码来使得设备 ready,具体做的事情各有差异。典型的过程:
struct net_device_ops
类型变量,这个变量包含了用于设备相关的回调函数,例如打开设备、发送数据到网络、设置 MAC 地址等
struct net_device
类型变量(一个变量就代表了一个设备)
我们来简单看下
igb
驱动的
igb_probe
包含哪些过程。下面的代码来自
igb_probe
,
drivers/net/ethernet/intel/igb/igb_main.c
:
|
|
详细的 PCI 驱动讨论不在本文范围,如果想进一步了解,推荐如下材料:
分享
,
wiki
,
Linux Kernel Documentation: PCI
。
igb_probe
做了很多重要的设备初始化工作。除了 PCI 相关的,还有如下一些通用网络功能和网络设备相关的工作:
struct net_device_ops
变量
net_device
特性标记
我们逐一看下这些过程,后面会用到。
struct net_device_ops
网络设备相关的操作函数都注册到这个类型的变量中。 igb_main.c :
|
|
这个变量会在
igb_probe()
中赋给
struct net_device
中的
netdev_ops
字段:
|
|
ethtool
函数注册
ethtool
是一个命令行工具,可以查看和修改网络设备的一些配置,常用于收集网卡统计数据。在 Ubuntu 上,可以通过
apt-get install ethtool
安装。
ethtool
通过
ioctl
和设备驱动通信。内核实现了一个通用
ethtool
接口,网卡驱动实现这些接口,就可以被
ethtool
调用。当
ethtool
发起一个系统调用之后,内核会找到对应操作的回调函数。回调实现了各种简单或复杂的函数,简单的如改变一个 flag 值,复杂的包括调整网卡硬件如何运行。
相关实现见: igb_ethtool.c 。
当一个数据帧通过 DMA 写到 RAM(内存)后,网卡是如何通知其他系统这个包可以被处理了呢?
传统的方式是,网卡会产生一个硬件中断(IRQ),通知数据包到了。有 三种常见的硬中断类型 :
稍后详细介绍到。
先来思考这样一个问题:如果有大量的数据包到达,就会产生大量的硬件中断。CPU 忙于处理硬件中断的时候,可用于处理其他任务的时间就会减少。
NAPI(New API)是一种新的机制,可以减少产生的硬件中断的数量(但不能完全消除硬中断)。
NAPI 接收数据包的方式和传统方式不同,它允许设备驱动注册一个
poll
方法,然后调用这个方法完成收包。
NAPI 的使用方式:
poll
方法收包
和传统方式相比,NAPI 一次中断会接收多个包,因此可以减少硬件中断的数量。
poll
方法是通过调用
netif_napi_add
注册到 NAPI 的,同时还可以指定权重
weight
,大部分驱动都 hardcode 为 64。后面会进一步解释这个 weight 以及 hardcode 64。
通常来说,驱动在初始化的时候注册 NAPI poll 方法。
igb
驱动的 NAPI 初始化
igb
驱动的初始化过程是一个很长的调用链:
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
打开它
igb_alloc_q_vector
创建每个发送和接收队列
igb_alloc_q_vector
会进一步调用
netif_napi_add
注册 poll 方法到 NAPI 变量
我们来看下
igb_alloc_q_vector
是如何注册 poll 方法和私有数据的:
drivers/net/ethernet/intel/igb/igb_main.c
:
|
|
q_vector
是新分配的队列,
igb_poll
是 poll 方法,当它收包的时候,会通过
这个接收队列找到关联的 NAPI 变量(
q_vector->napi
)。
这里很重要,后面我们介绍从驱动到网络协议栈的 flow(根据 IP 头信息做哈希,哈希相
同的属于同一个 flow)时会看到。
回忆前面我们提到的
structure net_device_ops
变量,它包含网卡启用、发包、设置mac 地址等回调函数(函数指针)。
当启用一个网卡时(例如,通过
ifconfig eth0 up
),
net_device_ops
的
ndo_open
方法会被调用。它通常会做以下事情:
igb
驱动中,这个方法对应的是
igb_open
函数。
今天的大部分网卡都 使用 DMA 将数据直接写到内存 ,接下来 操作系统可以直接从里面读取 。实现这一目的所使用的数据结构是 ring buffer(环形缓冲区)。
要实现这一功能,设备驱动必须和操作系统合作, 预留(reserve)出一段内存来给网卡使用 。预留成功后,网卡知道了这块内存的地址,接下来收到的包就会放到这里,进而被操作系统取走。
由于这块内存区域是有限的,如果数据包的速率非常快,单个 CPU 来不及取走这些包,新来的包就会被丢弃。这时候,Receive Side Scaling(RSS,接收端扩展)或者多队列(multiqueue)一类的技术可能就会排上用场。
一些网卡有能力将接收到的包写到 多个不同的内存区域,每个区域都是独立的接收队列 。这样操作系统就可以利用多个 CPU(硬件层面)并行处理收到的包。只有部分网卡支持这个功能。
Intel I350 网卡支持多队列,我们可以在
igb
的驱动里看出来。
igb
驱动启用的时候,最开始做的事情之一就是调用
igb_setup_all_rx_resources
函数。这个函数会对每个 RX 队列调用
igb_setup_rx_resources
, 里面会管理 DMA的内存.
如果对其原理感兴趣,可以进一步查看 Linux kernel’s DMA API HOWTO 。
RX 队列的数量和大小可以通过 ethtool 进行配置,调整这两个参数会对收包或者丢包产生可见影响。
网卡通过对 packet 头(例如源地址、目的地址、端口等)做哈希来决定将 packet 放到哪个 RX 队列。只有很少的网卡支持调整哈希算法。如果支持的话,那你可以根据算法将特定的 flow 发到特定的队列,甚至可以做到在硬件层面直接将某些包丢弃。
一些网卡支持调整 RX 队列的权重,你可以有意地将更多的流量发到指定的 queue。
后面会介绍如何对这些参数进行调优。
前面看到了驱动如何注册 NAPI
poll
方法,但是,一般直到网卡被启用之后,NAPI 才被启用。
启用 NAPI 很简单,调用
napi_enable
函数就行,这个函数会设置 NAPI 变量(
struct napi_struct
)中一个表示是否启用的标志位。前面说到,NAPI 启用后并不是立即开始工作(而是等硬中断触发)。
对于
igb
,驱动初始化或者通过 ethtool 修改 queue 数量或大小的时候,会启用每个
q_vector
的 NAPI 变量。
drivers/net/ethernet/intel/igb/igb_main.c
:
|
|
启用 NAPI 之后,下一步就是注册中断处理函数。设备有多种方式触发一个中断:
设备驱动的实现也因此而异。
驱动必须判断出设备支持哪种中断方式,然后注册相应的中断处理函数,这些函数在中断发生的时候会被执行。
一些驱动,例如
igb
,会试图为每种中断类型注册一个中断处理函数,如果注册失败,就尝试下一种(没测试过的)类型。
MSI-X 中断是比较推荐的方式,尤其是对于支持多队列的网卡
。因为每个 RX 队列有独立的MSI-X 中断,因此可以被不同的 CPU 处理(通过
irqbalance
方式,或者修改
/proc/irq/IRQ_NUMBER/smp_affinity
)。我们后面会看到,处理中断的 CPU 也是随后处理这个包的 CPU。这样的话,从网卡硬件中断的层面就可以设置让收到的包被不同的 CPU处理。
如果不支持 MSI-X,那 MSI 相比于传统中断方式仍然有一些优势,驱动仍然会优先考虑它。这个 wiki 介绍了更多关于 MSI 和 MSI-X 的信息。
在
igb
驱动中,函数
igb_msix_ring
,
igb_intr_msi
,
igb_intr
分别是 MSI-X,MSI, 和传统中断方式的中断处理函数。
这段代码显式了驱动是如何尝试各种中断类型的, drivers/net/ethernet/intel/igb/igb_main.c :
|
|
这就是
igb
驱动注册中断处理函数的过程,这个函数在一个包到达网卡触发一个硬件中断时就会被执行。
到这里,几乎所有的准备工作都就绪了。唯一剩下的就是打开硬中断,等待数据包进来。打开硬中断的方式因硬件而异,
igb
驱动是在
__igb_open
里调用辅助函数
igb_irq_enable
完成的。
中断通过写寄存器的方式打开:
|
|
现在,网卡已经启用了。驱动可能还会做一些额外的事情,例如启动定时器,工作队列(work queue),或者其他硬件相关的设置。这些工作做完后,网卡就可以收包了。
接下来看一下如何监控和调优网卡。
监控网络设备有几种不同的方式,每种方式的监控粒度(granularity)和复杂度不同。我们先从最粗的粒度开始,逐步细化。
ethtool -S
ethtool -S
可以查看网卡统计信息(例如丢弃的包数量):
|
|
监控这些数据比较困难。因为用命令行获取很容易,但是以上字段并没有一个统一的标准。不同的驱动,甚至同一驱动的不同版本可能字段都会有差异。
你可以先粗略的查看 “drop”, “buffer”, “miss” 等字样。然后,在驱动的源码里找到对应的更新这些字段的地方,这可能是在软件层面更新的,也有可能是在硬件层面通过寄存器更新的。如果是通过硬件寄存器的方式,你就得查看网卡的 data sheet(说明书),搞清楚这个寄存器代表什么。ethtoool 给出的这些字段名,有一些是有误导性的(misleading)。
sysfs
sysfs 也提供了统计信息,但相比于网卡层的统计,要更上层一些。
例如,获取 eth0 的接收端 drop 的数量:
|
|
不同类型的统计分别位于
/sys/class/net/<NIC>/statistics/
下面的不同文件,包括
collisions
,
rx_dropped
,
rx_errors
,
rx_missed_errors
等等。
不幸的是,每种类型代表什么意思,是由驱动来决定的,因此也是由驱动决定何时以及在哪里更新这些计数的。你可能会发现一些驱动将一些特定类型的错误归类为 drop,而另外一些驱动可能将它们归类为 miss。
这些值至关重要,因此你需要查看对应的网卡驱动,搞清楚它们真正代表什么。
/proc/net/dev
/proc/net/dev
提供了更高一层的网卡统计。
|
|
这个文件里显式的统计只是 sysfs 里面的一个子集,但适合作为一个常规的统计参考。
前面的警告(caveat)也适用于此:如果这些数据对你非常重要,那你必须得查看内核源码、驱动源码和驱动手册,搞清楚每个字段真正代表什么意思,计数是如何以及何时被更新的。
如果网卡及其驱动支持 RSS/多队列,那你可以会调整 RX queue(也叫 RX channel)的数量。这可以用 ethtool 完成。
查看 RX queue 数量:
|
|
这里可以看到允许的最大值(网卡及驱动限制),以及当前设置的值。
注意:不是所有网卡驱动都支持这个操作。如果你的网卡不支持,会看到如下类似的错误:
|
|
这意味着驱动没有实现 ethtool 的
get_channels
方法。可能的原因包括:该网卡不支持调整 RX queue 数量,不支持 RSS/multiqueue,或者驱动没有更新来支持此功能。
ethtool -L
可以修改 RX queue 数量。
注意:一些网卡和驱动只支持 combined queue,这种模式下,RX queue 和 TX queue 是一对一绑定的,上面的例子我们看到的就是这种。
设置 combined 类型网卡的收发队列为 8 个:
|
|
如果你的网卡支持独立的 RX 和 TX 队列数量,那你可以只修改 RX queue 数量:
|
|
注意:对于大部分驱动,修改以上配置会使网卡先 down 再 up,因此会造成丢包。请酌情使用。
一些网卡和驱动也支持修改 RX queue 的大小。底层是如何工作的,随硬件而异,但幸运的是,ethtool 提供了一个通用的接口来做这件事情。增加 RX queue 的大小可以在流量很大的时候缓解丢包问题,但是,只调整这个还不够,软件层面仍然可能会丢包,因此还需要其他的一些调优才能彻底的缓解或解决丢包问题。
ethtool -g
可以查看 queue 的大小。
|
|
以上显式网卡支持最多 4096 个接收和发送 descriptor(描述符,简单理解,存放的是指向包的指针),但是现在只用到了 512 个。
用
ethtool -G
修改 queue 大小:
|
|
注意:对于大部分驱动,修改以上配置会使网卡先 down 再 up,因此会造成丢包。请酌情使用。
一些网卡支持给不同的 queue 设置不同的权重,以此分发不同数量的网卡包到不同的队列。
如果你的网卡支持以下功能,那你可以使用:
get_rxfh_indir_size
和
get_rxfh_indir
方法
-x
和
-X
参数来设置 indirection table
ethtool -x
检查 flow indirection 设置:
|
|
第一列是哈希值索引,是该行的第一个哈希值;冒号后面的是每个哈希值对于的 queue,例如,第一行分别是哈希值 0,1,2,3,4,5,6,7,对应的 packet 应该分别被放到 RX queue 0,1,0,1,0,1,0,1。
例子:在前两个 RX queue 之间均匀的分发(接收到的包):
|
|
例子:用
ethtool -X
设置自定义权重:
|
|
以上命令分别给 rx queue 0 和 rx queue 1 不同的权重:6 和 2,因此 queue 0 接收到的数量更多。注意 queue 一般是和 CPU 绑定的,因此这也意味着相应的 CPU 也会花更多的时间片在收包上。
一些网卡还支持修改计算 hash 时使用哪些字段。
可以用 ethtool 调整 RSS 计算哈希时所使用的字段。
例子:查看 UDP RX flow 哈希所使用的字段:
|
|
可以看到只用到了源 IP(SA:Source Address)和目的 IP。
我们接下来修改一下,加入源端口和目的端口:
|
|
sdfn
的具体含义解释起来有点麻烦,请查看 ethtool 的帮助(man page)。
调整 hash 所用字段是有用的,而
ntuple
过滤对于更加细粒度的 flow control 更加有用。
一些网卡支持 “ntuple filtering” 特性。该特性允许用户(通过 ethtool )指定一些参数来在硬件上过滤收到的包,然后将其直接放到特定的 RX queue。例如,用户可以指定到特定目端口的 TCP 包放到 RX queue 1。
Intel 的网卡上这个特性叫 Intel Ethernet Flow Director,其他厂商可能也有他们的名字,这些都是出于市场宣传原因,底层原理是类似的。
我们后面会看到,ntuple filtering 是一个叫 Accelerated Receive Flow Steering(aRFS) 功能的核心部分之一,后者使得 ntuple filtering 的使用更加方便。
这个特性适用的场景:最大化数据本地性(data locality),以增加 CPU 处理网络数据时的缓存命中率。例如,考虑运行在 80 口的 web 服务器:
检查 ntuple filtering 特性是否打开:
|
|
可以看到,上面的 ntuple 是关闭的。
|
|
打开 ntuple filtering 功能,并确认打开之后,可以用
ethtool -u
查看当前的 ntuple
rules:
|
|
可以看到当前没有 rules。
我们来加一条:目的端口是 80 的放到 RX queue 2:
|
|
你也可以用 ntuple filtering 在硬件层面直接 drop 某些 flow 的包。当特定 IP 过来的流量太大时,这种功能可能会派上用场。更多关于 ntuple 的信息,参考 ethtool man page。
ethtool -S <DEVICE>
的输出统计里,Intel 的网卡有
fdir_match
和
fdir_miss
两项,是和 ntuple filtering 相关的。关于具体的、详细的统计计数,需要查看相应网卡的设备驱动和 data sheet。
在查看网络栈之前,让我们先开个小差,看下内核里一个叫 SoftIRQ 的东西。
内核的软中断系统是一种 在硬中断处理上下文(驱动中)之外执行代码 的机制。 硬中断处理函数(handler)执行时,会屏蔽部分或全部(新的)硬中断 。中断被屏蔽的时间越长,丢失事件的可能性也就越大。所以, 所有耗时的操作都应该从硬中断处理逻辑中剥离出来 ,硬中断因此能尽可能快地执行,然后再重新打开硬中断。
内核中也有其他机制将耗时操作转移出去,不过对于网络栈,我们接下来只看软中断这种方式。
可以把软中断系统想象成一系列
内核线程
(每个 CPU 一个),这些线程执行针对不同事件注册的处理函数(handler)。如果你执行过
top
命令,可能会注意到
ksoftirqd/0
这个内核线程,其表示这个软中断线程跑在 CPU 0 上。
内核子系统(比如网络)能通过
open_softirq
函数注册软中断处理函数。接下来我们会看到网络系统是如何注册它的处理函数的。现在先来学习一下软中断是如何工作的。
ksoftirqd
软中断对分担硬中断的工作量非常重要,因此软中断线程在内核启动的很早阶段就
spawn
出来了。
kernel/softirq.c
展示了
ksoftirqd
系统是如何初始化的:
|
|
看到注册了两个回调函数:
ksoftirqd_should_run
和
run_ksoftirqd
。这两个函数都会从
kernel/smpboot.c
里调用,作为事件处理循环的一部分。
kernel/smpboot.c
里面的代码首先调用
ksoftirqd_should_run
判断是否有 pending 的软中断,如果有,就执行
run_ksoftirqd
,后者做一些 bookeeping 工作,然后调用
__do_softirq
。
__do_softirq
__do_softirq
做的几件事情:
查看 CPU 利用率时,
si
字段对应的就是 softirq
,度量(从硬中断转移过来的)软中断的 CPU 使用量。
软中断的信息可以从
/proc/softirqs
读取:
|
|
监控这些数据可以得到软中断的执行频率信息。
例如,
NET_RX
一行显示的是软中断在 CPU 间的分布。如果分布非常不均匀,那某一列的值就会远大于其他列,这预示着下面要介绍的 Receive Packet Steering / Receive Flow
Steering 可能会派上用场。但也要注意:不要太相信这个数值,
NET_RX
太高并不一定都是网卡触发的,下面会看到其他地方也有可能触发之。
调整其他网络配置时,可以留意下这个指标的变动。
现在,让我们进入网络栈部分,跟踪一个包是如何被接收的。
我们已经知道了网络驱动和软中断是如何工作的,接下来看 Linux 网络设备子系统是如何初始化的。然后我们就可以从一个包到达网卡开始跟踪它的整个路径。
网络设备(netdev)的初始化在
net_dev_init
,里面有些东西很有意思。
struct softnet_data
变量初始化
net_dev_init
为每个 CPU 创建一个
struct softnet_data
变量。这些变量包含一些指向重要信息的指针:
接下来随着逐步进入网络栈,我们会一一查看这些功能。
net_dev_init
分别为接收和发送数据注册了一个软中断处理函数。
|
|
后面会看到驱动的中断处理函数是如何触发
net_rx_action
这个为
NET_RX_SOFTIRQ
软中断注册的处理函数的。
终于,网络数据来了!
如果 RX 队列有足够的描述符(descriptors),包会 通过 DMA 写到 RAM 。设备然后发起对应于它的中断(或者在 MSI-X 的场景,中断和包达到的 RX 队列绑定)。
一般来说,中断处理函数应该将尽可能多的处理逻辑移出(到软中断),这至关重要,因为发起一个中断后,其他的中断就会被屏蔽。
我们来看一下 MSI-X 中断处理函数的代码,它展示了中断处理函数是如何尽量简单的。
|
|
这个中断处理函数非常简短,只做了 2 个很快的操作就返回了。
首先,它调用
igb_write_itr
更新一个硬件寄存器。对这个例子,这个寄存器是记录硬件中断频率的。
这个寄存器和一个叫 "Interrupt Throttling"(也叫 "Interrupt Coalescing")的硬件特性 相关,这个特性可以平滑传送到 CPU 的中断数量。我们接下来会看到,ethtool 是怎么样提供了一个机制用于 调整 IRQ 触发频率 的。
第二,触发
napi_schedule
,如果 NAPI 的处理循环还没开始的话,这会唤醒它。注意,这个处理循环是在软中断中执行的,而不是硬中断。
这段代码展示了硬中断尽量简短为何如此重要;为我们接下来理解多核 CPU 的接收逻辑很有帮助。
napi_schedule
接下来看从硬件中断中调用的
napi_schedule
是如何工作的。
注意,NAPI 存在的意义是 无需硬件中断通知就可以接收网络数据 。前面提到,NAPI 的轮询循环(poll loop)是受硬件中断触发而跑起来的。换句话说,NAPI 功能启用了,但是默认是没有工作的,直到第一个包到达的时候,网卡触发的一个硬件将它唤醒。后面会看到,也还有其他的情况,NAPI 功能也会被关闭,直到下一个硬中断再次将它唤起。
napi_schedule
只是一个简单的封装,内层调用
__napi_schedule
。
net/core/dev.c
:
|
|
__get_cpu_var
用于获取属于这个 CPU 的
structure softnet_data
变量。
____napi_schedule
,
net/core/dev.c
:
|
|
这段代码了做了两个重要的事情:
napi_struct
变量,添加到 poll list,后者 attach 到这个 CPU 上的
softnet_data
__raise_softirq_irqoff
触发一个
NET_RX_SOFTIRQ
类型软中断。这会触发执行
net_rx_action
(如果没有正在执行),后者是网络设备初始化的时候注册的
接下来会看到,软中断处理函数
net_rx_action
会调用 NAPI 的 poll 函数来收包。
注意到目前为止,我们从硬中断处理函数中转移到软中断处理函数的逻辑,都是使用的本CPU 变量。
驱动的硬中断处理函数做的事情很少,但软中断将会在和硬中断相同的 CPU 上执行。 这就是为什么给每个 CPU 一个特定的硬中断非常重要:这个 CPU 不仅处理这个硬中断,而且通过 NAPI 处理接下来的软中断来收包 。
后面我们会看到,Receive Packet Steering 可以将软中断分给其他 CPU。
注意:监控硬件中断拿不到关于网络包处理的健康状况的全景图,一些驱动在 NAPI 运行的时候会关闭硬中断。这只是你整个监控方案的一个重要部分。
读取硬中断统计:
|
|
可以看到有多少包进来、硬件中断频率,RX 队列被哪个 CPU 处理等信息。这里只能看到硬中断数量,不能看出实际多少数据被接收或处理,因为一些驱动在 NAPI 收包时会关闭硬中断。进一步,使用 Interrupt Coalescing 时也会影响这个统计。监控这个指标能帮你判断出你设置的 Interrupt Coalescing 是不是在工作。
为了使监控更加完整,需要同时监控
/proc/softirqs
(前面提到)和
/proc
。
中断合并会将多个中断事件放到一起,到达一定的阈值之后才向 CPU 发起中断请求。
这可以防止中断风暴,提升吞吐。减少中断数量能使吞吐更高,但延迟也变大,CPU 使用量下降;中断数量过多则相反。
历史上,早期的 igb、e1000 版本,以及其他的都包含一个叫 InterruptThrottleRate 参数,最近的版本已经被 ethtool 可配置的参数取代。
|
|
ethtool 提供了用于中断合并相关的通用的接口。但切记,不是所有的设备都支持完整的配置。你需要查看你的驱动文档或代码来确定哪些支持,哪些不支持。ethtool 的文档说的:“驱动没有实现的接口将会被静默忽略”。
某些驱动支持一个有趣的特性“自适应 RX/TX 硬中断合并”。这个特性一般是在硬件实现的。驱动通常需要做一些额外的工作来告诉网卡需要打开这个特性(前面的 igb 驱动代码里有涉及)。
自适应 RX/TX 硬中断合并带来的效果是:带宽比较低时降低延迟,带宽比较高时提升吞吐。
用
ethtool -C
打开自适应 RX IRQ 合并:
|
|
还可以用
ethtool -C
更改其他配置。常用的包括:
rx-usecs
: How many usecs to delay an RX interrupt after a packet arrives.
rx-frames
: Maximum number of data frames to receive before an RX interrupt.
rx-usecs-irq
: How many usecs to delay an RX interrupt while an interrupt is being serviced by the host.
rx-frames-irq
: Maximum number of data frames to receive before an RX interrupt is generated while the system is servicing an interrupt.
请注意你的硬件可能只支持以上列表的一个子集,具体请参考相应的驱动说明或源码。
不幸的是,通常并没有一个很好的文档来说明这些选项,最全的文档很可能是头文件。查看 include/uapi/linux/ethtool.h ethtool 每个每个选项的解释。
注意:虽然硬中断合并看起来是个不错的优化项,但要你的网络栈的其他一些相应部分也要针对性的调整。只合并硬中断很可能并不会带来多少收益。
If your NIC supports RSS / multiqueue or if you are attempting to optimize for data locality, you may wish to use a specific set of CPUs for handling interrupts generated by your NIC.
Setting specific CPUs allows you to segment which CPUs will be used for processing which IRQs. These changes may affect how upper layers operate, as we’ve seen for the networking stack.
If you do decide to adjust your IRQ affinities, you should first check if you running the irqbalance daemon. This daemon tries to automatically balance IRQs to CPUs and it may overwrite your settings. If you are running irqbalance, you should either disable irqbalance or use the --banirq in conjunction with IRQBALANCE_BANNED_CPUS to let irqbalance know that it shouldn’t touch a set of IRQs and CPUs that you want to assign yourself.
Next, you should check the file /proc/interrupts for a list of the IRQ numbers for each network RX queue for your NIC.
Finally, you can adjust the which CPUs each of those IRQs will be handled by modifying /proc/irq/IRQ_NUMBER/smp_affinity for each IRQ number.
You simply write a hexadecimal bitmask to this file to instruct the kernel which CPUs it should use for handling the IRQ.
Example: Set the IRQ affinity for IRQ 8 to CPU 0
|
|
一旦软中断代码判断出有 softirq 处于 pending 状态,就会开始处理,执行
net_rx_action
,网络数据处理就此开始。
我们来看一下
net_rx_action
的循环部分,理解它是如何工作的。哪个部分可以调优,哪个可以被监控。
net_rx_action
处理循环
net_rx_action
从包所在的内存开始处理,包是被设备通过 DMA 直接送到内存的。
函数遍历本 CPU 队列的 NAPI 变量列表,依次出队并操作之。处理逻辑考虑任务量(work)和执行时间两个因素:
|
|
这里可以看到内核是如何防止处理数据包过程霸占整个 CPU 的,其中 budget 是该 CPU 的所有 NAPI 变量的总预算。这也是多队列网卡应该精心调整 IRQ Affinity 的原因。回忆前面讲的,处理硬中断的 CPU接下来会处理相应的软中断,进而执行上面包含 budget 的这段逻辑。
多网卡多队列可能会出现这样的情况:多个 NAPI 变量注册到同一个 CPU 上。每个 CPU 上的所有 NAPI 变量共享一份 budget。
如果没有足够的 CPU 来分散网卡硬中断,可以考虑增加
net_rx_action
允许每个 CPU处理更多包。增加 budget 可以增加 CPU 使用量(
top
等命令看到的
sitime
或
si
部分),但可以减少延迟,因为数据处理更加及时。
Note: the CPU will still be bounded by a time limit of 2 jiffies, regardless of the assigned budget.
回忆前面,网络设备驱动使用
netif_napi_add
注册 poll 方法,
igb
驱动有如下代码片段:
|
|
这注册了一个 NAPI 变量,hardcode 64 的权重。我们来看在
net_rx_action
处理循环中这个值是如何使用的。
net/core/dev.c
:
|
|
其中的
n
是
struct napi
的变量。其中的
poll
指向
igb_poll
。
poll()
返回处理的数据帧数量,budget 会减去这个值。
所以,假设驱动使用 weight 值 64(Linux 3.13.0 的所有驱动都是 hardcode 这个值),设置 budget 默认值 300,那系统将在如下条件之一停止数据处理:
igb_poll
函数被调用了最多 5 次(如果没有数据需要处理,那次数就会很少)
NAPI 子系统和设备驱动之间的合约,最重要的一点是关闭 NAPI 的条件。具体如下:
poll
方法用完了它的全部 weight(默认 hardcode 64),那它
不要更改
NAPI 状态。接下来
net_rx_action
loop 会做的
poll
方法没有用完全部 weight,那它
必须关闭
NAPI。下次有硬件中断触发,驱动的硬件处理函数调用
napi_schedule
时,NAPI 会被重新打开
接下来先看
net_rx_action
如何处理合约的第一部分,然后看
poll
方法如何处理第二部分。
net_rx_action
loop
net_rx_action
循环的最后一部分代码处理前面提到的
NAPI 合约的第一部分
。
net/core/dev.c
:
|
|
如果全部
work
都用完了,
net_rx_action
会面临两种情况:
ifconfig eth0 down
命令)
>= 1000
,所有最近被更新的 GRO network flow 都会被 flush。将这个 NAPI 变量移到 list 末尾,这个循环下次再进入时,处理的就是下一个 NAPI 变量
这就是包处理循环如何唤醒驱动注册的
poll
方法进行包处理的过程。接下来会看到,
poll
方法会收割网络数据,发送到上层栈进行处理。
net_rx_action
下列条件之一退出循环:
!list_empty(&sd->poll_list)
)
budget <= 0
|
|
如果跟踪
softnet_break
,会发现很有意思的东西:
From net/core/dev.c:
|
|
softnet_data
变量更新统计信息,软中断的
NET_RX_SOFTIRQ
被关闭。
time_squeeze
字段记录的是满足如下条件的次数:
net_rx_action
有很多
work
要做但是 budget 用完了,或者 work 还没做完但时间限制到了。这对理解网络处理的瓶颈至关重要。我们后面会看到如何监控这个值。关闭
NET_RX_SOFTIRQ
是为了释放 CPU 时间给其他任务用。这行代码是有意义的,因为只有我们有更多工作要做(还没做完)的时候才会执行到这里,我们主动让出 CPU,不想独占太久。
然后执行到了
out
标签所在的代码。另外还有一种条件也会跳转到
out
标签:所有NAPI 变量都处理完了,换言之,budget 数量大于网络包数量,所有驱动都已经关闭 NAPI,没有什么事情需要
net_rx_action
做了。
out
代码段在从
net_rx_action
返回之前做了一件重要的事情:调用
net_rps_action_and_irq_enable
。Receive Packet Steering 功能打开时这个函数有重要作用:唤醒其他 CPU 处理网络包。
我们后面会看到 RPS 是如何工作的。现在先看看怎样监控
net_rx_action
处理循环的健康状态,以及进入 NAPI
poll
的内部,这样才能更好的理解网络栈。
poll
回忆前文,驱动程序会分配一段内存用于 DMA,将数据包写到内存。就像这段内存是由驱动程序分配的一样,驱动程序也负责解绑(unmap)这些内存,读取数据,将数据送到网络栈。
我们看下
igb
驱动如何实现这一过程的。
igb_poll
可以看到
igb_poll
代码其实相当简单。
drivers/net/ethernet/intel/igb/igb_main.c
:
|
|
几件有意思的事情:
igb_clean_rx_irq
,这里做的事情非常多,我们后面看
clean_complete
,判断是否仍然有 work 可以做。如果有,就返回 budget(回忆,这里是 hardcode 64)。在之前我们已经看到,
net_rx_action
会将这个 NAPI变量移动到 poll 列表的末尾
work
都已经完成,驱动通过调用
napi_complete
关闭 NAPI,并通过调用
igb_ring_irq_enable
重新进入可中断状态。下次中断到来的时候回重新打开 NAPI
我们来看
igb_clean_rx_irq
如何将网络数据送到网络栈。
igb_clean_rx_irq
igb_clean_rx_irq
方法是一个循环,每次处理一个包,直到 budget 用完,或者没有数据需要处理了。
做的几件重要事情:
IGB_RX_BUFFER_WRITE (16)
个。
skb
类型的变量中
skb->len
,表示这个包已经处理的字节数
skb
的 hash, checksum, timestamp, VLAN id, protocol 字段。hash,checksum,timestamp,VLAN ID 信息是硬件提供的,如果硬件报告 checksum error,
csum_error
统计就会增加。如果 checksum 通过了,数据是 UDP 或者 TCP 数据,
skb
就会被标记成
CHECKSUM_UNNECESSARY
napi_gro_receive()
进入协议栈
循环结束的时候,这个函数设置收包的数量和字节数统计信息。
接下来在进入协议栈之前,我们先开两个小差:首先是看一些如何监控和调优软中断,其次是介绍 GRO。有了这个两个背景,后面(通过
napi_gro_receive
进入)协议栈部分会更容易理解。
/proc/net/softnet_stat
前面看到,如果 budget 或者 time limit 到了而仍有包需要处理,那
net_rx_action
在退出循环之前会更新统计信息。这个信息存储在该 CPU 的
struct softnet_data
变量中。
这些统计信息打到了
/proc/net/softnet_stat
,但不幸的是,关于这个的文档很少。每一列代表什么并没有标题,而且列的内容会随着内核版本可能发生变化。
在内核 3.13.0 中,你可以阅读内核源码,查看每一列分别对应什么。
net/core/net-procfs.c
:
|
|
其中一些的名字让人很困惑,而且在你意想不到的地方更新。在接下来的网络栈分析说,我们会举例说明其中一些字段是何时、在哪里被更新的。前面我们已经看到了
squeeze_time
是在
net_rx_action
在被更新的,到此时,如下数据你应该能看懂了:
|
|
关于
/proc/net/softnet_stat
的重要细节:
struct softnet_data
变量。因为每个 CPU 只有一个该变量,所以每行
sd->processed
,是处理的网络帧的数量。如果你使用了 ethernet bonding,
sd->dropped
,是因为处理不过来而 drop 的网络帧数量。后面会展开这一话题
sd->time_squeeze
,前面介绍过了,由于 budget 或 time limit 用完而退出
net_rx_action
循环的次数
sd->cpu_collision
,是为了发送包而获取锁的时候有冲突的次数
sd->received_rps
,是这个 CPU 被其他 CPU 唤醒去收包的次数
flow_limit_count
,是达到 flow limit 的次数。flow limit 是 RPS 的特性,后面会稍微介绍一下
如果你要画图监控这些数据,确保你的列和相应的字段是对的上的,最保险的方式是阅读相应版本的内核代码。
net_rx_action
budget
net_rx_action
budget 表示一个 CPU 单次轮询(
poll
)所允许的最大收包数量。单次poll 收包是,所有注册到这个 CPU 的 NAPI 变量收包数量之和不能大于这个阈值。 调整:
|
|
如果要保证重启仍然生效,需要将这个配置写到
/etc/sysctl.conf
。
Linux 3.13.0 的默认配置是 300。
Large Receive Offloading (LRO) 是一个硬件优化,GRO 是 LRO 的一种软件实现。
两种方案的主要思想都是: 通过合并“足够类似”的包来减少传送给网络栈的包数,这有助于减少 CPU 的使用量 。例如,考虑大文件传输的场景,包的数量非常多,大部分包都是一段文件数据。相比于每次都将小包送到网络栈,可以将收到的小包合并成一个很大的包再送到网络栈。这可以使得协议层只需要处理一个 header,而将包含大量数据的整个大包送到用户程序。
这类优化方式的缺点就是:信息丢失。如果一个包有一些重要的 option 或者 flag,那将这个包的数据合并到其他包时,这些信息就会丢失。这也是为什么大部分人不使用或不推荐使用LRO 的原因。
LRO 的实现,一般来说,对合并包的规则非常宽松。GRO 是 LRO 的软件实现,但是对于包合并的规则更严苛。
顺便说一下, 如果你曾经用过 tcpdump 抓包,并收到看起来不现实的非常大的包,那很可能是你的系统开启了 GRO 。接下来会看到, tcpdump 的抓包点(捕获包的 tap)在整个栈的更后面一些,在GRO 之后 。
-k
查看 GRO 配置:
|
|
-K
修改 GRO 配置:
|
|
注意:对于大部分驱动,修改 GRO 配置会涉及先 down 再 up 这个网卡,因此这个网卡上的连接
都会中断。
napi_gro_receive
如果开启了 GRO,
napi_gro_receive
将负责处理网络数据,并将数据送到协议栈,大部分相关的逻辑在函数
dev_gro_receive
里实现。
dev_gro_receive
这个函数首先检查 GRO 是否开启了,如果是,就准备做 GRO。GRO 首先遍历一个 offloadfilter 列表,如果高层协议认为其中一些数据属于 GRO 处理的范围,就会允许其对数据进行操作。
协议层以此方式让网络设备层知道,这个 packet 是不是当前正在处理的一个需要做 GRO 的network flow 的一部分,而且也可以通过这种方式传递一些协议相关的信息。例如,TCP 协议需要判断是否以及合适应该将一个 ACK 包合并到其他包里。
|
|
如果协议层提示是时候 flush GRO packet 了,那就到下一步处理了。这发生在
napi_gro_complete
,会进一步调用相应协议的
gro_complete
回调方法,然后调用
netif_receive_skb
将包送到协议栈。这个过程见
net/core/dev.c
:
|
|
接下来,如果协议层将这个包合并到一个已经存在的 flow,
napi_gro_receive
就没什么事情需要做,因此就返回了。如果 packet 没有被合并,而且 GRO 的数量小于
MAX_GRO_SKBS
(
默认是 8),就会创建一个新的 entry 加到本 CPU 的 NAPI 变量的
gro_list
。
net/core/dev.c
:
|
|
这就是 Linux 网络栈中 GRO 的工作原理。
napi_skb_finish
一旦
dev_gro_receive
完成,
napi_skb_finish
就会被调用,其如果一个 packet 被合并了,就释放不用的变量;或者调用
netif_receive_skb
将数据发送到网络协议栈(因为已经
有
MAX_GRO_SKBS
个 flow 了,够 GRO 了)。
接下来,是看
netif_receive_skb
如何将数据交给协议层。但在此之前,我们先看一下 RPS。
回忆前面我们讨论了网络设备驱动是如何注册 NAPI
poll
方法的。每个 NAPI 变量都会运行在相应 CPU 的软中断的上下文中。而且,触发硬中断的这个 CPU 接下来会负责执行相应的软中断处理函数来收包。
换言之,同一个 CPU 既处理硬中断,又处理相应的软中断。
一些网卡(例如 Intel I350)在硬件层支持多队列。这意味着收进来的包会被通过 DMA 放到位于不同内存的队列上,而不同的队列有相应的 NAPI 变量管理软中断
poll()
过程。因此,多个 CPU 同时处理从网卡来的中断,处理收包过程。
这个特性被称作 RSS(Receive Side Scaling,接收端扩展)。
RPS (Receive Packet Steering,接收包控制,接收包引导)是 RSS 的一种软件实现。因为是软件实现的,意味着任何网卡都可以使用这个功能,即便是那些只有一个接收队列的网卡。但是,因为它是软件实现的,这意味着 RPS 只能在 packet 通过 DMA 进入内存后,RPS 才能开始工作。
这意味着,RPS 并不会减少 CPU 处理硬件中断和 NAPI
poll
(软中断最重要的一部分)的时间,但是可以在 packet 到达内存后,将 packet 分到其他 CPU,从其他 CPU 进入协议栈。
RPS 的工作原理是对个 packet 做 hash,以此决定分到哪个 CPU 处理。然后 packet 放到每个 CPU独占的接收后备队列(backlog)等待处理。这个 CPU 会触发一个进程间中断(
IPI
,Inter-processor
Interrupt)向对端 CPU。如果当时对端 CPU 没有在处理 backlog 队列收包,这个进程间中断会触发它开始从 backlog 收包。
/proc/net/softnet_stat
其中有一列是记录
softnet_data
变量(也即这个 CPU)收到了多少 IPI(
received_rps
列)。
因此,
netif_receive_skb
或者继续将包送到协议栈,或者交给 RPS,后者会转交给其他 CPU 处理。
使用 RPS 需要在内核做配置(Ubuntu + Kernel 3.13.0 支持),而且需要一个掩码(bitmask)指定哪些 CPU 可以处理那些 RX 队列。相关的一些信息可以在 内核文档 里找到。
bitmask 配置位于:
/sys/class/net/DEVICE_NAME/queues/QUEUE/rps_cpus
。
例如,对于 eth0 的 queue 0,你需要更改
/sys/class/net/eth0/queues/rx-0/rps_cpus
。
内核文档
里说,对一些特定的配置下,RPS 没必要了。
注意:打开 RPS 之后,原来不需要处理软中断(softirq)的 CPU 这时也会参与处理。因此相应 CPU 的
NET_RX
数量,以及
si
或
sitime
占比都会相应增加。你可以对比启用 RPS 前后的
数据,以此来确定你的配置是否生效,以及是否符合预期(哪个 CPU 处理哪个网卡的哪个中断)。
RFS(Receive flow steering)和 RPS 配合使用。RPS 试图在 CPU 之间平衡收包,但是没考虑数据的本地性问题,如何最大化 CPU 缓存的命中率。RFS 将属于相同 flow 的包送到相同的 CPU进行处理,可以提高缓存命中率。
RPS 记录一个全局的 hash table,包含所有 flow 的信息。这个 hash table 的大小可以在
net.core.rps_sock_flow_entries
:
|
|
其次,你可以设置每个 RX queue 的 flow 数量,对应着
rps_flow_cnt
:
例如,eth0 的 RX queue0 的 flow 数量调整到 2048:
|
|
RFS 可以用硬件加速,网卡和内核协同工作,判断哪个 flow 应该在哪个 CPU 上处理。这需要网卡和网卡驱动的支持。
如果你的网卡驱动里对外提供一个
ndo_rx_flow_steer
函数,那就是支持 RFS。
假如你的网卡支持 aRFS,你可以开启它并做如下配置:
CONFIG_RFS_ACCEL
选项。Ubuntu kernel 3.13.0 是有的
以上配置完成后,aRFS 就会自动将 RX queue 数据移动到指定 CPU 的内存,每个 flow 的包都会到达同一个 CPU,不需要你再通过 ntuple 手动指定每个 flow 的配置了。
netif_receive_skb
进入协议栈
重新捡起我们前面已经几次提到过的
netif_receive_skb
,这个函数在好几个地方被调用。两个最重要的地方(前面都看到过了):
napi_skb_finish
:当 packet 不需要被合并到已经存在的某个 GRO flow 的时候
napi_gro_complete
:协议层提示需要 flush 当前的 flow 的时候
提示:
netif_receive_skb
和它调用的函数都运行在软中断处理循环(softirq processing loop)的上下文中,因此这里的时间会记录到
top
命令看到的
si
或者
sitime
字段。
netif_receive_skb
首先会检查用户有没有设置一个接收时间戳选项(sysctl),这个选项决定在 packet 在到达 backlog queue 之前还是之后打时间戳。如果启用,那立即打时间戳,在 RPS 之前(CPU 和 backlog queue 绑定);如果没有启用,那只有在它进入到 backlog queue 之后才会打时间戳。如果 RPS 开启了,那这个选项可以将打时间戳的任务分散个其他CPU,但会带来一些延迟。
你可以调整包被收到后,何时给它打时间戳。
关闭收包打时间戳:
|
|
默认是 1。
netif_receive_skb
处理完时间戳后,
netif_receive_skb
会根据 RPS 是否启用来做不同的事情。我们先来看简单情况,RPS 未启用。
如果 RPS 没启用,会调用
__netif_receive_skb
,它做一些 bookkeeping 工作,进而调用
__netif_receive_skb_core
,将数据移动到离协议栈更近一步。
__netif_receive_skb_core
工作的具体细节我们稍后再看,先看一下 RPS 启用的情况下的代码调用关系,它也会调到这个函数的。
如果 RPS 启用了,它会做一些计算,判断使用哪个 CPU 的 backlog queue,这个过程由
get_rps_cpu
函数完成。
net/core/dev.c
:
|
|
get_rps_cpu
会考虑前面提到的 RFS 和 aRFS 设置,以此选出一个合适的 CPU,通过调用
enqueue_to_backlog
将数据放到它的 backlog queue。
enqueue_to_backlog
首先从远端 CPU 的
struct softnet_data
变量获取 backlog queue 长度。如果 backlog 大于
netdev_max_backlog
,或者超过了 flow limit,直接 drop,并更新
softnet_data
的 drop统计。注意这是远端 CPU 的统计。
|
|
enqueue_to_backlog
被调用的地方很少。在基于 RPS 处理包的地方,以及
netif_rx
,会调用到它。大部分驱动都不应该使用
netif_rx
,而应该是用
netif_receive_skb
。如果你没用到 RPS,你的驱动也没有使用
netif_rx
,那增大
backlog
并不会带来益处,因为它根本没被用到。
注意:检查你的驱动,如果它调用了
netif_receive_skb
,而且你没用 RPS,那增大
netdev_max_backlog
并不会带来任何性能提升,因为没有数据包会被送到
input_pkt_queue
。
如果
input_pkt_queue
足够小,而 flow limit(后面会介绍)也还没达到(或者被禁掉了),那数据包将会被放到队列。这里的逻辑有点 funny,但大致可以归为为:
____napi_schedule
进一步处理
这里使用了
goto
,所以代码看起来有点 tricky。
|
|
RPS 在不同 CPU 之间分发 packet,但是,如果一个 flow 特别大,会出现单个 CPU 被打爆,而其他 CPU 无事可做(饥饿)的状态。因此引入了 flow limit 特性,放到一个 backlog 队列的属
于同一个 flow 的包的数量不能超过一个阈值。这可以保证即使有一个很大的 flow 在大量收包,小 flow 也能得到及时的处理。
检查 flow limit 的代码, net/core/dev.c :
|
|
默认,flow limit 功能是关掉的。要打开 flow limit,你需要指定一个 bitmap(类似于 RPS的 bitmap)。
input_pkt_queue
打满或 flow limit 导致的丢包
在
/proc/net/softnet_stat
里面的 dropped 列计数,包含本节提到的原因导致的 drop。
在调整这个值之前,请先阅读前面的“注意”。
如果你使用了 RPS,或者你的驱动调用了
netif_rx
,那增加
netdev_max_backlog
可以改善在
enqueue_to_backlog
里的丢包:
例如:increase backlog to 3000 with sysctl.
|
|
默认值是 1000。
net.core.dev_weight
决定了 backlog poll loop 可以消耗的整体 budget(参考前面更改
net.core.netdev_budget
的章节):
|
|
默认值是 64。
记住,backlog 处理逻辑和设备驱动的
poll
函数类似,都是在软中断(softirq)的上下文中执行,因此受整体 budget 和处理时间的限制,前面已经分析过了。
|
|
默认值是 4096.
这只会影响新分配的 flow hash table。所以,如果你想增加 table size 的话,应该在打开flow limit 功能之前设置这个值。
打开 flow limit 功能的方式是,在
/proc/sys/net/core/flow_limit_cpu_bitmap
中指定一个 bitmask,和通过 bitmask 打开 RPS 的操作类似。
每个 CPU 都有一个 backlog queue,其加入到 NAPI 变量的方式和驱动差不多,都是注册一个
poll
方法,在软中断的上下文中处理包。此外,还提供了一个
weight
,这也和驱动类似。
注册发生在网络系统初始化的时候。
net/core/dev.c
的
net_dev_init
函数:
|
|
backlog NAPI 变量和设备驱动 NAPI 变量的不同之处在于,它的 weight 是可以调节的,而设备驱动是 hardcode 64。在下面的调优部分,我们会看到如何用 sysctl 调整这个设置。
process_backlog
process_backlog
是一个循环,它会一直运行直至
weight
(前面介绍了)用完,或者backlog 里没有数据了。
backlog queue 里的数据取出来,传递给
__netif_receive_skb
。这个函数做的事情和 RPS关闭的情况下做的事情一样。即,
__netif_receive_skb
做一些 bookkeeping 工作,然后调用
__netif_receive_skb_core
将数据发送给更上面的协议层。
process_backlog
和 NAPI 之间遵循的合约,和驱动和 NAPI 之间的合约相同:NAPI isdisabled if the total weight will not be used. The poller is restarted with the
call to
____napi_schedule
from
enqueue_to_backlog
as described above.
函数返回接收完成的数据帧数量(在代码中是变量
work
),
net_rx_action
(前面介绍了)将会从 budget(通过
net.core.netdev_budget
可以调整,前面介绍了)里减去这个值。
__netif_receive_skb_core
:将数据送到抓包点(tap)或协议层
__netif_receive_skb_core
完成
将数据送到协议栈
这一繁重工作(the heavy lifting of delivering the data)。在此之前,它会先
检查是否插入了 packet tap(探测点),这些 tap 是抓包用的
。例如,
AF_PACKET
地址族就可以插入这些抓包指令,一般通过
libpcap
库。
如果存在抓包点(tap),数据就会先到抓包点,然后才到协议层。
如果有 packet tap(通常通过
libpcap
),packet 会送到那里。
net/core/dev.c
:
|
|
如果对 packet 如何经过 pcap 有兴趣,可以阅读 net/packet/af_packet.c 。
处理完 taps 之后,
__netif_receive_skb_core
将数据发送到协议层。它会从数据包中取出协议信息,然后遍历注册在这个协议上的回调函数列表。
可以看
__netif_receive_skb_core
函数,
net/core/dev.c
:
|
|
上面的
ptype_base
是一个 hash table,定义在
net/core/dev.c
中:
|
|
每种协议在上面的 hash table 的一个 slot 里,添加一个过滤器到列表里。这个列表的头用如下函数获取:
|
|
添加的时候用
dev_add_pack
这个函数。这就是协议层如何注册自身,用于处理相应协议的网络数据的。
现在,你已经知道了数据是如何从卡进入到协议层的了。
接下来我们看协议层注册自身的实现。
本文会拿 IP 层作为例子,因为它最常用,大部分读者都很熟悉。
IP 层在函数
inet_init
中将自身注册到
ptype_base
哈希表。
net/ipv4/af_inet.c
:
|
|
struct packet_type
的变量
ip_packet_type
定义在
net/ipv4/af_inet.c
:
|
|
__netif_receive_skb_core
会调用
deliver_skb
(前面介绍过了), 后者会调用
.func
方法(这个例子中就是
ip_rcv
)。
ip_rcv
ip_rcv
方法的核心逻辑非常简单直接,此外就是一些数据合法性验证,统计计数器更新等等。它在最后会以 netfilter 的方式调用
ip_rcv_finish
方法。这样做的目的是,任何iptables 规则都能在 packet 刚进入 IP 层协议的时候被应用,在其他处理之前。
我们可以在
ip_rcv
结束的时候看到交给 netfilter 的代码:
net/ipv4/ip_input.c
|
|
这里简单介绍下
netfilter
,
iptables
和
conntrack
。
NF_HOOK_THRESH
会检查是否有 filter 被安装,并会适时地返回到 IP 协议层,避免过深的进入 netfilter 处理,以及在 netfilter 下面再做 hook 的 iptables 和 conntrack。
注意: 如果你有很多或者很复杂的 netfilter 或 iptables 规则,这些规则都是在软中断的上下文中执行的,会导致网络延迟。 但如果你就是需要一些规则的话,那这个性能损失看起来是无法避免的。
ip_rcv_finish
netfilter 完成对数据的处理之后,就会调用
ip_rcv_finish
。当然,前提是 netfilter 没有决定丢掉这个包。
ip_rcv_finish
开始的地方做了一次优化。为了能将包送到合适的目的地,需要一个路由子系统的
dst_entry
变量。为了获取这个变量,早期的代码调用了
early_demux
函数,从这个数据的目的端的高层协议中。
early_demux
是一个优化项,试图路由这个包所需要的
dst_entry
变量,通过检查相应的变量是否缓存在
socket
变量上。
net/ipv4/ip_input.c
:
|
|
可以看到,这个函数只有在
sysctl_ip_early_demux
为
true
的时候才有可能被执行。默认
early_demux
是打开的。下一节会介绍如何关闭它,以及为什么你可能会需要关闭它。
如果这个优化打开了,但是并没有命中缓存(例如,这是第一个包),这个包就会被送到内核的路由子系统,在那里将会计算出一个
dst_entry
并赋给相应的字段。
路由子系统完成工作后,会更新计数器,然后调用
dst_input(skb)
,后者会进一步调用
dst_entry
变量中的
input
方法,这个方法是一个函数指针,有路由子系统初始化。例如,如果 packet 的最终目的地是本机(local system),路由子系统会将
ip_local_deliver
赋给
input
。
关闭
early_demux
优化:
|
|
默认是 1,即该功能默认是打开的。
添加这个
sysctl
开关的原因是,一些用户报告说,在某些场景下
early_demux
优化会导致 ~5% 左右的吞吐量下降。
ip_local_deliver
回忆我们看到的 IP 协议层过程:
ip_rcv
做一些初始的 bookkeeping
ip_rcv_finish
,将数据包送到协议栈的更上层
ip_local_deliver
的逻辑与此类似:
net/ipv4/ip_input.c
:
|
|
只要 packet 没有在 netfilter 被 drop,就会调用
ip_local_deliver_finish
函数。
ip_local_deliver_finish
ip_local_deliver_finish
从数据包中读取协议,寻找注册在这个协议上的
struct net_protocol
变量,并调用该变量中的回调方法。这样将包送到协议栈的更上层。
读取
/proc/net/snmp
获取详细的 IP 协议统计:
|
|
这个文件包含几个协议层的统计信息。先是 IP 层。
与这些列相关的,IP 层的统计类型都定义在 include/uapi/linux/snmp.h :
|
|
读取
/proc/net/netstat
获取更详细的 IP 层统计:
|
|
格式和
/proc/net/snmp
类似,除了每列的命字都一
IpExt
开头之外。
一些有趣的统计:
InReceives
: The total number of IP packets that reached ip_rcv before any data integrity checks.
InHdrErrors
: Total number of IP packets with corrupted headers. The header was too short, too long, non-existent, had the wrong IP protocol version number, etc.
InAddrErrors
: Total number of IP packets where the host was unreachable.
ForwDatagrams
: Total number of IP packets that have been forwarded.
InUnknownProtos
: Total number of IP packets with unknown or unsupported protocol specified in the header.
InDiscards
: Total number of IP packets discarded due to memory allocation failure or checksum failure when packets are trimmed.
InDelivers
: Total number of IP packets successfully delivered to higher protocol layers. Keep in mind that those protocol layers may drop data even if the IP layer does not.
注意这些计数分别在 IP 层的不同地方被更新。由于代码一直在更新,重复计数或者计数错误的 bug 可能会引入。如果这些计数对你非常重要,强烈建议你阅读内核的相应源码,确定它们是在哪里被更新的,以及更新的对不对,是不是有 bug。
本文介绍 UDP 处理函数的注册过程,TCP 的注册过程与此一样,并且是在相同的时间注册的。
在
net/ipv4/af_inet.c
中定义了 UDP、TCP 和 ICMP 协议的回调函数相关的数据结构,IP 层处理完毕之后会调用相应的回调. From
net/ipv4/af_inet.c
:
|
|
这些变量在
inet
地址族初始化的时候被注册。
net/ipv4/af_inet.c
:
|
|
接下来我们详细查看 UDP 协议。上面可以看到,UDP 的回调函数是
udp_rcv
。这是从 IP 层进入 UDP 层的入口。我们就从这里开始探索。
UDP 协议层的实现见 net/ipv4/udp.c 。
udp_rcv
这个函数只要一行,调用
__udp4_lib_rcv
接收 UDP 报文。
__udp4_lib_rcv
__udp4_lib_rcv
首先对包数据进行合法性检查,获取 UDP 头、UDP 数据报长度、源地址、目标地址等信息。然后进行其他一些完整性检测和 checksum 验证。
回忆前面的 IP 层内容,在送到更上面一层协议(这里是 UDP)之前,会将一个
dst_entry
会关联到
skb
。
如果对应的
dst_entry
找到了,并且有对应的 socket,
__udp4_lib_rcv
会将 packet 放到
socket
的接收队列:
|
|
如果
early_demux
中没有关联 socket 信息,那此时会调用
__udp4_lib_lookup_skb
查找对应的 socket。
以上两种情况,最后都会将 packet 放到 socket 的接收队列:
|
|
如果 socket 没有找到,数据报(datagram)会被丢弃:
|
|
udp_queue_rcv_skb
这个函数的前面部分所做的工作:
最后,我们来到了 socket 的接收队列逻辑,判断队列是不是满了:
net/ipv4/udp.c
:
|
|
sk_rcvqueues_full
定义如下:
|
|
Tuning these values is a bit tricky as there are many things that can be adjusted.
上面看到,判断 socket 接收队列是否满了是和
sk->sk_rcvbuf
做比较。这个值可以被两个 sysctl 参数控制:最大值和默认值:
|
|
你也可以在你的应用里调用
setsockopt
带上
SO_RCVBUF
来修改这个值(
sk->sk_rcvbuf
),能设置的最大值不能超过
net.core.rmem_max
。
但是,你也可以
setsockopt
带上
SO_RCVBUFFORCE
来覆盖
net.core.rmem_max
,但是执行应用的用户要有
CAP_NET_ADMIN
权限。
skb_set_owner_r
函数设置 UDP 数据包的 owner,并会更新计数器
sk->sk_rmem_alloc
。
我们接下来会看到。
sk_add_backlog
函数会更新
sk->sk_backlog.len
计数,后面看。
udp_queue_rcv_skb
判断 queue 未满之后,就会将数据报放到里面: net/ipv4/udp.c :
|
|
第一步先判断有没有用户空间的程序正在这个 socket 上进行系统调用。如果没有,就可以调用
__udp_queue_rcv_skb
将数据报放到接收队列;如果有,就调用
sk_add_backlog
将它放到 backlog 队列。
当用户空间程序释放在这个 socket 上的系统调用时(通过向内核调用
release_sock
),这个数据报就从 backlog 移动到了接收队列。
__udp_queue_rcv_skb
这个函数调用
sock_queue_rcv_skb
将数据报送到 socket 接收队列;如果失败,更新统计计数并释放 skb。
|
|
以下文件可以获取非常有用的 UDP 统计:
|
|
/proc/net/snmp
|
|
Much like the detailed statistics found in this file for the IP protocol, you will need to read the protocol layer source to determine exactly when and where these values are incremented.
|
|
/proc/net/udp
|
|
The first line describes each of the fields in the lines following:
sl
: Kernel hash slot for the socket
local_address
: Hexadecimal local address of the socket and port number, separated by :.
rem_address
: Hexadecimal remote address of the socket and port number, separated by :.
st
: The state of the socket. Oddly enough, the UDP protocol layer seems to use some TCP socket states. In the example above, 7 is TCP_CLOSE.
tx_queue
: The amount of memory allocated in the kernel for outgoing UDP datagrams.
rx_queue
: The amount of memory allocated in the kernel for incoming UDP datagrams.
tr
, tm->when, retrnsmt: These fields are unused by the UDP protocol layer.
uid
: The effective user id of the user who created this socket.
timeout
: Unused by the UDP protocol layer.
inode
: The inode number corresponding to this socket. You can use this to help you determine which user process has this socket open. Check /proc/[pid]/fd, which will contain symlinks to socket[:inode].
ref
: The current reference count for the socket.
pointer
: The memory address in the kernel of the struct sock.
drops
: The number of datagram drops associated with this socket. Note that this does not include any drops related to sending datagrams (on corked UDP sockets or otherwise); this is only incremented in receive paths as of the kernel version examined by this blog post.
打印这些信息的代码见 net/ipv4/udp.c .
网络数据通过
sock_queue_rcv
进入 socket 的接收队列。这个函数在将数据报最终送到接收队列之前,会做几件事情:
sk_filter
,这允许 BPF(Berkeley Packet Filter)过滤器在 socket 上被应用
sk_rmem_scedule
,确保有足够大的 receive buffer 接收这个数据报
skb_set_owner_r
,这会计算数据报的长度并更新
sk->sk_rmem_alloc
计数
__skb_queue_tail
将数据加到队列尾端
最后,所有在这个 socket 上等待数据的进程都收到一个通知通过
sk_data_ready
通知处理函数。
这就是一个数据包从到达机器开始,依次穿过协议栈,到达 socket,最终被用户程序读取的过程。
还有一些值得讨论的地方,放在前面哪里都不太合适,故统一放到这里。
前面提到,网络栈可以收集包的时间戳信息。如果使用了 RPS 功能,有相应的
sysctl
参数可以控制何时以及如何收集时间戳;更多关于 RPS、时间戳,以及网络栈在哪里完成这些工作的内容,请查看前面的章节。一些网卡甚至支持在硬件上打时间戳。
如果你想看内核网络栈给收包增加了多少延迟,那这个特性非常有用。
内核 关于时间戳的文档 非常优秀,甚至还包括一个 示例程序和相应的 Makefile ,有兴趣的话可以上手试试。
使用
ethtool -T
可以查看网卡和驱动支持哪种打时间戳方式:
|
|
从上面这个信息看,该网卡不支持硬件打时间戳。但这个系统上的软件打时间戳,仍然可以帮助我判断内核在接收路径上到底带来多少延迟。
socket 有个
SO_BUSY_POLL
选项,可以让内核在
阻塞式接收
(blocking receive)的时候做 busy poll。这个选项会减少延迟,但会增加 CPU 使用量和耗电量。
重要提示
:要使用此功能,首先要检查你的设备驱动是否支持。Linux 内核 3.13.0 的
igb
驱动不支持,但
ixgbe
驱动支持。如果你的驱动实现(并注册)了
struct net_device_ops
(前面介绍过了)的
ndo_busy_poll
方法,那它就是支持
SO_BUSY_POLL
。
Intel 有一篇非常好的 文章 介绍其原理。
对单个 socket 设置此选项,需要传一个以微秒(microsecond)为单位的时间,内核会在这个时间内对设备驱动的接收队列做 busy poll。当在这个 socket 上触发一个阻塞式读请求时,内核会 busy poll 来收数据。
全局设置此选项,可以修改
net.core.busy_poll
配置(毫秒,microsecond),当
poll
或
select
方法以阻塞方式调用时,busy poll 的时长就是这个值。
Linux 内核提供了一种方式,在内核挂掉(crash)的时候,设备驱动仍然可以接收和发送数据,相应的 API 被称作
Netpoll
。这个功能在一些特殊的网络场景有用途,比如最著名的两个例子:
kgdb
和
netconsole
。
大部分驱动都支持
Netpoll
功能。支持此功能的驱动需要实现
struct net_device_ops
的
ndo_poll_controller
方法(回调函数,探测驱动模块的时候注册的,前面介绍过)。
当网络设备子系统收包或发包的时候,会首先检查这个包的目的端是不是
netpoll
。
例如,我们来看下
__netif_receive_skb_core
,
net/dev/core.c
:
|
|
设备驱动收发包相关代码里,关于
netpoll
的判断逻辑在很前面。
Netpoll API 的消费者可以通过
netpoll_setup
函数注册
struct netpoll
变量,后者有收包和发包的 hook 方法(函数指针)。
如果你对使用 Netpoll API 感兴趣,可以看看
netconsole
的
驱动
,Netpoll API 的头文件
include/linux/netpoll.h
,以及
这个
精彩的分享。
SO_INCOMING_CPU
SO_INCOMING_CPU
直到 Linux 3.19 才添加, 但它非常有用,所以这里讨论一下。
使用
getsockopt
带
SO_INCOMING_CPU
选项,可以判断当前哪个 CPU 在处理这个 socket 的网络包。你的应用程序可以据此将 socket 交给在期望的 CPU 上运行的线程,增加数据本地性(data locality)和 CPU 缓存命中率。
在提出
SO_INCOMING_CPU
的
邮件列表
里有一个简单示例框架,展示在什么场景下使用这个功能。
DMA engine (直接内存访问引擎)是一个硬件,允许 CPU 将 很大的复制操作 (large copy operations)offload(下放)给它。这样 CPU 就从数据拷贝中解放出来,去做其他工作,而拷贝就交由硬件完成。合理的使用 DMA 引擎(代码要利用到 DMA 特性)可以减少 CPU 的使用量。
Linux 内核有一个通用的 DMA 引擎接口,DMA engine 驱动实现这个接口即可。更多关于这个接口的信息可以查看内核源码的 文档 。
内核支持的 DMA 引擎很多,这里我们拿 Intel 的 IOAT DMA engine 为例来看一下。
很多服务器都安装了
Intel I/O AT bundle
,其中包含了一系列性能优化相关的东西,包括一个硬件 DMA 引擎。可以查看
dmesg
里面有没有
ioatdma
,判断这个模块是否被加载,以及它是否找到了支持的硬件。
DMA 引擎在很多地方有用到,例如 TCP 协议栈。
Intel IOAT DMA engine 最早出现在 Linux 2.6.18,但随后 3.13.11.10 就禁用掉了,因为有一些 bug,会导致数据损坏。
3.13.11.10
版本之前的内核默认是开启的,将来这些版本的内核如果有更新,可能也会禁用掉。
Intel I/O AT bundle 中的另一个有趣特性是直接缓存访问(DCA)。
该特性允许网络设备(通过各自的驱动)直接将网络数据放到 CPU 缓存上。至于是如何实现的,随各家驱动而异。对于
igb
的驱动,你可以查看
igb_update_dca
和
igb_update_rx_dca
这两个函数的实现。
igb
驱动使用 DCA,直接写硬件网卡的一个寄存器。
要使用 DCA 功能,首先检查你的 BIOS 里是否打开了此功能,然后确保
dca
模块加载了,还要确保你的网卡和驱动支持 DCA。
如上所说,如果你不怕数据损坏的风险,那你可以使用
ioatdma
模块。监控上,可以看几个 sysfs 参数。
例如,监控一个 DMA 通道(channel)总共 offload 的
memcpy
操作次数:
|
|
类似的,一个 DMA 通道总共 offload 的字节数:
|
|
IOAT DMA engine 只有在包大小超过一定的阈值之后才会使用,这个阈值叫
copybreak
。之所以要设置阈值是因为,对于小包,设置和使用 DMA 的开销要大于其收益。
调整 DMA engine
copybreak
参数:
|
|
默认值是 4096。
Linux 网络栈很复杂。
对于这样复杂的系统(以及类似的其他系统),如果不能在更深的层次理解它正在做什么,就不可能做监控和调优。当遇到网络问题时,你可能会在网上搜到一些
sysctl.conf
最优实践一类的东西,然后应
用在自己的系统上,但这并不是网络栈调优的最佳方式。
监控网络栈需要从驱动开始,逐步往上,仔细地在每一层统计网络数据。这样你才能清楚地看出哪里有丢包(drop),哪里有收包错误(errors),然后根据导致错误的原因做相应的配置调整。
不幸的是,这项工作并没有捷径。
需要一些额外的关于网络栈的指导(navigating the network stack)?对本文有疑问,或有相关内容本文没有提到?以上问题,都可以发邮件给 我们 ,以便我们知道如何提供帮助。
如果你喜欢本文,你可能对下面这些底层技术文章也感兴趣:
聪明的小狗 · 关于指挥家莫斯特,你需要了解的三件事 - 新芭网 1 月前 |
聪明伶俐的领带 · 国外城镇化:“德国模式”值得借鉴 2 月前 |
乐观的签字笔 · 【回忆与思考·张维为】改革开放的试验田 2 月前 |