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的理解,我们就可以理解一下同步和异步的区别了。
同步和异步的关注点是用户线程和内核的交互方式。同步指的是用户线程发起I/O请求后需要等待或者轮询内核I/O操作完成后才能继续执行;异步是指用户线程发起I/O请求后仍然继续执行,当内核I/O操作完成后会通知用户线程,或者调用用户线程注册的回调函数。
阻塞和非阻塞的关注点是用户线程调用内核操作的方式。阻塞是指I/O操作需要彻底完成后才返回到用户空间,非阻塞是指I/O操作被调用后立即返回给用户一个状态值,无需等到I/O操作彻底完成。
同步阻塞I/O
同步阻塞I/O模型,最简单的I/O模型,用户线程在内核进行I/O操作时被阻塞。
同步阻塞I/O的操作为,用户线程通过系统调用read()函数,发起I/O读操作,由用户空间转到内核空间。内核等到数据包到达之后,然后将接收到的数据拷贝到用户空间,完成read()操作。整个过程中,用户线程需要等待read()函数将数据读取到用户空间缓冲区,才能够继续处理接收的数据。这将导致用户线程发起I/O请求时,不能做任何事情,对CPU的资源利用率不够。
同步非阻塞I/O
同步非阻塞I/O模型是建立在同步阻塞I/O模型的基础上的,用户线程发起I/O请求之后可以立即返回。
同步非阻塞I/O的操作,上面已经说了,用户线程发起I/O请求之后立即返回,但此时并未读取到任何数据,用户线程需要不断发起I/O请求,直到数据到达之后,才真正地读到数据,继续执行。整个过程中,用户需要不断地调用read(),尝试是否可以读取成功,读取成功才继续处理接收的数据。这样,虽然用户线程每次发起I/O请求后可以立即返回,但是这并没有什么意义,为了等到数据,仍然需要不断轮询、重复请求,消耗了大量的CPU资源,因此一般很少使用这种模型。
I/O多路复用
I/O多路复用模型是建立在内核提供的多路分离函数select基础之上的,使用select函数可以避免同步非阻塞I/O模型中轮询等待的问题。
I/O多路复用的操作位,用户线程首先将需要进行的I/O操作添加到select中,然后阻塞等待select系统调用返回。当数据到达时,I/O操作被激活,select函数返回,用户线程正式发起read()请求,读取数据并继续执行。
从流程上来看,使用select函数进行I/O请求和同步阻塞模型没有太大区别甚至还多了添加监视I/O,以及调用select函数的额外操作,效率更差。但是,使用select函数以后的最大优势就是可以在一个线程内同时处理多个I/O请求。用户可以注册多个I/O,然后不断地调用select读取被激活的I/O,即可达到在同一个线程内同时处理多个I/O请求的目的。而在同步阻塞模型中,用户必须通过多线程的方式才能达到这个目的地。
然而,使用select函数的优点并不仅限于此。虽然上述方式允许单线程内处理多个I/O请求,但是每个I/O请求的过程还是阻塞的(在select函数上阻塞),平均时间甚至比同步阻塞I/O模型还要长。如果用户线程只注册自己感兴趣的I/O请求,然后去做自己的事情,等到数据到来时再进行处理,那么则可以提高CPU的利用率。
I/O多路复用模型是最常使用的I/O模型,但是其异步程度还不够彻底,因为它使用了会阻塞线程的select系统调用。因此I/O多路复用模型只能称为异步阻塞I/O,而非真正的异步I/O。