IO和Socket编程
何为IO?
计算机结构
从计算机结构的角度来解读一下I/O,计算机结构分为 5 大部分:运算器、控制器、存储器、输入设备、输出设备。
输入设备(比如键盘)和输出设备(比如显示器)都属于外部设备。网卡、硬盘这种既可以属于输入设备,也可以属于输出设备。
从计算机结构的视角来看的话, I/O 描述了计算机系统与外部设备之间通信的过程。
操作系统
IO操作涉及到由用户态通过系统调用访问内核态。也就是说,我们的应用程序实际上只是发起了 IO 操作的调用而已,具体 IO 的执行是由操作系统的内核来完成的。
当应用程序发起 I/O 调用后,会经历两个步骤:
-
内核等待 I/O 设备准备好数据
-
内核将数据从内核空间拷贝到用户空间。
我们在平常开发过程中接触最多的就是 磁盘 IO(读写文件) 和 网络 IO(网络请求和响应)。
Socket编程
Socket是什么呢?
socket是在应用层和传输层中间的抽象层,它把传输层(TCP/UDP)的复杂操作抽象成一些简单的接口,供应用层调用实现进程在网络中的通信。
Socket通信过程
-
服务端和客户端初始化
socket
,得到文件描述符; -
服务端调用
bind
,将绑定在 IP 地址和端口; -
服务端调用
listen
,进行监听; -
服务端调用
accept
,等待客户端连接; -
客户端调用
connect
,向服务器端的地址和端口发起连接请求; -
服务端
accept
返回用于传输的socket
的文件描述符; -
客户端调用
write
写入数据;服务端调用read
读取数据; -
客户端断开连接时,会调用
close
,那么服务端read
读取数据的时候,就会读取到了EOF
,待处理完数据后,服务端调用close
,表示连接关闭。
Java中3中IO模型
BIO
BIO (Blocking I/O):同步阻塞 IO 模型中,应用程序发起 read 调用后,会一直阻塞,直到内核把数据拷贝到用户空间。
缺点:
-
当并发数较大时,需要创建大量线程来处理连接,系统资源占用较大。
-
连接建立后,如果当前线程暂时没有数据可读,则线程就阻塞在read 操作上,此时CPU为空闲状态会造成CPU资源浪费。
NIO
NIO (Non-blocking/New I/O):同步非阻塞IO模型中,通过轮询操作,反复调用read,避免了一直阻塞。
缺点:
应用程序不断轮询,查看数据是否已经准备好的过程是十分消耗 CPU 资源的。
基于上述缺点基于NIO的IO多路复用登场。
I/O多路复用
单个线程就可以同时处理多个网络连接。内核负责轮询所有socket,当某个socket有数据到达了,就通知用户进程。
-
select
-
poll
-
epoll
select
概述
它仅仅知道了,有I/O事件发生了,却并不知道是哪那几个流(可能有一个,多个,甚至全部),我们只能无差别轮询所有流,找出能读出数据,或者写入数据的流,对他们进行操作。所以select具有O(n)的无差别轮询复杂度,同时处理的流越多,无差别轮询时间就越长。
实现流程
Select 的实现思路很直接,假如程序同时监视如下图的 Sock1、Sock2 和 Sock3 三个 Socket,那么在调用 Select 之后,操作系统把进程 A 分别加入这三个 Socket 的等待队列中。操作系统把进程 A 分别加入这三个 Socket 的等待队列中,当任何一个 Socket 收到数据后,中断程序将唤起进程。
所谓唤起进程,就是将进程从所有的等待队列中移除,加入到工作队列里面,如下图所示:
将进程 A 从所有等待队列中移除,再加入到工作队列里面。经由这些步骤,当进程 A 被唤醒后,它知道至少有一个 Socket 接收了数据。程序只需遍历一遍 Socket 列表,就可以得到就绪的 Socket。
缺点
-
有连接限制
-
每次select都需要将进程加入到监视socket的等待队列,每次唤醒都要将进程从socket等待队列移除。这里涉及两次遍历操作,而且每次都要将文件列表传递给内核,有一定的开销。
-
进程被唤醒后,只能知道有socket接收到了数据,无法知道具体是哪一个socket接收到了数据,所以需要用户进程进行遍历,才能知道具体是哪个socket接收到了数据。
poll
poll本质上和select没有区别, 但是它没有最大连接数的限制,原因是它是基于链表来存储的.
缺点
除了select的第一点解决了其它两点还是没有解决
epoll
epoll可以理解为event poll,不同于忙轮询和无差别轮询,epoll会把哪个流发生了怎样的I/O事件通知我们。所以我们说epoll实际上是事件驱动(每个事件关联上fd)的,此时我们对这些流的操作都是有意义的。时间复杂度降低到了O(1)
通过红黑树和双链表数据结构,并结合回调机制,造就了epoll的高效。
设计思路
措施一:功能分离
select的添加等待队列和阻塞进程是合并在一起的,每次调用select()操作时都得执行一遍这两个操作,从而导致每次都要将fd[]传递到内核空间,并且遍历fd[]的每个fd的等待队列,将进程放入各个fd的等待队列中。
epoll优化的方案是:将添加等待队列和阻塞进程拆分成两个独立的操作,不用每次都去重新维护等待队列,先用 epoll_ctl 维护等待队列,再调用 epoll_wait 阻塞进程。显而易见地,效率就能得到提升。
措施二:就绪列表
Select 低效的另一个原因在于程序不知道哪些 Socket 收到数据,只能一个个遍历。如果内核维护一个“就绪列表”,引用收到数据的 Socket,就能避免遍历。
select会把整个fd[]返回给用户程序,让用户程序自己去遍历哪个fd有接收到网络数据。epoll只会把接收到网络数据的fd[]返回给用户程序,用户程序不用自己去进行遍历查询。
实现流程
创建Epoll对象
调用epoll_create方式时,会创建一个eventpoll对象(),eventpoll 对象也是文件系统中的一员,和 Socket 一样,它也会有等待队列。创建一个代表该 Epoll 的 eventpoll 对象是必须的,因为内核要维护“就绪列表”等数据,“就绪列表”可以作为 eventpoll 的成员。
维护监控对象
创建 Epoll 对象后,可以用 epoll_ctl 添加或删除所要监听的 Socket。eventpoll会通过一个红黑树来存储所有被监视的socket对象,实现快速查找,删除和添加。
eventpoll 对象相当于 Socket 和进程之间的中介,Socket 的数据接收并不直接影响进程,而是通过改变 eventpoll 的就绪列表来改变进程状态。
接受数据
当 Socket 收到数据后,中断程序会给 eventpoll 的“就绪列表”添加 Socket 引用。
阻塞和唤醒进程
当程序执行到 epoll_wait 时,如果 Rdlist 已经引用了 Socket,那么 epoll_wait 直接返回,如果 Rdlist 为空,阻塞进程。
当 Socket 接收到数据,中断程序一方面修改 Rdlist,另一方面唤醒 eventpoll 等待队列中的进程,进程 A 再次进入运行状态
优点
-
没有最大并发连接的限制,能打开的FD的上限远大于1024(1G的内存上能监听约10万个端口);
-
效率提升,不是轮询的方式,不会随着FD数目的增加效率下降。只有活跃可用的FD才会调用callback函数;即Epoll最大的优点就在于它只管你“活跃”的连接,而跟连接总数无关,因此在实际的网络环境中,Epoll的效率就会远远高于select和poll;
-
内存拷贝,利用mmap()文件映射内存加速与内核空间的消息传递;即epoll使用mmap减少复制开销。
缺点
-
epoll只能工作在 linux 下
select | poll | epoll | |
---|---|---|---|
操作方式 | 遍历 | 遍历 | 回调 |
数据结构 | bitmap | 数组 | 红黑树+双向链表 |
最大连接数 | 1024(x86)或 2048(x64) | 无上限 | 无上限 |
最大支持文件描述符数 | 一般有最大值限制 | 65535 | 65535 |
fd拷贝 | 每次调用select,都需要把fd集合从用户态拷贝到内核态 | 每次调用poll,都需要把fd集合从用户态拷贝到内核态 | fd首次调用epoll_ctl拷贝,每次调用epoll_wait不拷贝 |
工作模式 | LT | LT | 支持ET高效模式 |
工作效率 | 每次调用都进行线性遍历,时间复杂度为O(n) | 每次调用都进行线性遍历,时间复杂度为O(n) | 事件通知方式,每当fd就绪,系统注册的回调函数就会被调用,将就绪fd放到readyList里面,时间复杂度O(1) |
总结
select为什么在socket连接很多的情况下性能不佳?
将维护socket监控列表和阻塞进程的操作合并在了一起,每次select()调用都会触发这两个操作,从而导致每次调用select()都需要把全量的fd[]列表从用户空间传递到内核空间,内核线程在阻塞进程前需要遍历fd[]将待阻塞的进程放入到每个fd的等待队列里(第一次遍历)。当有网络数据到来时,并不知道网络数据属于具体哪个socket,只知道收到过网络数据,因此需要遍历fd[]唤醒等待队列里的阻塞进程(第二次遍历),并且把fd[]从内核空间拷贝到用户空间,让用户程序自己去遍历fd[]判别哪个socket收到了网络数据(第三次遍历,发生在用户空间)。fd[]比较大的情况,大量的遍历操作会导致性能急剧下降,所以select会默认限制最大文件句柄数为1024,间接控制fd[]最大为1024。
poll其实内部实现基本跟select一样,区别在于它们底层组织fd[]的数据结构不太一样,从而实现了poll的最大文件句柄数量限制去除了
epoll是怎么优化select上述那些问题?
引入了eventpoll这个中间结构,它通过红黑树(rbr)来组织所有待监控的socket对象,实现高效的查找,删除和添加。当收到网络数据时,会触发对应的fd的回调函数,这时不是去遍历各个fd的等待队列进行唤醒进程的操作了,而是把收到数据的socket加入到就绪列表(底层是一个双向链表)。eventpoll有个单独的等待队列来维护待唤醒的进程,避免了像select那样每次需要遍历fd[]来查找各个fd的等待队列的进程。
epoll有这几个核心的数据结构:
红黑树:存放所有待监听的socket
双向链表:存放收到网络数据的socket
等待队列:待唤醒的等待线程
AIO
异步 IO 是基于事件和回调机制实现的,也就是应用操作之后会直接返回,不会堵塞在那里,当后台处理完成,操作系统会通知相应的线程进行后续的操作。