聊聊Netty那些事儿之从内核角度看IO模型
网络包接收流程

-
当
网络数据帧通过网络传输到达网卡时,网卡会将网络数据帧通过DMA的方式放到环形缓冲区RingBuffer中。RingBuffer是网卡在启动的时候分配和初始化的环形缓冲队列。当RingBuffer满的时候,新来的数据包就会被丢弃 -
当
DMA操作完成时,网卡会向CPU发起一个硬中断,告诉CPU有网络数据到达。CPU调用网卡驱动注册的硬中断响应程序。网卡硬中断响应程序会为网络数据帧创建内核数据结构sk_buffer,并将网络数据帧拷贝到sk_buffer中。然后发起软中断请求,通知内核有新的网络数据帧到达。 -
sk_buff缓冲区,是一个维护网络帧结构的双向链表,链表中的每一个元素都是一个网络帧 -
内核线程
ksoftirqd发现有软中断请求到来,随后调用网卡驱动注册的poll函数,poll函数将sk_buffer中的网络数据包送到内核协议栈中注册的ip_rcv函数中。每个CPU会绑定一个ksoftirqd内核线程专门用来处理软中断响应。2个 CPU 时,就会有两个ksoftirpd两个内核线程。 -
在
ip_rcv函数中也就是上图中的网络层,取出数据包的IP头,判断该数据包下一跳的走向,如果数据包是发送给本机的,则取出传输层的协议类型(TCP或者UDP),并去掉数据包的IP头,将数据包交给上图中得传输层处理。 -
当我们采用的是
TCP协议时,数据包到达传输层时,会在内核协议栈中的tcp_rcv函数处理,在tcp_rcv函数中去掉TCP头,根据四元组(源IP,源端口,目的IP,目的端口)查找对应的Socket,如果找到对应的Socket则将网络数据包中的传输数据拷贝到Socket中的接收缓冲区中。如果没有找到,则发送一个目标不可达的icmp包。 -
内核在接收网络数据包时所做的工作我们就介绍完了,现在我们把视角放到应用层,当我们程序通过系统调用
read读取Socket接收缓冲区中的数据时,如果接收缓冲区中没有数据,那么应用程序就会在系统调用上阻塞,直到Socket接收缓冲区有数据,然后CPU将内核空间(Socket接收缓冲区)的数据拷贝到用户空间,最后系统调用read返回,应用程序读取数据。
性能开销
-
应用程序通过
系统调用从用户态转为内核态的开销以及系统调用返回时从内核态转为用户态的开销。 -
网络数据从
内核空间通过CPU拷贝到用户空间的开销。 -
内核线程
ksoftirqd响应软中断的开销。 -
CPU响应硬中断的开销。 -
DMA拷贝网络数据包到内存中的开销
网络包发送流程


性能开销:
-
和接收数据一样,应用程序在调用
系统调用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,并将原来的sk_buffer拷贝到多个小的sk_buffer中。
-
阻塞与非阻塞模型
经过前边对网络数据包接收流程的介绍,在这里我们可以将整个流程总结为两个阶段:

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


同步与异步
同步与异步主要的区别发生在第二阶段:数据拷贝阶段。
前边我们提到在数据拷贝阶段主要是将数据从内核空间拷贝到用户空间。然后应用程序才可以读取数据。当内核Socket的接收缓冲区有数据到达时,进入第二阶段。
同步模式在数据准备好后,是由用户线程的内核态来执行第二阶段。所以应用程序会在第二阶段发生阻塞,直到数据从内核空间拷贝到用户空间,系统调用才会返回。Linux下的 epoll和Mac 下的 kqueue都属于同步 IO。

异步模式下是由内核来执行第二阶段的数据拷贝操作,当内核执行完第二阶段,会通知用户线程IO操作已经完成,并将数据回调给用户线程。所以在异步模式下 数据准备阶段和数据拷贝阶段均是由内核来完成,不会对应用程序造成任何阻塞。
基于以上特征,我们可以看到异步模式需要内核的支持,比较依赖操作系统底层的支持。

IO多路复用
-
多路:我们的核心需求是要用尽可能少的线程来处理尽可能多的连接,这里的
多路指的就是我们需要处理的众多连接。 -
复用:核心需求要求我们使用
尽可能少的线程,尽可能少的系统开销去处理尽可能多的连接(多路),那么这里的复用指的就是用有限的资源,比如用一个线程或者固定数量的线程去处理众多连接上的读写事件。换句话说,在阻塞IO模型中一个连接就需要分配一个独立的线程去专门处理这个连接上的读写,到了IO多路复用模型中,多个连接可以复用这一个独立的线程去处理这多个连接上的读写。
IO多路复用(阻塞IO,非阻塞IO,select,poll,epoll)_量子学习法的博客-CSDN博客
深入理解epoll

