转自https://www.ibm.com/developerworks/cn/linux/l-cn-network-pt/index.htmlhtml
做者:赵 军linux
对于网络的行为,能够简单划分为 3 条路径:1) 发送路径,2) 转发路径,3) 接收路径,而网络性能的优化则可基于这 3 条路径来考虑。因为数据包的转发通常是具有路由功能的设备所关注,在本文中没有叙述,读者若是有兴趣,能够自行学习(在 Linux 内核中,分别使用了基于哈希的路由查找和基于动态 Trie 的路由查找算法)。本文集中于发送路径和接收路径上的优化方法分析,其中的 NAPI 本质上是接收路径上的优化,但由于它在 Linux 的内核出现时间较早,而它也是后续出现的各类优化方法的基础,因此将其单独分析。算法
NAPI 的核心在于:在一个繁忙网络,每次有网络数据包到达时,不须要都引起中断,由于高频率的中断可能会影响系统的总体效率,假象一个场景,咱们此时使用标准的 100M 网卡,可能实际达到的接收速率为 80MBits/s,而此时数据包平均长度为 1500Bytes,则每秒产生的中断数目为:api
80M bits/s / (8 Bits/Byte * 1500 Byte) = 6667 个中断 /s数组
每秒 6667 个中断,对于系统是个很大的压力,此时其实能够转为使用轮询 (polling) 来处理,而不是中断;但轮询在网络流量较小的时没有效率,所以低流量时,基于中断的方式则比较合适,这就是 NAPI 出现的缘由,在低流量时候使用中断接收数据包,而在高流量时候则使用基于轮询的方式接收。性能优化
如今内核中 NIC 基本上已经所有支持 NAPI 功能,由前面的叙述可知,NAPI 适合处理高速率数据包的处理,而带来的好处则是:网络
对 NAPI 的使用,通常包括如下的几个步骤:框架
1
2
3
4
|
void netif_rx_schedule(struct net_device *dev);
或者
if (netif_rx_schedule_prep(dev))
__netif_rx_schedule(dev);
|
1
|
int (*poll)(struct net_device *dev, int *budget);
|
这里的轮询函数用于在将网卡切换为轮询模式以后,用 poll() 方法处理接收队列中的数据包,如队列为空,则从新切换为中断模式。切换回中断模式须要先关闭轮询模式,使用的是函数 netif_rx_complete (),接着开启网卡接收中断 .。socket
1
|
void netif_rx_complete(struct net_device *dev);
|
1
2
|
dev->poll = my_poll;
dev->weight = 64;
|
里面另一个字段为权重 (weight),该值并无一个很是严格的要求,其实是个经验数据,通常 10Mb 的网卡,咱们设置为 16,而更快的网卡,咱们则设置为 64。jsp
下面是 NAPI 功能的一些接口,在前面都基本有涉及,咱们简单看看:
netif_rx_schedule(dev)
在网卡的中断处理函数中调用,用于将网卡的接收模式切换为轮询
netif_rx_schedule_prep(dev)
在网卡是 Up 且运行状态时,将该网卡设置为准备将其加入到轮询列表的状态,能够将该函数看作是 netif_rx_schedule(dev) 的前半部分
__netif_rx_schedule(dev)
将设备加入轮询列表,前提是须要 netif_schedule_prep(dev) 函数已经返回了 1
__netif_rx_schedule_prep(dev)
与 netif_rx_schedule_prep(dev) 类似,可是没有判断网卡设备是否 Up 及运行,不建议使用
netif_rx_complete(dev)
用于将网卡接口从轮询列表中移除,通常在轮询函数完成以后调用该函数。
__netif_rx_complete(dev)
与 netif_rx_complete(dev) 相似,可是须要确保本地中断被禁止
在最初实现的 NAPI 中,有 2 个字段在结构体 net_device 中,分别为轮询函数 poll() 和权重 weight,而所谓的 Newer newer NAPI,是在 2.6.24 版内核以后,对原有的 NAPI 实现的几回重构,其核心是将 NAPI 相关功能和 net_device 分离,这样减小了耦合,代码更加的灵活,由于 NAPI 的相关信息已经从特定的网络设备剥离了,再也不是之前的一对一的关系了。例若有些网络适配器,可能提供了多个 port,但全部的 port 倒是共用同一个接受数据包的中断,这时候,分离的 NAPI 信息只用存一份,同时被全部的 port 来共享,这样,代码框架上更好地适应了真实的硬件能力。Newer newer NAPI 的中心结构体是
napi_struct:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
|
/*
* Structure for NAPI scheduling similar to tasklet but with weighting
*/
struct napi_struct {
/* The poll_list must only be managed by the entity which
* changes the state of the NAPI_STATE_SCHED bit. This means
* whoever atomically sets that bit can add this napi_struct
* to the per-cpu poll_list, and whoever clears that bit
* can remove from the list right before clearing the bit.
*/
struct list_head poll_list;
unsigned long state;
int weight;
int (*poll)(struct napi_struct *, int);
#ifdef CONFIG_NETPOLL
spinlock_t poll_lock;
int poll_owner;
#endif
unsigned int gro_count;
struct net_device *dev;
struct list_head dev_list;
struct sk_buff *gro_list;
struct sk_buff *skb;
};
|
熟悉老的 NAPI 接口实现的话,里面的字段 poll_list、state、weight、poll、dev、没什么好说的,gro_count 和 gro_list 会在后面讲述 GRO 时候会讲述。须要注意的是,与以前的 NAPI 实现的最大的区别是该结构体再也不是 net_device 的一部分,事实上,如今但愿网卡驱动本身单独分配与管理 napi 实例,一般将其放在了网卡驱动的私有信息,这样最主要的好处在于,若是驱动愿意,能够建立多个 napi_struct,由于如今愈来愈多的硬件已经开始支持多接收队列 (multiple receive queues),这样,多个 napi_struct 的实现使得多队列的使用也更加的有效。
与最初的 NAPI 相比较,轮询函数的注册有些变化,如今使用的新接口是:
1
2
|
void netif_napi_add(struct net_device *dev, struct napi_struct *napi,
int (*poll)(struct napi_struct *, int), int weight)
|
熟悉老的 NAPI 接口的话,这个函数也没什么好说的。
值得注意的是,前面的轮询 poll() 方法原型也开始须要一些小小的改变:
1
|
int (*poll)(struct napi_struct *napi, int budget);
|
大部分 NAPI 相关的函数也须要改变以前的原型,下面是打开轮询功能的 API:
1
2
3
4
5
6
7
|
void netif_rx_schedule(struct net_device *dev,
struct napi_struct *napi);
/* ...or... */
int netif_rx_schedule_prep(struct net_device *dev,
struct napi_struct *napi);
void __netif_rx_schedule(struct net_device *dev,
struct napi_struct *napi);
|
轮询功能的关闭则须要使用 :
1
2
|
void netif_rx_complete(struct net_device *dev,
struct napi_struct *napi);
|
由于可能存在多个 napi_struct 的实例,要求每一个实例可以独立的使能或者禁止,所以,须要驱动做者保证在网卡接口关闭时,禁止全部的 napi_struct 的实例。
函数 netif_poll_enable() 和 netif_poll_disable() 再也不须要,由于轮询管理再也不和 net_device 直接管理,取而代之的是下面的两个函数:
1
2
|
void napi_enable(struct napi *napi);
void napi_disable(struct napi *napi);
|
TSO (TCP Segmentation Offload) 是一种利用网卡分割大数据包,减少 CPU 负荷的一种技术,也被叫作 LSO (Large segment offload) ,若是数据包的类型只能是 TCP,则被称之为 TSO,若是硬件支持 TSO 功能的话,也须要同时支持硬件的 TCP 校验计算和分散 - 汇集 (Scatter Gather) 功能。
能够看到 TSO 的实现,须要一些基本条件,而这些实际上是由软件和硬件结合起来完成的,对于硬件,具体说来,硬件可以对大的数据包进行分片,分片以后,还要可以对每一个分片附着相关的头部。TSO 的支持主要有须要如下几步:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
|
static void be_netdev_init(struct net_device *netdev)
{
struct be_adapter *adapter = netdev_priv(netdev);
netdev->features |= NETIF_F_SG | NETIF_F_HW_VLAN_RX | NETIF_F_TSO |
NETIF_F_HW_VLAN_TX | NETIF_F_HW_VLAN_FILTER | NETIF_F_HW_CSUM |
NETIF_F_GRO | NETIF_F_TSO6;
netdev->vlan_features |= NETIF_F_SG | NETIF_F_TSO | NETIF_F_HW_CSUM;
netdev->flags |= IFF_MULTICAST;
adapter->rx_csum = true;
/* Default settings for Rx and Tx flow control */
adapter->rx_fc = true;
adapter->tx_fc = true;
netif_set_gso_max_size(netdev, 65535);
BE_SET_NETDEV_OPS(netdev, &be_netdev_ops);
SET_ETHTOOL_OPS(netdev, &be_ethtool_ops);
netif_napi_add(netdev, &adapter->rx_eq.napi, be_poll_rx,
BE_NAPI_WEIGHT);
netif_napi_add(netdev, &adapter->tx_eq.napi, be_poll_tx_mcc,
BE_NAPI_WEIGHT);
netif_carrier_off(netdev);
netif_stop_queue(netdev);
}
|
在代码中,同时也用 netif_set_gso_max_size 函数设置了 net_device 的 gso_max_size 字段。该字段代表网络接口一次能处理的最大 buffer 大小,通常该值为 64Kb,这意味着只要 TCP 的数据大小不超过 64Kb,就不用在内核中分片,而只需一次性的推送到网络接口,由网络接口去执行分片功能。
1
2
3
4
5
6
7
8
9
10
11
|
/* This will initiate an outgoing connection. */
int tcp_v4_connect(struct sock *sk, struct sockaddr *uaddr, int addr_len)
{
……
/* OK, now commit destination to socket. */
sk->sk_gso_type = SKB_GSO_TCPV4;
sk_setup_caps(sk, &rt->dst);
……
}
|
代码中的 sk_setup_caps() 函数则设置了上面所说的 sk_route_caps 字段,同时也检查了硬件是否支持分散 - 汇集功能和硬件校验计算功能。须要这 2 个功能的缘由是:Buffer 可能不在一个内存页面上,因此须要分散 - 汇集功能,而分片后的每一个分段须要从新计算 checksum,所以须要硬件支持校验计算。
TSO 是使得网络协议栈可以将大块 buffer 推送至网卡,而后网卡执行分片工做,这样减轻了 CPU 的负荷,但 TSO 须要硬件来实现分片功能;而性能上的提升,主要是由于延缓分片而减轻了 CPU 的负载,所以,能够考虑将 TSO 技术通常化,由于其本质实际是延缓分片,这种技术,在 Linux 中被叫作 GSO(Generic Segmentation Offload),它比 TSO 更通用,缘由在于它不须要硬件的支持分片就可以使用,对于支持 TSO 功能的硬件,则先通过 GSO 功能,而后使用网卡的硬件分片能力执行分片;而对于不支持 TSO 功能的网卡,将分片的执行,放在了将数据推送的网卡的前一刻,也就是在调用驱动的 xmit 函数前。
咱们再来看看内核中数据包的分片都有可能在哪些时刻:
对于支持 GSO 的状况,主要使用了状况 2 或者是状况 2.、3,其中状况二是在硬件不支持 TSO 的状况下,而状况 二、3 则是在硬件支持 TSO 的状况下。
代码中是在 dev_hard_start_xmit 函数里调用 dev_gso_segment 执行分片,这样尽可能推迟分片的时间以提升性能:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
int dev_hard_start_xmit(struct sk_buff *skb, struct net_device *dev,
struct netdev_queue *txq)
{
……
if (netif_needs_gso(dev, skb)) {
if (unlikely(dev_gso_segment(skb)))
goto out_kfree_skb;
if (skb->next)
goto gso;
} else {
……
}
……
}
|
Linux 在 2.6.24 中加入了支持 IPv4 TCP 协议的 LRO (Large Receive Offload) ,它经过将多个 TCP 数据聚合在一个 skb 结构,在稍后的某个时刻做为一个大数据包交付给上层的网络协议栈,以减小上层协议栈处理 skb 的开销,提升系统接收 TCP 数据包的能力。
固然,这一切都须要网卡驱动程序支持。理解 LRO 的工做原理,须要理解 sk_buff 结构体对于负载的存储方式,在内核中,sk_buff 能够有三种方式保存真实的负载:
合并了多个 skb 的超级 skb,可以一次性经过网络协议栈,而不是屡次,这对 CPU 负荷的减轻是显然的。
LRO 的核心结构体以下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
|
/*
* Large Receive Offload (LRO) Manager
*
* Fields must be set by driver
*/
struct net_lro_mgr {
struct net_device *dev;
struct net_lro_stats stats;
/* LRO features */
unsigned long features;
#define LRO_F_NAPI 1 /* Pass packets to stack via NAPI */
#define LRO_F_EXTRACT_VLAN_ID 2 /* Set flag if VLAN IDs are extracted
from received packets and eth protocol
is still ETH_P_8021Q */
/*
* Set for generated SKBs that are not added to
* the frag list in fragmented mode
*/
u32 ip_summed;
u32 ip_summed_aggr; /* Set in aggregated SKBs: CHECKSUM_UNNECESSARY
* or CHECKSUM_NONE */
int max_desc; /* Max number of LRO descriptors */
int max_aggr; /* Max number of LRO packets to be aggregated */
int frag_align_pad; /* Padding required to properly align layer 3
* headers in generated skb when using frags */
struct net_lro_desc *lro_arr; /* Array of LRO descriptors */
/*
* Optimized driver functions
*
* get_skb_header: returns tcp and ip header for packet in SKB
*/
int (*get_skb_header)(struct sk_buff *skb, void **ip_hdr,
void **tcpudp_hdr, u64 *hdr_flags, void *priv);
/* hdr_flags: */
#define LRO_IPV4 1 /* ip_hdr is IPv4 header */
#define LRO_TCP 2 /* tcpudp_hdr is TCP header */
/*
* get_frag_header: returns mac, tcp and ip header for packet in SKB
*
* @hdr_flags: Indicate what kind of LRO has to be done
* (IPv4/IPv6/TCP/UDP)
*/
int (*get_frag_header)(struct skb_frag_struct *frag, void **mac_hdr,
void **ip_hdr, void **tcpudp_hdr, u64 *hdr_flags,
void *priv);
};
|
在该结构体中:
dev:指向支持 LRO 功能的网络设备
stats:包含一些统计信息,用于查看 LRO 功能的运行状况
features:控制 LRO 如何将包送给网络协议栈,其中的 LRO_F_NAPI 代表驱动是 NAPI 兼容的,应该使用 netif_receive_skb() 函数,而 LRO_F_EXTRACT_VLAN_ID 代表驱动支持 VLAN
ip_summed:代表是否须要网络协议栈支持 checksum 校验
ip_summed_aggr:代表汇集起来的大数据包是否须要网络协议栈去支持 checksum 校验
max_desc:代表最大数目的 LRO 描述符,注意,每一个 LRO 的描述符描述了一路 TCP 流,因此该值代表了作多同时能处理的 TCP 流的数量
max_aggr:是最大数目的包将被汇集成一个超级数据包
lro_arr:是描述符数组,须要驱动本身提供足够的内存或者在内存不足时处理异常
get_skb_header()/get_frag_header():用于快速定位 IP 或者 TCP 的头,通常驱动只提供其中的一个实现
通常在驱动中收包,使用的函数是 netif_rx 或者 netif_receive_skb,但在支持 LRO 的驱动中,须要使用下面的函数,这两个函数将进来的数据包根据 LRO 描述符进行分类,若是能够进行汇集,则汇集为一个超级数据包,否者直接传递给内核,走正常途径。须要 lro_receive_frags 函数的缘由是某些驱动直接将数据包放入了内存页,以后去构造 sk_buff,对于这样的驱动,应该使用下面的接口:
1
2
3
4
5
6
7
8
|
void lro_receive_skb(struct net_lro_mgr *lro_mgr,
struct sk_buff *skb,
void *priv);
void lro_receive_frags(struct net_lro_mgr *lro_mgr,
struct skb_frag_struct *frags,
int len, int true_size,
void *priv, __wsum sum);
|
由于 LRO 须要汇集到 max_aggr 数目的数据包,但有些状况下可能致使延迟比较大,这种状况下,能够在汇集了部分包以后,直接传递给网络协议栈处理,这时可使用下面的函数,也能够在收到某个特殊的包以后,不通过 LRO,直接传递个网络协议栈:
1
2
3
4
5
|
void lro_flush_all(struct net_lro_mgr *lro_mgr);
void lro_flush_pkt(struct net_lro_mgr *lro_mgr,
struct iphdr *iph,
struct tcphdr *tcph);
|
前面的 LRO 的核心在于:在接收路径上,将多个数据包聚合成一个大的数据包,而后传递给网络协议栈处理,但 LRO 的实现中存在一些瑕疵:
而解决这些问题的办法就是新提出的 GRO(Generic Receive Offload),首先,GRO 的合并条件更加的严格和灵活,而且在设计时,就考虑支持全部的传输协议,所以,后续的驱动,都应该使用 GRO 的接口,而不是 LRO,内核可能在全部先有驱动迁移到 GRO 接口以后将 LRO 从内核中移除。而 Linux 网络子系统的维护者 David S. Miller 就明确指出,如今的网卡驱动,有 2 个功能须要使用,一是使用 NAPI 接口以使得中断缓和 (interrupt mitigation) ,以及简单的互斥,二是使用 GRO 的 NAPI 接口去传递数据包给网路协议栈。
在 NAPI 实例中,有一个 GRO 的包的列表 gro_list,用堆积收到的包,GRO 层用它来将汇集的包分发到网络协议层,而每一个支持 GRO 功能的网络协议层,则须要实现 gro_receive 和 gro_complete 方法。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
struct packet_type {
__be16 type; /* This is really htons(ether_type). */
struct net_device *dev; /* NULL is wildcarded here */
int (*func) (struct sk_buff *,
struct net_device *,
struct packet_type *,
struct net_device *);
struct sk_buff *(*gso_segment)(struct sk_buff *skb,
int features);
int (*gso_send_check)(struct sk_buff *skb);
struct sk_buff **(*gro_receive)(struct sk_buff **head,
struct sk_buff *skb);
int (*gro_complete)(struct sk_buff *skb);
void *af_packet_priv;
struct list_head list;
};
|
其中,gro_receive 用于尝试匹配进来的数据包到已经排队的 gro_list 列表,而 IP 和 TCP 的头部则在匹配以后被丢弃;而一旦咱们须要向上层协议提交数据包,则调用 gro_complete 方法,将 gro_list 的包合并成一个大包,同时 checksum 也被更新。在实现中,并没要求 GRO 长时间的去实现聚合,而是在每次 NAPI 轮询操做中,强制传递 GRO 包列表跑到上层协议。GRO 和 LRO 的最大区别在于,GRO 保留了每一个接收到的数据包的熵信息,这对于像路由器这样的应用相当重要,而且实现了对各类协议的支持。以 IPv4 的 TCP 为例,匹配的条件有:
而不少其它事件将致使 GRO 列表向上层协议传递聚合的数据包,例如 TCP 的 ACK 不匹配或者 TCP 的序列号没有按序等等。
GRO 提供的接口和 LRO 提供的接口很是的相似,但更加的简洁,对于驱动,明确可见的只有 GRO 的收包函数了 , 由于大部分的工做实际是在协议层作掉了:
1
2
|
gro_result_t napi_gro_receive(struct napi_struct *napi, struct sk_buff *skb)
gro_result_t napi_gro_frags(struct napi_struct *napi)
|
从上面的分析,能够看到,Linux 网络性能优化方法,就像一部进化史,但每步的演化,都让解决问题的办法更加的通用,更加的灵活;从 NAPI 到 Newer newer NAPI,从 TSO 到 GSO,从 LRO 到 GRO,都是一个从特例到一个更通用的解决办法的演化,正是这种渐进但连续的演化,让 Linux 保有了如此的活力。