聊聊Netty那些事儿之从内核角度看IO模型
Posted 热爱编程的大忽悠
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了聊聊Netty那些事儿之从内核角度看IO模型相关的知识,希望对你有一定的参考价值。
聊聊Netty那些事儿之从内核角度看IO模型
- 参考
从今天开始我们来聊聊Netty的那些事儿,我们都知道Netty是一个高性能异步事件驱动的网络框架。
它的设计异常优雅简洁,扩展性高,稳定性强。拥有非常详细完整的用户文档。
同时内置了很多非常有用的模块基本上做到了开箱即用,用户只需要编写短短几行代码,就可以快速构建出一个具有高吞吐
,低延时
,更少的资源消耗
,高性能(非必要的内存拷贝最小化)
等特征的高并发网络应用程序。
本文我们来探讨下支持Netty具有高吞吐
,低延时
特征的基石----netty的网络IO模型
。
由Netty的网络IO模型
开始,我们来正式揭开本系列Netty源码解析的序幕:
网络包接收流程
- 当
网络数据帧
通过网络传输到达网卡时,网卡会将网络数据帧通过DMA的方式
放到环形缓冲区RingBuffer
中。
RingBuffer
是网卡在启动的时候分配和初始化
的环形缓冲队列
。当RingBuffer满
的时候,新来的数据包就会被丢弃
。我们可以通过ifconfig
命令查看网卡收发数据包的情况。其中overruns
数据项表示当RingBuffer满
时,被丢弃的数据包
。如果发现出现丢包情况,可以通过ethtool命令
来增大RingBuffer长度。
- 当
DMA操作完成
时,网卡会向CPU发起一个硬中断
,告诉CPU
有网络数据到达。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拷贝
网络数据包到内存
中的开销。
网络包发送流程
- 当我们在应用程序中调用
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
的时候,在把指针移动一下就行,避免频繁的内存申请和拷贝,效率很高。
为什么不直接使用
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
,并将原来的sk_buffer拷贝
到多个小的sk_buffer中。
- 在内核协议栈的传输层中,
再谈(阻塞,非阻塞)与(同步,异步)
在我们聊完网络数据的接收和发送过程后,我们来谈下IO中特别容易混淆的概念:阻塞与同步
,非阻塞与异步
。
网上各种博文还有各种书籍中有大量的关于这两个概念的解释,但是笔者觉得还是不够形象化,只是对概念的生硬解释,如果硬套概念的话,其实感觉阻塞与同步
,非阻塞与异步
还是没啥区别,时间长了,还是比较模糊容易混淆。
所以笔者在这里尝试换一种更加形象化,更加容易理解记忆的方式来清晰地解释下什么是阻塞与非阻塞
,什么是同步与异步
。
经过前边对网络数据包接收流程的介绍,在这里我们可以将整个流程总结为两个阶段:
- 数据准备阶段: 在这个阶段,网络数据包到达网卡,通过
DMA
的方式将数据包拷贝到内存中,然后经过硬中断,软中断,接着通过内核线程ksoftirqd
经过内核协议栈的处理,最终将数据发送到内核Socket
的接收缓冲区中。 - 数据拷贝阶段: 当数据到达
内核Socket
的接收缓冲区中时,此时数据存在于内核空间
中,需要将数据拷贝
到用户空间
中,才能够被应用程序读取。
阻塞与非阻塞
阻塞与非阻塞的区别主要发生在第一阶段:数据准备阶段
。
当应用程序发起系统调用read
时,线程从用户态转为内核态,读取内核Socket
的接收缓冲区中的网络数据。
阻塞
如果这时内核Socket
的接收缓冲区没有数据,那么线程就会一直等待
,直到Socket
接收缓冲区有数据为止。随后将数据从内核空间拷贝到用户空间,系统调用read
返回。
从图中我们可以看出:阻塞的特点是在第一阶段和第二阶段都会等待
。
非阻塞
阻塞
和非阻塞
主要的区分是在第一阶段:数据准备阶段
。
- 在第一阶段,当
Socket
的接收缓冲区中没有数据的时候,阻塞模式下
应用线程会一直等待。非阻塞模式下
应用线程不会等待,系统调用
直接返回错误标志EWOULDBLOCK
。 - 当
Socket
的接收缓冲区中有数据的时候,阻塞
和非阻塞
的表现是一样的,都会进入第二阶段等待
数据从内核空间
拷贝到用户空间
,然后系统调用返回
。
从上图中,我们可以看出:非阻塞的特点是第一阶段不会等待
,但是在第二阶段还是会等待
。
同步与异步
同步
与异步
主要的区别发生在第二阶段:数据拷贝阶段
。
前边我们提到在数据拷贝阶段
主要是将数据从内核空间
拷贝到用户空间
。然后应用程序才可以读取数据。
当内核Socket
的接收缓冲区有数据到达时,进入第二阶段。
同步
同步模式
在数据准备好后,是由用户线程
的内核态
来执行第二阶段
。所以应用程序会在第二阶段发生阻塞
,直到数据从内核空间
拷贝到用户空间
,系统调用才会返回。
Linux下的 epoll
和Mac 下的 kqueue
都属于同步 IO
。
异步
异步模式
下是由内核
来执行第二阶段的数据拷贝操作,当内核
执行完第二阶段,会通知用户线程IO操作已经完成,并将数据回调给用户线程。所以在异步模式
下 数据准备阶段
和数据拷贝阶段
均是由内核
来完成,不会对应用程序造成任何阻塞。
基于以上特征,我们可以看到异步模式
需要内核的支持,比较依赖操作系统底层的支持。
在目前流行的操作系统中,只有Windows 中的 IOCP
才真正属于异步 IO,实现的也非常成熟。但Windows很少用来作为服务器使用。
而常用来作为服务器使用的Linux,异步IO机制
实现的不够成熟,与NIO相比性能提升的也不够明显。
但Linux kernel 在5.1版本由Facebook的大神Jens Axboe引入了新的异步IO库io_uring
改善了原来Linux native AIO的一些性能问题。性能相比Epoll
以及之前原生的AIO
提高了不少,值得关注。
IO模型
在进行网络IO操作时,用什么样的IO模型来读写数据将在很大程度上决定了网络框架的IO性能。所以IO模型的选择是构建一个高性能网络框架的基础。
在《UNIX 网络编程》一书中介绍了五种IO模型:阻塞IO
,非阻塞IO
,IO多路复用
,信号驱动IO
,异步IO
,每一种IO模型的出现都是对前一种的升级优化。
下面我们就来分别介绍下这五种IO模型各自都解决了什么问题,适用于哪些场景,各自的优缺点是什么?
阻塞IO(BIO)
经过前一小节对阻塞
这个概念的介绍,相信大家可以很容易理解阻塞IO
的概念和过程。
既然这小节我们谈的是IO
,那么下边我们来看下在阻塞IO
模型下,网络数据的读写过程。
阻塞读
当用户线程发起read
系统调用,用户线程从用户态切换到内核态,在内核中去查看Socket
接收缓冲区是否有数据到来。
Socket
接收缓冲区中有数据
,则用户线程在内核态将内核空间中的数据拷贝到用户空间,系统IO调用返回。Socket
接收缓冲区中无数据
,则用户线程让出CPU,进入阻塞状态
。当数据到达Socket
接收缓冲区后,内核唤醒阻塞状态
中的用户线程进入就绪状态
,随后经过CPU的调度获取到CPU quota
进入运行状态
,将内核空间的数据拷贝到用户空间,随后系统调用返回。
阻塞写
当用户线程发起send
系统调用时,用户线程从用户态切换到内核态,将发送数据从用户空间拷贝到内核空间中的Socket
发送缓冲区中。
- 当
Socket
发送缓冲区能够容纳下发送数据时,用户线程会将全部的发送数据写入Socket
缓冲区,然后执行在《网络包发送流程》这小节介绍的后续流程,然后返回。 - 当
Socket
发送缓冲区空间不够,无法容纳下全部发送数据时,用户线程让出CPU,进入阻塞状态
,直到Socket
发送缓冲区能够容纳下全部发送数据时,内核唤醒用户线程,执行后续发送流程。
阻塞IO
模型下的写操作做事风格比较硬刚,非得要把全部的发送数据写入发送缓冲区才肯善罢甘休。
阻塞IO模型
由于阻塞IO
的读写特点,所以导致在阻塞IO
模型下,每个请求都需要被一个独立的线程处理。一个线程在同一时刻只能与一个连接绑定。来一个请求,服务端就需要创建一个线程用来处理请求。
当客户端请求的并发量突然增大时,服务端在一瞬间就会创建出大量的线程,而创建线程是需要系统资源开销的,这样一来就会一瞬间占用大量的系统资源。
如果客户端创建好连接后,但是一直不发数据,通常大部分情况下,网络连接也并不
总是有数据可读,那么在空闲的这段时间内,服务端线程就会一直处于阻塞状态
,无法干其他的事情。CPU也无法得到充分的发挥
,同时还会导致大量线程切换的开销
。
适用场景
基于以上阻塞IO模型
的特点,该模型只适用于连接数少
,并发度低
的业务场景。
比如公司内部的一些管理系统,通常请求数在100个左右,使用阻塞IO模型
还是非常适合的。而且性能还不输NIO。
该模型在C10K之前,是普遍被采用的一种IO模型。
非阻塞IO(NIO)
阻塞IO模型
最大的问题就是一个线程只能处理一个连接,如果这个连接上没有数据的话,那么这个线程就只能阻塞在系统IO调用上,不能干其他的事情。这对系统资源来说,是一种极大的浪费。同时大量的线程上下文切换,也是一个巨大的系统开销。
所以为了解决这个问题,我们就需要用尽可能少的线程去处理更多的连接。,网络IO模型的演变
也是根据这个需求来一步一步演进的。
基于这个需求,第一种解决方案非阻塞IO
就出现了。我们在上一小节中介绍了非阻塞
的概念,现在我们来看下网络读写操作在非阻塞IO
下的特点:
非阻塞读
当用户线程发起非阻塞read
系统调用时,用户线程从用户态
转为内核态
,在内核中去查看Socket
接收缓冲区是否有数据到来。
Socket
接收缓冲区中无数据
,系统调用立马返回,并带有一个EWOULDBLOCK
或EAGAIN
错误,这个阶段用户线程不会阻塞
,也不会让出CPU
,而是会继续轮询
直到Socket
接收缓冲区中有数据为止。Socket
接收缓冲区中有数据
,用户线程在内核态
会将内核空间
中的数据拷贝到用户空间
,注意这个数据拷贝阶段,应用程序是阻塞的
,当数据拷贝完成,系统调用返回。
非阻塞写
前边我们在介绍阻塞写
的时候提到阻塞写
的风格特别的硬朗,头比较铁非要把全部发送数据一次性都写到Socket
的发送缓冲区中才返回,如果发送缓冲区中没有足够的空间容纳,那么就一直阻塞死等,特别的刚。
相比较而言非阻塞写
的特点就比较佛系,当发送缓冲区中没有足够的空间容纳全部发送数据时,非阻塞写
的特点是能写多少写多少
,写不下了,就立即返回。并将写入到发送缓冲区的字节数返回给应用程序,方便用户线程不断的轮询
尝试将剩下的数据
写入发送缓冲区中。
非阻塞IO模型
基于以上非阻塞IO
的特点,我们就不必像阻塞IO
那样为每个请求分配一个线程去处理连接上的读写了。
我们可以利用一个线程或者很少的线程,去不断地轮询
每个Socket
的接收缓冲区是否有数据到达,如果没有数据,不必阻塞
线程,而是接着去轮询
下一个Socket
接收缓冲区,直到轮询到数据后,处理连接上的读写,或者交给业务线程池去处理,轮询线程则继续轮询
其他的Socket
接收缓冲区。
这样一个非阻塞IO模型
就实现了我们在本小节开始提出的需求:我们需要用尽可能少的线程去处理更多的连接
适用场景
虽然非阻塞IO模型
与阻塞IO模型
相比,减少了很大一部分的资源消耗和系统开销。
但是它仍然有很大的性能问题,因为在非阻塞IO模型
下,需要用户线程去不断地
发起系统调用
去轮训Socket
接收缓冲区,这就需要用户线程不断地从用户态
切换到内核态
,内核态
切换到用户态
。随着并发量的增大,这个上下文切换的开销也是巨大的。
所以单纯的非阻塞IO
模型还是无法适用于高并发的场景。只能适用于C10K
以下的场景。
IO多路复用
在非阻塞IO
这一小节的开头,我们提到网络IO模型
的演变都是围绕着—如何用尽可能少的线程去处理更多的连接这个核心需求开始展开的。
本小节我们来谈谈IO多路复用模型
,那么什么是多路
?,什么又是复用
呢?
我们还是以这个核心需求来对这两个概念展开阐述:
- 多路:我们的核心需求是要用尽可能少的线程来处理尽可能多的连接,这里的
多路
指的就是我们需要处理的众多连接。 - 复用:核心需求要求我们使用
尽可能少的线程
,尽可能少的系统开销
去处理尽可能多
的连接(多路
),那么这里的复用
指的就是用有限的资源
,比如用一个线程或者固定数量的线程去处理众多连接上的读写事件。换句话说,在阻塞IO模型
中一个连接就需要分配一个独立的线程去专门处理这个连接上的读写,到了IO多路复用模型
中,多个连接可以复用
这一个独立的线程去处理这多个连接上的读写。
好了,IO多路复用模型
的概念解释清楚了,那么问题的关键是我们如何去实现这个复用
,也就是如何让一个独立的线程去处理众多连接上的读写事件呢?
这个问题其实在非阻塞IO模型
中已经给出了它的答案,在非阻塞IO模型
中,利用非阻塞
的系统IO调用去不断的轮询众多连接的Socket
接收缓冲区看是否有数据到来,如果有则处理,如果没有则继续轮询下一个Socket
。这样就达到了用一个线程去处理众多连接上的读写事件了。
但是非阻塞IO模型
最大的问题就是需要不断的发起系统调用
去轮询各个Socket
中的接收缓冲区是否有数据到来,频繁
的系统调用
随之带来了大量的上下文切换开销。随着并发量的提升,这样也会导致非常严重的性能问题。
那么如何避免频繁的系统调用同时又可以实现我们的核心需求呢?
这就需要操作系统的内核来支持这样的操作,我们可以把频繁的轮询操作交给操作系统内核来替我们完成,这样就避免了在用户空间
频繁的去使用系统调用来轮询所带来的性能开销。
正如我们所想,操作系统内核也确实为我们提供了这样的功能实现,下面我们来一起看下操作系统对IO多路复用模型
的实现。
select
select
是操作系统内核提供给我们使用的一个系统调用
,它解决了在非阻塞IO模型
中需要不断的发起系统IO调用
去轮询各个连接上的Socket
接收缓冲区所带来的用户空间
与内核空间
不断切换的系统开销
。
select
系统调用将轮询
的操作交给了内核
来帮助我们完成,从而避免了在用户空间
不断的发起轮询所带来的的系统性能开销。
- 首先用户线程在发起
select
系统调用的时候会阻塞
在select
系统调用上。此时,用户线程从用户态
切换到了内核态
完成了一次上下文切换
- 用户线程将需要监听的
Socket
对应的文件描述符fd
数组通过select
系统调用传递给内核。此时,用户线程将用户空间
中的文件描述符fd
数组拷贝
到内核空间
。
这里的文件描述符数组其实是一个BitMap
,BitMap
下标为文件描述符fd
,下标对应的值为:1
表示该fd
上有读写事件,0
表示该fd
上没有读写事件。
文件描述符fd其实就是一个整数值
,在Linux中一切皆文件,Socket
也是一个文件。描述进程所有信息的数据结构task_struct
中有一个属性struct files_struct *files
,它最终指向了一个数组,数组里存放了进程打开的所有文件列表,文件信息封装在struct file
结构体中,这个数组存放的类型就是struct file
结构体,数组的下标
则是我们常说的文件描述符fd
。
- 当用户线程调用完
select
后开始进入阻塞状态
,内核
开始轮询遍历fd
数组,查看fd
对应的Socket
接收缓冲区中是否有数据到来。如果有数据到来,则将fd
对应BitMap
的值设置为1
。如果没有数据到来,则保持值为0
。
注意这里内核会修改原始的
fd
数组!!
- 内核遍历一遍
fd
数组后,如果发现有些fd
上有IO数据到来,则将修改后的fd
数组返回给用户线程。此时,会将fd
数组从内核空间
拷贝到用户空间
。 - 当内核将修改后的
fd
数组返回给用户线程后,用户线程解除阻塞
,由用户线程开始遍历fd
数组然后找出fd
数组中值为1
的Socket
文件描述符。最后对这些Socket
发起系统调用读取数据。
select
不会告诉用户线程具体哪些fd
上有IO数据到来,只是在IO活跃
的fd
上打上标记,将打好标记的完整fd
数组返回给用户线程,所以用户线程还需要遍历fd
数组找出具体哪些fd
上有IO数据
到来。
- 由于内核在遍历的过程中已经修改了
fd
数组,所以在用户线程遍历完fd
数组后获取到IO就绪
的Socket
后,就需要重置
fd数组,并重新调用select
传入重置后的fd
数组,让内核发起新的一轮遍历轮询。
API介绍
当我们熟悉了select
的原理后,就很容易理解内核给我们提供的select API
了。
int select(int maxfdp1,fd_set *readset,fd_set *writeset,fd_set *exceptset,const struct timeval *timeout)
从select API
中我们可以看到,select
系统调用是在规定的超时时间内
,监听(轮询
)用户感兴趣的文件描述符集合上的可读
,可写
,异常
三类事件。
maxfdp1 :
select传递给内核监听的文件描述符集合中数值最大的文件描述符+1
,目的是用于限定内核遍历范围。比如:select
监听的文件描述符集合为0,1,2,3,4
,那么maxfdp1
的值为5
。fd_set *readset:
对可读事件
感兴趣的文件描述符集合。fd_set *writeset:
对可写事件
感兴趣的文件描述符集合。fd_set *exceptset:
对异常事件
感兴趣的文件描述符集合。
这里的
fd_set
就是我们前边提到的文件描述符数组
,是一个BitMap
结构。
const struct timeval *timeout:
select系统调用超时时间,在这段时间内,内核如果没有发现有IO就绪
的文件描述符,就直接返回。
上小节提到,在内核
遍历完fd
数组后,发现有IO就绪
的fd
,则会将该fd
对应的BitMap
中的值设置为1
,并将修改后的fd
数组,返回给用户线程。
在用户线程中需要重新遍历fd
数组,找出IO就绪
的fd
出来,然后发起真正的读写调用。
下面介绍下在用户线程中重新遍历fd
数组的过程中,我们需要用到的API
:
void FD_ZERO(fd_set *fdset):
清空指定的文件描述符集合,即让fd_set
中不在包含任何文件描述符。void FD_SET(int fd, fd_set *fdset):
将一个给定的文件描述符加入集合之中。
每次调用
select
之前都要通过FD_ZERO
和FD_SET
重新设置文件描述符,因为文件描述符集合会在内核
中被修改
。
int FD_ISSET(int fd, fd_set *fdset):
检查集合中指定的文件描述符是否可以读写。用户线程遍历
文件描述符集合,调用该方法检查相应的文件描述符是否IO就绪
。void FD_CLR(int fd, fd_set *fdset):
将一个给定的文件描述符从集合中删除
性能开销
虽然select
解决了非阻塞IO模型
中频繁发起系统调用
的问题,但是在整个select
工作过程中,我们还是看出了select
有些不足的地方。
- 在发起
select
系统调用以及返回时,用户线程各发生了一次用户态
到内核态
以及内核态
到用户态
的上下文切换开销。发生2次上下文切换
- 在发起
select
系统调用以及返回时,用户线程在内核态
需要将文件描述符集合
从用户空间拷贝
到内核空间。以及在内核修改完文件描述符集合
后,又要将它从内核空间拷贝
到用户空间。发生2次文件描述符集合的拷贝
- 虽然由原来在
用户空间
发起轮询优化成了
在内核空间
发起轮询但select
不会告诉用户线程到底是哪些Socket
上发生了IO就绪
事件,只是对IO就绪
的Socket
作了标记,用户线程依然要遍历
文件描述符集合去查找具体IO就绪
的Socket
。时间复杂度依然为O(n)
。
大部分情况下,网络连接并不总是活跃的,如果
select
监听了大量的客户端连接,只有少数的连接活跃,然而使用轮询的这种方式会随着连接数的增大,效率会越来越低。
内核
会对原始的文件描述符集合
进行修改。导致每次在用户空间重新发起select
调用时,都需要对文件描述符集合
进行重置
。BitMap
结构的文件描述符集合,长度为固定的1024
,所以只能监听0~1023
的文件描述符。select
系统调用 不是线程安全的。
以上select
的不足所产生的性能开销
都会随着并发量的增大而线性增长
。
很明显select
也不能解决C10K
问题,只适用于1000
个左右的并发连接场景。
poll
poll
相当于是改进版的select
,但是工作原理基本和select
没有本质的区别。
int poll(struct pollfd *fds, unsigned int nfds, int timeout)
struct pollfd
int fd; /* 文件描述符 */
short events; /* 需要监听的事件 */
short revents; /* 实际发生的事件 由内核修改设置 */
;
select
中使用的文件描述符集合是采用的固定长度为1024的BitMap
结构的fd_set
,而poll
换成了一个pollfd
结构没有固定长度的数组,这样就没有了最大描述符数量的限制(当然还会受到系统文件描述符限制)
poll
只是改进了select
只能监听1024
个文件描述符的数量限制,但是并没有在性能方面做出改进。和select
上本质并没有多大差别。
- 同样需要在
内核空间
和用户空间
中对文件描述符集合进行轮询
,查找出IO就绪
的Socket
的时间复杂度依然为O(n)
。 - 同样需要将
包含大量文件描述符的集合
整体在用户空间
和内核空间
之间来回复制
,无论这些文件描述符是否就绪。他们的开销都会随着文件描述符数量的增加而线性增大。 select,poll
在每次新增,删除需要监听的socket时,都需要将整个新的socket
集合全量传至内核
。
poll
同样不适用高并发的场景。依然无法解决C10K
问题。
epoll
通过上边对select,poll
核心原理的介绍,我们看到select,poll
的性能瓶颈主要体现在下面三个地方:
- 因为内核不会保存我们要监听的
socket
集合,所以在每次调用select,poll
的时候都需要传入,传出全量的socket
文件描述符集合。这导致了大量的文件描述符在用户空间
和内核空间
频繁的来回复制。 - 由于内核不会通知具体
IO就绪
的socket
,只是在这些IO就绪
的socket上打好标记,所以当select
系统调用返回时,在用户空间
还是需要完整遍历
一遍socket
文件描述符集合来获取具体IO就绪
的socket
。 - 在
内核空间
中也是通过遍历的方式来得到IO就绪
的socket
。
下面我们来看下epoll
是如何解决这些问题的。在介绍epoll
的核心原理之前,我们需要介绍下理解epoll
工作过程所需要的一些核心基础知识。
Socket的创建
服务端线程调用accept
系统调用后开始阻塞
,当有客户端连接上来并完成TCP三次握手
后,内核
会创建一个对应的Socket
作为服务端与客户端通信的内核
接口。
在Linux内核的角度看来,一切皆是文件,Socket
也不例外,当内核创建出Socket
之后,会将这个Socket
放到当前进程所打开的文件列表中管理起来。
下面我们来看下进程管理这些打开的文件列表相关的内核数据结构是什么样的?在了解完这些数据结构后,我们会更加清晰的理解Socket
在内核中所发挥的作用。并且对后面我们理解epoll
的创建过程有很大的帮助。
进程中管理文件列表结构
struct tast_struct
是内核中用来表示进程的一个数据结构,它包含了进程的所有信息。本小节我们只列出和文件管理相关的属性。
其中进程内打开的所有文件是通过一个数组fd_array
来进行组织管理,数组的下标即为我们常提到的文件描述符
,数组中存放的是对应的文件数据结构struct file
。每打开一个文件,内核都会创建一个struct file
与之对应,并在fd_array
中找到一个空闲位置分配给它,数组中对应的下标,就是我们在用户空间
用到的文件描述符
。
对于任何一个进程,默认情况下,文件描述符
0
表示stdin 标准输入
,文件描述符1
表示stdout 标准输出
,文件描述符2
表示stderr 标准错误输出
。
进程中打开的文件列表fd_array
定义在内核数据结构struct files_struct
中,在struct fdtable
结构中有一个指针struct fd **fd
指向fd_array
。
由于本小节讨论的是内核网络系统部分的数据结构,所以这里拿Socket
文件类型来举例说明:
用于封装文件元信息的内核数据结构struct file
中的private_data
指针指向具体的Socket
结构。
struct file
中的file_operations
属性定义了文件的操作函数,不同的文件类型,对应的file_operations
是不同的,针对Socket
文件类型,这里的file_operations
指向socket_file_ops
。
我们在
用户空间
对Socket
发起的读写等系统调用,进入内核首先会调用的是Socket
对应的struct file
中指向的socket_file_ops
。比如:对Socket
发起write
写操作,在内核中首先被调用的就是socket_file_ops
中定义的sock_write_iter
。Socket
发起read
读操作内核中对应的则是sock_read_iter
。
static const struct file_operations socket_file_ops =
.owner = THIS_MODULE,
.llseek = no_llseek,
.read_iter = sock_read_iter,
.write_iter = sock_write_iter,
.poll = sock_poll,
.unlocked_ioctl = sock_ioctl,
.mmap = sock_mmap,
.release = sock_close,
.fasync = sock_fasync,
.sendpage = sock_sendpage,
.splice_write = generic_splice_sendpage,
.splice_read = sock_splice_read,
;
Socket内核结构
在我们进行网络程序的编写时会首先创建一个Socket
,然后基于这个Socket
进行bind
,listen
,我们先将这个Socket
称作为监听Socket
。
- 当我们调用
accept
后,内核会基于监听Socket
创建出来一个新的Socket
专门用于与客户端之间的网络通信。并将监听Socket
中的Socket操作函数集合
(inet_stream_ops
)ops
赋值到新的Socket
的ops
属性中。
const struct proto_ops inet_stream_ops =
.bind = inet_bind,
.connect = inet_stream_connect,
.accept = inet_accept,
.poll = tcp_poll,
.listen = inet_listen,
.sendmsg = inet_sendmsg,
.recvmsg = inet_recvmsg,
......
这里需要注意的是,
监听的 socket
和真正用来网络通信的Socket
,是两个 Socket,一个叫作监听 Socket
,一个叫作已连接的Socket
。
- 接着内核会为
已连接的Socket
创建struct file
并初始化,并把Socket文件操作函数集合(socket_file_ops
)赋值给struct file
中的f_ops
指针。然后将struct socket
中的file
指针指向这个新分配申请的struct file
结构体。
内核会维护两个队列:
- 一个是已经完成
TCP三次握手
,连接状态处于established
的连接队列。内核中为icsk_accept_queue
。- 一个是还没有完成
TCP三次握手
,连接状态处于syn_rcvd
的半连接队列。
- 然后调用
socket->ops->accept
,从Socket内核结构图
中我们可以看到其实调用的是inet_accept
,该函数会在icsk_accept_queue
中查找是否有已经建立好的连接,如果有的话,直接从icsk_accept_queue
中获取已经创建好的struct sock
。并将这个struct sock
对象赋值给struct socket
中的sock
指针。
struct sock`在`struct socket`中是一个非常核心的内核对象,正是在这里定义了我们在介绍`网络包的接收发送流程`中提到的`接收队列`,`发送队列`,`等待队列`,`数据就绪回调函数指针`,`内核协议栈操作函数集合
- 根据创建
Socket
时发起的系统调用sock_create
中的protocol
参数(对于TCP协议
这里的参数值为SOCK_STREAM
)查找到对于 tcp 定义的操作方法实现集合inet_stream_ops
和tcp_prot
。并把它们分别设置到socket->ops
和sock->sk_prot
上。
这里可以回看下本小节开头的《Socket内核结构图》捋一下他们之间的关系。
socket
相关的操作接口定义在inet_stream_ops
函数集合中,负责对上给用户提供接口。而socket
与内核协议栈之间的操作接口定义在struct sock
中的sk_prot
指针上,这里指向tcp_prot
协议操作函数集合。
struct proto tcp_prot =
.name = "TCP",
.owner = THIS_MODULE,
.close = tcp_close,
.connect = tcp_v4_connect,
.disconnect = tcp_disconnect,
.accept = inet_csk_accept,
.keepalive = tcp_set_keepalive,
.recvmsg = tcp_recvmsg,
.sendmsg = tcp_sendmsg,
.backlog_rcv = tcp_v4_do_rcv,
......
之前提到的对
Socket
发起的系统IO调用,在内核中首先会调用Socket
的文件结构struct file
中的file_operations
文件操作集合,然后调用struct socket
中的ops
指向的inet_stream_ops
socket操作函数,最终调用到struct sock
中sk_prot
指针指向的tcp_prot
内核协议栈操作函数接口集合。
- 将
struct sock
对象中的sk_data_ready
函数指针设置为sock_def_readable
,在Socket
数据就绪的时候内核会回调该函数。 struct sock
中的等待队列
中存放的是系统IO调用发生阻塞的进程fd
,以及相应的回调函数
。记住这个地方,后边介绍epoll的时候我们还会提到!
- 当
struct file
,struct socket
,struct sock
这些核心的内核对象创建好之后,最后就是把socket
对象对应的struct file
放到进程打开的文件列表fd_array
中。随后系统调用accept
返回socket
的文件描述符fd
给用户程序。
阻塞IO中用户进程阻塞以及唤醒原理
在前边小节我们介绍阻塞IO
的时候提到,当用户进程发起系统IO调用时,这里我们拿read
举例,用户进程会在内核态
查看对应Socket
接收缓冲区是否有数据到来。
Socket
接收缓冲区有数据,则拷贝数据到用户空间
,系统调用返回。Socket
接收缓冲区没有数据,则用户进程让出CPU
进入阻塞状态
,当数据到达接收缓冲区时,用户进程会被唤醒,从阻塞状态
进入就绪状态
,等待CPU调度。
本小节我们就来看下用户进程是如何 以上是关于聊聊Netty那些事儿之从内核角度看IO模型的主要内容,如果未能解决你的问题,请参考以下文章 聊聊Netty那些事儿之Reactor在Netty中的实现(创建篇) 3万字 | 34 图 | Netty | 内核角度看IO模型阻塞
在Socket
上,又是如何在Socket
上被唤醒的。理解这个过程很重要,对我们理解e