深入理解Linux网络学习笔记(一)


概述

最近买了本技术书<<深入理解Linux网络-修炼底层内功,掌握高性能原理>>一听就非常硬核啊,感兴趣了。买来放了很久才翻开,发现里面的排版真的非常舒服,没有像别的技术书籍一样通篇贴代码,排版稀烂,并且这本书还是彩印的,大部分地方都画了图帮助理解,同时代码量都尽量控制在一页内并且大部分不跨页,真的爱了。除了这些,内容质量也非常过硬,推荐所有想要深入学习网络的人都买来看看。

这本书分成了几个问题,将一些你见过思考过但是没深入了解过的问题拆分成一个个章节和一个个问题,来诱导你思考,直到最后理解,作者非常的有水平,当然书的难度也很大。另外直接拍书上的图用不太美观,也不方便,所以图是从网上偷的,实际上在书上是彩色的。

1)Linux网络收包

在TCP/IP网络分层模型里,整个协议栈被分为了物理层、链路层、网络层、传输层和应用层。Liunx内核以及网卡驱动主要实现链路层、网络层和传输层这三层上的功能,内核为更上面的应用层提供socket接口来支持用户进程访问。以Linux的视角看到的TCP/IP网络分层模型如下图所示:

内核和网络设备驱动是通过中断的方式来处理的。当设备上有数据到达时,会给CPU的相关引脚触发一个电压变化,以通知CPU来处理数据。对于网络模块来说,由于处理过程比较复杂和耗时,如果在中断函数中完成所有的处理,将会导致中断处理函数(优先级过高)过度占用CPU,使得CPU无法响应其他设备。

因此Linux中断处理函数是分上半部和下半部的。上半部只进行最简单的工作,快速处理然后释放CPU,接着CPU就可以允许其他中断进来。将剩下的绝大部分的工作都放到下半部,可以慢慢、从容处理。2.4以后的Linux内核版本采用的下半部实现方式是软中断,由ksoftirqd内核线程全权处理。硬中断是通过给CPU物理引脚施加电压变化实现的,而软中断是通过给内存中的一个变量赋予二进制值以标记有软中断发生,注意,这个软中断是贯穿后面所有的连接线。

一个完整的逻辑流程如下图:

当网卡收到数据以后,以DMA的方式把网卡收到的帧写到内存里,再向CPU发起硬一个中断通知CPU有数据到达。当CPU收到中断请求后,会去调用网络设备驱动注册的中断处理函数。网卡的中断处理函数并不做过多工作,发出软中断请求,然后尽快释放CPU资源。ksoftirqd内核线程检测到有软中断请求到达,调用poll开始轮询收包,收到后交由各级协议栈处理。对于TCP包来说,会被放到用户socket的接收队列中。

2)Linux,启动!

接下来让我们看看为了接受这个信息,Linux都需要做哪些准备?原书在这里会贴内核代码,在这里我只贴我认为重要的代码,过于细节的部分不会贴上来其实是我不会,不重要的部分就留着到书里面自己看了。

1.创建ksoftirqd线程

Linux软中断由ksoftirqd内核线程处理,该线程数等于CPU核数,系统初始化的时候会执行到spawn_ksoftirqd(位于kernel/softirq.c)来创建出ksoftirqd线程,执行过程如下图所示:

ksoftirqd被创建出来以后,它就会进入自己的线程循环函数ksoftirqd_should_runrun_ksoftirqd。接下来判断有没有软中断需要处理,软中断不仅有网络软中断,还有其他类型,这里只需要关注下面两个类型:

    NET_TX_SOFTIRQ, // 网络传输发送软中断
    NET_RX_SOFTIRQ, // 网络传输接收软中断

2 网络子系统初始化

在网络子系统的初始化过程中,会为每个CPU初始化softnet_data,也会为NET_TX_SOFTIRQNET_RX_SOFTIRQ注册处理函数,执行过程如下图所示:

Linux内核通过调用subsys_initcall来初始化各个子系统,网络子系统的初始化会执行net_dev_init函数:

