内核角度来看“网络 IO 模型的演变”以及 “Netty 中的 IO 线程模型”

我们都知道Netty是一个高性能异步事件驱动的网络框架。

它的设计异常优雅简洁,扩展性高,稳定性强。拥有非常详细完整的用户文档。

同时内置了很多非常有用的模块基本上做到了开箱即用,用户只需要编写短短几行代码,就可以快速构建出一个具有 高吞吐 , 低延时 , 更少的资源消耗 , 高性能(非必要的内存拷贝最小化) 等特征的高并发网络应用程序。

本文我们来探讨下支持Netty具有 高吞吐 , 低延时 特征的基石----netty的 网络IO模型 。

由Netty的 网络IO模型 开始,我们来正式揭开本系列Netty源码解析的序幕:

网络包接收流程

网络包收发过程.png

  • 网络数据帧 DMA的方式 环形缓冲区RingBuffer

RingBuffer 是网卡在启动的时候 分配和初始化 的 环形缓冲队列 。当 RingBuffer满 的时候,新来的数据包就会被 丢弃 。我们可以通过 ifconfig 命令查看网卡收发数据包的情况。其中 overruns 数据项表示当 RingBuffer满 时,被 丢弃的数据包 。如果发现出现丢包情况,可以通过 ethtool命令 来增大RingBuffer长度。

  • DMA操作完成 硬中断 CPU 硬中断响应程序 sk_buffer 拷贝 sk_buffer 软中断请求 内核

sk_buff 缓冲区,是一个维护网络帧结构的 双向链表 ,链表中的每一个元素都是一个 网络帧 。虽然 TCP/IP 协议栈分了好几层,但上下不同层之间的传递,实际上只需要操作这个数据结构中的指针,而 无需进行数据复制 。

  • ksoftirqd poll函数 poll函数 sk_buffer 网络数据包 ip_rcv函数

每个CPU 会绑定 一个ksoftirqd 内核线程 专门 用来处理 软中断响应 。2个 CPU 时,就会有 ksoftirqd/0 和 ksoftirqd/1 这两个内核线程。

这里有个事情需要注意下:网卡接收到数据后,当 DMA拷贝完成 时,向CPU发出 硬中断 ,这时 哪个CPU 上响应了这个 硬中断 ,那么在网卡 硬中断响应程序 中发出的 软中断请求 也会在 这个CPU绑定的ksoftirqd线程 中响应。所以如果发现Linux软中断,CPU消耗都 集中在一个核上 的话,那么就需要调整硬中断的 CPU亲和性 ,来将硬中断 打散 到 不通的CPU核 上去。

  • ip_rcv函数 网络层 取出 IP头 TCP UDP 去掉 IP头 传输层

传输层的处理函数: TCP协议 对应内核协议栈中注册的 tcp_rcv函数 , UDP协议 对应内核协议栈中注册的 udp_rcv函数 。

  • 当我们采用的是 TCP协议 时,数据包到达传输层时,会在内核协议栈中的 tcp_rcv函数 处理,在tcp_rcv函数中 去掉 TCP头,根据 四元组(源IP,源端口,目的IP,目的端口) 查找 对应的Socket ,如果找到对应的Socket则将网络数据包中的传输数据拷贝到 Socket 中的 接收缓冲区 中。如果没有找到,则发送一个 目标不可达 的 icmp 包。
  • 内核在接收网络数据包时所做的工作我们就介绍完了,现在我们把视角放到应用层,当我们程序通过系统调用 read 读取 Socket接收缓冲区 中的数据时,如果接收缓冲区中 没有数据 ,那么应用程序就会在系统调用上 阻塞 ,直到Socket接收缓冲区 有数据 ,然后 CPU 将 内核空间 (Socket接收缓冲区)的数据 拷贝 到 用户空间 ,最后系统调用 read返回 ,应用程序 读取 数据。

性能开销

从内核处理网络数据包接收的整个过程来看,内核帮我们做了非常之多的工作,最终我们的应用程序才能读取到网络数据。

