由于文章内容太多,而且有很多地方比较抽象。所以下文中我用红色标记了重点,重点内容是必须得清楚的。
在计算机系统中I/O就是输入(Input)和输出(Output)的意思,针对不同的操作对象,可以划分为磁盘I/O模型,网络I/O模型,内存映射I/O, Direct I/O、数据库I/O等,只要具有输入输出类型的交互系统都可以认为是I/O系统,也可以说I/O是整个操作系统数据交换与人机交互的通道,这个概念与选用的开发语言没有关系,是一个通用的概念。
一、I/O 软件目标
现在让我们转向对 I/O 软件的研究,I/O 软件设计一个很重要的目标就是设备独立性(device independence)。
啥意思呢?这意味着我们需要编写能够访问任何设备的应用程序,而不用单独为每一种设备定制访问的应用程序。
比如你编写了一个能够从设备读入文件的应用程序,那么这个应用程序可以从硬盘、DVD 或者 USB 进行读入,不必再为每个设备定制应用程序(就是说这个程序必须是通用的)。这其实就体现了设备独立性的概念。
计算机操作系统是这些硬件的媒介,因为不同硬件它们的指令序列不同,所以需要操作系统来做指令间的转换(也就是说,访问硬件设备的应用程序其实是由操作系统提供的,这个程序其实就是后面说的设备驱动程序)。
在 UNIX 中,所有的磁盘都能够被集成到文件系统中(即所有的磁盘都能够映射为一个文件路径),所以用户不用记住每个设备的具体名称,直接记住对应的路径即可,如果路径记不住,也可以通过 ls 等指令找到具体的集成位置。
举个例子来说,比如一个 USB 磁盘被挂载到了 /usr/cxuan/backup 下,那么你把文件复制到 /usr/cxuan/backup/device 下,就相当于是把文件复制到了这个路径下对应的磁盘中,通过这种方式,实现了向任何磁盘写入文件都相当于是向指定的路径输出文件。
除了设备独立性外,I/O 软件实现的第二个重要的目标就是错误处理(error handling)。
通常情况下来说,错误应该交给硬件层面去处理。如果设备控制器发现了读错误的话,它会尽可能的去修复这个错误。
如果设备控制器处理不了这个问题,那么设备驱动程序应该进行处理,并再次尝试读取操作,很多错误都是偶然性的,如果设备驱动程序无法处理这个错误,才会把错误向上抛到硬件层面(下层)进行处理,很多时候,上层并不需要知道下层是如何解决错误的。
这就很像项目经理不用把每个决定都告诉老板;程序员不用把每行代码如何写告诉项目经理。这种处理方式不够透明。
I/O 软件实现的第三个目标就是 同步(synchronous) 和 异步(asynchronous,即中断驱动)传输。这里先说一下同步和异步是怎么回事吧。
同步传输中数据通常以块或帧的形式发送。发送方和接收方在数据传输之前应该具有同步时钟。
而在异步传输中,数据通常以字节或者字符的形式发送,异步传输则不需要同步时钟,但是会在传输之前向数据添加奇偶校验位。下面是同步和异步的主要区别
回到正题。大部分物理IO(physical I/O) 是异步的。物理 I/O 中的 CPU 是很聪明的,CPU 发送IO请求后会转而做其他事情,它和中断心灵相通,等到中断发生后,CPU 才会回到传输这件事情上来。
这里体现了异步I/O的一个另一个好处,就是数据在内存和磁盘等硬件设备之间传输的过程中CPU无需等待而是去处理其他事情(因为数据在磁盘和内存之间传输这件事不用CPU去做);同步I/O的话,CPU就得亲手负责不能腾出手来做其他事。一般逻辑IO是同步的,为什么逻辑IO是同步的,因为逻辑IO是从缓冲区中读写数据,这个过程的速度远比物理IO的速度快(相比于磁盘读写而言,内存读写的时间几乎可以忽略不计),所以即使这个过程会占用CPU也只是一瞬间的事。
I/O 分为两种:物理I/O 和 逻辑I/O(Logical I/O)。
物理 I/O 通常是从磁盘等硬件存储设备实际获取数据。逻辑 I/O 是对存储器(块,缓冲区)获取数据。
I/O 软件的一个重要问题是缓冲(buffering)。通常情况下,从一个设备发出的数据不会直接到达最后的设备(或者从用户程序产生的数据不会直接写入磁盘设备而是会先写入内核缓冲区,再从内核缓冲区异步写入到设备控制器的缓冲区,最后才到磁盘)。其间会经过一系列的校验、检查、缓冲等操作才能到达。
举个例子来说,从网络上发送一个数据包,会经过一系列检查之后首先到达缓冲区,从而消除缓冲区填满速率和缓冲区过载。
I/O 软件引起的最后一个问题就是共享设备和独占设备的问题。有些 I/O 设备能够被许多用户程序共同使用。
一些设备比如磁盘,让多个用户程序使用一般不会产生什么问题(例如进程A读磁盘M的某一个文件x,进程B可以同时写入磁盘M的另一个文件y,这样就是多个用户进程共同使用磁盘M这个设备。这里说的同时可以是并发或并行。但是如果进程A和进程B读写的是同一个文件x,那么此时就不能够同时发生,而必须先让进程A读完,再让B写入,否则会数据不一致)。但是某些设备必须具有独占性,即只允许单个用户程序使用完成后才能让其他用户使用,比如鼠标,键盘和显示器。
下面,我们来探讨一下如何使用程序来控制 I/O 设备。一共有三种控制 I/O 设备的方法
PS:我们所说的IO操作不只是磁盘IO,网络IO,也包括内存IO。只要是读写,都算是IO。
使用程序控制 I/O 又被称为 可编程I/O,它是指 CPU 在被驱动程序软件(即操作系统内部程序而非用户程序,这个驱动程序是由操作系统提供)控制下启动的数据传输,来访问设备上的寄存器或者其他存储器。
(假设有一个磁盘读操作)CPU 会向设备发出执行IO的请求命令,然后等待 (磁盘)设备准备数据(即磁盘寻址 + 数据从磁盘的扇区写到磁盘的寄存器的过程。这个过程就是磁盘IO。到了寄存器里面的数据就是内存数据了)并在这个过程中不断检查设备IO是否就绪或者说IO是否完成(这里说的IO就绪就是IO完成的意思,就是设备已准备好数据的意思)直到CPU检测到设备I/O 完成,CPU就会从设备的相关寄存器和缓冲器读取数据并写入到内核态的缓冲区(这个过程是从内存到内存),再从内核缓冲区传输到用户缓冲区。大部分时间都花在磁盘寻址和磁盘设备将扇区中的数据拷贝到磁盘寄存器的过程,因为磁盘速率比内存和cpu的速率低很多。
从CPU发起IO请求到IO就绪的过程中CPU无法处理其他事情
由于 CPU 的速度比 I/O 模块的速度快很多,因此可编程 I/O 的问题在于,CPU 必须等待很长时间才能等到处理结果。CPU 在等待时会采用主动轮询(polling)或者 忙等(busy waiting) 的方式,结果整个系统的性能被严重拉低。
可编程 I/O 十分简单,如果需要等待的时间非常短的话,可编程 I/O 倒是一个很好的方式。一个可编程的 I/O 会经历如下操作
鉴于上面可编程 I/O 的缺陷,我们提出一种改良方案,我们想要在 CPU 等待 I/O 完成的同时,能够做其他事情,等到 I/O 完成(即设备数据就绪),I/O设备会主动产生一个中断以通知CPU已经完成了IO操作,让CPU停止当前进程的运行并保存当前进程的上下文环境和状态,然后CPU就放下用户进程的任务转而去接收I/O设备的响应(接收响应的过程就是CPU从IO设备或者设备控制器的寄存器缓冲器中传输数据到CPU的寄存器)。然后CPU再把数据传输到内核缓冲区再从内核缓冲区传输到用户缓冲区。处理完IO的响应后,CPU会继续刚刚用户进程的任务运行。一个可能的示意图如下
尽管中断减轻了 CPU 等待I/O时间的负担,但是由于还需要在 CPU 和 I/O 模块之前进行大量的逐字传输(意思是,数据从设备到内核缓冲区的传输需要CPU的参与,要占用CPU,这段时间CPU做不了其他事),因此在大量数据传输中效率仍然很低。下面是中断的基本操作
下图是中断驱动IO的模式下,从用户进程发起IO请求到用户进程接收到数据的过程:
红色框框中的地方就是CPU在内存间传输数据而没在处理用户程序的时间。
所以我们现在着手需要解决的就是 CPU 和 I/O 模块间数据传输的效率问题。
DMA 的中文名称是直接内存访问,它意味着 CPU 授予 I/O 模块权限在不涉及 CPU 的情况下读取或写入内核的内存。也就是说数据从磁盘写入到内核缓冲区可以不需要 CPU 的参与。
这个过程由称为 DMA 控制器(DMAC)的芯片管理。由于 DMA 设备可以直接在内存之间传输数据, 而不是使用 CPU 作为中介,因此可以缓解总线上的拥塞(这里说的“内存之间”指数据在设备缓冲器的内存与内核缓冲区的内存之间传输)。
DMA 通过允许 CPU 执行任务,同时 DMA 系统通过系统和内存总线传输数据来提高系统并发性。
下图是使用DMA的 IO的模式下,从用户进程发起IO请求到用户进程接收到数据的过程:
红色框框中的地方就是CPU在传输数据而没在处理用户程序的时间。这里CPU只负责将数据从内核缓冲区去拷贝到用户缓冲区,而DMA会负责数据从设备缓冲到内核缓冲的传输。
二、 I/O 层次结构
I/O 软件通常组织成四个层次,它们的大致结构如下图所示
每一层和其上下层都有明确的功能和接口。下面我们采用和计算机网络相反的套路,即自下而上的了解一下这些程序。
下面是另一幅图,这幅图显示了输入/输出软件系统所有层及其主要功能。
什么叫中断?
由于某个事件(比如IO响应就绪)的发生,CPU暂停当前正在执行的程序(当前进程),转而执行处理该事件的一个程序。该程序执行完成后,CPU接着执行被暂停的程序。这个过程称为中断。
在计算机系统中,中断就像女人的脾气一样无时无刻不在产生,中断的出现往往是让人很不爽的。中断处理程序又被称为中断服务程序 或者是 ISR(Interrupt Service Routines),它是最靠近硬件的一层。
中断处理程序由硬件中断、软件中断或者是软件异常启动产生的中断。
中断是并发的基础
中断不止会发生在有IO操作的时候,还会发生在其他各种各样的情况下。而且中断不只是一个信号或者动作,还是一段程序,我们称之为中断程序,中断程序会告诉CPU把当前进程中断后要干些什么事情(比如从发出中断的设备控制器的寄存器中提取数据)。中断可以由硬件或者系统软件或者用户进程发出。
中断的类型有:
非嵌套中断(处理程序)
嵌套中断
可重入中断
简单优先级中断
标准优先级中断
高优先级中断
优先级分组中断
下面是一些通用的中断处理程序的步骤,不同的操作系统实现细节不一样
保存所有没有被中断硬件保存的寄存器
为中断服务程序设置上下文环境,可能包括设置 TLB、MMU 和页表
为中断服务程序设置栈
对中断控制器作出响应,如果不存在集中的中断控制器,则继续响应中断
把寄存器从保存它的地方拷贝到进程表中
运行中断服务程序,它会从发出中断的设备控制器的寄存器中提取信息
操作系统会选择一个合适的进程来运行。如果中断造成了一些优先级更高的进程变为就绪态,则选择运行这些优先级高的进程
为进程设置 MMU 上下文,可能也会需要 TLB,根据实际情况决定
加载进程的寄存器,包括 PSW 寄存器
开始运行新的进程
设备控制器和设备驱动程序
先说一下什么是设备控制器
设备控制器是计算机中的一个实体,其主要职责是控制一个或多个I/O设备,以实现I/O设备和计算机之间的数据交换。它是CPU与I/O设备之间的接口(设备控制器又称为IO接口),它接收从CPU发来的命令,并去控制I/O设备工作,以使CPU从繁杂的设备控制事务中解脱出来。
设备控制器分成两类:一类是用于控制字符设备(键盘鼠标)的控制器,另一类是用于控制块设备(磁盘)的控制器。
设备控制器是CPU与设备之间的通信桥梁。
设备控制器的功能是:
接收和识别命令
CPU可以向控制器发送多种不同的命令,设备控制器应能接收并识别这些命令。为此,在控制器中应具有相应的控制寄存器,用来存放接收的命令和参数,并对所接收的命令进行译码。
数据交换
这是指实现CPU与控制器之间、控制器与设备之间的数据交换,因此在控制器中须设置数据寄存器。
标识和报告设备的状态
控制器应记下设备的状态供CPU了解(比如设备是否已经IO就绪)。例如,仅当该设备处于发送就绪状态时,CPU才能启动控制器从设备中读出数据。为此,在控制器中应设置一状态寄存器,用其中的每一位来反映设备的某一种状态。当CPU将该寄存器的内容读入后,便可了解该设备的状态
数据缓冲
由于I/O设备的速率较低而CPU和内存的速率却很高,故在控制器中必须设置一缓冲器。在输出时,用此缓冲器暂存由主机高速传来的数据,然后才以I/O设备所具有的速率将缓冲器中的数据传送给I/O设备;在输入时,缓冲器则用于暂存从I/O设备送来的数据,待接收到一批数据后,再将缓冲器中的数据高速地传送给主机。
差错控制
设备控制器还兼管对由I/O设备传送来的数据进行差错检测。若发现传送中出现了错误,通常是将差错检测码置位,并向CPU报告,于是CPU将本次传送来的数据作废,并重新进行一次传送。这样便可保证数据输入的正确性。
说完了设备控制器,再说说设备驱动程序
每个连接到计算机的 I/O 设备都需要有某些特定设备的代码对其进行控制,例如鼠标控制器需要从鼠标接受指令,告诉下一步应该移动到哪里,键盘控制器需要知道哪个按键被按下,磁盘需要写入还是读取,读取哪些地方的数据等。
这些操控IO设备的程序代码称为 设备驱动程序(Device driver)。设备驱动程序通常是操作系统内核的一部分(是写在内核的代码程序),至少现在的体系结构是这样的。但是也可以构造用户空间的设备驱动程序,通过系统调用来完成读写操作。
设备驱动程序具有很多功能,比如接受读写请求,对设备进行初始化、对输入参数进行有效性检查等。
设备驱动程序接受到用户进程的读写请求后,会检查当前设备是否在使用,如果设备在使用,请求被排入队列中,等待后续的处理。如果此时设备是空闲的,驱动程序会检查硬件以了解请求是否能够被处理。等待设备就绪完成,再对设备发出指令。设备驱动程序发出命令后,设备控制器便开始将它们写入控制器的设备寄存器。然后设备控制器发送这些指令(指令序列)给设备,进行相应IO操作。
操作系统通常会将驱动程序归为 字符设备 和 块设备,我们上面也介绍过了
PS:磁盘的最小存储单位是扇区,一个扇区大小是512B。但是数据传输的时候,不可能一个扇区一个扇区的传输,这样会比较慢。因此就有了块的概念,一个块包括多个扇区,块是磁盘数据传输的最小单位。每个块都有自己的物理地址。
I/O 软件有两种,一种是我们上面介绍过的基于特定设备的(设备驱动程序),还有一种是设备无关性的软件,设备无关性也就是对所有设备通用。下面显示的功能由设备无关的软件实现
与设备无关的软件的基本功能是对所有设备执行公共的 I/O 功能,并且向用户层软件提供一个统一的接口。
无论是对于块设备还是字符设备来说,缓冲都是一个非常重要的考量标准。下面以 ADSL(调制解调器) 读取数据的过程为例,调制解调器是我们用来联网的设备(是一个字符设备)。
在没有缓冲的情况下,用户程序调用 read 系统调用会阻塞用户进程,等待字符的到来,每一次read都会阻塞进程。直到有数据就绪,中断服务程序就会给用户进程提供字符,并解除进程阻塞。每次read都只能接受1个字符(因为ADSL是字设备,所以每次read只能读1个字符;如果是磁盘,则每次read能读1个块大小)。将字符提供给用户程序后,进程会去读取其他字符并继续阻塞,这种模型如下
这一种方案是没有缓冲区的存在,用户进程如果读不到数据会阻塞,直到读到数据为止,该方式会直接阻止用户进程做其他事情,这对用户来说是不能接受的。
而且每次用户进程都会重启(多次休眠和多次被唤醒,这意味着这个用户进程需要频繁的让出CPU,切换到其他用户进程),对于每个字符的到来都会重启用户进程,这种效率会严重降低,所以无缓冲区的软件不是一个好的设计。
作为一个改良点,我们可以尝试在用户空间中使用一个能读取 n 个字节缓冲区来读取多个字符。这样的话,中断服务程序会先把字符放到缓冲区中直到缓冲区变满为止(这个过程用户进程都在休眠中,),然后再去唤醒用户进程。
这种方案要比上面的方案改良很多,虽然用户进程还是干不了其他事但是起码不会频繁的沉睡和被唤醒。
但是这种方案也存在问题,当字符到来时,如果缓冲区被调出内存会出现什么问题?解决方案是把缓冲区锁定在内存中,但是这种方案也会出现问题,如果少量的缓冲区被锁定还好,如果大量的缓冲区被锁定在内存中,那么可以换进换出的页面就会收缩,造成系统性能的下降。
一种解决方案是在内核中内部创建一块缓冲区,让中断服务程序将字符放在内核内部的缓冲区中
当内核中的缓冲区要满的时候,会将用户空间中的页面调入内存,然后将内核空间的缓冲区复制到用户空间的缓冲区中,这种方案也面临一个问题就是假如用户空间的页面被换入内存,此时内核空间的缓冲区已满,这时候仍有新的字符到来,这个时候会怎么办?因为缓冲区满了,没有空间来存储新的字符了。
一种非常简单的方式就是再设置一个缓冲区就行了,在第一个缓冲区填满后,使用第二个缓冲区接收数据,这种解决方式如下
缓冲区对输出来说也很重要。对输出的描述和输入相似。
总结一下缓冲区:
没有缓冲区的话,IO操作时用户进程会频繁的进行系统调用read()或者write()(意味着频繁的进行用户态内核态切换,因为系统调用必须在内核态下进行),频繁发起IO请求,频繁的被阻塞被唤醒,频繁的发生中断程序,这些都是会消耗操作系统性能和资源的。而频繁做这些事的原因是用户进程每次read/recv只能接受1个字节。
假设要接收200k的数据,如果没有缓冲区,只能一个字节一个字节的接收数据,一个字节是8B。用户进程就要发生2048000/8=2560次read/recv系统调用,2560次IO请求,被阻塞2560次;
如果使用了缓冲区,缓冲区大小为4k(用户缓冲区和内核缓冲区都是4k),而recv()中传参指定每次接收的数据量为1024,4k的用户缓冲区被填满之前recv(1024)这行代码都会阻塞用户程序。用户缓冲区被填满的时候,recv(1024)就可以从缓冲区中1次性读1k的数据,分4次读完用户缓冲区的数据,那么这4次recv只有第1次是发起了IO请求,后面3次recv是直接从内存拿数据的是非阻塞的,当第5次recv时缓冲区已空才会再发起IO请求,所以此时用户进程和内核发起200k/4k=50次IO请求和系统调用,用户进程调用了4*50=200次recv但是大多数recv都是从用户缓冲区取数据,50次远比之前的2560次少,大大节省系统开销。当然啦,用户进程的IO请求次数变成了50次,但是设备控制器向设备的请求次数还是2560次,因为设备控制器和CPU可以一次4K的方式传给内核进程和用户进程,可是设备本身还是只能一次8B的方式传给设备控制器)
当然,如果是读取磁盘这种块设备的话,用户进程和内核可能就无需设置缓冲区或者说缓冲区的意义不大,因为磁盘是以块(4k/8k/16k)为单位传输数据的,每次传输给用户进程的数据都比较多,而缓冲区本身大小也就几k而已,缓冲区接收1次数据说不定就被已经占满了。所以我们更多的看到网络IO去使用缓冲区,因为网络传输是以字节为单位传输的,每次传输的数据是1个字节,这就很有必要使用缓冲区。
那既然说到这里,我们就说说缓冲区的作用:
1.如上所述,可以合并多次零碎IO为一次大IO,减少用户进程IO请求的次数和系统调用的次数。
2.数据重用,有些数据会重复读取,比如文件索引等内容,有缓存就可以避免这些数据重复的执行真正的IO操作
3. 解决数据在不同设备传输速率不一致的问题,比如设备控制器就设置了缓冲区先以慢速接收设备的数据(这个过程中CPU可以做其他事情),积累到缓冲区满了之后再以高速传输给CPU
缓冲技术应用广泛,但它也有缺点。如果数据被缓冲次数太多,会影响性能。考虑例如如下这种情况
数据经过用户进程 -> 内核空间 -> 网络控制器,这里的网络控制器应该就相当于是 socket 缓冲区,然后发送到网络上,再到接收方的网络控制器 -> 接收方的内核缓冲 -> 接收方的用户缓冲,一条数据包被缓存了太多次意味着数据的重复拷贝,很容易降低性能。
在 I/O 中,出错是一种再正常不过的情况了。当出错发生时,操作系统必须尽可能处理这些错误。有一些错误是只有特定的设备才能处理,有一些是由框架进行处理,这些错误和特定的设备无关。
I/O 错误的一类是程序员编程错误,比如还没有打开文件前就读流,或者不关闭流导致内存溢出等等。这类问题由程序员处理;另外一类是实际的 I/O 错误,例如向一个磁盘坏块写入数据,无论怎么写都写入不了。
这类问题由驱动程序处理,驱动程序处理不了交给硬件处理,这个我们上面也说过。
我们在操作系统概述中说到,操作系统一个非常重要的功能就是屏蔽了硬件和软件的差异性,为硬件和软件提供了统一的标准,这个标准还体现在为设备驱动程序提供统一的接口,因为不同的硬件和厂商编写的设备驱动程序不同,所以如果为每个驱动程序都单独提供接口的话,这样没法搞,所以必须统一。
一些设备例如打印机,它只能由一个进程来使用,这就需要操作系统根据实际情况判断是否能够对设备的请求进行检查,判断是否能够接受其他请求,一种比较简单直接的方式是在特殊文件上执行 open操作。如果设备不可用,那么直接 open 会导致失败。还有一种方式是不直接导致失败,而是让其阻塞,等到另外一个进程释放资源后,在进行 open 打开操作。这种方式就把选择权交给了用户,由用户判断是否应该等待。
注意:阻塞的实现有多种方式,有阻塞队列等
不同的磁盘会具有不同的扇区大小,但是软件不会关心扇区大小,只管存储就是了。一些字符设备可以一次一个字节的交付数据,而其他的设备则以较大的单位交付数据,这些差异也可以隐藏起来。
虽然大部分 I/O 软件都在内核结构中,但是还有一些在用户空间实现的 I/O 软件,凡事没有绝对。一些 I/O 软件和库过程在用户空间存在,然后以提供系统调用的方式实现。