// net/core/dev.c
static int __init net_dev_init(void){
    ...
    for_each_possible_cpu(i) {
        struct softnet_data *sd = &per_cpu(softnet_data, i);

        memset(sd, 0, sizeof(*sd));
        skb_queue_head_init(&sd->input_pkt_queue);
        skb_queue_head_init(&sd->process_queue);
        sd->completion_queue = NULL;
        INIT_LIST_HEAD(&sd->poll_list);
        ...
    }
    ...
    open_softirq(NET_TX_SOFTIRQ, net_tx_action);
    open_softirq(NET_RX_SOFTIRQ, net_rx_action);
    ...
}

subsys_initcall(net_dev_init);

在这个函数里,会为每个CPU都申请一个softnet_data数据结构,这个数据结构里的poll_list用于等待驱动程序将其poll函数注册进来,后面讲到“网卡驱动初始化”时可以看到这一过程。另外,open_softirq为每一种软中断都注册一个处理函数。NET_TX_SOFTIRQ的处理函数为net_tx_action``,NET_RX_SOFTIRQ的处理函数为net_rx_action。继续跟踪open_softirq后发现这个注册的方式是记录在softirq_vec变量里的。后面讲到ksoftirqd内核线程处理软中断”时,也会使用这个变量来找到每一种软中断对应的处理函数。

// kernel/softirq.c
void open_softirq(int nr, void (*action)(struct softirq_action *)){
    softirq_vec[nr].action = action;
}

3 协议栈注册

内核实现了网络层的IP协议,也实现了传输层的TCP协议和UDP协议。这些协议对应的实现函数分为是ip_rcv()tcp_v4_rcv()udp_rcv()。Linux内核中的fs_initcallsubsys_initcall类似,也是初始化模块的入口。fs_initcall调用inet_init后开始网络协议栈注册,通过inet_init,将这些函数注册到inet_protosptype_base数据结构中,如下图所示:

// net/ipv4/af_inet.c
static struct packet_type ip_packet_type __read_mostly = {
    .type = cpu_to_be16(ETH_P_IP),
    .func = ip_rcv,
};

static const struct net_protocol udp_protocol = {
    .handler =    udp_rcv,
    .err_handler =    udp_err,
    .no_policy =    1,
    .netns_ok =    1,
};

static const struct net_protocol tcp_protocol = {
    .early_demux    =    tcp_v4_early_demux,
    .handler    =    tcp_v4_rcv,
    .err_handler    =    tcp_v4_err,
    .no_policy    =    1,
    .netns_ok    =    1,
};

udp_protocol结构体中的handler是udp_rcvtcp_protocol结构体中的handler是tcp_v4_rcv,它们通过inet_add_protocol函数被初始化进来,并通过inet_add_protocol函数将TCP和UDP对应的处理函数都注册到inet_protos数组中:

// net/ipv4/protocol.c
int inet_add_protocol(const struct net_protocol *prot, unsigned char protocol){
    if (!prot->netns_ok) {
        pr_err("Protocol %u is not namespace aware, cannot register.\n",
            protocol);
        return -EINVAL;
    }

    return !cmpxchg((const struct net_protocol **)&inet_protos[protocol],
            NULL, prot) ? 0 : -1;
}

并且通过dev_add_pack(&ip_packet_type)函数,其中ip_packet_type结构体中的type是协议栈名,被注册到ptype_base哈希表中:

// net/core/dev.c
void dev_add_pack(struct packet_type *pt){
    struct list_head *head = ptype_head(pt);
    ...
}

static inline struct list_head *ptype_head(const struct packet_type *pt){
    if (pt->type == htons(ETH_P_ALL))
        return &ptype_all;
    else
        return &ptype_base[ntohs(pt->type) & PTYPE_HASH_MASK];
}