随着而来的也带来了很多的性能开销,结合前面介绍的网络数据包接收过程我们来看下网络数据包接收的过程中都有哪些性能开销:

  • 系统调用 用户态 内核态 返回 内核态 用户态
  • 内核空间 CPU拷贝 用户空间
  • 内核线程 ksoftirqd 响应 软中断 的开销。
  • CPU 响应 硬中断 的开销。
  • DMA拷贝 网络数据包到 内存 中的开销。

网络包发送流程

网络包发送过程.png

  • 当我们在应用程序中调用 send 系统调用发送数据时,由于是系统调用所以线程会发生一次用户态到内核态的转换,在内核中首先根据 fd 将真正的Socket找出,这个Socket对象中记录着各种协议栈的函数地址,然后构造 struct msghdr 对象,将用户需要发送的数据全部封装在这个 struct msghdr 结构体中。
  • 调用内核协议栈函数 inet_sendmsg ,发送流程进入内核协议栈处理。在进入到内核协议栈之后,内核会找到Socket上的具体协议的发送函数。

比如:我们使用的是 TCP协议 ,对应的 TCP协议 发送函数是 tcp_sendmsg ,如果是 UDP协议 的话,对应的发送函数为 udp_sendmsg 。

  • TCP协议 tcp_sendmsg sk_buffer struct msghdr 拷贝 sk_buffer tcp_write_queue_tail Socket sk_buffer Socket

Socket 的发送队列是由 sk_buffer 组成的一个 双向链表 。

发送流程走到这里,用户要发送的数据总算是从 用户空间 拷贝到了 内核 中,这时虽然发送数据已经 拷贝 到了内核 Socket 中的 发送队列 中,但并不代表内核会开始发送,因为 TCP协议 的 流量控制 和 拥塞控制 ,用户要发送的数据包 并不一定 会立马被发送出去,需要符合 TCP协议 的发送条件。如果 没有达到发送条件 ,那么本次 send 系统调用就会直接返回。

  • 如果符合发送条件,则开始调用 tcp_write_xmit 内核函数。在这个函数中,会循环获取 Socket 发送队列中待发送的 sk_buffer ,然后进行 拥塞控制 以及 滑动窗口的管理 。
  • 将从 Socket 发送队列中获取到的 sk_buffer 重新 拷贝一份 ,设置 sk_buffer副本 中的 TCP HEADER 。

sk_buffer 内部其实包含了网络协议中所有的 header 。在设置 TCP HEADER 的时候,只是把指针指向 sk_buffer 的合适位置。后面再设置 IP HEADER 的时候,在把指针移动一下就行,避免频繁的内存申请和拷贝,效率很高。

sk_buffer.png

为什么不直接使用 Socket 发送队列中的 sk_buffer 而是需要拷贝一份呢? 因为 TCP协议 是支持 丢包重传 的,在没有收到对端的 ACK 之前,这个 sk_buffer 是不能删除的。内核每次调用网卡发送数据的时候,实际上传递的是 sk_buffer 的 拷贝副本 ,当网卡把数据发送出去后, sk_buffer 拷贝副本会被释放。当收到对端的 ACK 之后, Socket 发送队列中的 sk_buffer 才会被真正删除。

  • 当设置完 TCP头 后,内核协议栈 传输层 的事情就做完了,下面通过调用 ip_queue_xmit 内核函数,正式来到内核协议栈 网络层 的处理。
    通过 route 命令可以查看本机路由配置。
    如果你使用 iptables 配置了一些规则,那么这里将检测 是否命中 规则。如果你设置了非常 复杂的 netfilter 规则 ,在这个函数里将会导致你的线程 CPU 开销 会 极大增加 。
    • 将 sk_buffer 中的指针移动到 IP头 位置上,设置 IP头 。
    • 执行 netfilters 过滤。过滤通过之后,如果数据大于 MTU 的话,则执行分片。
    • Socket Socket sk_buffer
  • 内核协议栈 网络层 的事情处理完后,现在发送流程进入了到了 邻居子系统 , 邻居子系统 位于内核协议栈中的 网络层 和 网络接口层 之间,用于发送 ARP请求 获取 MAC地址 ,然后将 sk_buffer 中的指针移动到 MAC头 位置,填充 MAC头 。
  • 经过 邻居子系统 的处理,现在 sk_buffer 中已经封装了一个完整的 数据帧 ,随后内核将 sk_buffer 交给 网络设备子系统 进行处理。 网络设备子系统 主要做以下几项事情:
    • 选择发送队列( RingBuffer )。因为网卡拥有多个发送队列,所以在发送前需要选择一个发送队列。
    • 将 sk_buffer 添加到发送队列中。
    • RingBuffer sk_buffer sch_direct_xmit 网卡驱动程序

