第五章 深度理解本机网络IO

知识星球与交流群

在知识星球中我们会进行内核等底层技术的视频讲解,能让你的底层学起来更快,事半功倍。还会进行线上问题排查以及性能优化等方面的案例分享和交流。对大家技术深度和广度的积累很有好处。有想继续加入知识星球的同学微信扫描下面的二维码即可加入。另外在公众号后台发送「星球优惠券」可以获取开发内功修炼读者的专属优惠券。

5.1 相关实际问题

我们在前面的章节里深度分析了网络包的接收,也拆分了网络包的发送,总之收发流程算是闭环了。不过我们还有一种特殊的情况没有讨论,那就是接收和发送都在本机进行。而且实践中这种本机网络IO出现的场景还不少,而且还有越来越多的趋势。例如LNMP技术栈中的nginx和php-fpm进程就是通过本机来通信的,还有就是最近流行的微服务中sidecar模式也是本机网络IO。

所以,我想如果能深度理解这个问题在实践中将非常的有意义。按照习惯,我们还是从几个实际中的问题来作为引入。

  1. 127.0.0.1本机网络IO需要经过网卡吗? 在跨机网络IO中,数据包肯定都是要经过网卡发送出去的。那么,本机网络IO的情况下,收发数据需要经过网卡吗?如果把网卡拔了,127.0.0.1上数据收发能否正常工作?

  2. 和外网网络通信相比,在内核收发流程上有什么差别? 假如说本机网络IO和跨机IO收发流程上不一样,那么是在哪几个环节上不同呢?

  3. 访问本机Server时,使用127.0.0.1能比使用本机ip(例如192.168.x.x)更快吗?

图5.1 数据发送流程(对应原文中图像占位符[Image 17 on Page 128])

实际上,使用本机IO通信的时候也有两种方法。一种方法是用127.0.0.1,一种方法是使用本机IP,例如192.168.x.x这种。那么这两种方法在性能上会有什么差异吗?那种方法性能更好呢?

铺垫完毕,拆解正式开始!!

5.2 跨机网络通信过程

在开始讲述本机通信过程之前,我们还是先回顾一下跨机网络通信。

5.2.1 跨机数据发送

在第四章中,我们介绍了数据包的发送过程。如图5.1,从send系统调用开始,直到网卡把数据发送出去。

图5.1 数据发送流程(对应原文中图像占位符[Image 17 on Page 128]) 描述:从用户态开始,经过send系统调用,数据拷贝到内核态,经过协议栈处理,进入RingBuffer,最后网卡驱动发送数据。

如图所示,用户数据被拷贝到内核态,然后经过协议栈处理后进入到了RingBuffer中。随后网卡驱动真正将数据发送了出去。当发送完成的时候,是通过硬中断来通知CPU,然后清理RingBuffer。从代码的视角的流程如图5.2。

图5.2 数据发送源码(对应原文中图像占位符[Image 390 on Page 129])

等网络发送完毕之后。网卡在发送完毕的时候,会给CPU发送一个硬中断来通知CPU。收到这个硬中断后会释放RingBuffer中使用的内存,如图5.3。

图5.3 RingBuffer清理(对应原文中图像占位符[Image 391 on Page 129])

5.2.2 跨机数据接收

在第二章中,我们介绍了数据接收过程。当数据包到达另外一台机器的时候,Linux数据包的接收过程开始了,如图5.4。

图5.4 接收过程(对应原文中图像占位符[Image 391 on Page 130]) 描述:网卡接收数据,发起硬中断,CPU调用驱动中断处理函数,触发软中断,ksoftirqd轮询收包,协议栈处理,数据放入接收队列,唤醒用户进程。

当网卡收到数据以后,向CPU发起一个中断,以通知CPU有数据到达。当CPU收到中断请求后,会去调用网络驱动注册的中断处理函数,触发软中断。ksoftirqd检测到有软中断请求到达,开始轮询收包,收到后交由各级协议栈处理。当协议栈处理完并把数据放到接收队列的之后,唤醒用户进程(假设是阻塞方式)。

我们再同样从内核组件和源码视角看一遍,如图5.5。

图5.5 数据接收源码(对应原文中图像占位符[Image 391 on Page 131])

