0


深入理解网络 I/O 多路复用:SELECT、POLL

在这里插入图片描述

🔭 嗨,您好 👋 我是 vnjohn,在互联网企业担任 Java 开发,CSDN 优质创作者
📖 推荐专栏:Spring、MySQL、Nacos、Java,后续其他专栏会持续优化更新迭代
🌲文章所在专栏:网络 I/O
🤔 我当前正在学习微服务领域、云原生领域、消息中间件等架构、原理知识
💬 向我询问任何您想要的东西,ID:vnjohn
🔥觉得博主文章写的还 OK,能够帮助到您的,感谢三连支持博客🙏
😄 代词: vnjohn
⚡ 有趣的事实:音乐、跑步、电影、游戏

目录

前言

Unix/Linux 下可用的 I/O 模型有以下五种:

  1. 阻塞式 I/O
  2. 非阻塞式 I/O
  3. I/O 复用(select、poll)
  4. 信号驱动式 I/O(SIGIO)
  5. 异步 I/O

在 Linux 中操作内核时,所有的无非三种操作,分别是输入、输出、报错输出

0-输入
1-输出
2-报错输出

一个输入操作通常包括两个不同的阶段:

  • 等待数据准备好
  • 从内核向进程复制数据

对于一个套接字(Socket)的输入操作,第一步通常涉及等待数据从网络中;当所等待分组到达时,它被复制到内核中的某个缓冲区,第二步就是把数据从内核缓冲区复制到应用进程缓冲区

I/O 复用模型

I/O 复用(I/O multiplexing):SELECT、POLL,阻塞在这两个系统「用户态、内核态」调用中的某一个之上,而不是阻塞在真正的 I/O 系统调用上

在这里插入图片描述

将阻塞于 select 调用,等待数据报套接字变为可读,当 select 返回的套接字可读这一条件时,再调用 recvfrom 把所读的数据报复制到应用进程缓冲区

使用 select 的优势在于我们可以等待多个描述符就绪

与 I/O 复用密切相关的另外一种模型就是在多线程的场景下使用阻塞时 I/O 模型 BIO,两者极其相似,但它没有使用 select 阻塞在多个文件描述符上,而是使用多个线程「每个文件描述符分配一个线程的方式」这样每个线程都可以自由地调用诸如:recvfrom 之类的阻塞式 I/O 系统调用了.

图解分析

SELECT 属于 synchronous I/O multiplexing 同步 I/O 多路复用

它允许程序监视多个文件描述符,等待一个或多个文件描述符尾某种类型(read、write)的 I/O 操作

在这里插入图片描述

SELECT 仅用一次系统调用,recv | recvfrom 接收数据都只是会接收具体可操作的文件描述符数量,例如:图中带有 data 的 IO 操作

SELECT 函数

该函数允许进程指示内核等待多个事件「文件描述符」中的任何一个发生,并且只有在一个或多个时间发生或经历一段指定的时间后才能唤醒它

以上图中的 IO 块作为例子延迟,当调用 select 函数时,告知内核仅在以下情况发生时才进行返回

  1. IO 集合:1、7 中任何描述符准备好读
  2. IO 集合:4、5 中任何描述符准备好写
  3. IO 集合:2、3、6 中任何描述符有异常条件待处理
  4. 这个过程经历了 Xxx 秒

当调用 select 时要告知内核对哪些描述符(read、write、exception)感兴趣以及需等待多长时间

通过 Linux 内核帮助文档来学习 SELECT

man 2 select
在已经有的 Linux 内核中已经不支持使用 SELECT 函数了,查看文档时会出现:

none - deprecated system calls

,例如内核版本:Linux version 5.11.12-300.el7.aarch64

当然在 x86_64 内核版本仍然可以查看该帮助文档,例如内核版本:Linux version 3.10.0-1160.90.1.el7.x86_64

可通过 **

cat /proc/version

** 命令来查看

在它的文档中可以看到以下这些函数都是同步 I/O 多路复用使用的.

