关于linux:不为人知的网络编程十深入操作系统从内核理解网络包的接收过程Linux篇

13次阅读

共计 23654 个字符,预计需要花费 60 分钟才能阅读完成。

本文作者张彦飞,原题“图解 Linux 网络包接管过程”,内容有少许改变。

1、引言

因为要对百万、千万、甚至是过亿的用户提供各种网络服务,所以在一线互联网企业里面试和降职后端开发同学的其中一个重点要求就是要能撑持高并发,要了解性能开销,会进行性能优化。而很多时候,如果你对网络底层的了解不深的话,遇到很多线上性能瓶颈你会感觉狗拿刺猬,无从下手。

这篇文章将用图解的形式,从操作系统这一层来深度了解一下网络包的接管过程(因为能间接看到内核源码,本文以 Linux 为例)。

依照常规来借用一段最简略的代码开始思考。

为了简略起见,咱们用 udp 来举例,如下:

int main(){

    intserverSocketFd = socket(AF_INET, SOCK_DGRAM, 0);

    bind(serverSocketFd, …);

    char buff[BUFFSIZE];

    int readCount = recvfrom(serverSocketFd, buff, BUFFSIZE, 0, …);

    buff[readCount] = ‘0’;

    printf(“Receive from client:%sn”, buff);

}

下面代码是一段 udp server 接管收据的逻辑。当在开发视角看的时候,只有客户端有对应的数据发送过去,服务器端执行 recv_from 后就能收到它,并把它打印进去。

咱们当初想晓得的是:当网络包达到网卡,直到咱们的 recvfrom 收到数据,这两头,到底都产生过什么?
通过本文,你将从操作系统外部这一层深刻了解网络是如何实现的,以及各个局部之间是如何交互的。置信这对你的工作将会有十分大的帮忙(本文将以 Linux 为例,源码基于 Linux 3.10,源代码参见:https://mirrors.edge.kernel.org/pub/linux/kernel/v3.x/,网卡驱动采纳 Intel 的 igb 网卡举例)。
情谊提醒:本文略长,能够先 Mark 后看!

(本文同步公布于:http://www.52im.net/thread-3247-1-1.html)

2、系列文章

本文是系列文章中的第 10 篇,本系列文章的纲要如下:

《鲜为人知的网络编程 (一):浅析 TCP 协定中的疑难杂症 (上篇)》

《鲜为人知的网络编程 (二):浅析 TCP 协定中的疑难杂症 (下篇)》

《鲜为人知的网络编程 (三):敞开 TCP 连贯时为什么会 TIME_WAIT、CLOSE_WAIT》

《鲜为人知的网络编程 (四):深入研究剖析 TCP 的异样敞开》

《鲜为人知的网络编程 (五):UDP 的连接性和负载平衡》

《鲜为人知的网络编程 (六):深刻地了解 UDP 协定并用好它》

《鲜为人知的网络编程 (七):如何让不牢靠的 UDP 变的牢靠?》

《鲜为人知的网络编程 (八):从数据传输层深度解密 HTTP》

《鲜为人知的网络编程 (九):实践联系实际,全方位深刻了解 DNS》

《鲜为人知的网络编程 (十):深刻操作系统,从内核了解网络包的接管过程 (Linux 篇)》(本文)

3、网络收包总览

在 TCP/IP 网络分层模型里,整个协定栈被分成了:物理层、链路层、网络层,传输层和应用层。

物理层对应的是网卡和网线,应用层对应的是咱们常见的 Nginx,FTP 等等各种利用。对于 Linux 来说,它实现的是链路层、网络层和传输层这三层。

在 Linux 内核实现中,链路层协定靠网卡驱动来实现,内核协定栈来实现网络层和传输层。内核对更下层的应用层提供 socket 接口来供用户过程拜访。

咱们用 Linux 的视角来看到的 TCP/IP 网络分层模型应该是上面这个样子的:

在 Linux 的源代码中,网络设备驱动对应的逻辑位于 driver/net/ethernet。

其中:

1)intel 系列网卡的驱动在 driver/net/ethernet/intel 目录下;

2)协定栈模块代码位于 kernel 和 net 目录。

内核和网络设备驱动是通过中断的形式来解决的。

当设施上有数据达到的时候:会给 CPU 的相干引脚上触发一个电压变动,以告诉 CPU 来解决数据。

对于网络模块来说:因为处理过程比较复杂和耗时,如果在中断函数中实现所有的解决,将会导致中断处理函数(优先级过高)将适度占据 CPU,将导致 CPU 无奈响应其它设施,例如鼠标和键盘的音讯。

