I/O 模型
Java传统IO,读取磁盘文件的数据过程大致如下图所示:
以FileInputStream类为例,对数据的读取进行说明。FileInputStream类有一个read(byte b[ ])方法,byte b[ ]就是我们用来存储读取到的数据,储存在用户空间的缓冲区。read(byte b[ ])方法在内部调用了readBytes(b,0,b.length)本地方法,通过调用readBytes(b,0,b.length)方法(调用系统内核的read()方法),数据从磁盘被读取到内核的缓冲区(由磁盘控制器通过DMA操作将数据从磁盘到内核缓冲区,这个过程不依赖于CPU);然后用户进程再将数据从内核缓冲区拷贝到用户空间的缓冲区,用户进程再从用户空间缓冲区中读取数据,整个过程通过内核缓冲区来充当中间人的作用来实现文件的读取(用户进程不可以直接访问硬件)。
用户空间和内核空间
一个计算机通常有一定大小的内存空间,如一台4GB的运行地址空间(内存)的计算机,但是程序并不能完全的使用这些地址空间,因为这些地址空间是被划分为用户空间和内核空间的,程序只能使用用户空间的内存,这里所说的使用是指程序能够申请的内存空间,并不是真正访问的地址空间,下面看下什么是用户空间和内核空间:
1.用户空间
用户空间是常规进程所在的区域,什么是常规进程,打开任务管理器看到的就是常规进程:
用户空间是非特权区域,在该区的进程不能直接访问硬件设备。
2.内核空间
内核空间主要是指操作系统运行时所使用的用于程序调度、虚拟内存的使用或者连接硬件资源等的程序逻辑。内核进程有特别权利,比如能直接与设备控制器通讯,控制着整个用户进程的运行状态,和IO相关的一点是:所有的IO都直接或者间接的通过内核空间。
为什么要分用户空间和内核空间呢?这也是为了保证系统的稳定性和安全性。用户进程不可以直接访问硬件资源,如果用户进程需要访问硬件资源,必须调用操作系统提供的接口,这个调用接口的过程也就是系统调用。每一次系统调用都会存在两个内存空间之间的相互切换,通常的网络传输也是一次系统调用,通过网络传输的数据先是从内核空间接收到远程主机的数据,然后再从内核空间赋值到用户空间,供用户程序使用。这种从内核空间到用户空间的数据复制很费时的,虽然保住了程序的安全性和稳定性,但是牺牲了一部分的效率。
用户空间和内核空间的比例,在Windows32位操作系统中,默认的用户空间:内核空间的比例是 1:1;而在32位的Linux系统中默认的是3:1(3G用户空间,1G内核空间)。
进程执行I/O步骤
缓冲区,以及缓冲区如何工作,是所有I/O的基础。所谓的“输入/输出”讲的无非也就是把数据移入或移除缓冲区。
进程执行I/O操作,总结起来,就是向操作系统发出请求,让它要么把缓冲区里的数据排干净(写),要么用数据将缓冲区填满(读)。下面是进程执行I/O操作的步骤:
- 用户进程使用底层函数read(),建立和执行适当的系统调用,要求将其缓冲区填满,此时将控制权移交给内核;
- 内核进程向磁盘控制硬件发出命令,要求其从磁盘读取数据;
- 磁盘控制器和数据直接写入内核中的缓冲区,这一步是通过DMA完成,无需CPU协助(DMA:它允许不同速度的硬件装置来沟通,而不需要依赖于CPU的大量中断负载,否则,CPU 需要从来源把每一片段的资料复制到暂存器,然后把它们再次写回到新的地方。在这个时间中,CPU 对于其他的工作来说就无法使用)。
- 一旦磁盘控制器把缓冲区填满,内核随即将数据从内核缓冲区拷贝到用户空间中的缓冲区(进程执行read()调用时指定的缓冲区)。
- 进程从用户空间缓冲区拿到数据。
当然,如果内核空间已经有数据了,那么该数据只需要简单的拷贝出来即可。由于硬件通常不能直接访问用户空间,所以不能直接让磁盘控制器把数据送到用户空间的缓冲区。
Linux网络I/O模型
由于绝大多数的Java应用都部署在Linux系统上,Linux网络I/O模型也是需要了解的。
Linux的内核将所有外部设备都看做一个文件夹来操作,对一个文件的读写操作会调用内核提供的系统命令,返回一个file descriptor(fd,文件描述符),而对一个Socket的读写也会有相应的描述符,成为Socketfd(Socket描述符),描述符就是一个数字,它指向内核中的一个结构体(结构体:C/C++数据类型,类似于Java的类,存储各种不同类型的数据,这里存储的是文件路径、数据区等一些属性)。
根据UNIX网络编程对于I/O模型的分类,UNIX提供了5种I/O模型:
1.阻塞I/O模型
阻塞I/O模型是最常用的I/O模型,缺省情况下所有的文件操作都是阻塞的,以Socket为例:在用户空间中调用recvfrom,其系统调用直到数据包到达且被复制到应用进程的缓冲区或者发生错误时才返回,在此期间会一直等待,进程在从调用recvfrom开始到它返回的整段时间内都是被阻塞的,因此被称为阻塞I/O。
2.非阻塞I/O模型
recvfrom从用户空间到内核空间的时候,如果该缓冲区没有数据的话,就直接返回一个EWOULDBOCK错误,一般都对非阻塞I/O模型进行轮询检查这个状态,看内核空间是否有数据到来,有数据到来则从内核空间复制到用户空间。
3.I/O复用模型
Linux提供select/poll,进程通过将一个或者多个fd传递给select或poll系统调用,阻塞在select操作上,这样select/poll可以帮助我们侦测多个fd是否处于就绪状态。select/poll是顺序扫描fd是否就绪,而且支持的fd数量有限,因此它的使用受到了一些制约。Linux还提供了一个epoll系统调用,epoll使用基于事件驱动方式替代顺序扫描,因此性能更高。当有fd就绪时,立即会调用函数callback。
4.信号驱动I/O模型
首先开启Socket信号驱动I/O功能,并通过系统调用sigaction执行一个信号处理函数(此函数调用立即返回,进程继续工作,它是非阻塞的),当数据准备就绪时,就为进程生成一个SIGIO信号,通过信号会通知应用程序调用recvfrom来读取数据,并通知主循环函数来处理数据。
5.异步I/O
告知内核启动某个操作,并让内核在整个操作完成后(包括将数据从内核复制到用户自己的缓冲区)通知开发者。这种模型与信号驱动I/O模型的主要区别是:信号驱动I/O模型由内核通知开发者何时可以开始一个I/O操作,异步I/O模型由内核通知开发者I/O操作何时已经完成。
I/O多路复用技术
I/O编程中,经常需要处理多个客户端的接入请求,这时就可以利用多线程或者I/O多路复用技术进行处理。正如上面介绍的一样,I/O多路复用技术是通过把多个I/O的阻塞复用到同一个select的阻塞上,从而使得系统在单线程的情况下也可以同时处理多个客户端请求。
与传统的多线程模型相比,I/O多路复用的最大优势就是系统开销小,系统不需要创建新的额外线程,也不需要维护这些线程的运行,降低了系统的维护工作量,节省了系统资源。
I/O多路复用的主要应用场景:
❤ 服务器需要同时处理多个处于监听状态或者多个连接状态的套接字;
❤ 服务器需要同时处理多个网络协议的套接字;
❤ 一个服务器处理多个服务或者协议。
使用epoll的优势:
❤ 支持一个进程打开的socket fd不受限制;
❤ I/O效率不会随着fd数目的增加而线性下降;
❤ 使用mmap加速内核与用户空间的消息传递;
❤ epoll拥有更加简单的API。
BIO和NIO
上面五种I/O模型,经常使用到的为1和2,BIO(Blocking IO)和NIO(Nonblocking IO)。下面使用图片来加深理解:
BIO:
NIO:
从上图中就可以看出,NIO的单线程能处理的连接数量比BIO要高很多,原因就是因为Selector。
当一个连接建立之后,需要做两个步骤:
❤ 接收完客户端发过来的所有数据;
❤ 服务端处理完请求业务之后返回结果给客户端;
NIO和BIO的主要区别就在第一步:
❤ 在BIO中,等待客户端发送数据这个过程是阻塞的,这就造成了一个线程只能处理一个请求的情况,而机器能支持的最大线程数是有限的,这就是为什么BIO不能支持高并发的原因。
❤ 在BIO中,当一个Socket建立好之后,Thread并不会去阻塞接收这个Socket,而是将这个请求交给Selector,Selector会判断哪个Socket建立完成,然后通知对应线程,对应线程处理完数据后再返回给客户端,这样就可以让一个线程处理更多的请求了。
Selector原理
Selector是NIO的核心,它所做的事情就是:以单线程监视多个Socket I/O状态,空闲时阻塞当前线程,当有一个或者多个Socket有I/O事件时,就从阻塞状态中醒来。Selector 的发展大致经历了select,poll,epoll三个阶段的发展(Linux操作系统,Windows操作系统是其他函数实现)。
select的缺点:
❤ 单个线程能够监视的文件描述符数量存在最大限制,通常为1024,当然可以更改数量,但是数量越多性能越差;
❤ 内核/用户空间内存拷贝问题,select需要复制大量句柄数据结构从而产生巨大开销;
❤ select返回的是含有整个句柄的数组,应用程序如果没有完成对一个已经就绪的文件描述符进行I/O操作,那么之后每次select调用还是会将这些文件描述符通知线程。
相比select,poll使用链表保存文件描述符,因此没有了监视文件数量的限制,但是其他三个缺点依旧存在。
select和poll的实现机制差不多是一样的,只不过函数不同,参数不同,但是基本流程是相同的:
1.复制用户数据到内核空间;
2.估计超时时间;
3.遍历每个文件并调用f_op->poll()取得文件状态;
4.遍历完成检查状态,如果有就绪状态的文件则跳转至5,如果有信号产生则重新启动select或者poll,否则挂起线程并等待超时或唤醒超时或再次遍历每个文件状态;
5.将所有文件的就绪状态复制到用户空间;
6.清理申请的资源。
epoll:
epoll函数是第三阶段,它改进了select和poll的所有缺点,epoll将select与poll分为了三个部分:
1.epoll_ecreate()建立一个epoll对象;
2.epoll_ctl向epoll对象中添加socket套接字顺便给内核中断处理程序注册一个callback,高速内核,当文件描述符上有事件到达(或者中断)的时候就调用这个callback;
3.调用epoll_wait收集发生事件的链接。
epoll的三个核心点是:
❤ 使用mmap共享内存,即用户空间和内核空间共享一块物理地址,这样当内核空间要对文件描述符上的时间进行检查时就不需要来回拷贝数据了;
❤ 红黑树,用于存储文件描述符,当内核初始化epoll时,会开辟出一块内核告诉cache区,这块区域用于存储我们需要监管的所有Socket描述符,由于红黑树的数据结构,对文件描述符的增删查效率大为提高。
❤ rdlist,就绪描述符链表区,这是一个双向链表,epoll_wait()函数返回的也就是这个就绪链表,上面的epoll_ctl说了添加对象的时候会注册一个callback,这个callback的作用实际就是将描述符放入这个rdlist中,所以当一个socket上的数据到达的时候内核就会把网卡上的数据复制到内核,然后把socket描述符插入到就绪链表rdlist中。
参考:https://www.cnblogs.com/xrq730/p/5074199.html