select, pselect, FD_CLR, FD_ISSET, FD_SET, FD_ZERO - synchronous I/O multiplexing

观察 select 函数源码,如下:

intselect(int nfds, fd_set *readfds, fd_set *writefds,
                  fd_set *exceptfds,structtimeval*timeout);

nfds:代表文件描述符的数量
*readfds:代表读取的文件描述符
*writefds:代表写入的文件描述符
*exceptfds:代表异常条件的文件描述符
*timeout:代表等待多长的时间

它的 timeval 结构用于指定这段时间的秒数、微妙数,结构源码如下:

structtimeval{long    tv_sec;/* seconds */long    tv_usec;/* microseconds */};

其中 select > timeout 参数有以下三种可能性:

  1. 永远等待下去:仅在有一个描述符准备好 I/O 时才返回,为此,可以将该参数设置为空
  2. 等待一段固定的时间:在有一个描述符准备好 I/O 时才返回,但是不会超过该参数所设置的秒数、微妙数
  3. 根本不等待:检查描述符完成后立即返回,称之为 轮询(polling),为此,该参数中的秒数、微妙数必须设置为 0

前面两种可能性的等待通常会被进程在等待期间捕获的信号中断,并从信号处理函数中返回

其中 select > readfds、writefds、exceptfds 三个参数指向的是一组描述符结果集

最大描述符数
SELECT 最大描述符数,大多数应用程序不会用到许多描述符,臂如说很少能够找到一个同时使用几百个描述符的应用程序,然而使用那么多描述符的应用程序确实村再,它们往往使用 select 来复选描述符。最初设计 select 时,操作系统通常对每个进程可用的最大描述符数设置了上限

取自于 4.4 BSD <sys/types.h> 头文件中,最大的描述符在内核被定义为一个常量:FD_SETSIZE 值为 1024,此时可以使用 poll 代表 select,这样可以避免描述符有限的问题.

POLL 函数

执行与 SELECT 类似的任务,它等待一组文件描述符中的一个准备好执行 I/O,不过在处理流设备时,它能够提供额外的信息

intpoll(structpollfd*fds,nfds_t nfds,int timeout);

第一个参数是一个指向一个结构数组第一个元素的指针,每个数组元素都是一个 pollfd 结构,用于指定测试某个给定描述符 fd 的条件.

structpollfd{int   fd;/* file descriptor */short events;/* requested events */short revents;/* returned events */};

要测试的条件由 events 成员指定,函数在相应的 revents 成员中返回该描述符的状态(每个描述符都有两个变量,一个为调用值,另外一个为返回结果,从而避免了「值-结果」参数)这两个成员的每一个都由指定某个特定条件的一位或多位构成

值-结果参数:代表传入的值和输出的值不是同一个参数,对比 select 来说,它的中间三个参数都是「值-结果」参数

字段 events:代表一个输入参数,一个指定应用程序对文件描述符 fd 感兴趣的事件的位 bit 掩码;若将此字段指定为零,则忽略 fd 中所有的事件,并且 revents 返回 0

字段 revents:代表一个输出参数,由内核填充实际发生的事件,事件返回的 bit 位可以包括事件中指定的任何位,也可以包括:POLLERR、POLLHUP 或 POLLNVAL 值之一

POLLERR、POLLHUP 或 POLLNVAL 这三个值在 events 字段中设置是没有意义的,当相应的条件为真时,将在 revents 字段中设置.