因而 Linux 中断处理函数是分上半部和下半部的。上半部是只进行最简略的工作,疾速解决而后开释 CPU,接着 CPU 就能够容许其它中断进来。剩下将绝大部分的工作都放到下半部中,能够缓缓从容解决。Linux 2.4 当前的内核版本采纳的下半部实现形式是软中断,由 ksoftirqd 内核线程全权处理。和硬中断不同的是,硬中断是通过给 CPU 物理引脚施加电压变动,而软中断是通过给内存中的一个变量的二进制值以告诉软中断处理程序。

好了,大略理解了网卡驱动、硬中断、软中断和 ksoftirqd 线程之后,咱们在这几个概念的根底上给出一个内核收包的门路示意。

Linux 内核网络收包总览:

如上图所示: 当网卡上收到数据当前,Linux 中第一个工作的模块是网络驱动。网络驱动会以 DMA 的形式把网卡上收到的帧写到内存里。再向 CPU 发动一个中断,以告诉 CPU 有数据达到。第二,当 CPU 收到中断请求后,会去调用网络驱动注册的中断处理函数。网卡的中断处理函数并不做过多工作,收回软中断请求,而后尽快开释 CPU。ksoftirqd 检测到有软中断请求达到,调用 poll 开始轮询收包,收到后交由各级协定栈解决。对于 UDP 包来说,会被放到用户 socket 的接管队列中。

咱们从下面这张图中曾经从整体上把握到了操作系统对数据包的处理过程。然而要想理解更多网络模块工作的细节,咱们还得往下看。

4、网络数据到来前操作系统的筹备

Linux 驱动、内核协定栈等等模块在具备接管网卡数据包之前,要做很多的筹备工作才行。

比方: 要提前创立好 ksoftirqd 内核线程,要注册好各个协定对应的处理函数,网络设备子系统要提前初始化好,网卡要启动好。只有这些都 Ready 之后,咱们能力真正开始接管数据包。

那么咱们当初来看看这些筹备工作都是怎么做的。

4.1 创立 ksoftirqd 内核线程

Linux 的软中断都是在专门的内核线程(ksoftirqd)中进行的,因而咱们十分有必要看一下这些过程是怎么初始化的,这样咱们能力在前面更精确地理解收包过程。该过程数量不是 1 个,而是 N 个,其中 N 等于你的机器的核数。

零碎初始化的时候在 kernel/smpboot.c 中调用了 smpboot_register_percpu_thread,该函数进一步会执行到 spawn_ksoftirqd(位于 kernel/softirq.c)来创立出 softirqd 过程。

创立 ksoftirqd 内核线程:

相干代码如下:

//file: kernel/softirq.c

static struct smp_hotplug_thread softirq_threads = {

    .store          = &ksoftirqd,

    .thread_should_run  = ksoftirqd_should_run,

    .thread_fn      = run_ksoftirqd,

    .thread_comm        = “ksoftirqd/%u”,};

static__init intspawn_ksoftirqd(void){

    register_cpu_notifier(&cpu_nfb);

    BUG_ON(smpboot_register_percpu_thread(&softirq_threads));

    return0;

}

early_initcall(spawn_ksoftirqd);

当 ksoftirqd 被创立进去当前,它就会进入本人的线程循环函数 ksoftirqd_should_run 和 run_ksoftirqd 了。不停地判断有没有软中断须要被解决。

这里须要留神的一点是,软中断不仅仅只有网络软中断,还有其它类型:

//file: include/linux/interrupt.h

enum{

    HI_SOFTIRQ=0,

    TIMER_SOFTIRQ,

    NET_TX_SOFTIRQ,

    NET_RX_SOFTIRQ,

    BLOCK_SOFTIRQ,

    BLOCK_IOPOLL_SOFTIRQ,

    TASKLET_SOFTIRQ,

    SCHED_SOFTIRQ,

    HRTIMER_SOFTIRQ,

    RCU_SOFTIRQ, 

};

4.2 网络子系统初始化

网络子系统初始化:

linux 内核通过调用 subsys_initcall 来初始化各个子系统,在源代码目录里你能够 grep 出许多对这个函数的调用。

这里咱们要说的是网络子系统的初始化,会执行到 net_dev_init 函数:

//file: net/core/dev.c

