多路复用 select poll epoll

avatar 2021年05月15日22:29:55 0 137 views

1. BIO 的缺陷

BIO中的B 是 Blocking 的阻塞的意思

作为服务端开发,使用ServerSocket 绑定端口号之后会监听该端口,等待accept事件,accept是会阻塞当前线程

当我们收到accept事件的时候,程序就会拿到客户端与当前服务端连接的Socket

针对这个socket我们可以进行读写,但是呢,这个socket读写都是会阻塞当前线程的。

一般我们会有使用多线程方式进行c/s交互,但是这样很难做到C10K(比如说:1W个客户端就需要和服务端用1W个线程支持,这样的话CPU肯定就爆炸了,同时线程上下文切换也会把机器负载给拉飞。)

2. NIO 解决C10K问题

2.1 Java角度

站在java 角度去看,NIO包提供了一套非阻塞的接口,这样就不需要我们为每一个c/s长连接保留一个单独的处理线程了。

这个阻塞BIO之所以需要给每个socket长连接指定一个线程,就是因为它阻塞嘛

现在这个NIO API具有非阻塞的特性了,就可以用1个线程去检查n个socket

在 java 层面,nio 提供了一个这样的选择器selector

然后我们需要把需要检查的socket注册到这个selector中,然后主线程阻塞在selector的select方法中。

当选择器发现socket就绪了,某个socket就绪了。就会唤醒主线程,

然后咱们可以通过selector 获取就绪状态的socket 进行相应的处理。

其实这里selector 里面是native api ,底层Jvm调用SystemCall kernel去实现的

 

3. select(…) 实现原理

每次调用kernel 的 select函数,都会涉及到用户态/内核态的切换,还需要传递需要检查的socket集合,其实就是需要检查的fd(文件描述符)集合

因为咱的程序都是运行在linux或者unix操作系统上,这种操作系统,一切皆文件,socket也不例外,这里传递的fd其实就是文件系统中对应socket生成的文件描述符

操作系统 这个select函数被调用以后,

首先会去fd集合中去检查内存中socket套接字的状态,这个时间复杂度是O(N)的,然后检查完一遍之后,如果有就绪状态的socket,那么就会直接返回,不会阻塞当前线程。

否则的话,那个就说明当前指定fd集合对应的socket没有就绪状态,那么就需要阻塞当前调用线程了,直到有某个socket有数据之后,才唤醒线程

select(…) 对监听socket有1024的大小限制

 

这个是因为fd集合这个结构是一个bitmap位图的结构,这个位图结构就是一个长的二进制数,类似0101这种

这个bitmap默认长度是1024个bit,想要修改长度非常麻烦,需要重新编译操作系统内核

处于某种性能考虑,select函数做了两件事

第一件事,跑到就绪状态的socket对应的fd文件中设置一个标记mask,表示这个fd对应的socket就绪了

第二件事,返回select函数,对应的也就是唤醒java线程,站在java层面,他会收到一个int结果值,表示有几个socket处于就绪状态

但是具体是哪个socket就绪,java是不知道的,所以接下来会是一个O(N)的系统调用,检查fd集合中每一个socket的就绪状态,其实就是检查文件系统中指定socket的文件描述符的状态,涉及到用户态和内核态的来回切换,如果bitmap再大,就非常耗费性能

还有就是系统调用涉及到参数的数据拷贝,如果数据太庞大,他也不利于系统的调用速度

4. select(..) 深入问题

问题:select (…) 第一遍 O(N) 去检查未发现就绪的socket ,后续某个socket就绪后,select(…)是如何感知道的?是不断的轮询吗?

 

铺垫知识

 

操作系统调度

cpu同一时刻,它只能运行一个进程,操作系统做主要的任务就是系统调度,就是有n个进程,然后让这n个进程在cpu上切换进行

未挂起的进程都在工作队列内,都有机会获取到cpu的执行权

挂起的进程就会从这个工作队列里移除出去,反映到咱们java层面就是线程阻塞了

linux系统线程其实就是轻量级进程

操作系统中断

比如说,咱们用键盘打字,如果cpu正执行其他程序,一直不释放,那咱这个打字就也没法打了

 