用于指定 events 标志以及 revents 标志的一些常量值,如下表格:
常量值作为 events 输入作为 events 结果说明POLLIN☑️☑️普通或优先级带数据可读POLLRDNORM
(Read Normal)☑️☑️普通数据可读POLLRDBAND
(Read Band)☑️☑️优先级带数据可读POLLPRI
(Priority)☑️☑️高优先级数据可读POLLOUT☑️☑️普通数据可写POLLWRNORM
(Write Normal)☑️☑️普通数据可写POLLWRBAND
(Write Band)☑️☑️优先级带数据可写POLLERR
(Error)☑️发生错误条件(仅输出)POLLHUP
(Hang up)☑️发生挂起(仅输出)POLLNVAL☑️无效请求:fd未打开(仅输出)
描述符不是一个打开的文件
该表格分为三个部分:第一部分是处理输入的四个常量值,第二部分是处理输出的三个常量值,第三部分是处理错误的三个常量值,其中第三部分的常量值不能在 events 中设置,但是当相应条件(第三部分常量值)存在时就在 revents 中返回.

POLL 识别三类数据:普通(Normal)、优先级带(Priority Band)、高优先级(Hign Priority)

POLLIN 可被定义为 POLLRDNORM、POLLRDBAND 的逻辑或

第二个参数 nfds:调用者应该在 nfds 中指定 fds 数组中的项数

第三个参数 timeout:timeout 参数指定 poll() 将阻塞的最小毫秒数(这个时间间隔将四舍五入到系统时钟粒度,内核调度延迟意味着阻塞时间间隔可能会超出一小部分)在 timeout 中指定负值意味着无限超时。指定超时为零将导致 poll() 立即返回,即使没有文件描述符准备好

负值:无限超时
0:立即返回
正数:阻塞的最小毫秒数

两者函数区别

在 SELECT 函数中提及到了 FD_SETSIZE,每个描述符集最大的描述符数量,当有了 POLL 就无须考虑这个问题,因为分配了一个 pollfd 结构的数组并把数组中元素的数目通知内核成了调用者的责任,内核不再需要知道 fd_set(SELECT 中的参数类型) 固定大小的数据类型

POSIX 规范对 select、poll 都有需要,然而从当今的可移植性来考虑,支持 select 系统比支持 poll 系统要多,另外除了 select 函数还定义了 pselect 函数,它能够处理信号阻塞并提供了更高时间分辨率的 select 的增强版本,而在 POSIX 规范中没有为 poll 定义类似的东西.

源码实践

以下代码是服务端侧的所有代码,处理三种类型事件:注册事件、写事件、读事件,客户端代码可以沿用之前 BIO、NIO 中的源码,或者说可以在命令窗口通过 nc 命令进行连接!!!

服务端代码