总的来说,inet_protos记录着UDP、TCP的处理函数地址,ptype_base存储着ip_rcv()函数的处理地址。”ksoftirqd内核线程处理软中断”中会通过ptype_base找到ip_rcv函数地址,进而将IP包正确地发送到ip_rcv()中执行。在ip_rcv中将会通过inet_protos找到TCP或者UDP的处理函数,再把包转发给udp_rcv()tcp_v4_rcv()函数。

4 网卡驱动初始化

每一个驱动程序(不仅仅包括网卡驱动程序)会使用module_init向内核注册一个初始化函数,当驱动程序被加载时,内核会调用这个函数。书中使用的是Intel的网卡,那么igb网卡驱动程序的代码位于drivers/net/ethernet/intel/igb/igb_main.c中,这里因为每家驱动厂商的驱动写的都不一样,大体上了解这个逻辑就行,不必死磕代码。

// drivers/net/ethernet/intel/igb/igb_main.c
static struct pci_driver igb_driver = {
    .name     = igb_driver_name,
    .id_table = igb_pci_tbl,
    .probe    = igb_probe,
    .remove   = igb_remove,
    ...
};

static int __init igb_init_module(void)
{
    ...
    ret = pci_register_driver(&igb_driver);
    return ret;
}

驱动的pci_register_driver调用完成后,Linux内核就知道了该驱动的相关信息,比如igb网卡驱动的igb_driver_nameigb_probe函数地址等等。当网卡设备被识别以后,内核会调用其驱动的probe方法(igb_driverprobe方法是igb_probe)。驱动的probe方法执行的目的就是让设备处于ready状态。对于igb网卡,函数igb_probe主要执行的操作如下图所示:

5 启动网卡

当上面的初始化都完成以后,就可以启动网卡了。在前面“网卡驱动初始化”的部分,驱动向内核注册了struct net_device_ops变量,它包含着网卡启动、发包、设置MAC地址等回调函数的函数指针。当启动一个网卡时,net_device_ops变量中定义的ndo_open方法会被调用。对于igb网卡来说,该函数指针指向的是igb_open方法,它主要执行的操作如下图所示:

__igb_open方法中主要将传输描述符数组接收描述符数组以及中断处理函数初始化,并在igb_setup_all_tx_resources中分配RingBuffer,建立内存和Rx队列的映射关系。

关键点来了,每一个队列是如何创建出来的,前面的代码可以了解即可,但这里你需要理解:

// drivers/net/ethernet/intel/igb/igb_main.c
int igb_setup_rx_resources(struct igb_ring *rx_ring){
    ...
    // 1.申请igb_rx_buffer数组内存
    size = sizeof(struct igb_rx_buffer) * rx_ring->count;

    rx_ring->rx_buffer_info = vzalloc(size);
    if (!rx_ring->rx_buffer_info)
        goto err;

    // 2.申请e1000_adv_rx_desc DMA数组内存
    rx_ring->size = rx_ring->count * sizeof(union e1000_adv_rx_desc);
    rx_ring->size = ALIGN(rx_ring->size, 4096);

    rx_ring->desc = dma_alloc_coherent(dev, rx_ring->size,
                       &rx_ring->dma, GFP_KERNEL);
    if (!rx_ring->desc)
        goto err;
    // 3.初始化队列成员
    rx_ring->next_to_alloc = 0;
    rx_ring->next_to_clean = 0;
    rx_ring->next_to_use = 0;

    return 0;
    ...
}

实际上从代码也能看出来,一个RingBuffer的内部不是仅有一个环形队列数组,而是有两个:

  • igb_rx_buffer数组:给内核使用,通过vzalloc申请的
  • e1000_adv_rx_desc数组:给网卡硬件使用的,通过dma_alloc_coherent分配

再接着看中断函数是如何注册的,注册过程见igb_request_irq:

