05网络设备子系统

邻居子系统通过 dev_queue_xmit 进入到网络设备子系统中来。

//file: net/core/dev.c
int dev_queue_xmit(struct sk_buff *skb)
{
//选择发送队列
txq = netdev_pick_tx(dev, skb);

//获取与此队列关联的排队规则
q = rcu_dereference_bh(txq->qdisc);

//如果有队列,则调用__dev_xmit_skb 继续处理数据
if (q->enqueue) {
rc = __dev_xmit_skb(skb, q, dev, txq);
goto out;
}

//没有队列的是回环设备和隧道设备
......
}

开篇第二节网卡启动准备里我们说过,网卡是有多个发送队列的(尤其是现在的网卡)。上面对 netdev_pick_tx 函数的调用就是选择一个队列进行发送。

netdev_pick_tx 发送队列的选择受 XPS 等配置的影响,而且还有缓存,也是一套小复杂的逻辑。这里我们只关注两个逻辑,首先会获取用户的 XPS 配置,否则就自动计算了。代码见 netdev_pick_tx => __netdev_pick_tx。

//file: net/core/flow_dissector.c
u16 __netdev_pick_tx(struct net_device *dev, struct sk_buff *skb)
{
//获取 XPS 配置
int new_index = get_xps_queue(dev, skb);

//自动计算队列
if (new_index < 0)
new_index = skb_tx_hash(dev, skb);}

然后获取与此队列关联的 qdisc。在 linux 上通过 tc 命令可以看到 qdisc 类型,例如对于我的某台多队列网卡机器上是 mq disc。

#tc qdisc
qdisc mq 0: dev eth0 root

大部分的设备都有队列(回环设备和隧道设备除外),所以现在我们进入到 __dev_xmit_skb。

//file: net/core/dev.c
static inline int __dev_xmit_skb(struct sk_buff *skb, struct Qdisc *q,
struct net_device *dev,
struct netdev_queue *txq)
{
//1.如果可以绕开排队系统
if ((q->flags & TCQ_F_CAN_BYPASS) && !qdisc_qlen(q) &&
qdisc_run_begin(q)) {
......
}

//2.正常排队
else {

//入队
q->enqueue(skb, q)

//开始发送
__qdisc_run(q);
}
}

上述代码中分两种情况,1 是可以 bypass(绕过)排队系统的,另外一种是正常排队。我们只看第二种情况。

先调用 q->enqueue 把 skb 添加到队列里。然后调用 __qdisc_run 开始发送。

//file: net/sched/sch_generic.c
void __qdisc_run(struct Qdisc *q)
{
int quota = weight_p;

//循环从队列取出一个 skb 并发送
while (qdisc_restart(q)) {

// 如果发生下面情况之一,则延后处理:
// 1. quota 用尽
// 2. 其他进程需要 CPU
if (--quota <= 0 || need_resched()) {
//将触发一次 NET_TX_SOFTIRQ 类型 softirq
__netif_schedule(q);
break;
}
}
}

在上述代码中,我们看到 while 循环不断地从队列中取出 skb 并进行发送。注意,这个时候其实都占用的是用户进程的系统态时间(sy)。只有当 quota 用尽或者其它进程需要 CPU 的时候才触发软中断进行发送。

所以这就是为什么一般服务器上查看 /proc/softirqs,一般 NET_RX 都要比 NET_TX 大的多的第二个原因。对于读来说,都是要经过 NET_RX 软中断,而对于发送来说,只有系统态配额用尽才让软中断上。

我们来把精力在放到 qdisc_restart 上,继续看发送过程。

static inline int qdisc_restart(struct Qdisc *q)
{
//从 qdisc 中取出要发送的 skb
skb = dequeue_skb(q);
...

return sch_direct_xmit(skb, q, dev, txq, root_lock);
}

qdisc_restart 从队列中取出一个 skb,并调用 sch_direct_xmit 继续发送。

//file: net/sched/sch_generic.c
int sch_direct_xmit(struct sk_buff *skb, struct Qdisc *q,
struct net_device *dev, struct netdev_queue *txq,
spinlock_t *root_lock)
{
//调用驱动程序来发送数据
ret = dev_hard_start_xmit(skb, dev, txq);
}