咱们都知道,不是这样的情况,因为就是有系统中断的存在,当按下一个键以后会给主板发送一个电流信号,主板感知到以后,它就会触发这个cpu中断、

 

所以中断 其实就是让cpu给正在执行的进程先保留程序上下文,然后避让出cpu,给中断程序绕道

 

中断程序就会拿到cpu的执行权限,进行相应代码的执行,比如说键盘的中断程序,就会执行输出的逻辑

 

回到最开始的问题

 

这个select函数,它第一遍轮询,没有发现就绪状态的socket的话,它就会把当前进程保留给需要检查的socket等待队列中

socket 结构 有三块核心区域,分别就是读缓存,写缓存还有这个等待队列

这个 select 函数,它会把当前进程保留到每个需要检查的socket 的等待队列中,就会把当前进程从工作队列里面移除了,移除了之后其实就是挂起了当前线程,然后这个select 函数也就不会再运行了

下一个阶段,假设我们客户端往当前服务器发送了数据,数据通过网线到网卡,网卡再到DMA硬件的这种方式直接将数据写到内存里面,然后整个过程,CPU是不参与的

当传输完成以后,它就会触发网络数据传输完毕的中断程序,这个中断程序它会把cpu正在执行的进程给顶掉,然后cpu就会执行咱这个中断程序的逻辑

对应的逻辑是:根据内存中的数据包,然后分析出来数据包是哪个socket的数据,

同时tcp/ip它又是保证传输的时候是有端口号的,然后根据端口号就能找到对用的socket实例,找到socket实例以后,就会把数据导入到socket读缓冲里面

导入完成以后,它就开始去检查socket等待队列,看是不是有等待者,如果有等待者的话,就会把等待者移动到工作队列里面去,中断程序到这一步就执行完了

这样咱们的进程就又回到了工作队列,又有机会获取到cpu时间片了

然后当前进程执行的select函数再次检查,就会发现这个就绪的socket了,就会给就绪的socket的fd文件描述符打标记,然后select函数就执行完了,返回到java层面就涉及到内核态和用户态的转换,后面的事情就是轮询检查每一个socket的fd是否被打了标记,然后就是处理被打了标记的socket就ok了

5. poll() 和 select()区别

传参不一样

select 用的是bitmap ,它表示需要检查的socket集合

poll 使用的是 链表结构,表示需要检查的socket集合(主要是为了解决socket监听长度超过1024的socket的限制)

6. epoll 的 产生背景

select 和 poll 的共有缺陷

第一个缺陷: select 和 poll 函数,这两系统函数每次调用都需要我们提供给它所有的需要监听的socket文件描述符集合,而且主线程是死循环调用select/poll函数的,这里面涉及到用户空间数据到内核空间拷贝的过程

咱们需要监听的socket集合,数据变化非常小

每次就一到两个socket_fd需要更改,但是没有办法,因为select和poll函数,只是一个很单纯的函数

它在kernel层面,不会保留任何的数据信息,所以说每次调用都进行了数据拷贝

第二个缺陷: select 和 poll 函数它的返回值都是int整型值,只能代表有几个socket就绪或者有错误了,它没办法表示具体是哪个socket就绪了

这就导致了程序被唤醒以后,还需要新的一轮系统调用去检查哪个socket是就绪状态的,然后再进行socket数据处理逻辑,这里走了不少弯路(同时还存在用户态和内核态的切换,这样缺陷就更严重了)

epoll 就是为了解决这两个问题

 

7. epoll (…) 实现原理

epoll 函数在内核空间内,它有一个对应的数据结构去存储一些数据,这个数据结构其实就是eventpoll对象

这个eventpoll 可以通过一个系统函数epoll_create()函数去创建的

创建完成之后,系统函数返回一个eventpoll对象的id,相当于我们在内核空间开辟了一小块空间,并且我们也知道这块空间的位置

先说下eventpoll 的数据结构:三块重要的区域

 

一块是存放需要监听的socket_fd描述符列表

另一块就是就绪列表,存放就绪状态的socket信息

eventpoll 还有一块空间是eventpoll 的等待队列,这个等待队列保存的就是调用epoll_wait的进程