图5.6 单次跨机网络通信过程(对应原文中图像占位符[Image 391 on Page 132])

5.2.3 跨机网络通信汇总

那么汇总起来,一次跨机网络通信的过程就如图5.6。

图5.6 单次跨机网络通信过程(对应原文中图像占位符[Image 391 on Page 132]) 描述:展示从发送端用户进程到接收端用户进程的完整路径,包括send系统调用、协议栈、网卡发送、网络传输、网卡接收、协议栈、recv系统调用。

知识星球推广

在知识星球中我们会进行内核等底层技术的视频串讲,还会进行线上问题排查以及性能优化等方面的交流。对大家技术深度和广度的积累很有好处。有想继续加入知识星球的同学微信扫描下面的二维码即可加入。另外在公众号后台发送「星球优惠券」可以获取开发内功修炼读者的专属优惠券。

5.3 本机发送过程

在上面小节中,我们看到了跨机时整个网络发送过程在本机网络IO的过程中,流程会有一些差别。为了突出重点,将不再介绍整体流程,而是只介绍和跨机逻辑不同的地方。有差异的地方总共有两个,分别是路由和驱动程序。

5.3.1 网络层路由

发送数据会进入协议栈到网络层的时候,网络层入口函数是ip_queue_xmit。在网络层里会进行路由选择,路由选择完毕后,再设置一些IP头、进行一些netfilter的过滤后,将包交给邻居子系统。网络层工作流程如图5.7。

图5.7 网络层路由(对应原文中图像占位符[Image 404 on Page 132])

对于本机网络IO来说,特殊之处在于在local路由表中就能找到路由项,对应的设备都将使用loopback网卡,也就是我们常见的lo

我们来详细看看路由网络层里这段路由相关工作过程。从网络层入口函数ip_queue_xmit看起。

查找路由项的函数是ip_route_output_ports,它又依次调用到ip_route_output_flow__ip_route_output_keyfib_lookup。调用过程省略掉,直接看fib_lookup的关键代码。

// file: net/ipv4/ip_output.c
int ip_queue_xmit(struct sk_buff *skb, struct flowi *fl)
{
    // 检查 socket 中是否有缓存的路由表
    rt = (struct rtable *)__sk_dst_check(sk, 0);
    if (rt == NULL) {
        // 没有缓存则展开查找
        // 则查找路由项,并缓存到 socket 中
        rt = ip_route_output_ports(...);
        sk_setup_caps(sk, &rt->dst);
    }
    ...
}
// file: include/net/ip_fib.h
static inline int fib_lookup(struct net *net, const struct flowi4 *flp,
                             struct fib_result *res)
{
    struct fib_table *table;
 
    table = fib_get_table(net, RT_TABLE_LOCAL);
    if (!fib_table_lookup(table, flp, res, FIB_LOOKUP_NOREF))
        return 0;
 
    table = fib_get_table(net, RT_TABLE_MAIN);
    if (!fib_table_lookup(table, flp, res, FIB_LOOKUP_NOREF))
        return 0;
 
    return -ENETUNREACH;
}

fib_lookup将会对local和main两个路由表展开查询,并且是先查local后查询main。我们在Linux上使用命令名可以查看到这两个路由表,这里只看local路由表(因为本机网络IO查询到这个表就终止了)。

# ip route list table local
local 10.143.x.y dev eth0 proto kernel scope host src 10.143.x.y
local 127.0.0.1 dev lo proto kernel scope host src 127.0.0.1

从上述结果可以看出,对于目的是127.0.0.1的路由在local路由表中就能够找到了。fib_lookup工作完成,返回__ip_route_output_key继续。

// file: net/ipv4/route.c
struct rtable *__ip_route_output_key(struct net *net, struct flowi4 *fl4)
{
    if (fib_lookup(net, fl4, &res)) {
    }
    if (res.type == RTN_LOCAL) {
        dev_out = net->loopback_dev;
        ...
    }
    rth = __mkroute_output(&res, fl4, orig_oif, dev_out, flags);
    return rth;
}

对于是本机的网络请求,设备将全部都使用net->loopback_dev,也就是lo虚拟网卡。