packageorg.vnjohn.select;importjava.io.IOException;importjava.net.InetSocketAddress;importjava.nio.ByteBuffer;importjava.nio.channels.SelectionKey;importjava.nio.channels.Selector;importjava.nio.channels.ServerSocketChannel;importjava.nio.channels.SocketChannel;importjava.util.Iterator;importjava.util.Set;/**
 * @author vnjohn
 * @since 2023/12/7
 */publicclassSelectMultiplexingSocketThreadextendsThread{privateSelector selector =null;/**
     * 初始化 socket 服务端实例,进行 accept
     */publicvoidinitServer(){try{ServerSocketChannel server =ServerSocketChannel.open();
            server.configureBlocking(false);
            server.bind(newInetSocketAddress(8090));//  select、poll、*epoll 都是使用同样的方式打开
            selector =Selector.open();
            server.register(selector,SelectionKey.OP_ACCEPT);}catch(IOException e){
            e.printStackTrace();}}@Overridepublicvoidstart(){initServer();System.out.println("Socket Server start...");try{while(true){// select()// select(long timeout):毫秒级别的等待while(selector.select()>0){Set<SelectionKey> selectionKeys = selector.selectedKeys();Iterator<SelectionKey> iterator = selectionKeys.iterator();while(iterator.hasNext()){SelectionKey key = iterator.next();
                        iterator.remove();if(key.isAcceptable()){acceptHandler(key);}elseif(key.isReadable()){// 只处理了{read}并注册一个对这个 key 感兴趣的 write 事件readHandler(key);}elseif(key.isWritable()){// 写事件->当 Send-Queue 为空时,就一定会给你返回可以写的事件,就会回调我们的写方法// 多路复用器能不能写是参考:Send-Queue 有没有空间// 1、你准备好要写什么了,这是第一步// 2、第二步你才关心 Send-Queue 是否有空间// 3、so,读 read 一开始就要注册,但是 write 依赖以上关系,什么时候用什么时候注册// 4、若一开始就注册了 write 事件,进入死循环,一直调起!!!writeHandler(key);}}}}}catch(IOException e){
            e.printStackTrace();}}/**
     * 该方法用于接收新的客户端连接进来,在此处就会向 SELECT 结果集注册一个 read 事件,用于接收客户端发送过来的数据
     *
     * @param key
     */publicvoidacceptHandler(SelectionKey key){try{ServerSocketChannel ssc =(ServerSocketChannel) key.channel();SocketChannel client = ssc.accept();// NIO->NON_BLOCKING!!!
            client.configureBlocking(false);// 分配给 8M 写入的空间ByteBuffer buffer =ByteBuffer.allocate(8192);
            client.register(selector,SelectionKey.OP_READ, buffer);System.out.println("-------------------------------------------");System.out.println("new SocketClient:"+ client.getRemoteAddress());System.out.println("-------------------------------------------");}catch(IOException e){
            e.printStackTrace();}}/**
     *
     * @param key
     */privatevoidwriteHandler(SelectionKey key){System.out.println("write handler...");SocketChannel client =(SocketChannel) key.channel();ByteBuffer buffer =(ByteBuffer) key.attachment();
        buffer.flip();// 判断服务端与客户端之间所在的缓冲区是否有数据存在,有则进行写入操作,将数据发送给客户端while(buffer.hasRemaining()){try{
                client.write(buffer);}catch(IOException e){
                e.printStackTrace();}}// 模拟一下业务延迟,将注册进当前客户端的写事件注销掉,下一次 select 时该事件不会被获取到// 当下一次客户端有数据要读取时,写入该客户端的事件将会再次被注册try{Thread.sleep(2000);}catch(InterruptedException e){
            e.printStackTrace();}
        buffer.clear();
        key.cancel();try{
            client.close();}catch(IOException e){
            e.printStackTrace();}}/**
     * 写事件,针对当前选中的键做读取操作,当读取到的数据不为空时,在我们这里模拟写的操作,此时 Send-Queue 肯定是有数据存在的
     * 所以在这里会模拟>注册上一个 write 写事件,把数据写给对应的客户端
     *
     * @param key 当前选中的键 > 文件描述符
     */publicvoidreadHandler(SelectionKey key){System.out.println("readHandler...");SocketChannel client =(SocketChannel) key.channel();ByteBuffer buffer =(ByteBuffer) key.attachment();
        buffer.clear();int read;try{while(true){
                read = client.read(buffer);if(read >0){// 关心 OP_WRITE 其实就是关心 Send-Queue 是不是有空间
                    client.register(key.selector(),SelectionKey.OP_WRITE, buffer);}elseif(read ==0){break;}else{
                    client.close();break;}}}catch(IOException e){
            e.printStackTrace();}}publicstaticvoidmain(String[] args){SelectMultiplexingSocketThread service =newSelectMultiplexingSocketThread();
        service.start();}}

通过模拟一个线程,死循环的方式从 Selector 中获取所有的事件 Key,通过 Key 类型的不同来执行不同的处理逻辑

注册 > accept:acceptHandler
写 > write:writeHandler
读 > read:readHandler

Selector 是 SELECT/POLL、Epoll 在 Java 应用程序中的抽象,通过 Selector 优先选择的是 Epoll,但是可以通过 -D 参数来修正,主要就是更改所依赖的 Selector 实现类
select/poll-Djava.nio.channels.spi.SelectorProvider=sun.nio.ch.PollSelectorProviderepoll-Djava.nio.channels.spi.SelectorProvider=sun.nio.ch.EPollSelectorProvider

  1. Selector#open:在 select/poll 模型下,它和内核之间是没有交互的,至于 Epoll 我们在下篇文章详细讲解
  2. SelectableChannel#register(Selector, SelectionKey.OP_ACCEPT):相当于在内核中是一个 listen 函数的调用

