[TOC]
IO技术
计算机执行IO的三种技术:
方式 | 无中断 | 使用中断 |
---|---|---|
通过处理器实现IO-内存间的传送 | 可编程IO | 中断驱动IO |
IO-内存间直接传送 | - | 直接存储器访问(DMA) |
- 可编程IO:处理器代表一个 进程向IO模块发送一个IO指令;该进程进入忙等待,直到操作完成后才可以继续执行。
- 中断驱动IO : 处理器代表进程向IO模块发出IO指令,有两种可能:如果来自进程的IO指令时非阻塞的,那么处理器继续执行发出IO命令的进程的后续指令;如果IO指令时阻塞的,那么处理器执行的下一条指令来自操作系统,它将当前进程设置为阻塞态并且调度其他进程。
- DMA:一个DMA模块控制内存和IO模块之间的数据交换。为传送一块数据,处理器给DMA模块发请求,并且只有当怎个数据块传送结束后,它才被中断(DMA模块给处理器发送一个中断信号)。
IO设备和缓冲方案
IO设备
分为面向块和面向流两大类。
- 面向块的设备: 将信息存储在块中,块的大小通常固定,传输过程中一次传送一块磁盘和USB智能卡都是面向块的设备;
- 面向流: 以字节流的形式输入输出数据,没有块结构。终端、打印机、通信端口、鼠标和其他指示设备都属于面向流的设备。
缓冲方案
- 无缓冲
- 单缓冲
- 双缓冲
- 循环缓冲
- 单缓冲
在面向块的设备中,输入传送的数据被放到系统缓冲区中,当传送完成时,从系统缓冲区拷贝数据到用户空间,并立即请求另一块。用户进程可以在下一个数据块读取的同时处理已读入的数据块。在面向块的输出中,可以将数据从用户空间拷贝到系统缓冲区,最终从系统缓冲区输出。
在面向流的设备,单缓冲方案可以以每次传送一行的方式或者每次传送一个字节的方式使用。 - 双缓冲
在一个进程往一个缓冲区传送数据(从缓冲区取数据)的同时,操作系统正在清空或者填充另一个缓冲区。 - 循环缓冲
当使用两个以上的缓冲区是,这组缓冲区自身被当做循环缓冲区。
磁盘性能参数
磁盘驱动器工作时,磁盘以一种恒定的速度旋转,如5400r/min, 7200r/min等。为了读写,磁头必须定位于指定的磁道和磁道中指定的扇区的开始处。
- 寻道时间: 将磁头臂移到指定磁道所需要的时间;
- 旋转延迟: 将磁盘的带访问区域转到读写磁头可访问的位置所需要的时间;
- 传送时间: 传输数据所需时间;
- 排队延迟:进程发出一个IO请求时,必须首先在一个队列中等待该设备可用。
IO模型
常见的IO模型有四种:
- 同步阻塞IO(Blocking IO):即传统的IO模型。
- 同步非阻塞IO(Non-blocking IO):默认创建的socket都是阻塞的,非阻塞IO要求socket被设置为NONBLOCK。注意这里所说的NIO并非Java的NIO(New IO)库。
- IO多路复用(IO Multiplexing):即经典的Reactor设计模式,有时也称为异步阻塞IO,Java中的Selector和Linux中的epoll都是这种模型。
- 异步IO(Asynchronous IO)
同步和异步
同步和异步的概念描述的是用户线程与内核的交互方式:同步是指用户线程发起IO请求后需要等待或者轮询(主动同步)内核IO操作完成后才能继续执行;而异步是指用户线程发起IO请求后仍继续执行,当内核IO操作完成后会通知用户线程,或者调用用户线程注册的回调函数。
阻塞和非阻塞
阻塞和非阻塞的概念描述的是用户线程调用内核IO操作的方式:阻塞是指IO操作需要彻底完成后才返回到用户空间;而非阻塞是指IO操作被调用后立即返回给用户一个状态值,无需等到IO操作彻底完成。
同步和异步描述的是调用方和被调用方之间的状态的关系,同步情况下,调用方和被调用方的状态是需要同步的,即被调用方返回结果后,调用方才会产生结果,异步的情况即二者的返回状态不是同步的,调用方调用了被调用方之后,无需后续步骤(调用之后,忘记这个调用)。
阻塞和非阻塞是调用者自身的状态,阻塞是无响应直到完成,非阻塞则是不管是否执行完成都立即返回。
所以,阻塞非阻塞是站在进程的角度上来审视进程的执行形态的,这个状态描述和同步、异步不是互斥也不是相关的,他们描述的是不同层次的形态。比如A进程向B进程发起一个请求,以HTTP请求为例,如果A发出请求后依赖B返回的响应(依赖这个结果,否则无法继续),那么A请求B这个过程就是同步的,也许B处理这个请求的方式可能是阻塞也可能是非阻塞的,但是都不影响我们描述A->B这个请求过程。还有就是如果A使用子线程去发起请求那么是不是异步请求,也不是,使用子线程只能把阻塞改成非阻塞,A依然依赖B的响应结果,依然是同步的过程。
如果A通过ajax方式向B发送请求,A无需等待B返回结果,可以直接做后面的事情,也不需要再关心响应到来时去接受和处理,这一切都在调用之前的回调函数里面定下来了,A的任务就是发出一个请求,然后就不管了。这是典型的异步模型。在A->B这个过程中,A不会等待B返回结果,甚至连看一眼都没有看(A并不需要B返回的结果),所以异步必然是非阻塞的。
缓冲区操作
缓冲区以及缓冲区如何操作是IO的基础,输入输出实际上就是将数据移入或移除缓冲区。
进程执行IO操作,就是向操作系统发出请求,让它将缓冲区数据排干或将缓冲区填满。进程使用这一机制处理所有的数据进出操作。所有IO操作都间接或直接通过内核空间。
当进程请求IO操作时,会执行一个系统调用陷入内核(将控制权交给内核),内核以这种方式呗调用后,随机执行必要步骤将进程所需的数据传送到内核空间的缓冲区中,然后将数据从内核的缓冲区拷贝到用户空间的缓冲区。
为什么不直接将数据拷贝到用户空间的缓冲区?
- 硬件通常不能直接访问用户空间
- 像磁盘这些基于块的存储设备操作的数据单位是块,用户进程请求的可能是任意大小的或非对齐的数据块。在数据往来于用户空间和存储设备过程中,内核负责数据的分解和组合。
- 发散和汇聚
进程只需要请求一个系统调用就可以填充多个缓冲区或者排干多个缓冲区,是一种高效的批量操作。
虚拟内存
虚拟内存涉及物理地址和逻辑地址,逻辑地址通过MMU(内存管理单元)映射到真实的物理地址。多个逻辑地址可以指向同一个物理地址。
虚拟内存是使用磁盘空间作为内存的延展,给一块磁盘空间分片虚拟地址,使其作为内存空间的一部分可以被寻址。在虚拟分页的内存管理技术中,将进程分为多个页,在装入进程时一部分载入内存一部分载入虚拟内存,在实际需要时再换入内存中。这使得逻辑上的内存空间变大了,支持更多的进程。
在IO操作上使用虚拟内存,实际上使用的是虚拟内存地址的映射,MMU将虚拟地址映射到实际的地址,在虚拟地址层面,内核并不知道实际指向是在用户空间还是在内核空间,这样就可以使得硬件可以通过一次映射直接访问用户空间。原先在内核空间到用户空间的数据拷贝处理,就可以让用户空间的缓冲区和内核空间缓冲区虚拟地址指向同一个地址,当内核读入数据到内核缓冲区时,实际上就已经读入用户空间的缓冲区了,这样减少了一次数据拷贝,自然提升了性能。
系统IO
UNIX I/O
Unix文件
一个Unix文件是一个m个字节的序列。在Unix中,所有的IO设备都被模型化为文件,包括磁盘、终端和网络设备。
对UNIX来说,所有的IO设备都是文件,通过一个称为文件描述符的数据结构来进行抽象。
抽象接口
因为设备被抽象成统一的文件,所以内核可以抽象出统一的的接口,称为UNIX I/O.
- 打开文件
应用程序通过系统调用要求UNIX内核来打开文件,内核打开文件后返回文件的描述符给应用程序。
// 打开成功返回文件描述符数字,否则返回-1
int open(char *filename,int flags,mode_t mode)
文件中的位置
内核会保存每个打开的文件中的文件位置,初始值是0,可以通过seek来修改position。读写文件
文件<-->主存的数据迁移过程。
// 读操作,成功返回读到的字节数,若为EOF返回0 ,出错返回-1
ssize_t read(int fd,void *buf,size_t n);
// 成功则返回写的字节数,出错返回-1
ssize_t write(int fd,const void *buf,size_t n)
- 关闭文件
应用程序通过系统调用要求内核关闭文件,内核关闭文件后,内核会释放文件打开时创建的数据结构,并将该文件描述符返还到描述符池中。
// 成功返回0 否则-1,入参为文件描述符
int close(int fd);
RIO(Robust IO)
是基于UNIX IO封装的IO lib,对底层UNIX IO的一些如不足值(读到的数据没有达到应用程序预期的数量)的情况。
RIO提供了两类函数:
- 无应用级缓冲
- 有应用级缓冲
实际上二者的区别就是在执行UNIX IO操作读写时,在应用方是否有应用级的缓冲区来暂存从内核缓冲区读取的数据或者是需要写到内核缓冲区的数据。
对网络应用来说,无缓冲的函数更加适合。
无缓冲函数
无缓冲函数和UNIX IO的函数相似,从描述符fd当前位置读取最多n个字节到存储器,或者从存储器传送n个字节到fd。
有缓冲函数
实际上只是在方法里面封装了一个byte数组来缓存从fd读到的数据,或是暂存写入到fd的数据,然后再循环调用UNIX IO的读写来对方法内的这个buffer进行操作。
实际上这个区别只是在封装的方法内使用buffer数组和直接使用buffer数组而已,无缓冲函数使用时,也是要使用byte数组,但是它可能是直接的target或者source,可以称之为客户端缓冲区。有缓冲的函数就是把这个客户端缓冲区封装到方法内部,并且多次调用UNIX IO。
标准I/O
是ANSI C定义的高级IO库。是基于UNIX IO封装的lib。
流
标准IO将一个打开的文件抽象成一个流。类型为FILE的流是对文件描述符和流缓冲区的一种抽象。
缓冲区的目的是减少开销较高的UNIX IO系统调用的次数。也就是在调用内核的read时,将内核缓冲区的数据读到缓冲区中(用户态),然后应用程序读用户态下的缓冲区,以提高性能。
IO函数的选择
- 原生UNIX IO(直接的系统调用)
- RIO封装的健壮lib
- ANSI C封装的标准IO
2和3都是基于UNIX IO系统调用函数封装的,所以本质上没有多大区别,区别在于对异常的处理细节,以及标准IO使用流来进行抽象。
标准IO适合磁盘和终端设备的IO,而RIO更适合网络IO。主要是因为网络IO的特殊性和标准IO的限制想斥。
UNIX对网络的抽象是使用一个叫做socket的文件描述符,这个文件描述符的读写,不支持seek来设置位置。
而流作为一种全双工的IO,能够在同一个流上执行输入和输出操作(有条件),但是哟限制条件:
- write之后的read,需要在中间插入flush、fseek、fsetpos或者rewind的调用,否则不能跟在后面执行。后三个函数式通过UNIX的lseek函数来重置位置的。
- read之后的write,中间需要插入fseek、setpos或者rewind。
然而,上面的两个限制和socket冲突,使得socket不能使用标准IO的全双工通信方式:对socket使用lseek函数式非法的。
所以如果要在socket使用标准IO,只能在socket上打开两个流,一个用于输入,一个用于输出。(JAVA BIO底层实际上就是用的就是标准IO,在java层面的API也是分成输入流和输出流来对socket进行读写操作)。
小结
- UNIX IO是底层系统调用
- 系统调用要陷入内核
- 调用系统输入IO是内核去读“文件”数据到内核缓冲区,然后将内核缓冲区的数据拷贝到用户空间下
- 用户态的缓冲区的作用,是尽可能一次系统调用多读取一些数据到用户空间,从而减少系统调用的次数来降低开销。
参考资料:
[1] 高性能IO模型浅析
[2] Ron Hitchens.Java NIO
[3] William Stallings.操作系统精髓与设计原理 机械工业出版社 第六版 第11章
[4] 知乎回答
[5] csapp