以上过程全部是用户线程的内核态在执行,占用的CPU时间是系统态时间( sy ),当分配给用户线程的 CPU quota 用完的时候,会触发 NET_TX_SOFTIRQ 类型的软中断,内核线程 ksoftirqd 会响应这个软中断,并执行 NET_TX_SOFTIRQ 类型的软中断注册的回调函数 net_tx_action ,在回调函数中会执行到驱动程序函数 dev_hard_start_xmit 来发送数据。

注意:当触发 NET_TX_SOFTIRQ 软中断来发送数据时,后边消耗的 CPU 就都显示在 si 这里了,不会消耗用户进程的系统态时间( sy )了。

从这里可以看到网络包的发送过程和接受过程是不同的,在介绍网络包的接受过程时,我们提到是通过触发 NET_RX_SOFTIRQ 类型的软中断在内核线程 ksoftirqd 中执行 内核网络协议栈 接受数据。而在网络数据包的发送过程中是 用户线程的内核态 在执行 内核网络协议栈 ,只有当线程的 CPU quota 用尽时,才触发 NET_TX_SOFTIRQ 软中断来发送数据。

在整个网络包的发送和接受过程中, NET_TX_SOFTIRQ 类型的软中断只会在发送网络包时并且当用户线程的 CPU quota 用尽时,才会触发。剩下的接受过程中触发的软中断类型以及发送完数据触发的软中断类型均为 NET_RX_SOFTIRQ 。所以这就是你在服务器上查看 /proc/softirqs ,一般 NET_RX 都要比 NET_TX 大很多的的原因。

  • 现在发送流程终于到了网卡真实发送数据的阶段,前边我们讲到无论是用户线程的内核态还是触发 NET_TX_SOFTIRQ 类型的软中断在发送数据的时候最终会调用到网卡的驱动程序函数 dev_hard_start_xmit 来发送数据。在网卡驱动程序函数 dev_hard_start_xmit 中会将 sk_buffer 映射到网卡可访问的 内存 DMA 区域 ,最终网卡驱动程序通过 DMA 的方式将 数据帧 通过物理网卡发送出去。
  • 当数据发送完毕后,还有最后一项重要的工作,就是清理工作。数据发送完毕后,网卡设备会向 CPU 发送一个硬中断, CPU 调用网卡驱动程序注册的 硬中断响应程序 ,在硬中断响应中触发 NET_RX_SOFTIRQ 类型的软中断,在软中断的回调函数 igb_poll 中清理释放 sk_buffer ,清理 网卡 发送队列( RingBuffer ),解除 DMA 映射。

无论 硬中断 是因为 有数据要接收 ,还是说 发送完成通知 ,从硬中断触发的软中断都是 NET_RX_SOFTIRQ 。

这里释放清理的只是 sk_buffer 的副本,真正的 sk_buffer 现在还是存放在 Socket 的发送队列中。前面在 传输层 处理的时候我们提到过,因为传输层需要 保证可靠性 ,所以 sk_buffer 其实还没有删除。它得等收到对方的 ACK 之后才会真正删除。

性能开销

前边我们提到了在网络包接收过程中涉及到的性能开销,现在介绍完了网络包的发送过程,我们来看下在数据包发送过程中的性能开销:

  • 和接收数据一样,应用程序在调用 系统调用send 的时候会从 用户态 转为 内核态 以及发送完数据后, 系统调用 返回时从 内核态 转为 用户态 的开销。
  • 用户线程内核态 CPU quota 用尽时触发 NET_TX_SOFTIRQ 类型软中断,内核响应软中断的开销。
  • 网卡发送完数据,向 CPU 发送硬中断, CPU 响应硬中断的开销。以及在硬中断中发送 NET_RX_SOFTIRQ 软中断执行具体的内存清理动作。内核响应软中断的开销。
  • 内存拷贝的开销。我们来回顾下在数据包发送的过程中都发生了哪些内存拷贝:
    • TCP协议 tcp_sendmsg sk_buffer 拷贝 sk_buffer
    • 拷贝 sk_buffer副本 sk_buffer副本 sk_buffer Socket ACK ACK Socket sk_buffer ACK Socket TCP协议
    • MTU sk_buffer 拷贝