register:在 select/poll 模型中,JVM 里面会为其开辟一个数组将监听的文件描述符放进去,期间是无系统调用的

  1. Selector#select:相当于就是内核中的 select(fd)、poll(fd) 函数

select 有两种方式支持调用,select()、select(long timeout)
当设置了时间,等待对应参数值的毫秒级别数返回
当未设置时间,值为 0

也就是在介绍两个函数时所描述的一样,0 代表立即返回,设置了时间那么就等待一定时间以后才返回调用的结果

  1. Selector#selectedKeys:返回的是哪些有状态的 fds 文件描述符集合 > SelectionKey

无论是什么样的多路复用器:select、poll、epoll、kqueue,只需要返回给我有状态的 fd 文件描述符即可

在 NIO 处理时,是服务端这一侧对着每一个 fd 触发系统调用,这样肯定是会浪费很多资源的,而这里调用一次 select 就可以知道那么 fd 可以进行 R/W

在这个处理期间仍然会有两种类型的 Socket:服务端 Socket 用于 accept 接收客户端连接的、客户端连接后的 Socket(用于读写数据使用的)

  1. SelectionKey#isAcceptable:准备好去接收新的 Socket 连接

在语义上,accept 接收连接且返回新连接的 FD,那么在 select/poll 中 FD 是如何存储的呢?

因为在它们内核是没有空间的,所在会在 JVM 中保存这些 FD 信息,与第二点所描述的 SelectableChannel#register(Selector, SelectionKey.OP_ACCEPT) 存放在一起

在源码中的 acceptHandler 方法中,出现 client.register(selector, SelectionKey.OP_READ, buffer) 将其装入 JVM 数组

  1. SelectionKey#isReadable:准备去接收读取的 Socket 连接,在这里会将 R/W 同时都处理

在当前线程,这个方法可能会被阻塞住(处理过多的数据与逻辑)

所以,后面提出了 IO Threads 概念(拿到数据以后就不管后续的事情了,丢给后面的 worker 线程去进行处理)

Redis 就是运用 epoll 模型,同时它里面也有 IO Threads 概念,Redis 工作线程虽然是单线程的,但是它的网络 I/O、数据处理流都是采用多线程的

在源码中的 readHandler 方法中,拿到 FD buffer 中的数据以后,进行 read 读取,有以下几种情况:
1、若返回 > 0,就会注册上一个 write 写事件,把数据写给对应的客户端
2、若返回为 0,就退出当前循环
3、若返回为 -1,说明客户端已经断开连接,同时将当前读取的 socket 连接关闭

流程说明

以上对源码以及相关的方法进行了说明,接下来我们来测试 select/poll 模型与 TCP/IP 挥手的问题

通过 select/poll I/O 多路复用运行,并 strace 追踪如上代码:

1、先将首行 package 通过 vi 命令移除
2、编译源文件为 class 文件:javac SelectMultiplexingSocketThread.java
3、以 select/poll 模型追踪运行 class 文件:strace -ff -o poll java -Djava.nio.channels.spi.SelectorProvider=sun.nio.ch.PollSelectorProvider SelectMultiplexingSocketThread

运行服务端以后,观察内核所输出的系统调用函数,如下:

在这里插入图片描述