06软中断调度

在 4.5 咱们看到了如果系统态 CPU 发送网络包不够用的时候,会调用 __netif_schedule 触发一个软中断。该函数会进入到 __netif_reschedule,由它来实际发出 NET_TX_SOFTIRQ 类型软中断。

软中断是由内核线程来运行的,该线程会进入到 net_tx_action 函数,在该函数中能获取到发送队列,并也最终调用到驱动程序里的入口函数 dev_hard_start_xmit。

//file: net/core/dev.c
static inline void __netif_reschedule(struct Qdisc *q)
{
sd = &__get_cpu_var(softnet_data);
q->next_sched = NULL;
*sd->output_queue_tailp = q;
sd->output_queue_tailp = &q->next_sched;

......
raise_softirq_irqoff(NET_TX_SOFTIRQ);
}

在该函数里在软中断能访问到的 softnet_data 里设置了要发送的数据队列,添加到了 output_queue 里了。紧接着触发了 NET_TX_SOFTIRQ 类型的软中断。(T 代表 transmit 传输)

软中断的入口代码我这里也不详细扒了,感兴趣的同学参考《图解Linux网络包接收过程》一文中的 3.2 小节 - ksoftirqd内核线程处理软中断。

我们直接从 NET_TX_SOFTIRQ softirq 注册的回调函数 net_tx_action讲起。用户态进程触发完软中断之后,会有一个软中断内核线程会执行到 net_tx_action。

牢记,这以后发送数据消耗的 CPU 就都显示在 si 这里了,不会消耗用户进程的系统时间了

//file: net/core/dev.c
static void net_tx_action(struct softirq_action *h)
{
//通过 softnet_data 获取发送队列
struct softnet_data *sd = &__get_cpu_var(softnet_data);

// 如果 output queue 上有 qdisc
if (sd->output_queue) {

// 将 head 指向第一个 qdisc
head = sd->output_queue;

//遍历 qdsics 列表
while (head) {
struct Qdisc *q = head;
head = head->next_sched;

//发送数据
qdisc_run(q);
}
}
}

软中断这里会获取 softnet_data。前面我们看到进程内核态在调用 __netif_reschedule 的时候把发送队列写到 softnet_data 的 output_queue 里了。软中断循环遍历 sd->output_queue 发送数据帧。

来看 qdisc_run,它和进程用户态一样,也会调用到 __qdisc_run。

//file: include/net/pkt_sched.h
static inline void qdisc_run(struct Qdisc *q)
{
if (qdisc_run_begin(q))
__qdisc_run(q);
}

然后一样就是进入 qdisc_restart => sch_direct_xmit,直到驱动程序函数 dev_hard_start_xmit。

07igb 网卡驱动发送

我们前面看到,无论是对于用户进程的内核态,还是对于软中断上下文,都会调用到网络设备子系统中的 dev_hard_start_xmit 函数。在这个函数中,会调用到驱动里的发送函数 igb_xmit_frame。

在驱动函数里,将 skb 会挂到 RingBuffer上,驱动调用完毕后,数据包将真正从网卡发送出去。

我们来看看实际的源码:

//file: net/core/dev.c
int dev_hard_start_xmit(struct sk_buff *skb, struct net_device *dev,
struct netdev_queue *txq)
{
//获取设备的回调函数集合 ops
const struct net_device_ops *ops = dev->netdev_ops;

//获取设备支持的功能列表
features = netif_skb_features(skb);

//调用驱动的 ops 里面的发送回调函数 ndo_start_xmit 将数据包传给网卡设备
skb_len = skb->len;
rc = ops->ndo_start_xmit(skb, dev);
}

其中 ndo_start_xmit 是网卡驱动要实现的一个函数,是在 net_device_ops 中定义的。

//file: include/linux/netdevice.h
struct net_device_ops {
netdev_tx_t (*ndo_start_xmit) (struct sk_buff *skb,
struct net_device *dev);

}

在 igb 网卡驱动源码中,我们找到了。

