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 是一个非阻塞调用的
每次调用都会去检查就绪列表
您可以选择一种方式赞助本站
支付宝扫一扫赞助
微信钱包扫描赞助
赏