static int __init net_dev_init(void){

    ……

    for_each_possible_cpu(i) {

        structsoftnet_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 线程收到软中断的时候,也会应用这个变量来找到每一种软中断对应的处理函数。

//file: kernel/softirq.c

void open_softirq(int nr, void(action)(struct softirq_action )){

    softirq_vec[nr].action = action;

}

4.3 协定栈注册

操作系统内核实现了网络层的 ip 协定,也实现了传输层的 tcp 协定和 udp 协定。这些协定对应的实现函数别离是 ip_rcv(),tcp_v4_rcv() 和 udp_rcv()。和咱们平时写代码的形式不一样的是,内核是通过注册的形式来实现的。

Linux 内核中的 fs_initcall 和 subsys_initcall 相似,也是初始化模块的入口。fs_initcall 调用 inet_init 后开始网络协议栈注册。通过 inet_init,将这些函数注册到了 inet_protos 和 ptype_base 数据结构中了。

如下图:

相干代码如下:

//file: 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,

};

static int __init inet_init(void){

    ……

    if(inet_add_protocol(&icmp_protocol, IPPROTO_ICMP) < 0)

        pr_crit(“%s: Cannot add ICMP protocoln”, __func__);

    if(inet_add_protocol(&udp_protocol, IPPROTO_UDP) < 0)

        pr_crit(“%s: Cannot add UDP protocoln”, __func__);

    if(inet_add_protocol(&tcp_protocol, IPPROTO_TCP) < 0)

        pr_crit(“%s: Cannot add TCP protocoln”, __func__);

    ……

    dev_add_pack(&ip_packet_type);

}

下面的代码中咱们能够看到,udp_protocol 构造体中的 handler 是 udp_rcv,tcp_protocol 构造体中的 handler 是 tcp_v4_rcv,通过 inet_add_protocol 被初始化了进来。

int inet_add_protocol(const struct net_protocol *prot, unsigned charprotocol){

    if(!prot->netns_ok) {

        pr_err(“Protocol %u is not namespace aware, cannot register.n”,

            protocol);

        return-EINVAL;

    }

    return !cmpxchg((conststructnet_protocol **)&inet_protos[protocol],

            NULL, prot) ? 0 : -1;

}

inet_add_protocol 函数将 tcp 和 udp 对应的处理函数都注册到了 inet_protos 数组中了。再看 dev_add_pack(&ip_packet_type); 这一行,ip_packet_type 构造体中的 type 是协定名,func 是 ip_rcv 函数,在 dev_add_pack 中会被注册到 ptype_base 哈希表中。

//file: 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() 函数的解决地址。前面咱们会看到软中断中会通过 ptype_base 找到 ip_rcv 函数地址,进而将 ip 包正确地送到 ip_rcv() 中执行。在 ip_rcv 中将会通过 inet_protos 找到 tcp 或者 udp 的处理函数,再而把包转发给 udp_rcv() 或 tcp_v4_rcv() 函数。

扩大一下,如果看一下 ip_rcv 和 udp_rcv 等函数的代码能看到很多协定的处理过程。

例如:ip_rcv 中会解决 netfilter 和 iptable 过滤,如果你有很多或者很简单的 netfilter 或 iptables 规定,这些规定都是在软中断的上下文中执行的,会加大网络提早。

再例如:udp_rcv 中会判断 socket 接管队列是否满了。对应的相干内核参数是 net.core.rmem_max 和 net.core.rmem_default。如果有趣味,倡议大家好好读一下 inet_init 这个函数的代码。

4.4 网卡驱动初始化

每一个驱动程序(不仅仅只是网卡驱动)会应用 module_init 向内核注册一个初始化函数,当驱动被加载时,内核会调用这个函数。

比方 igb 网卡驱动的代码位于 drivers/net/ethernet/intel/igb/igb_main.c:

//file: 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_name 和 igb_probe 函数地址等等。当网卡设施被辨认当前,内核会调用其驱动的 probe 办法(igb_driver 的 probe 办法是 igb_probe)。驱动 probe 办法执行的目标就是让设施 ready,对于 igb 网卡,其 igb_probe 位于 drivers/net/ethernet/intel/igb/igb_main.c 下。

次要执行的操作如下: 

第 5 步中咱们看到: 网卡驱动实现了 ethtool 所须要的接口,也在这里注册实现函数地址的注册。当 ethtool 发动一个零碎调用之后,内核会找到对应操作的回调函数。对于 igb 网卡来说,其实现函数都在 drivers/net/ethernet/intel/igb/igb_ethtool.c 下。

置信你这次能彻底了解 ethtool 的工作原理了吧?这个命令之所以能查看网卡收发包统计、能批改网卡自适应模式、能调整 RX 队列的数量和大小,是因为 ethtool 命令最终调用到了网卡驱动的相应办法,而不是 ethtool 自身有这个超能力。

第 6 步: 注册的 igb_netdev_ops 中蕴含的是 igb_open 等函数,该函数在网卡被启动的时候会被调用。

//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_get_stats64        = igb_get_stats64,

  .ndo_set_rx_mode        = igb_set_rx_mode,

  .ndo_set_mac_address    = igb_set_mac,

  .ndo_change_mtu         = igb_change_mtu,

  .ndo_do_ioctl           = igb_ioctl,

 ……

第 7 步: 在 igb_probe 初始化过程中,还调用到了 igb_alloc_q_vector。他注册了一个 NAPI 机制所必须的 poll 函数,对于 igb 网卡驱动来说,这个函数就是 igb_poll,如下代码所示。

static int igb_alloc_q_vector(struct igb_adapter *adapter,

                  int v_count, int v_idx,

                  int txr_count, int txr_idx,

                  int rxr_count, int rxr_idx){

    ……

    / initialize NAPI /

    netif_napi_add(adapter->netdev, &q_vector->napi, igb_poll, 64);

}

4.5 启动网卡

当下面的初始化都实现当前,就能够启动网卡了。

回顾后面网卡驱动初始化时,咱们提到了驱动向内核注册了 structure net_device_ops 变量,它蕴含着网卡启用、发包、设置 mac 地址等回调函数(函数指针)。当启用一个网卡时(例如,通过 ifconfig eth0 up),net_device_ops 中的 igb_open 办法会被调用。

它通常会做以下事件:

//file: drivers/net/ethernet/intel/igb/igb_main.c

static int __igb_open(struct net_device *netdev, bool resuming){

    / allocate transmit descriptors /

    err = igb_setup_all_tx_resources(adapter);

    / allocate receive descriptors /

    err = igb_setup_all_rx_resources(adapter);

    / 注册中断处理函数 /

    err = igb_request_irq(adapter);

    if(err)

        goto err_req_irq;

    / 启用 NAPI /

    for(i = 0; i < adapter->num_q_vectors; i++)

        napi_enable(&(adapter->q_vector[I]->napi));

    ……

}

在下面__igb_open 函数调用了 igb_setup_all_tx_resources, 和 igb_setup_all_rx_resources。在 igb_setup_all_rx_resources 这一步操作中,调配了 RingBuffer,并建设内存和 Rx 队列的映射关系。(Rx Tx 队列的数量和大小能够通过 ethtool 进行配置)。

咱们再接着看中断函数注册 igb_request_irq:

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,

    }