其中进程内打开的所有文件是通过一个数组fd_array来进行组织管理,数组的下标即为我们常提到的文件描述符,数组中存放的是对应的文件数据结构struct file。每打开一个文件,内核都会创建一个struct file与之对应,并在fd_array中找到一个空闲位置分配给它,数组中对应的下标,就是我们在用户空间用到的文件描述符.
对于任何一个进程,默认情况下,文件描述符
0表示stdin 标准输入,文件描述符1表示stdout 标准输出,文件描述符2表示stderr 标准错误输出。
-
当我们调用
accept后,内核会基于监听Socket创建出来一个新的Socket专门用于与客户端之间的网络通信。并将监听Socket中的Socket操作函数集合(inet_stream_ops)ops赋值到新的Socket的ops属性中。 -
接着内核会为
已连接的Socket创建struct file并初始化,并把Socket文件操作函数集合(socket_file_ops)赋值给struct file中的f_ops指针。然后将struct socket中的file指针指向这个新分配申请的struct file结构体。 -
然后调用
socket->ops->accept,从Socket内核结构图中我们可以看到其实调用的是inet_accept,该函数会在icsk_accept_queue中查找是否有已经建立好的连接,如果有的话,直接从icsk_accept_queue中获取已经创建好的struct sock。并将这个struct sock对象赋值给struct socket中的sock指针。 -
struct sock在struct socket中是一个非常核心的内核对象,正是在这里定义了我们在介绍网络包的接收发送流程中提到的接收队列,发送队列,等待队列,数据就绪回调函数指针,内核协议栈操作函数集合

之前提到的对Socket发起的系统IO调用,在内核中首先会调用Socket的文件结构struct file中的file_operations文件操作集合,然后调用struct socket中的ops指向的inet_stream_opssocket操作函数,最终调用到struct sock中sk_prot指针指向的tcp_prot内核协议栈操作函数接口集合。
本小节我们就来看下用户进程是如何阻塞在Socket上,又是如何在Socket上被唤醒的。理解这个过程很重要,对我们理解epoll的事件通知过程很有帮助

-
首先我们在用户进程中对
Socket进行read系统调用时,用户进程会从用户态转为内核态。 -
在进程的
struct task_struct结构找到fd_array,并根据Socket的文件描述符fd找到对应的struct file,调用struct file中的文件操作函数结合file_operations,read系统调用对应的是sock_read_iter。 -
在
sock_read_iter函数中找到struct file指向的struct socket,并调用socket->ops->recvmsg,这里我们知道调用的是inet_stream_ops集合中定义的inet_recvmsg。 -
在
inet_recvmsg中会找到struct sock,并调用sock->skprot->recvmsg,这里调用的是tcp_prot集合中定义的tcp_recvmsg函数。 -

epoll_create创建epoll对象

-
epoll中的等待队列,队列里存放的是
阻塞在epoll上的用户进程。在IO就绪的时候epoll可以通过这个队列找到这些阻塞的进程并唤醒它们,从而执行IO调用读写Socket上的数据。 -
epoll中的就绪队列,队列里存放的是都是
IO就绪的Socket,被唤醒的用户进程可以直接读取这个队列获取IO活跃的Socket。无需再次遍历整个Socket集合。
-
struct rb_root rbr :由于红黑树在查找,插入,删除等综合性能方面是最优的,所以epoll内部使用一颗红黑树来管理海量的Socket连接。
首先要在epoll内核中创建一个表示Socket连接的数据结构struct epitem

socket等待队列中类型是wait_queue_t无法关联到epitem。所以就出现了struct eppoll_entry结构体,它的作用就是关联Socket等待队列中的等待项wait_queue_t和epitem。


-
当网络数据包在软中断中经过内核协议栈的处理到达
socket的接收缓冲区时,紧接着会调用socket的数据就绪回调指针sk_data_ready,回调函数为sock_def_readable。在socket的等待队列中找出等待项,其中等待项中注册的回调函数为ep_poll_callback。 -
在回调函数
ep_poll_callback中,根据struct eppoll_entry中的struct wait_queue_t wait通过container_of宏找到eppoll_entry对象并通过它的base指针找到封装socket的数据结构struct epitem,并将它加入到epoll中的就绪队列rdllist中。 -
随后查看
epoll中的等待队列中是否有等待项,也就是说查看是否有进程阻塞在epoll_wait上等待IO就绪的socket。如果没有等待项,则软中断处理完成。 -
如果有等待项,则回到注册在等待项中的回调函数
default_wake_function,在回调函数中唤醒阻塞进程,并将就绪队列rdllist中的epitem的IO就绪socket信息封装到struct epoll_event中返回。 -
用户进程拿到
epoll_event获取IO就绪的socket,发起系统IO调用读取数据
水平触发和边缘触发
-
水平触发:在这种模式下,用户线程调用
epoll_wait获取到IO就绪的socket后,对Socket进行系统IO调用读取数据,假设socket中的数据只读了一部分没有全部读完,这时再次调用epoll_wait,epoll_wait会检查这些Socket中的接收缓冲区是否还有数据可读,如果还有数据可读,就将socket重新放回rdllist。所以当socket上的IO没有被处理完时,再次调用epoll_wait依然可以获得这些socket,用户进程可以接着处理socket上的IO事件。 -
边缘触发: 在这种模式下,
epoll_wait就会直接清空rdllist,不管socket上是否还有数据可读。所以在边缘触发模式下,当你没有来得及处理socket接收缓冲区的剩下可读数据时,再次调用epoll_wait,因为这时rdlist已经被清空了,socket不会再次从epoll_wait中返回,所以用户进程就不会再次获得这个socket了,也就无法在对它进行IO处理了。除非,这个socket上有新的IO数据到达,根据epoll的工作过程,该socket会被再次放入rdllist中。
参考文献
聊聊Netty那些事儿之从内核角度看IO模型



















