多路复用
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生成的文件描述符
- 因为咱的程序都是运行在linux或者unix操作系统上,这种操作系统,一切皆文件,
-
操作系统 这个
select
函数被调用以后,- 首先会去fd集合中去检查内存中
socket
套接字的状态,这个时间复杂度是O(N)
的,然后检查完一遍之后,如果有就绪状态的socket
,那么就会直接返回,不会阻塞当前线程。 - 否则的话,那个就说明当前指定
fd
集合对应的socket
没有就绪状态,那么就需要阻塞当前调用线程了,直到有某个socket
有数据之后,才唤醒线程。
- 首先会去fd集合中去检查内存中
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
是一个非阻塞调用的 - 每次调用都会去检查就绪列表