在下面的代码中跟踪函数调用,__igb_open => igb_request_irq => igb_request_msix, 在 igb_request_msix 中咱们看到了,对于多队列的网卡,为每一个队列都注册了中断,其对应的中断处理函数是 igb_msix_ring(该函数也在 drivers/net/ethernet/intel/igb/igb_main.c 下)。

咱们也能够看到,msix 形式下,每个 RX 队列有独立的 MSI-X 中断,从网卡硬件中断的层面就能够设置让收到的包被不同的 CPU 解决。(能够通过 irqbalance,或者批改 /proc/irq/IRQ_NUMBER/smp_affinity 可能批改和 CPU 的绑定行为)。

当做好以上筹备工作当前,就能够开门迎客(数据包)了!

5、开始迎接数据的到来

5.1 硬中断解决

首先: 当数据帧从网线达到网卡上的时候,第一站是网卡的接管队列。

网卡在调配给本人的 RingBuffer 中寻找可用的内存地位,找到后 DMA 引擎会把数据 DMA 到网卡之前关联的内存里,这个时候 CPU 都是无感的。当 DMA 操作实现当前,网卡会像 CPU 发动一个硬中断,告诉 CPU 有数据达到。

网卡数据硬中断处理过程:

留神:当 RingBuffer 满的时候,新来的数据包将给抛弃。ifconfig 查看网卡的时候,能够外面有个 overruns,示意因为环形队列满被抛弃的包。如果发现有丢包,可能须要通过 ethtool 命令来加大环形队列的长度。

在启动网卡一节,咱们说到了网卡的硬中断注册的处理函数是 igb_msix_ring:

//file: drivers/net/ethernet/intel/igb/igb_main.c

static irqreturn_t igb_msix_ring(intirq, void *data){

    struct igb_q_vector *q_vector = data;

    / Write the ITR value calculated from the previous interrupt. /

    igb_write_itr(q_vector);

    napi_schedule(&q_vector->napi);

    return IRQ_HANDLED;

}

igb_write_itr 只是记录一下硬件中断频率(据说目标是在缩小对 CPU 的中断频率时用到)。

顺着 napi_schedule 调用一路跟踪上来,__napi_schedule=>____napi_schedule:

/ Called with irq disabled /

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,这个所谓的触发过程只是对一个变量进行了一次或运算而已。

void __raise_softirq_irqoff(unsigned int nr){

    trace_softirq_raise(nr);

    or_softirq_pending(1UL << nr);

}

//file: include/linux/irq_cpustat.h