//file: drivers/net/ethernet/intel/igb/igb_main.c
static const struct net_device_ops igb_netdev_ops = {
.ndo_open = igb_open,
.ndo_stop = igb_close,
.ndo_start_xmit = igb_xmit_frame,
...
};

也就是说,对于网络设备层定义的 ndo_start_xmit, igb 的实现函数是 igb_xmit_frame。这个函数是在网卡驱动初始化的时候被赋值的。

所以在上面网络设备层调用 ops->ndo_start_xmit 的时候,会实际上进入 igb_xmit_frame 这个函数中。我们进入这个函数来看看驱动程序是如何工作的。

//file: drivers/net/ethernet/intel/igb/igb_main.c
static netdev_tx_t igb_xmit_frame(struct sk_buff *skb,
struct net_device *netdev)
{
......
return igb_xmit_frame_ring(skb, igb_tx_queue_mapping(adapter, skb));
}

netdev_tx_t igb_xmit_frame_ring(struct sk_buff *skb,
struct igb_ring *tx_ring)
{
//获取TX Queue 中下一个可用缓冲区信息
first = &tx_ring->tx_buffer_info[tx_ring->next_to_use];
first->skb = skb;
first->bytecount = skb->len;
first->gso_segs = 1;

//igb_tx_map 函数准备给设备发送的数据。
igb_tx_map(tx_ring, first, hdr_len);
}

在这里从网卡的发送队列的 RingBuffer 中取下来一个元素,并将 skb 挂到元素上。

igb_tx_map 函数处理将 skb 数据映射到网卡可访问的内存 DMA 区域。

