JavaIO-IO模型
IO模型
操作系统
根据冯.诺依曼结构计算机定义,计算机分成5个部分:运算器、控制器、存储器、输入设备、输出设备,操作系统\用户程序工作在这5个部分之上,对数据进行计算转换、发送传输,本质上是对这5个部分进行协调控制的过程;
操作系统负责计算机的资源管理和进程的调度。为了维护操作系统的安全(操作系统能够正常运行、数据的安全性),操作系统需要对某些操作或者某些关键数据区域进行保护,故而将内存空间划分为内核空间和用户空间:内核空间是操作系统内核访问的区域,是受保护的内存空间,而用户空间是用户应用程序访问的内存区域。由于计算机的5个部分为整个计算机的基础,所以5个部分的数据和操作都属于内核空间。
IO
应用程序是跑在用户空间的,需要经过操作系统才能做IO操作,如磁盘文件读写、内存的读写等等。应用程序要把数据发送到io设备,只能通过调用操作系统开放出来的API来操作;它不存在实质的IO过程,真正的IO是在操作系统执行的。即应用程序的IO操作分为两种动作:IO调用和IO执行。
IO调用是由进程(应用程序的运行态)发起,而IO执行是操作系统内核的工作。此时所说的IO是应用程序对操作系统IO功能的一次触发,即IO调用。
应用程序发起的一次IO操作包含两个阶段:
- IO调用:应用程序进程向操作系统内核发起调用。
- IO执行:操作系统内核完成IO操作。
操作系统内核完成IO操作还包括两个过程:
- 准备数据阶段:内核等待I/O设备准备好数据
- 拷贝数据阶段:将数据从内核缓冲区拷贝到用户进程缓冲区
** **
总结:IO模型,IO由用户程序和操作系统共同完成,由IO调用、IO执行两步组成,用户态的程序发起IO调用,内核态的操作系统完成IO执行;
IO过程是否阻塞、同步还是异步,体现在IO调用、IO执行这两步的执行过程上;
- IO调用是否阻塞决定是程序是否阻塞;
- IO 过程的主导权决定了同步与异步:程序主导 IO 的执行(包括主动等待数据准备和触发数据拷贝)则为同步 IO,其发起、执行、结果处理在同一个执行流中;内核主导 IO 全程(自动完成数据准备、拷贝并主动通知)则为异步 IO,其 IO 发起与结果处理分属不同执行流,程序无需参与 IO 执行过程。
五种IO模型
同步阻塞 IO(blocking IO)
同步阻塞 IO 模型是最常用的一个模型,也是最简单的模型。在linux中,默认情况下所有的socket都是blocking。它符合人们最常见的思考逻辑。阻塞就是进程 “被” 休息, CPU处理其它进程去了。
所以,blocking IO的特点就是在IO执行的两个阶段都被block了。
优点:
- 能够及时返回数据,无延迟;
- 对内核开发者来说这是省事了;
缺点:
- 对用户来说处于等待就要付出性能的代价了;
同步非阻塞 IO
同步非阻塞就是 “每隔一会儿瞄一眼进度条” 的轮询(polling)方式。在这种模型中设备是以非阻塞的形式打开的。
在网络IO时候,用户查询通过IO调用检查数据是否准备好。与阻塞IO相比,相当于非阻塞将大的整片阻塞时间分成N多的小的阻塞, 所以用户程序有机会干别的。
非阻塞的IO调用之后,进程并没有被阻塞,内核马上返回给进程,如果数据还没准备好,此时会返回一个error。
进程在返回之后,可以干点别的事情,然后再发起recvform系统调用。
重复上面的过程,循环往复的进行IO调用。这个过程通常被称之为轮询。轮询检查内核数据,直到数据准备好,再拷贝数据到进程,进行数据处理。需要注意,拷贝数据整个过程用户程序仍然是属于阻塞的状态。
同步非阻塞 IO特点是用户进程需要不断的主动询问kernel数据好了没有 优点:能够在等待任务完成的时间里干其他活了(包括提交其他任务,也就是 “后台” 可以有多个任务在同时执行)。 缺点:任务完成的响应延迟增大了,因为每过一段时间才去轮询read操作,而任务可能在两次轮询之间的任意时间完成。这会导致整体数据吞吐量的降低。
IO 多路复用
由于同步非阻塞IO需要用户线程自己不断主动轮询,轮询会消耗大量的CPU时间。改进点就是构造一个机制,可以帮用户程序同时轮询多个任务,只要有任何一个任务完成就去处理它。那么这就是所谓的 “IO 多路复用”。UNIX/Linux 下的 select、poll、epoll 就是干这个的(epoll 比 poll、select 效率高,做的事情是一样的)。
IO多路复用有三个特别的内核级别的调用select、poll、epoll函数。
实现方式是:
- 首先,用户程序创建一个监听线程,负责通过select|poll|epoll监听用户程序发起的IO调用的状态;
- 然后,当用户程序的其他线程需要发起IO时,先创建或者获取到IO调用标识符交给并监听线程(注册到Selector);
- 接着,内核准备好数据后被监听线程监听到,将对应的IO调用标识符交给其发起IO调用的用户线程,然后用户线程发起IO调用将数据由内核拷贝到用户进程,当然这个过程是阻塞的。
内核select|poll|epoll调用相对比非阻塞IO的轮询的区别在于select可以等待多个socket,能实现同时对多个IO端口进行监听,当其中任何一个socket的数据准好就能返回可读状态,然后进程再进行recvform系统调用,将数据由内核拷贝到用户进程。
内核select|poll|epoll调用之后会阻塞进程,与blocking IO阻塞不同在于,阻塞的是监听线程而非用户发起io的线程,并且此时的select不是等到socket数据全部到达再处理, 而是有了一部分数据就会调用用户进程来处理。
IO 多路复用相当于是在阻塞IO前添加了一套机制,用于监听目标IO的就绪状态,从而避免用户程序的阻塞;从整个IO过程来看,他们都是顺序执行的,因此可以归为同步模型(synchronous)。都是进程主动等待且向内核检查状态。
select、poll、epoll的区别
select
select既做到了一个线程处理多个fd,又减少了系统调用的开销
使用过程:
- 将所有需要监听的fd组装成数组(BitMap);
- select调用,将fd数组作为参数调用,此处会阻塞,直到其中至少一个fd有数据;
- 遍历返回的fd数组,找到有数据的fd,通过fd进行数据读写;
- 重置fd数组,进行下一轮的select调用
优点:
- select使用数组保存监听的fd,然后用户态把要遍历的fd数组拷贝到了内核态让内核态来遍历,这样遍历判断时不用一直用户态和内核态频繁切换;
- 所有平台都支持,良好的跨平台性
缺点:
遍历数量有限制,bitmap最大1024位,一个进程最多只能处理1024个客户端
fd数组拷贝到了内核态仍然有开销。select 调用需要传入 fd 数组,拷贝一份到内核,高并发场景下拷贝消耗的资源是惊人的。(可优化为不复制)
select并没有通知用户态fd有数据,仍然需要O(n)的遍历;
poll
- 底层操作的数据结构
pollfd,使用链表存储,去掉了 select 只能监听 1024 个文件描述符的限制 - 其他和select一致
epoll
epoll是一个大的改进,不在只是一个api函数调用,epoll定义定义了一个epollfd的文件,对epoll的数据进行统一的管理,具有了面向对象的思维模式。
使用过程:
- 通过
epoll_create函数,创建epollfd文件; - 创建用户程序需要关注的
epoll_events事件数据,事件包含fd、event事件类型; - 通过
epoll_ctl函数将epoll_events事件注册到内核,这里需要拷贝epoll_events到内核,epoll_ctl支持epfd的新增,修改,删除操作; - 调用
epoll_wait函数等待fd就绪事件,这个操作是阻塞的; epoll_wait返回,fd就绪。从放回的就绪事件数据中获取fd,进行fd的数据读写;- 进行下一轮的
epoll_wait调用
epoll的使用就是epoll_create、epoll_ctl、epoll_wait这三个内核方法的调用过程
具体细节:
- epoll_create创建
epollfd对象,内部使用红黑树+链表- 红黑树:为了避免fd数量过多导致epoll过程遍历|操作fd的开销,红黑树可以提供O(logn)的查询和修改效率
- 链表:epoll维护额外的链表来记录就绪事件,用户调用
epoll_wait()函数时,只会返回有事件发生的文件描述符的个数,不需要像 select/poll 那样轮询扫描整个 socket 集合,大大提高了检测的效率
- 双链表存储就绪的文件描述符列表,epoll_wait调用时,检测此链表中是否有数据,有的话直接返回;当有数据的时候,会把相应的文件描述符’置位’,但是
epoll没有revent标志位,所以并不是真正的置位。这时候会把有数据的文件描述符放到队首。 - epoll 使用事件驱动的机制,这个事件驱动是内核内部的回调。就绪事件发生时,通过回调函数将其加入到这个就绪事件列表中。也就是说从就绪事件到添加到就绪列表的过程是O(1)的
epoll会返回有数据的文件描述符的个数,根据返回的个数,读取前N个文件描述符即可
事件触发模式:
epoll 支持两种事件触发模式,分别是边缘触发(edge-triggered,ET)和水平触发(level-triggered,LT)。
水平触发LT模式:支持阻塞和非阻塞。当接收缓冲区有数据、发送缓冲区有空闲空间时,则会触发事件(epoll_wait函数返回)。有数据则会一直触发事件,用户查询可以延迟处理。优点主要在于其简单且稳定,不容易出现问题。缺点是事件触发过多导致效率降低。
- 坑:
- 未及时处理就绪数据,导致事件高频重复触发:接收缓冲区有未处理完的数据,导致一直触发
OP_READ占用CPU。处理方法时一次性读完缓冲区 - 误注册 “不必要的事件”,导致无效触发:如客户端连接建立后立即注册
OP_WRITE事件(想后续发送数据)发送缓冲区默认是空闲,LT一直触发可发送事件,浪费资源。处理方法:按需注册,有数据要写时才注册OP_WRITE事件;写完后立即取消OP_WRITE注册
- 未及时处理就绪数据,导致事件高频重复触发:接收缓冲区有未处理完的数据,导致一直触发
边缘触发ET模式:支持非阻塞。不关注缓冲区,每次新数据到达、每次新数据可写时,则会触发事件。只触发一次,用户查询需要及时处理。优点就是减少了epoll的触发次数,缺点是要求必须一次性将所有的数据处理完。
| select | poll | epoll | |
|---|---|---|---|
| 底层数据结构 | 数组 | 链表 | 红黑树和双链表 |
| 获取就绪的fd | 遍历 | 遍历 | 事件回调 |
| 事件复杂度 | O(n) | O(n) | O(1) |
| 最大连接数 | 1024 | ·无限制 | 无限制 |
| fd数据拷贝 | 每次调用select,需要将fd数据从用户空间拷贝到内核空间 | 每次调用poll,需要将fd数据从用户空间拷贝到内核空间 | 使用内存映射(mmap),不需要从用户空间频繁拷贝fd数据到内核空间 |