define or_softirq_pending(x)  (local_softirq_pending() |= (x))

咱们说过:Linux 在硬中断里只实现简略必要的工作,剩下的大部分的解决都是转交给软中断的。

通过下面代码能够看到:硬中断处理过程真的是十分短。只是记录了一个寄存器,批改了一下下 CPU 的 poll_list,而后收回个软中断。就这么简略,硬中断工作就算是实现了。

5.2 ksoftirqd 内核线程解决软中断

ksoftirqd 内核线程:

内核线程初始化的时候,咱们介绍了 ksoftirqd 中两个线程函数 ksoftirqd_should_run 和 run_ksoftirqd。

其中 ksoftirqd_should_run 代码如下:

static int ksoftirqd_should_run(unsigned int cpu){

    return local_softirq_pending();

}

define local_softirq_pending()     __IRQ_STAT(smp_processor_id(), __softirq_pending)

这里看到和硬中断中调用了同一个函数 local_softirq_pending。应用形式不同的是硬中断地位是为了写入标记,这里仅仅只是读取。如果硬中断中设置了 NET_RX_SOFTIRQ, 这里天然能读取的到。

接下来会真正进入线程函数中 run_ksoftirqd 解决:

static void run_ksoftirqd(unsigned int cpu){

    local_irq_disable();

    if(local_softirq_pending()) {

        __do_softirq();

        rcu_note_context_switch(cpu);

        local_irq_enable();

        cond_resched();

        return;

    }

    local_irq_enable();

}

在__do_softirq 中,判断依据以后 CPU 的软中断类型,调用其注册的 action 办法。

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

}

在网络子系统初始化大节,咱们看到咱们为 NET_RX_SOFTIRQ 注册了处理函数 net_rx_action。所以 net_rx_action 函数就会被执行到了。

这里须要留神一个细节,硬中断中设置软中断标记,和 ksoftirq 的判断是否有软中断达到,都是基于 smp_processor_id() 的。这意味着只有硬中断在哪个 CPU 上被响应,那么软中断也是在这个 CPU 上解决的。所以说,如果你发现你的 Linux 软中断 CPU 耗费都集中在一个核上的话,做法是要把调整硬中断的 CPU 亲和性,来将硬中断打散到不同的 CPU 核下来。

咱们再来把精力集中到这个外围函数 net_rx_action 上来:

static void net_rx_action(struct softirq_action *h){

    struct softnet_data *sd = &__get_cpu_var(softnet_data);

    unsigned long time_limit = jiffies + 2;

    int budget = netdev_budget;

    void *have;

    local_irq_disable();

    while(!list_empty(&sd->poll_list)) {

        ……

        n = list_first_entry(&sd->poll_list, struct napi_struct, poll_list);

        work = 0;

        if(test_bit(NAPI_STATE_SCHED, &n->state)) {

            work = n->poll(n, weight);

            trace_napi_poll(n);

        }

        budget -= work;

    }

}

函数结尾的 time_limit 和 budget 是用来管制 net_rx_action 函数被动退出的,目标是保障网络包的接管不霸占 CPU 不放。等下次网卡再有硬中断过去的时候再解决剩下的接管数据包。其中 budget 能够通过内核参数调整。这个函数中剩下的外围逻辑是获取到以后 CPU 变量 softnet_data,对其 poll_list 进行遍历, 而后执行到网卡驱动注册到的 poll 函数。

对于 igb 网卡来说,就是 igb 驱动力的 igb_poll 函数了:

static int igb_poll(struct napi_struct *napi, int budget){

    …

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

    …

}

在读取操作中,igb_poll 的重点工作是对 igb_clean_rx_irq 的调用:

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

        / 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);

}

igb_fetch_rx_buffer 和 igb_is_non_eop 的作用就是把数据帧从 RingBuffer 上取下来。

为什么须要两个函数呢?因为有可能帧要占多多个 RingBuffer,所以是在一个循环中获取的,直到帧尾部。获取下来的一个数据帧用一个 sk_buff 来示意。收取完数据当前,对其进行一些校验,而后开始设置 sbk 变量的 timestamp, VLAN id, protocol 等字段。

接下来进入到 napi_gro_receive 中:

//file: net/core/dev.c

gro_result_t napi_gro_receive(struct napi_struct napi, struct sk_buff skb){

    skb_gro_reset_offset(skb);

    return napi_skb_finish(dev_gro_receive(napi, skb), skb);

}

dev_gro_receive 这个函数代表的是网卡 GRO 个性,能够简略了解成把相干的小包合并成一个大包就行,目标是缩小传送给网络栈的包数,这有助于缩小 CPU 的使用量。咱们暂且疏忽,间接看 napi_skb_finish。