接下来的网络层仍然和跨机网络IO一样,最终会经过ip_finish_output,最终进入到邻居子系统的入口函数dst_neigh_output中。

本机网络IO需要进行IP分片吗?

因为和正常的网络层处理过程一样会经过ip_finish_output函数。在这个函数中,如果skb大于MTU的话,仍然会进行分片。只不过lo的MTU比Ethernet要大很多。通过ifconfig命令就可以查到,普通网卡一般为1500,而lo虚拟接口能有65535。

在邻居子系统函数中经过处理,进入到网络设备子系统(入口函数是dev_queue_xmit)。

5.3.2 本机IP路由

开篇我们提到的第三个问题的答案就在前面的网络层路由一小节中。但这个问题描述起来有点长,因此单独拉一小节出来。

问题:用本机ip(例如192.168.x.x)和用127.0.0.1性能上有差别吗?

前面看到选用哪个设备是路由相关函数__ip_route_output_key中确定的。

这里会查询到local路由表。

# ip route list table local
local 10.162.*.* dev eth0  proto kernel  scope host  src 10.162.*.*
local 127.0.0.1 dev lo  proto kernel  scope host  src 127.0.0.1

很多人看到这个路由表的时候就被它给迷惑了,以为上面10.162..真的会被路由到eth0(其中10.162..是我的本机局域网IP,我把后面两段用*号隐藏起来了)。

但其实内核在初始化local路由表的时候,把local路由表里所有的路由项都设置成了RTN_LOCAL,不仅仅只是127.0.0.1。这个过程是在设置本机ip的时候,调用fib_inetaddr_event函数完成设置的。

// file: net/ipv4/route.c
struct rtable *__ip_route_output_key(struct net *net, struct flowi4 *fl4)
{
    if (fib_lookup(net, fl4, &res)) {
    }
    if (res.type == RTN_LOCAL) {
        dev_out = net->loopback_dev;
        ...
    }
    rth = __mkroute_output(&res, fl4, orig_oif, dev_out, flags);
    return rth;
}
// file: ipv4/fib_frontend.c
static int fib_inetaddr_event(struct notifier_block *this,
    unsigned long event, void *ptr)
{
    switch (event) {
    case NETDEV_UP:
        fib_add_ifaddr(ifa);
        break;
    case NETDEV_DOWN:
        fib_del_ifaddr(ifa, NULL);
        ...
    }
}
 
void fib_add_ifaddr(struct in_ifaddr *ifa)
{
    fib_magic(RTM_NEWROUTE, RTN_LOCAL, addr, 32, prim);
}

所以即使本机IP,不用127.0.0.1,内核在路由项查找的时候判断类型是RTN_LOCAL,仍然会使用net->loopback_dev。也就是lo虚拟网卡。

为了稳妥起见,飞哥再抓包确认一下。开启两个控制台窗口,一个对lo设备进行抓包。因为局域网内会有大量的网络请求,为了方便过滤,这里使用一个特殊的端口号8888。如果这个端口号在你的机器上占用了,那需要再换一个。

# tcpdump -i eth0 port 8888

另外一个窗口使用telnet对本机IP端口发出几条网络请求。

# telnet 10.162.*.* 8888
Trying 10.162.129.56...
telnet: connect to address 10.162.129.56: Connection refused

这时候切换回第一个控制台,发现啥反应都没有。说明包根本就没有过eth0这个设备。

再把设备换成lo再抓。

# tcpdump -i lo port 8888
tcpdump: verbose output suppressed, use -v or -vv for full protocol decode
listening on lo, link-type EN10MB (Ethernet), capture size 65535 bytes
08:22:31.956702 IP 10.162.*.*.62705 > 10.162.*.*.ddi-tcp-1: Flags [S], seq 678725385,
win 43690, options [mss 65495,nop,wscale 8], length 0
08:22:31.956720 IP 10.162.*.*.ddi-tcp-1 > 10.162.*.*.62705: Flags [R.], seq 0, ack
678725386, win 0, length 0

当telnet发出网络请求以后,在tcpdump所在的窗口下看到了抓包结果。

5.3.3 网络设备子系统