// drivers/net/ethernet/intel/igb/igb_main.c
static int igb_request_irq(struct igb_adapter *adapter){
    ...
    if (adapter->msix_entries) {
        err = igb_request_msix(adapter);
        if (!err)
            goto request_done;
        ...
}

static int igb_request_msix(struct igb_adapter *adapter){
    ...
    for (i = 0; i < adapter->num_q_vectors; i++) {
        ...
        err = request_irq(adapter->msix_entries[vector].vector,
                  igb_msix_ring, 0, q_vector->name,
                  q_vector);
        ...
}  

函数调用顺序为__igb_open => igb_request_irq => igb_request_msix。在igb_request_msix中,对于多队列的网卡,为每一个队列都注册了中断,其对应的中断处理函数是igb_msix_ring。还可以看到,在msix方式下,每个RX队列都有独立的MSI-X中断,从网卡硬件中断的层面就可以设置让收到的包被不同的CPU处理。

3)接收网络数据

1 硬中断处理

首先,当数据帧从网线到达网卡上的时候,第一站是网卡的接收队列。网卡在分配给自己的RingBuffer中寻找可用的内存位置,找到后DMA引擎会把数据DMA到网卡之前关联的内存里,到这个时候CPU都是无感的。当DMA操作完成以后,网卡会向CPU发起一个硬中断,通知CPU有数据到达。硬中断的处理过程如下图所示

在前面”启动网卡“的部分,讲到了网卡的硬中断注册的处理函数是igb_msix_ring,进入其中会发现调用链为igb_msix_ring => napi_schedule => __napi_schedule => ____napi_schedule

而在最后____napi_schedule中

// net/core/dev.c
static inline void ____napi_schedule(struct softnet_data *sd,
                     struct napi_struct *napi){
    list_add_tail(&napi->poll_list, &sd->poll_list);
    __raise_softirq_irqoff(NET_RX_SOFTIRQ);
}

list_add_tail修改了每个CPU变量softnet_data里的poll_list,将驱动napi_struct传过来的poll_list添加了进来。softnet_data里的poll_list是一个双向列表,其中的设备都带有输入帧等着被处理。紧接着__raise_softirq_irqoff触发了一个软中断NET_RX_SOFTIRQ,这个所谓的触发过程只是对一个变量进行了一次或运算(对软中断有点感觉了没有,mask魅力时刻)。

// kernel/softirq.c
void __raise_softirq_irqoff(unsigned int nr)
{
    trace_softirq_raise(nr);
    or_softirq_pending(1UL << nr);
}
// include/linux/interrupt.h
#define or_softirq_pending(x)  (local_softirq_pending() |= (x))
// include/linux/irq_cpustat.h
#define local_softirq_pending() \
    __IRQ_STAT(smp_processor_id(), __softirq_pending)

通过以上代码可以看到,硬中断处理过程真的非常短(因为只需要负责通知,后面的软中断就可以交给操作系统去调度了,解决独占的问题),只是记录了一个寄存器,修改了一下CPU的poll_list,然后发出一个软中断。

2 ksoftirqd内核线程处理软中断

网络包的接收处理过程主要都在ksoftirqd内核线程中完成,软中断都是在这里处理的,流程如下图所示:

ksoftirqd中两个线程函数ksoftirqd_should_runrun_ksoftirqd。其中ksoftirqd_should_run函数的代码如下:

// kernel/softirq.c
static int ksoftirqd_should_run(unsigned int cpu){
    return local_softirq_pending();
}
// include/linux/irq_cpustat.h
#define local_softirq_pending() \
    __IRQ_STAT(smp_processor_id(), __softirq_pending)

该函数和硬中断中调用了同一个函数local_softirq_pending。使用方式不同在于,在硬中断处理中是为了写入标记,这里只是读取。如果硬中断中设置了NET_RX_SOFTIRQ,这里就能读取到。接下来由内核线程处理函数run_ksoftirqd进行处理:

// kernel/softirq.c
static void run_ksoftirqd(unsigned int cpu){
    local_irq_disable();
    if (local_softirq_pending()) {
        __do_softirq();
        ...
    }
    local_irq_enable();
}

__do_softirq中,判断根据当前CPU的软中断类型,调用其注册的action方法:

// kernel/softirq.c
asmlinkage void __do_softirq(void){
    ...
    do {
        if (pending & 1) {
            unsigned int vec_nr = h - softirq_vec;
            int prev_count = preempt_count();
            ...
            trace_softirq_entry(vec_nr);
            h->action(h);
            trace_softirq_exit(vec_nr);
            ...
            }
            ...
        }
        h++;
        pending >>= 1;
    } while (pending);
    ...
}