这个函数次要就是调用了 netif_receive_skb:

//file: net/core/dev.c

static gro_result_t napi_skb_finish(gro_result_t ret, struct sk_buff *skb){

    switch(ret) {

    case GRO_NORMAL:

        if(netif_receive_skb(skb))

            ret = GRO_DROP;

        break;

    ……

}

在 netif_receive_skb 中,数据包将被送到协定栈中。申明,以下的 5.3、5.4、5.5 也都属于软中断的处理过程,只不过因为篇幅太长,独自拿进去成大节。

5.3 网络协议栈解决

netif_receive_skb 函数会依据包的协定,如果是 udp 包,会将包顺次送到 ip_rcv(),udp_rcv() 协定处理函数中进行解决。

网络协议栈解决:

//file: net/core/dev.c

int netif_receive_skb(struct sk_buff *skb){

    //RPS 解决逻辑,先疏忽    ……

    return __netif_receive_skb(skb);

}

static int __netif_receive_skb(struct sk_buff *skb){

    …… 

    ret = __netif_receive_skb_core(skb, false);}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 中,我看着原来常常应用的 tcpdump 的抓包点,很是冲动,看来读一遍源代码工夫真的没白节约。

接着__netif_receive_skb_core 取出 protocol,它会从数据包中取出协定信息,而后遍历注册在这个协定上的回调函数列表。ptype_base 是一个 hash table,在协定注册大节咱们提到过。ip_rcv 函数地址就是存在这个 hash table 中的。

//file: 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)。

5.4 IP 协定层解决

咱们再来大抵看一下 linux 在 ip 协定层都做了什么,包又是怎么样进一步被送到 udp 或 tcp 协定处理函数中的。

//file: 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 是一个钩子函数,当执行完注册的钩子后就会执行到最初一个参数指向的函数 ip_rcv_finish。

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_route_input_mc。

在 ip_route_input_mc 中,函数 ip_local_deliver 被赋值给了 dst.input, 如下:

//file: net/ipv4/route.c

static int ip_route_input_mc(struct sk_buff skb, __be32 daddr, __be32 saddr,u8 tos, struct net_device dev, int our){

    if(our) {

        rth->dst.input= ip_local_deliver;

        rth->rt_flags |= RTCF_LOCAL;

    }

}

所以回到 ip_rcv_finish 中的 return dst_input(skb):

/ Input packet from network to transport.  /

static inline intdst_input(struct sk_buff *skb){

    return skb_dst(skb)->input(skb);

}

skb_dst(skb)->input 调用的 input 办法就是路由子系统赋的 ip_local_deliver:

//file: net/ipv4/ip_input.c

int ip_local_deliver(struct sk_buff *skb){

    /       Reassemble IP fragments.     */

    if(ip_is_fragment(ip_hdr(skb))) {

        if(ip_defrag(skb, IP_DEFRAG_LOCAL_DELIVER))

            return 0;

    }

    return NF_HOOK(NFPROTO_IPV4, NF_INET_LOCAL_IN, skb, skb->dev, NULL, ip_local_deliver_finish);

}

static int ip_local_deliver_finish(struct sk_buff *skb){

    ……

    int protocol = ip_hdr(skb)->protocol;

    const struct net_protocol *ipprot;

    ipprot = rcu_dereference(inet_protos[protocol]);

    if(ipprot != NULL) {

        ret = ipprot->handler(skb);

    }

}

如协定注册大节看到 inet_protos 中保留着 tcp_rcv() 和 udp_rcv() 的函数地址。这里将会依据包中的协定类型抉择进行散发,在这里 skb 包将会进一步被派送到更下层的协定中,udp 和 tcp。

5.5 UDP 协定层解决

在协定注册大节的时候咱们说过,udp 协定的处理函数是 udp_rcv。

//file: net/ipv4/udp.c

int udp_rcv(struct sk_buff *skb){

    return __udp4_lib_rcv(skb, &udp_table, IPPROTO_UDP);

}

int __udp4_lib_rcv(struct sk_buff skb, struct udp_table udptable,

           int proto){

    sk = __udp4_lib_lookup_skb(skb, uh->source, uh->dest, udptable);

    if(sk != NULL) {

        intret = udp_queue_rcv_skb(sk, skb

    }

    icmp_send(skb, ICMP_DEST_UNREACH, ICMP_PORT_UNREACH, 0);

}

__udp4_lib_lookup_skb 是依据 skb 来寻找对应的 socket,当找到当前将数据包放到 socket 的缓存队列里。如果没有找到,则发送一个指标不可达的 icmp 包。

//file: net/ipv4/udp.c

int udp_queue_rcv_skb(struct sock sk, struct sk_buff skb){

    ……

    if(sk_rcvqueues_full(sk, skb, sk->sk_rcvbuf))

        goto drop;

    rc = 0;

    ipv4_pktinfo_prepare(skb);

    bh_lock_sock(sk);

    if(!sock_owned_by_user(sk))

        rc = __udp_queue_rcv_skb(sk, skb);

    else if(sk_add_backlog(sk, skb, sk->sk_rcvbuf)) {

        bh_unlock_sock(sk);

        goto drop;

    }

    bh_unlock_sock(sk);

    return rc;

}

sock_owned_by_user 判断的是用户是不是正在这个 socker 上进行零碎调用(socket 被占用),如果没有,那就能够间接放到 socket 的接管队列中。如果有,那就通过 sk_add_backlog 把数据包增加到 backlog 队列。

当用户开释的 socket 的时候,内核会查看 backlog 队列,如果有数据再挪动到接管队列中。

sk_rcvqueues_full 接管队列如果满了的话,将间接把包抛弃。接管队列大小受内核参数 net.core.rmem_max 和 net.core.rmem_default 影响。

6、recvfrom 零碎调用

花开两朵,各表一枝。下面咱们说完了整个 Linux 内核对数据包的接管和处理过程,最初把数据包放到 socket 的接管队列中了。那么咱们再回头看用户过程调用 recvfrom 后是产生了什么。

咱们在代码里调用的 recvfrom 是一个 glibc 的库函数,该函数在执行后会将用户进行陷入到内核态,进入到 Linux 实现的零碎调用 sys_recvfrom。

在了解 Linux 对 sys_revvfrom 之前,咱们先来简略看一下 socket 这个外围数据结构。这个数据结构太大了,咱们只把对和咱们明天主题相干的内容画进去。

如下(socket 内核数据机构):

socket 数据结构中的 const struct proto_ops 对应的是协定的办法汇合。每个协定都会实现不同的办法集,对于 IPv4 Internet 协定族来说,每种协定都有对应的解决办法,如下。对于 udp 来说,是通过 inet_dgram_ops 来定义的,其中注册了 inet_recvmsg 办法。

//file: net/ipv4/af_inet.c

const struct proto_ops inet_stream_ops = {

    ……

    .recvmsg       = inet_recvmsg,

    .mmap          = sock_no_mmap,

    ……

}

const struct proto_ops inet_dgram_ops = {

    ……

    .sendmsg       = inet_sendmsg,

    .recvmsg       = inet_recvmsg,

    ……

}

socket 数据结构中的另一个数据结构 struct sock *sk 是一个十分大,十分重要的子结构体。其中的 sk_prot 又定义了二级处理函数。对于 UDP 协定来说,会被设置成 UDP 协定实现的办法集 udp_prot。

//file: net/ipv4/udp.c

struct proto udp_prot = {

    .name          = “UDP”,

    .owner         = THIS_MODULE,

    .close         = udp_lib_close,

    .connect       = ip4_datagram_connect,

    ……

    .sendmsg       = udp_sendmsg,

    .recvmsg       = udp_recvmsg,

    .sendpage      = udp_sendpage,

    ……

}

看完了 socket 变量之后,咱们再来看 sys_revvfrom 的实现过程。

recvfrom 函数外部实现过程:

在 inet_recvmsg 调用了 sk->sk_prot->recvmsg:

//file: net/ipv4/af_inet.c

int inet_recvmsg(struct kiocb iocb, struct socket sock, struct msghdr *msg,size_tsize, int flags){

    ……

    err = sk->sk_prot->recvmsg(iocb, sk, msg, size, flags & MSG_DONTWAIT,

                   flags & ~MSG_DONTWAIT, &addr_len);

    if(err >= 0)

        msg->msg_namelen = addr_len;

    return err;

}

下面咱们说过这个对于 udp 协定的 socket 来说,这个 sk_prot 就是 net/ipv4/udp.c 下的 struct proto udp_prot。由此咱们找到了 udp_recvmsg 办法。

//file:net/core/datagram.c:EXPORT_SYMBOL(__skb_recv_datagram);

struct sk_buff __skb_recv_datagram(struct sock sk, unsigned int flags,intpeeked, int off, int *err){

    ……

    do{

        struct sk_buff_head *queue = &sk->sk_receive_queue;

        skb_queue_walk(queue, skb) {

            ……

        }

        / User doesn’t want to wait /

        error = -EAGAIN;

        if(!timeo)

            goto no_packet;

    } while(!wait_for_more_packets(sk, err, &timeo, last));

}

终于:咱们找到了咱们想要看的重点,在下面咱们看到了所谓的读取过程,就是拜访 sk->sk_receive_queue。如果没有数据,且用户也容许期待,则将调用 wait_for_more_packets() 执行期待操作,它退出会让用户过程进入睡眠状态。

7、本文小结

网络模块是操作系统内核中最简单的模块了,看起来一个简简单单的收包过程就波及到许多内核组件之间的交互,如网卡驱动、协定栈、内核 ksoftirqd 线程等,看起来很简单。本文想通过图示的形式,尽量以容易了解的形式来将内核收包过程讲清楚。

当初让咱们再串一串整个收包过程:当用户执行完 recvfrom 调用后,用户过程就通过零碎调用进行到内核态工作了。如果接管队列没有数据,过程就进入睡眠状态被操作系统挂起。这块绝对比较简单,剩下大部分的戏份都是由 Linux 内核其它模块来表演了。

首先在开始收包之前,操作系统要做许多的筹备工作(以 Linux 为例):

  • 1)创立 ksoftirqd 线程,为它设置好它本人的线程函数,前面指望着它来解决软中断呢;
  • 2)协定栈注册,linux 要实现许多协定,比方 arp,icmp,ip,udp,tcp,每一个协定都会将本人的处理函数注册一下,不便包来了迅速找到对应的处理函数;
  • 3)网卡驱动初始化,每个驱动都有一个初始化函数,内核会让驱动也初始化一下。在这个初始化过程中,把本人的 DMA 筹备好,把 NAPI 的 poll 函数地址通知内核;
  • 4)启动网卡,调配 RX,TX 队列,注册中断对应的处理函数。