再谈(阻塞,非阻塞)与(同步,异步)

在我们聊完网络数据的接收和发送过程后,我们来谈下IO中特别容易混淆的概念: 阻塞与同步 , 非阻塞与异步 。

网上各种博文还有各种书籍中有大量的关于这两个概念的解释,但是笔者觉得还是不够形象化,只是对概念的生硬解释,如果硬套概念的话,其实感觉 阻塞与同步 , 非阻塞与异步 还是没啥区别,时间长了,还是比较模糊容易混淆。

所以笔者在这里尝试换一种更加形象化,更加容易理解记忆的方式来清晰地解释下什么是 阻塞与非阻塞 ,什么是 同步与异步 。

经过前边对网络数据包接收流程的介绍,在这里我们可以将整个流程总结为两个阶段:

数据接收阶段.png

  • 数据准备阶段:在这个阶段,网络数据包到达网卡,通过 DMA 的方式将数据包拷贝到内存中,然后经过硬中断,软中断,接着通过内核线程 ksoftirqd 经过内核协议栈的处理,最终将数据发送到 内核Socket 的接收缓冲区中。
  • 数据拷贝阶段:当数据到达 内核Socket 的接收缓冲区中时,此时数据存在于 内核空间 中,需要将数据 拷贝 到 用户空间 中,才能够被应用程序读取。

阻塞与非阻塞

阻塞与非阻塞的区别主要发生在第一阶段: 数据准备阶段 。

当应用程序发起 系统调用read 时,线程从用户态转为内核态,读取内核 Socket 的接收缓冲区中的网络数据。

阻塞

如果这时内核 Socket 的接收缓冲区没有数据,那么线程就会一直 等待 ,直到 Socket 接收缓冲区有数据为止。随后将数据从内核空间拷贝到用户空间, 系统调用read 返回。

阻塞IO.png

从图中我们可以看出: 阻塞 的特点是在第一阶段和第二阶段 都会等待 。

非阻塞

阻塞 和 非阻塞 主要的区分是在第一阶段: 数据准备阶段 。

  • 在第一阶段,当 Socket 的接收缓冲区中没有数据的时候, 阻塞模式下 应用线程会一直等待。 非阻塞模式下 应用线程不会等待, 系统调用 直接返回错误标志 EWOULDBLOCK 。
  • 当 Socket 的接收缓冲区中有数据的时候, 阻塞 和 非阻塞 的表现是一样的,都会进入第二阶段 等待 数据从 内核空间 拷贝到 用户空间 ,然后 系统调用返回 。

非阻塞IO.png

从上图中,我们可以看出: 非阻塞 的特点是第一阶段 不会等待 ,但是在第二阶段还是会 等待 。

同步与异步

同步 与 异步 主要的区别发生在第二阶段: 数据拷贝阶段 。

前边我们提到在 数据拷贝阶段 主要是将数据从 内核空间 拷贝到 用户空间 。然后应用程序才可以读取数据。

当内核 Socket 的接收缓冲区有数据到达时,进入第二阶段。

同步

同步模式 在数据准备好后,是由 用户线程 的 内核态 来执行 第二阶段 。所以应用程序会在第二阶段发生 阻塞 ,直到数据从 内核空间 拷贝到 用户空间 ,系统调用才会返回。

Linux下的 epoll 和Mac 下的 kqueue 都属于 同步 IO 。

同步IO.png

异步

异步模式 下是由 内核 来执行第二阶段的数据拷贝操作,当 内核 执行完第二阶段,会通知用户线程IO操作已经完成,并将数据回调给用户线程。所以在 异步模式 下 数据准备阶段 和 数据拷贝阶段 均是由 内核 来完成,不会对应用程序造成任何阻塞。

基于以上特征,我

评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值