硬中断中的设置软中断标记,和ksoftirqd中的判断是否有软中断到达,都是基于smp_processor_id()的。这意味着只要硬中断在哪个CPU上被响应,那么软中断也是在这个CPU上处理的。注意,非常重要,只要硬中断在哪个CPU上被响应,那么软中断也是在这个CPU上处理的

至于具体的net_rx_action是怎么工作的,这个函数中除了定时控制保证网络包的接收不霸占CPU不放,核心逻辑是获取当前CPU变量softnet_data,对其poll_list进行遍历,然后执行到网卡驱动注册的poll函数。对于igb网卡来说,就是igb驱动里的igb_poll函数。在读取操作中,igb_poll的重点工作是对igb_clean_rx_irq的调用,也就是去RingBuffer取数据。

// drivers/net/ethernet/intel/igb/igb_main.c
static bool igb_clean_rx_irq(struct igb_q_vector *q_vector, const int budget){
    ...
    do {
        ...
        /* retrieve a buffer from the ring */
        skb = igb_fetch_rx_buffer(rx_ring, rx_desc, skb);

        /* exit if we failed to retrieve a buffer */
        if (!skb)
            break;

        cleaned_count++;

        /* fetch next buffer in frame if non-eop */
        if (igb_is_non_eop(rx_ring, rx_desc))
            continue;

        /* verify the packet layout is correct */
        if (igb_cleanup_headers(rx_ring, rx_desc, skb)) {
            skb = NULL;
            continue;
        }
        ...
        /* populate checksum, timestamp, VLAN, and protocol */
        igb_process_skb_fields(rx_ring, rx_desc, skb);

        napi_gro_receive(&q_vector->napi, skb);
        ...
    } while (likely(total_packets < budget));
    ...
}

igb_fetch_rx_bufferigb_is_non_eop的作用就是把数据帧从RingBuffer取下来。skb被从RingBuffer取下来以后,会通过igb_alloc_rx_buffers申请新的skb再重新挂上去。为什么需要两个函数呢?因为有可能数据帧要占用多个RingBuffer,所以是在一个循环里获取的,直到帧尾部。获取的一个数据帧用一个sk_buff来表示。收取完数据后,对其进行一些校验,然后开始设置skb变量的timestamp、VLAN id、protocol等字段。接下来进入napi_gro_receive函数,也就是网卡中,把相关的小包合并成一个大包,目的是减少传给网络栈的包数,有助于减少对CPU的使用量。

3 网络协议栈处理

netif_receive_skb函数会根据包的协议进程处理,假如是UDP包,将包依次送到ip_rcv、udp_rcv等协议处理函数中进行处理,如下图所示:

// net/core/dev.c
static int __netif_receive_skb_core(struct sk_buff *skb, bool pfmemalloc)
{
    ...
    // pcap逻辑,这里会将数据送入抓包点 tcpdump就是从这个入口获取包的
    list_for_each_entry_rcu(ptype, &ptype_all, list) {
        if (!ptype->dev || ptype->dev == skb->dev) {
            if (pt_prev)
                ret = deliver_skb(skb, pt_prev, orig_dev);
            pt_prev = ptype;
        }
    }
    ...
    list_for_each_entry_rcu(ptype,
            &ptype_base[ntohs(type) & PTYPE_HASH_MASK], list) {
        if (ptype->type == type &&
            (ptype->dev == null_or_dev || ptype->dev == skb->dev ||
             ptype->dev == orig_dev)) {
            if (pt_prev)
                ret = deliver_skb(skb, pt_prev, orig_dev);
            pt_prev = ptype;
        }
    }
    ...
}