以上是内核筹备收包之前的重要工作,当下面都 ready 之后,就能够关上硬中断,期待数据包的到来了。

当数据到来了当前,第一个迎接它的是网卡(我去,这不是废话么):

  • 1)网卡将数据帧 DMA 到内存的 RingBuffer 中,而后向 CPU 发动中断告诉;
  • 2)CPU 响应中断请求,调用网卡启动时注册的中断处理函数;
  • 3)中断处理函数简直没干啥,就发动了软中断请求;
  • 4)内核线程 ksoftirqd 线程发现有软中断请求到来,先敞开硬中断;
  • 5)ksoftirqd 线程开始调用驱动的 poll 函数收包;
  • 6)poll 函数将收到的包送到协定栈注册的 ip_rcv 函数中;
  • 7)ip_rcv 函数再讲包送到 udp_rcv 函数中(对于 tcp 包就送到 tcp_rcv)。

当初,咱们能够回到开篇的问题了:咱们在用户层看到的简略一行 recvfrom,Linux 内核要替咱们做如此之多的工作,能力让咱们顺利收到数据。

这还是简简单单的 UDP,如果是 TCP,内核要做的工作更多,不由得感叹内核的开发者们真的是用心良苦。

了解了整个收包过程当前,咱们就能明确晓得 Linux 收一个包的 CPU 开销了:

  • 1)首先第一块是用户过程调用零碎调用陷入内核态的开销;
  • 2)其次第二块是 CPU 响应包的硬中断的 CPU 开销;
  • 3)接着第三块是 ksoftirqd 内核线程的软中断上下文破费的。