//file: drivers/net/ethernet/intel/igb/igb_main.c
static void igb_tx_map(struct igb_ring *tx_ring,
struct igb_tx_buffer *first,
const u8 hdr_len)
{
//获取下一个可用描述符指针
tx_desc = IGB_TX_DESC(tx_ring, i);

//为 skb->data 构造内存映射,以允许设备通过 DMA 从 RAM 中读取数据
dma = dma_map_single(tx_ring->dev, skb->data, size, DMA_TO_DEVICE);

//遍历该数据包的所有分片,为 skb 的每个分片生成有效映射
for (frag = &skb_shinfo(skb)->frags[0];; frag++) {

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

//设置最后一个descriptor
cmd_type |= size | IGB_TXD_DCMD;
tx_desc->read.cmd_type_len = cpu_to_le32(cmd_type);

/* Force memory writes to complete before letting h/w know there
* are new descriptors to fetch
*/
wmb();
}

当所有需要的描述符都已建好,且 skb 的所有数据都映射到 DMA 地址后,驱动就会进入到它的最后一步,触发真实的发送。

08发送完成硬中断

当数据发送完成以后,其实工作并没有结束。因为内存还没有清理。当发送完成的时候,网卡设备会触发一个硬中断来释放内存。

在发送完成硬中断里,会执行 RingBuffer 内存的清理工作,如图。

再回头看一下硬中断触发软中断的源码。

//file: drivers/net/ethernet/intel/igb/igb_main.c
static inline void ____napi_schedule(...){
list_add_tail(&napi->poll_list, &sd->poll_list);
__raise_softirq_irqoff(NET_RX_SOFTIRQ);
}

这里有个很有意思的细节,无论硬中断是因为是有数据要接收,还是说发送完成通知,从硬中断触发的软中断都是 NET_RX_SOFTIRQ。这个我们在第一节说过了,这是软中断统计中 RX 要高于 TX 的一个原因。

好我们接着进入软中断的回调函数 igb_poll。在这个函数里,我们注意到有一行 igb_clean_tx_irq,参见源码:

//file: drivers/net/ethernet/intel/igb/igb_main.c
static int igb_poll(struct napi_struct *napi, int budget)
{
//performs the transmit completion operations
if (q_vector->tx.ring)
clean_complete = igb_clean_tx_irq(q_vector);
...
}

我们来看看当传输完成的时候,igb_clean_tx_irq 都干啥了。

//file: drivers/net/ethernet/intel/igb/igb_main.c
static bool igb_clean_tx_irq(struct igb_q_vector *q_vector)
{
//free the skb
dev_kfree_skb_any(tx_buffer->skb);

//clear tx_buffer data
tx_buffer->skb = NULL;
dma_unmap_len_set(tx_buffer, len, 0);

// clear last DMA location and unmap remaining buffers */
while (tx_desc != eop_desc) {
}
}

无非就是清理了 skb,解除了 DMA 映射等等。到了这一步,传输才算是基本完成了。

为啥我说是基本完成,而不是全部完成了呢?因为传输层需要保证可靠性,所以 skb 其实还没有删除。它得等收到对方的 ACK 之后才会真正删除,那个时候才算是彻底的发送完毕。

最后

用一张图总结一下整个发送过程

了解了整个发送过程以后,我们回头再来回顾开篇提到的几个问题。

1.我们在监控内核发送数据消耗的 CPU 时,是应该看 sy 还是 si ?

在网络包的发送过程中,用户进程(在内核态)完成了绝大部分的工作,甚至连调用驱动的事情都干了。只有当内核态进程被切走前才会发起软中断。发送过程中,绝大部分(90%)以上的开销都是在用户进程内核态消耗掉的。

只有一少部分情况下才会触发软中断(NET_TX 类型),由软中断 ksoftirqd 内核进程来发送。

所以,在监控网络 IO 对服务器造成的 CPU 开销的时候,不能仅仅只看 si,而是应该把 si、sy 都考虑进来。

2. 在服务器上查看 /proc/softirqs,为什么 NET_RX 要比 NET_TX 大的多的多?

之前我认为 NET_RX 是读取,NET_TX 是传输。对于一个既收取用户请求,又给用户返回的 Server 来说。这两块的数字应该差不多才对,至少不会有数量级的差异。但事实上,飞哥手头的一台服务器是这样的:

经过今天的源码分析,发现这个问题的原因有两个。

第一个原因是当数据发送完成以后,通过硬中断的方式来通知驱动发送完毕。但是硬中断无论是有数据接收,还是对于发送完毕,触发的软中断都是 NET_RX_SOFTIRQ,而并不是 NET_TX_SOFTIRQ。

第二个原因是对于读来说,都是要经过 NET_RX 软中断的,都走 ksoftirqd 内核进程。而对于发送来说,绝大部分工作都是在用户进程内核态处理了,只有系统态配额用尽才会发出 NET_TX,让软中断上。

综上两个原因,那么在机器上查看 NET_RX 比 NET_TX 大的多就不难理解了。

3.发送网络数据的时候都涉及到哪些内存拷贝操作?

这里的内存拷贝,我们只特指待发送数据的内存拷贝。

第一次拷贝操作是内核申请完 skb 之后,这时候会将用户传递进来的 buffer 里的数据内容都拷贝到 skb 中。如果要发送的数据量比较大的话,这个拷贝操作开销还是不小的。

第二次拷贝操作是从传输层进入网络层的时候,每一个 skb 都会被克隆一个新的副本出来。网络层以及下面的驱动、软中断等组件在发送完成的时候会将这个副本删除。传输层保存着原始的 skb,在当网络对方没有 ack 的时候,还可以重新发送,以实现 TCP 中要求的可靠传输。

第三次拷贝不是必须的,只有当 IP 层发现 skb 大于 MTU 时才需要进行。会再申请额外的 skb,并将原来的 skb 拷贝为多个小的 skb。

这里插入个题外话,大家在网络性能优化中经常听到的零拷贝,我觉得这有点点夸张的成分。TCP 为了保证可靠性,第二次的拷贝根本就没法省。如果包再大于 MTU 的话,分片时的拷贝同样也避免不了。

看到这里,相信内核发送数据包对于你来说,已经不再是一个完全不懂的黑盒了。本文哪怕你只看懂十分之一,你也已经掌握了这个黑盒的打开方式。这在你将来优化网络性能时你就会知道从哪儿下手了。

举报/反馈

丹鱼先生

70获赞 140粉丝
日常介绍互联网相关的热点和干货
关注
0
0
收藏
分享