聊聊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_ops
socket操作函数,最终调用到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模型