前面咱们再专门发一篇文章理论察看一下这些开销。

另外: 网络收发中有很多末支细节咱们并没有开展了说,比如说:no NAPI,GRO,RPS 等。因为我感觉说的太对了反而会影响大家对整个流程的把握,所以尽量只保留主框架了,少即是多!

附录:更多网络编程精髓文章

如果您感觉本系列文章过于业余,可先浏览《网络编程懒人入门》系列,目录如下:

《网络编程懒人入门 (一):疾速了解网络通信协定(上篇)》

《网络编程懒人入门 (二):疾速了解网络通信协定(下篇)》

《网络编程懒人入门 (三):疾速了解 TCP 协定一篇就够》

《网络编程懒人入门 (四):疾速了解 TCP 和 UDP 的差别》

《网络编程懒人入门 (五):疾速了解为什么说 UDP 有时比 TCP 更有劣势》

《网络编程懒人入门 (六):史上最艰深的集线器、交换机、路由器性能原理入门》

《网络编程懒人入门 (七):深入浅出,全面了解 HTTP 协定》

《网络编程懒人入门 (八):手把手教你写基于 TCP 的 Socket 长连贯》

《网络编程懒人入门 (九):艰深解说,有了 IP 地址,为何还要用 MAC 地址?》

本文已同步公布于“即时通讯技术圈”公众号。

▲ 本文在公众号上的链接是:点此进入,原文链接是:http://www.52im.net/thread-3247-1-1.html

正文完
 0