I/O模型
在开始NIO的学习之前,先对I/O的模型有一个理解,这对NIO的学习是绝对有好处的。我画一张图,简单表示一下数据从外部磁盘向运行中进程的内存区域移动的过程:
这张图片明显忽略了很多细节,只涉及了基本操作,下面分析一下这张图。
用户空间和内核空间
一个计算机通常有一定大小的内存空间,如一台计算机有4GB的地址空间,但是程序并不能完全使用这些地址空间,因为这些地址空间是被划分为用户空间和内核空间的。程序只能使用用户空间的内存,这里所说的使用是指程序能够申请的内存空间,并不是真正访问的地址空间。下面看下什么是用户空间和内核空间:
1、用户空间
用户空间是常规进程所在的区域,什么是常规进程,打开任务管理器看到的就是常规进程:
JVM就是常规进程,驻守于用户空间,用户空间是非特权区域,比如在该区域执行的代码不能直接访问硬件设备。
2、内核空间
内核空间主要是指操作系统运行时所使用的用于程序调度、虚拟内存的使用或者连接硬件资源等的程序逻辑。内核代码有特别的权利,比如它能与设备控制器通讯,控制着整个用于区域进程的运行状态。和I/O相关的一点是:所有I/O都直接或间接通过内核空间。
那么,为什么要划分用户空间和内核空间呢?这也是为了保证操作系统的稳定性和安全性。用户程序不可以直接访问硬件资源,如果用户程序需要访问硬件资源,必须调用操作系统提供的接口,这个调用接口的过程也就是系统调用。每一次系统调用都会存在两个内存空间之间的相互切换,通常的网络传输也是一次系统调用,通过网络传输的数据先是从内核空间接收到远程主机的数据,然后再从内核空间复制到用户空间,供用户程序使用。这种从内核空间到用户控件的数据复制很费时,虽然保住了程序运行的安全性和稳定性,但是牺牲了一部分的效率。
最后,如何分配用户空间和内核空间的比例也是一个问题,是更多地分配给用户空间供用户程序使用,还是首先保住内核有足够的空间来运行,还是要平衡一下。在当前的Windows 32位操作系统中,默认用户空间:内核空间的比例是1:1,而在32位Linux系统中的默认比例是3:1(3GB用户空间、1GB内核空间)。
进程执行I/O操作的步骤
缓冲区,以及缓冲区如何工作,是所有I/O的基础。所谓"输入/输出"讲的无非也就是把数据移入或移出缓冲区。
进程执行I/O操作,归结起来,就是向操作系统发出请求,让它要么把缓冲区里的数据排干净(写),要么用数据把缓冲区填满(读)。进程利用这一机制处理所有数据进出操作,操作系统内部处理这一任务的机制,其复杂程度可能超乎想像,但就概念而言,却非常直白易懂,从上面的图,可以总结一下进程执行I/O操作的几步:
1、进程使用底层函数read(),建立和执行适当的系统调用,要求其缓冲区被填满,此时控制权移交给内核
2、内核随即向磁盘控制硬件发出命令,要求其从磁盘读取数据
3、磁盘控制器和数据直接写入内核内存缓冲区,这一步通过DMA完成,无需主CPU协助。这里多提一句,关于DMA,可以百度一下,它是现代电脑的重要特色,它允许不同速度的硬件装置来沟通,而不需要依赖于CPU的大量中断负载,大大提升了整个系统的效率
4、一旦磁盘控制器把缓冲区填满,内核随即把数据从内核空间的临时缓冲区拷贝到进程执行read()调用时指定的缓冲区
5、进程从用户空间的缓冲区中拿到数据
当然,如果内核空间里已经有数据了,那么该数据只需要简单地拷贝出来即可。至于为什么不能直接让磁盘控制器把数据送到用户空间的缓冲区呢?最简单的一个理由就是,硬件通常不能直接访问用户空间。
同步和异步、阻塞和非阻塞
有了上面对于I/O的解读,我们来看一下同步和异步、阻塞和非阻塞两组概念的区别,主要二者在关注点上有所不同。
1、同步和异步
同步和异步这个概念比较广,不仅仅是在I/O,其他的还有诸如同步调用/异步调用、同步请求/异步请求,都是一个意思。同步和异步,关注的是消息通信机制。
所谓同步,就是在发出一个"调用请求"时,在没有得到结果之前,该"调用请求"就不返回,但是一旦调用返回就得到返回值了。换句话说,就是由"调用者"主动等待"调用"的结果。像我们平时写的,方法A调用Math.random()方法、方法B调用String.substring()方法都是同步调用,因为调用者主动在等待这些方法的返回。
所谓异步,则正好相反,"调用"发出之后,这个调用就直接返回了,所有没有返回结果。换句话说,当一个异步调用请求发出之后,调用者不会立刻得到结果,因此异步调用适用于那些对数据一致性要求不是很高的场景,比如模块A更新了缓存中的某个值,模块B将某个内容分享到新浪微博,这些模块的关注点更多是"做了这件事"而不是"做了这件事是否马上成功",用分布式的话说,就是牺牲了系统的强一致性而提高了整个系统的可用性及分区容错性。如果这种场景下,我们希望获取异步调用的结果,"被调用者"可以通过状态、通知来通知调用者,或通过回调函数处理这个调用,对应Java中的有Future/FutureTask、wait/notify。
2、阻塞和非阻塞
阻塞和非阻塞关注的是程序在等待调用结果时的状态。
阻塞调用指的是调用结果返回之前,当前线程会被挂起,调用线程只有在得到结果之后才会返回。
非阻塞调用指的是在不能立即得到结果之前,该调用不会阻塞当前线程。
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操作何时已经完成。
再谈BIO与NIO
上面讲了五种IO模型,其实重点就是1和3,1为BIO(Blocking IO),3为NIO(Nonblocking IO),所以再用图加深一下理解,首先是BIO的:
接着是NIO的:
从图中可以看出,NIO的单线程能处理的连接数量比BIO要高出很多,为什么呢?原因就是NIO的Selector。
当一个连接建立之后,有两个步骤要做:
接受完客户端发过来的所有数据
服务端处理完请求业务之后返回Response给客户端
NIO与BIO的主要区别就在第一步:
在BIO中,等待客户端发送数据这个过程是阻塞的,这就造成了一个线程只能处理一个请求的情况,而机器能支持的最大线程数是有限的,这就是为什么BIO不能支持高并发的原因
在NIO中,当一个Socket建立好之后,Thread并不会去阻塞接收这个Socket,而是将这个请求交给Selector,Selector会判断哪个Socket建立完成,然后通知对应线程,对应线程处理完数据再返回给客户端,这样就可以让一个线程处理更多的请求了
在NIO上,我们看到了主要是使用Selector使得一条线程可以处理多个Socket,接着我们来理解一下Selector。
Selector原理
在上图中,我们可以看到NIO的核心就是Selector,Selector做的事情就是:
以单条线程监视多Socket I/O的状态,空闲时阻塞当前线程,当有一个或者多个Socket有I/O事件时就从阻塞状态中醒来
Selector就是这种思想的实现,其发展大致经历了select、poll、epoll三个阶段的发展(Linux操作系统,Windows操作系统是其他函数实现)。
第一阶段为select阶段,select有如下缺点:
单个进程能够监视的文件描述符数量存在最大限制,通常是1024,当然可以更改数量,但是数量越多性能越差
内核/用户空间的内存拷贝问题,select需要复制大量句柄数据结构从而产生巨大开销
select返回的是含有整个句柄的数组,应用程序需要遍历整个数组才能发现哪些句柄发生了事件
select的触发方式是水平触发,应用程序如果没有完成对一个已经就绪的文件描述符进行I/O操作,那么之后每次select调用还是会将这些文件描述符通知进程
相比select,poll使用链表保存文件描述符,因此没有了监视文件数量的限制,但是其它三个缺点依旧存在。
综上,再总结一下,select和poll的实现机制差不多是一样的,只不过函数不同、参数不同,但是基本流程是相同的:
复制用户数据到内核空间
估计超时时间
遍历每个文件并调用f_op->poll()取得文件状态
遍历完成检查状态,如果有就绪的文件则跳转至5、如果有信号产生则重新启动select或者poll、否则挂起进程并等待超时或唤醒超时或再次遍历每个文件状态
将所有文件的就绪状态复制到用户空间
清理申请的资源
epoll函数是第三个阶段,它改进了select与poll的所有缺点,epoll将select与poll分为了三个部分:
epoll_ecreate()简历一个epoll对象
epoll_ctl向epoll对象中添加socket套接字顺便给内核中断处理程序注册一个callback,高速内核,当文件描述符上有事件到达(或者中断)的时候就调用这个callback
调用epoll_wait收集发生事件的链接
在实现上epoll()的三个核心点是:
使用mmap共享内存,即用户空间和内核空间共享的一块物理地址,这样当内核空间要对文件描述符上的事件进行检查时就不需要来回拷贝数据了
红黑树,用于存储文件描述符,当内核初始化epoll时,会开辟出一块内核高速cache区,这块区域用于存储我们需要监管的所有Socket描述符,由于红黑树的数据结构,对文件描述符增删查效率大为提高
rdlist,就绪描述符链表区,这是一个双向链表,epoll_wait()函数返回的也就是这个就绪链表,上面的epoll_ctl说了添加对象的时候会注册一个callback,这个callbakc的作用实际就是将描述符放入rdlist中,所以当一个socket上的数据到达的时候内核就会把网卡上的数据复制到内核,然后把socket描述符插入到就绪链表rdlist中