网络设备子系统的入口函数是dev_queue_xmit。简单回忆下之前讲述跨机发送过程的时候,对于真的有队列的物理设备,在该函数中进行了一系列复杂的排队等处理以后,才调用dev_hard_start_xmit,从这个函数再进入驱动程序来发送。在这个过程中,甚至还有可能会触发软中断来进行发送,流程如图5.8。

图5.8 物理网卡设备数据发送(对应原文中图像占位符[Image 562 on Page 135]) 描述:数据包经过dev_queue_xmit,进入队列(qdisc),排队处理,然后调用dev_hard_start_xmit,进入驱动程序发送,涉及软中断触发。

但是对于启动状态的回环设备来说(q->enqueue判断为false),就简单多了。没有队列的问题,直接进入dev_hard_start_xmit。接着进入回环设备的”驱动”里的发送回调函数loopback_xmit,将skb”发送”出去,如图5.9。

图5.9 回环设备数据发送(对应原文中图像占位符[Image 562 on Page 135]) 描述:数据包经过dev_queue_xmit,判断回环设备无队列,直接调用dev_hard_start_xmit,再调用loopback_xmit。

我们来看下详细的过程,从网络设备子系统的入口dev_queue_xmit看起。

// file: net/core/dev.c
int dev_queue_xmit(struct sk_buff *skb)
{
    q = rcu_dereference_bh(txq->qdisc);
    if (q->enqueue) {   // 回环设备这里为 false
        rc = __dev_xmit_skb(skb, q, dev, txq);
        goto out;
    }
    // 开始回环设备处理
    if (dev->flags & IFF_UP) {
        dev_hard_start_xmit(skb, dev, txq, ...);
        ...
    }
}

dev_hard_start_xmit中还是将调用设备驱动的操作函数。

// 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;
    // 调用驱动的 ndo_start_xmit 来进行发送
    rc = ops->ndo_start_xmit(skb, dev);
    ...
}

图5.10 回环设备驱动(对应原文中图像占位符[Image 570 on Page 136])

5.3.4 “驱动”程序

对于真实的igb网卡来说,它的驱动代码都在drivers/net/ethernet/intel/igb/igb_main.c文件里。顺着这个路子,我找到了loopback设备的”驱动”代码位置,在drivers/net/loopback.c

// file: drivers/net/loopback.c
static const struct net_device_ops loopback_ops = {
    .ndo_init       = loopback_dev_init,
    .ndo_start_xmit = loopback_xmit,
    .ndo_get_stats64 = loopback_get_stats64,
};

所以对dev_hard_start_xmit调用实际上执行的是loopback”驱动”里的loopback_xmit。为什么我把”驱动”加个引号呢,因为loopback是一个纯软件性质的虚拟接口,并没有真正意义上的驱动。

// file: drivers/net/loopback.c
static netdev_tx_t loopback_xmit(struct sk_buff *skb,
         struct net_device *dev)
{
    // 剥离掉和原 socket 的联系
    skb_orphan(skb);
    // 调用 netif_rx
    if (likely(netif_rx(skb) == NET_RX_SUCCESS)) {
    }
    ...
}

skb_orphan中先是把skb上的socket指针去掉了(剥离了出来)。

本机IO的skb释放优化

注意,在本机网络IO发送的过程中,传输层下面的skb就不需要释放了,直接给接收方传过去就行了。总算是省了一点点开销。不过可惜传输层的skb同样节约不了,还是得频繁地申请和释放。

接着调用netif_rx,在该方法中最终会执行到enqueue_to_backlog中(netif_rx netif_rx_internal enqueue_to_backlog)。

// file: net/core/dev.c
static int enqueue_to_backlog(struct sk_buff *skb, int cpu,
                              unsigned int *qtail)
{
    sd = &per_cpu(softnet_data, cpu);
    ...
    __skb_queue_tail(&sd->input_pkt_queue, skb);
    ...
    ____napi_schedule(sd, &sd->backlog);
    ...
}
 
// file: 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);
}

enqueue_to_backlog把要发送的skb插入softnet_data->input_pkt_queue队列中并调用napi_schedule来触发软中断。

只有触发完软中断,发送过程就算是完成了。

5.4 本机接收过程