另外呢还提供了两个函数,一个是epoll_ctl函数,一个是epoll_wait函数

其中存放的socket集合信息采用的是红黑树的数据结构,socket集合信息经常用增删改查的,这种红黑树再适合不过了,保持了时间复杂度为O(logN)

epoll_ctl()

它可以根据eventpoll-id去增删改内核空间上eventpoll 对象的检查列表(socket信息)

 

epoll_wait() 

它主要的参数是eventpoll-id 表示此次系统调用需要检测的socket_fd集合,是eventpoll 中已经指定好的那些socket信息

 

epoll_wait 默认情况下会阻塞系统的调用线程,直到eventpoll 对象中关联的某个或者某些个socket就绪以后,epoll_wait函数才会返回

返回值是Int类型的

返回0,表示没有就绪的socket

返回大于0,表示有几个就绪的socket

返回-1表示异常

8. eventpoll 对象就绪列表的维护

select函数调用的流程:

 

socket对象有三块区域

读缓冲区

写缓冲区

等待队列

select函数调用的时候会把当前进程从工作队列里面拿出来

然后把进程引用追加到当前进程关注的每一个socket对象的等待队列中

然后当socket连接的客户端发送完数据之后,数据还是通过硬件DMA的方式把数据写入到内存,然后相应的硬件向CPU发出中断信号,CPU就会让出当前进程位置去执行网络数据就绪的中断程序,

这个中断程序就会把内存中的网络数据写入到对应的socket读缓冲区里面,之后把这个socket等待队列中的进程全部移动到工作队列中,再然后select函数返回

epoll函数流程非常相似

 

当我们调用系统函数epoll_ ctl时候,比如我们新添加一个需要关注的socket,其实内核程序会把当前的eventpoll对象追加到这个socket的等待队列里头

 

然后当socket连接的客户端发送完数据之后,数据还是通过硬件DMA的方式把数据写入到内存,然后相应的硬件向CPU发出中断信号,CPU就会让出当前进程位置去执行网络数据就绪的中断程序,

 

这个中断程序就会把内存中的网络数据写入到对应的socket读缓冲区里面,然后它发现这个socket的等待队列里头不是进程,而是一个eventpoll对象的引用

 

这个时候呢,他就会根据这个eventpoll对象的引用,将当前socket的引用追加到eventpoll的就绪链表的末尾(eventpoll 还有一块空间是eventpoll 的等待队列,这个等待队列保存的就是调用epoll_wait的进程)

 

然后,当中断程序把socket的引用追加到就绪列表的末尾之后,就继续检查eventpoll对象的等待队列,如果有进程,就会把进程转移到工作队列中

 

转移完毕之后,进程就有获取到CPU执行的时间片了,然后就是调用epoll_wait 函数,他这个函数就返回到java层面了

 

总结:

eventpoll对象等待队列里面,它有调用epoll_wait(,,,)函数进去的进程

然后再把这个进程,从这个eventpoll的等待队列里面迁移到工作队列里面

 

9. epoll_wait() 获取就绪的socket

epoll_wait() 返回值是Int类型的

 

返回0,表示没有就绪的socket

返回大于0,表示有几个就绪的socket

返回-1表示异常

那么获取就绪的socket是怎么实现的呢?

 

epoll_wait 函数,调用的时候会传入一个epoll_event事件数组指针

epoll_wait 函数正常返回之前,会把就绪的socket事件信息拷贝到这个数组指针里头

这样返回到上层程序,就能通过这个数组拿到就绪列表

10. epoll_wait 可不可以设置成非阻塞的

默认epoll_wait 是阻塞的

它有一个参数,表示阻塞时间的长度,如果这个参数设置为0,表示这个epoll_wait 是一个非阻塞调用的

每次调用都会去检查就绪列表

历史上的今天
May
15日
  • 微信
  • 交流学习,有偿服务
  • weinxin
  • 博客/Java交流群
  • 资源分享,问题解决,技术交流。群号:590480292
  • weinxin
avatar

发表评论

avatar 登录者:匿名
可以匿名评论或者登录后台评论,评论回复后会有邮件通知

  

已通过评论:0   待审核评论数:0