ppoll([{fd=5, events=POLLIN}, {fd=4, events=POLLIN}], 2, NULL, NULL, 0
fd:4、5,请求事件:POLLIN,无返回的事件类型

开启另外一个命令窗口,通过 nc localhost 8090 连接服务端,此时在服务端会打印出具体的客户端连接信息,但观察内核文件中时它不会有任何的具体输出内容,由此可见,它并未与内核发生调用关系,而是存储在了自身的 JVM 数组中

在这里插入图片描述

此时,再观察 netstat -natp TCP/IP 连接信息,会出现以下两条条目:

在这里插入图片描述

此时在客户端写入一定内容,观察服务端的执行过程,它会先读取客户端写入的内容,然后通过写事件将内容回放给客户端的窗口中,如下图所示:

客户端:

在这里插入图片描述

服务端:

在这里插入图片描述

在内核侧做出的具体处理,如下图:

在这里插入图片描述

正常状态下,将 nc localhost 8090 开启的客户端,正常退出,再观察 TCP/IP 网络条目,可以发现只有服务端侧的一条条目信息存在,也就是进行了四次挥手的正常流程!!!

在这里插入图片描述

测试挥手问题

在流程说明的最后,客户端与服务端之间的挥手流程是正常走完的,也就是说不会在服务端中存在非正常状态的客户端 sockfd 存在

若我们将源码中 client.close(); 代码进行注释,然后再测试客户端关闭的一个过程

1、注释 client.close(); 客户端关闭的代码,然后重新编译执行服务端代码

2、通过 nc localhost 8090 模拟客户端再次连接到服务端上.

3、关闭客户端的连接以后,此时再观察 TCP/IP 条目,如下所示:

在这里插入图片描述

tcp6       00 ::1:50306               ::1:8090                FIN_WAIT2   -
tcp6       00 ::1:8090                ::1:50306               CLOSE_WAIT  16838/java

TCP 需要经过四次挥手,虽然在客户端这边 FIN 标记成功了,但是在服务端这边还没有关闭,因为服务端的 FIN 标记在客户端那一侧未作出 ACK 反应,所以在 TCP/IP 条目中就会出现不完整的状态

FIN_WAIT2

|

CLOSE_WAIT

信息

由此可见,TCP 四次挥手的过程,是两端都需要经过 FIN+ACK 处理以后,整个信息才能完全保证安全的释放

以上就会造成一个问题:服务端一直持有客户端的连接信息,但是这条信息是已经是没必要的,它会浪费在服务端这一侧的一条「四元组:源 IP:端口>目标 IP:端口」信息,**

内核中的 socketfd 就一直会被占用,相同的对端服务端也就一直不能使用这个资源建立新的连接,浪费的资源和名额!!!

**

NIO、I/O 多路复用

在这里插入图片描述

在 NIO 中,每次都需要遍历获取 I/O 状态,每次都需要经过用户态、内核态之间的转换才能实现,然后再交由给应用程序去进行 R/W

在 I/O 多路复用中,通过一个系统调用,可以获取到所有 I/O 状态,由应用程序对有状态的 IO 进行 R/W

总结

该篇博文主要介绍的是 I/O 模型中的多路复用:SELECT、POLL,简要分析了 I/O 多路复用的模型,通过图解分析的方式告知多路复用所带来的好处「介绍了 SELECT、POLL 函数以及它们的区别」,通过实践代码的方式来分析 I/O 多路复用在系统调用中所涉及到的内核流程代码,同时也当代码编写不当时会给 TCP 挥手带来的问题,最后介绍了上篇 NIO 博文与 I/O 多路复用之间的区别,希望能够得到你的支持,感谢三连

四元组唯一:源 IP、源端口、目标 IP、目标端口

🌟🌟🌟愿你我都能够在寒冬中相互取暖,互相成长,只有不断积累、沉淀自己,后面有机会自然能破冰而行!

博文放在 网络 I/O 专栏里,欢迎订阅,会持续更新!

如果觉得博文不错,关注我 vnjohn,后续会有更多实战、源码、架构干货分享!

推荐专栏:Spring、MySQL,订阅一波不再迷路

大家的「关注❤️ + 点赞👍 + 收藏⭐」就是我创作的最大动力!谢谢大家的支持,我们下文见!


本文转载自: https://blog.csdn.net/vnjohn/article/details/134772931
版权归原作者 vnjohn 所有, 如有侵权,请联系我们删除。

“深入理解网络 I/O 多路复用:SELECT、POLL”的评论:

还没有评论