发送过程触发软中断完毕以后,会进入软中断处理函数net_rx_action,如图5.11。

图5.11 数据接收(对应原文中图像占位符[Image 579 on Page 138]) 描述:软中断触发后,进入net_rx_action处理,从input_pkt_queue队列取出skb,送往协议栈。

在跨机的网络包的接收过程中,需要经过硬中断,然后才能触发软中断。而在本机的网络IO过程中,由于并不真的过网卡,所以网卡实际传输,硬中断就都省去了。直接从软中断开始。

在软中断被触发以后,会进入到NET_RX_SOFTIRQ对应的处理方法net_rx_action中(至于细节参见第2章中的ksoftirqd内核线程处理软中断小节)。

// file: net/core/dev.c
static void net_rx_action(struct softirq_action *h)
{
    while (!list_empty(&sd->poll_list)) {
        work = n->poll(n, weight);
    }
    ...
}

我们还记得对于igb网卡来说,poll实际调用的是igb_poll函数。那么loopback网卡的poll函数是谁呢?由于poll_list里面是struct softnet_data对象,我们在net_dev_init中找到了蛛丝马迹。

// file: net/core/dev.c
static int __init net_dev_init(void)
{
    for_each_possible_cpu(i) {
        sd->backlog.poll = process_backlog;
    }
    ...
}

原来struct softnet_data默认的poll在初始化的时候设置成了process_backlog函数,来看看它都干了啥。

static int process_backlog(struct napi_struct *napi, int quota)
{
    while () {
        while ((skb = __skb_dequeue(&sd->process_queue))) {
            __netif_receive_skb(skb);
        }
        // skb_queue_splice_tail_init()函数用于将链表a连接到链表b上,
        // 形成一个新的链表b,并将原来a的头变成空链表.
        qlen = skb_queue_len(&sd->input_pkt_queue);
        if (qlen)
            skb_queue_splice_tail_init(&sd->input_pkt_queue,
                                       &sd->process_queue);
    }
    ...
}

这次先看对skb_queue_splice_tail_init的调用。源码就不看了,直接说它的作用是把sd->input_pkt_queue里的skb链到sd->process_queue链表上去。

然后再看__skb_dequeue__skb_dequeue是从sd->process_queue上取下来包来处理。这样和前面发送过程的结尾处就对上了,发送过程是把包放到了input_pkt_queue队列里。

图5.12 队列执行(对应原文中图像占位符[Image 594 on Page 142]) 描述:展示input_pkt_queue队列和process_queue队列之间的数据转移与处理过程。

最后调用__netif_receive_skb将数据送往协议栈。在此之后的调用过程就和跨机网络IO又一致了。送往协议栈的调用链是__netif_receive_skb __netif_receive_skb_core deliver_skb后将数据包送入到ip_rcv中(参见第2章)。网络层再往后依次是传输层,最后唤醒用户进程。

5.5 本章总结

我们来总结一下本机网络IO的内核执行流程,总体流程如图5.13。

图5.13 本机网络IO过程(对应原文中图像占位符[Image 623 on Page 148]) 描述:展示本机网络IO的完整数据流:用户进程 send系统调用 协议栈 路由(lo设备) loopback_xmit enqueue_to_backlog 触发软中断 net_rx_action process_backlog __netif_receive_skb 协议栈 唤醒接收进程。整个过程无需经过物理网卡和硬中断。

阶段跨机网络IO本机网络IO
网卡经过物理网卡(eth0等)经过loopback虚拟网卡(lo)
中断需要硬中断无需硬中断,直接触发软中断
路由main路由表local路由表
队列需要qdisc排队无需排队(qenqueue=false)
驱动真实网卡驱动(如igb)虚拟驱动(loopback_xmit)
skb释放需等待网卡发送完毕释放直接传给接收方,省去一次释放

性能建议

使用127.0.0.1和使用本机IP(如192.168.x.x)在内核处理上没有性能差异,因为两者最终都会路由到lo设备。两者的区别仅在于路由表查找时的匹配项不同,但最终都会走lo设备的loopback_xmit路径。

欢迎加入我的知识星球,也欢迎加入我的技术交流群 Github:https://github.com/yanfeizhang/coder-kung-fu