__netif_receive_skb_core函数中取出protocol,它会从数据包中取出协议信息,然后遍历注册在这个协议上的回调函数列表。ptype_base是一个哈希表,在前面“协议栈注册”的部分提到过。ip_rcv函数地址就是存在其中。

// net/core/dev.c
static inline int deliver_skb(struct sk_buff *skb,
                  struct packet_type *pt_prev,
                  struct net_device *orig_dev){
    ...
    return pt_prev->func(skb, skb->dev, pt_prev, orig_dev);
}

pt_prev->func这一行就调用到了协议层注册的处理函数。对于IP包来说,就会进入ip_rcv(如果是ARP包,会进入arp_rcv

4 IP层处理

再来看看Linux在IP层都做了什么,包又是怎样进一步被送到UDP或TCP处理函数中的。下面是IP层接收网络包的主入口ip_rcv:

// net/ipv4/ip_input.c
int ip_rcv(struct sk_buff *skb, struct net_device *dev, struct packet_type *pt, struct net_device *orig_dev)
{
    ...
    return NF_HOOK(NFPROTO_IPV4, NF_INET_PRE_ROUTING, skb, dev, NULL,
               ip_rcv_finish);
    ...
}

这里的NF_HOOK是一个钩子函数,就是iptables netfilter过滤。当执行完注册的钩子后就会执行到最后一个参数指向的函数ip_rcv_finish

// net/ipv4/ip_input.c
static int ip_rcv_finish(struct sk_buff *skb){
    ...
    if (!skb_dst(skb)) {
        int err = ip_route_input_noref(skb, iph->daddr, iph->saddr,
                           iph->tos, skb->dev);
        ...
    }
    ...
    return dst_input(skb);
    ...
}

然后ip_route_input_noref不停的回调(省略部分,不然太摧残心智了),最后会发现回到ip_rcv_finish中调用的dst_input函数:

// include/net/dst.h
static inline int dst_input(struct sk_buff *skb){
    return skb_dst(skb)->input(skb);
}

如协议栈注册部分所讲,inet_protos中保存着tcp_v4_rcvudp_rcv的函数地址。这里将会根据包中的协议类型选择转发,在这里skb包将会进一步被派送到更上层的协议中(UDP和TCP)。

4)小结

是不是发现回调函数多了非常摧残人的心智(为什么说事件驱动编程难以编写),回到正文:首先在开始收包之前,Linux要做许多的准备工作:

  • 创建ksoftirqd线程,为它设置好它自己的线程函数,后面由它来处理软中断
  • 协议栈注册,Linux要实现需要协议,比如ARP、ICMP、IP、UDP和TCP,每一个协议都会将自己的处理函数注册一下,方便包来了迅速找到对应的处理函数
  • 网卡驱动初始化,每个驱动都有一个初始化函数,内核会让驱动也初始化一下。在这个初始化过程中,把自己的DMA准备好,把NAPI的poll函数地址告诉内核
  • 启动网卡,分配RX、TX队列,注册中断对应的处理函数

以上是内核准备收包之前的重要工作,当上面这些都准备好之后,就可以打开硬中断,等待数据包的到来了

当数据到来以后,第一个迎接它的是网卡:

  • 网卡将数据帧DMA到内存的RingBuffer中,然后向CPU发起中断通知
  • CPU响应中断请求,调用网卡启动时注册的中断处理函数
  • 中断处理函数几乎没干什么,只发起了软中断请求
  • 内核线程ksoftirqd发现有软中断请求到来,先关闭硬中断
  • ksoftirqd线程开始调用驱动的poll函数收包
  • poll函数将收到的包送到协议栈注册的ip_rcv函数中
  • 如果是UDP包,ip_rcv函数将包送到udp_rcv函数中(对于TCP包是送到tcp_rcv_v4)

5)开篇提问总结

1)RingBuffer到底是什么,RingBuffer为什么会丢包?

RingBuffer这个数据结构包括igb_rx_buffer环形队列数组、e1000_adv_rx_desc环形队列数组及众多的skb,如下图所示:

网卡在收到数据的时候以DMA的方式将包写到RingBuffer中。软中断收包的时候来这里把skb取走,并申请新的skb重新挂上去。RingBuffer中指针数组是预先分配好的,而skb虽然也会预先分配好,但是在后面收包过程中会不断动态地分配申请。如果内核处理得不及时导致RingBuffer满了,那后面新来的数据包就会被丢弃。

2)网络相关的硬中断、软中断都是什么?

在网卡将数据放到RingBuffer中后,接着就发起硬中断,通知CPU进行处理。不过硬中断的上下文里做的工作很少,将传过来的poll_list添加到了Per-CPU变量softnet_data的poll_list里(softnet_data的poll_list是一个双向列表,其中的设备都带有输入帧等着被处理),接着触发软中断NET_RX_SOFTIRQ

在软中断中对softnet_data的设备列表poll_list进行遍历,执行完卡驱动提供的poll来收取网络包。处理完后会送到协议栈的ip_rcv、udp_rcv、tcp_rcv_v4等函数中

3)Linux里的ksoftirqd内核线程是干什么的?

一台两核的虚拟机上有两个ksoftirqd内核线程。机器上有几个核,内核就会创建几个ksoftirqd线程出来。内核线程ksoftirqd包含了所有的软中断处理函数,也包括这里提到的NET_RX_SOFTIRQ。在__do_softirq中根据软中断的类型,执行不同的处理函数。对于软中断NET_RX_SOFTIRQ来说是net_rx_action函数

4)为什么网卡开启多队列能提升网络性能?

每个队列都会有独立的、不同的中断号,而中断号亲和的CPU不同,不同的队列在将数据收到自己的RingBuffer后,可以分别向不同的CPU发起硬中断通知。而在硬中断的处理中,调用__raise_softirq_irqoff发起软中断的时候,是基于当前CPU核心smp_processor_id的(__raise_softirq_irqoff => or_softirq_pending => local_softirq_pending),哪个核响应的硬中断,那么该硬中断发起的软中断任务就必然由这个核来处理,所以在工作实践中,如果网络包的接收频率高而导致个别核si偏高,那么通过加大网卡队列数,并设置每个队列中断号上的smp_affinity,将各个队列的硬中断打散到不同的CPU上就行了。这样硬中断后面的软中断CPU开销也将由多个核来分担。

5)网络接收过程中的CPU开销如何查看?

在网络的接收过程中,主要工作集中在硬中断和软中断上,二者的消耗都可以通过top命令来查看:

# top
top - 08:10:30 up  1:04,  1 user,  load average: 0.07, 0.11, 0.09
Tasks: 186 total,   2 running, 184 sleeping,   0 stopped,   0 zombie
%Cpu(s):  1.4 us,  2.4 sy,  0.0 ni, 96.0 id,  0.0 wa,  0.0 hi,  0.2 si,  0.0 st
MiB Mem :   7933.7 total,   6385.5 free,    576.6 used,    971.6 buff/cache
MiB Swap:   2680.0 total,   2680.0 free,      0.0 used.   7105.4 avail Mem 
123456

其中hi是CPU处理硬中断的开销,si是处理软中断的开销,都是以百分比的形式来展示的。

如果发现某个核的si过高,那么很有可能你的业务上当前数据包的接收已经非常频繁了,需要通过上面说的多队列网卡配置让其他核参与进来,分担这个核接收包的内核工作量。

书里面只是介绍了怎么接受数据,如果想要了解怎么发送数据的可以看这篇深入理解Linux 网络包发送过程,以及了解为什么:

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

答:因为虽然是发送数据,但是硬中断最终触发的软中断却是 NET_RX_SOFTIRQ,而并不是 NET_TX_SOFTIRQ。

如果还想继续深入,推荐阅读(不要过分沉溺其中):

Linux 中断(IRQ/softirq)基础:原理及内核实现(2022)

Linux 网络栈接收数据(RX):原理及内核实现(2022)

网卡多队列总结(转)


评论
  目录