操作系统-IO模型

操作系统-IO模型

文件描述符(file descriptor,简称 fd)在形式上是一个非负整数。实际上,它是一个索引值,指向内核为每一个进程所维护的该进程打开文件的记录表。当程序打开一个现有文件或者创建一个新文件时,内核向进程返回一个文件描述符。在程序设计中,一些涉及底层的程序编写往往会围绕着文件描述符展开。但是文件描述符这一概念往往只适用于UNIX、Linux这样的操作系统。

在 Linux 中,内核将所有的外部设备都当做一个文件来进行操作,而对一个文件的读写操作会调用内核提供的系统命令,返回一个 fd,对一个 socket 的读写也会有相应的描述符,称为 socketfd(socket 描述符),实际上描述符就是一个数字,它指向内核中的一个结构体(文件路径、数据区等一些属性)。

PIO与DMA

  • PIO我们拿磁盘来说,很早以前,磁盘和内存之间的数据传输是需要CPU控制的,也就是说如果我们读取磁盘文件到内存中,数据要经过CPU存储转发,这种方式称为PIO。显然这种方式非常不合理,需要占用大量的CPU时间来读取文件,造成文件访问时系统几乎停止响应。

  • 后来,DMA(直接内存访问,Direct Memory Access)取代了PIO,它可以不经过CPU而直接进行磁盘和内存的数据交换。在DMA模式下,CPU只需要向DMA控制器下达指令,让DMA控制器来处理数据的传送即可,DMA控制器通过系统总线来传输数据,传送完毕再通知CPU,这样就在很大程度上降低了CPU占有率,大大节省了系统资源,而它的传输速度与PIO的差异其实并不十分明显,因为这主要取决于慢速设备的速度。

可以肯定的是,PIO模式的计算机我们现在已经很少见到了。

发散/汇聚

许多操作系统能把组装/分解过程进行得更加高效。根据发散/汇聚的概念,进程只需一个系统调用,就能把一连串缓冲区地址传递给操作系统。然后,内核就可以顺序填充或排干多个缓冲区,读的时候就把数据发散到多个用户空间缓冲区,写的时候再从多个缓冲区把数据汇聚起来。

这样用户进程就不必多次执行系统调用(那样做可能代价不菲),内核也可以优化数据的处理过程,因为它已掌握待传输数据的全部信息。如果系统配有多个 CPU,甚至可以同时填充或排干多个缓冲区。

虚拟内存

所有现代操作系统都使用虚拟内存。虚拟内存意为使用虚假(或虚拟)地址取代物理(硬件RAM)内存地址。这样做好处颇多,总结起来可分为两大类:

  1. 一个以上的虚拟地址可指向同一个物理内存地址。
  2. 虚拟内存空间可大于实际可用的硬件内存。

设备控制器不能通过 DMA 直接存储到用户空间,但通过利用上面提到的第一项,则可以达到相同效果。把内核空间地址与用户空间的虚拟地址映射到同一个物理地址,这样,DMA 硬件(只能访问物理内存地址)就可以填充对内核与用户空间进程同时可见的缓冲区。

这样省去了内核与用户空间的往来拷贝,但前提条件是,内核与用户缓冲区必须使用相同的页对齐,缓冲区的大小还必须是磁盘控制器块大小(通常为 512 字节磁盘扇区)的倍数。操作系统把内存地址空间划分为页,即固定大小的字节组。内存页的大小总是磁盘块大小的倍数,通常为 2 次幂(这样可简化寻址操作)。典型的内存页为 1,024、2,048 和 4,096 字节。虚拟和物理内存页的大小总是相同的。

为了支持虚拟内存的第二个特性(寻址空间大于物理内存),就必须进行虚拟内存分页(经常称为交换,虽然真正的交换是在进程层面完成,而非页层面)。依照该方案,虚拟内存空间的页面能够继续存在于外部磁盘存储,这样就为物理内存中的其他虚拟页面腾出了空间。从本质上说,物理内存充当了分页区的高速缓存;而所谓分页区,即从物理内存置换出来,转而存储于磁盘上的内存页面。

把内存页大小设定为磁盘块大小的倍数,这样内核就可直接向磁盘控制硬件发布命令,把内存页写入磁盘,在需要时再重新装入。结果是,所有磁盘 I/O 都在页层面完成。对于采用分页技术的现代操作系统而言,这也是数据在磁盘与物理内存之间往来的唯一方式。

现代 CPU 包含一个称为内存管理单元(MMU)的子系统,逻辑上位于 CPU 与物理内存之间。该设备包含虚拟地址向物理内存地址转换时所需映射信息。当 CPU 引用某内存地址时,MMU负责确定该地址所在页(往往通过对地址值进行移位或屏蔽位操作实现),并将虚拟页号转换为物理页号(这一步由硬件完成,速度极快)。如果当前不存在与该虚拟页形成有效映射的物理内存页,MMU 会向 CPU 提交一个页错误。

页错误随即产生一个陷阱(类似于系统调用),把控制权移交给内核,附带导致错误的虚拟地址信息,然后内核采取步骤验证页的有效性。内核会安排页面调入操作,把缺失的页内容读回物理内存。这往往导致别的页被移出物理内存,好给新来的页让地方。在这种情况下,如果待移出的页已经被碰过了(自创建或上次页面调入以来,内容已发生改变),还必须首先执行页面调出,把页内容拷贝到磁盘上的分页区。

如果所要求的地址不是有效的虚拟内存地址(不属于正在执行的进程的任何一个内存段),则该页不能通过验证,段错误随即产生。于是,控制权转交给内核的另一部分,通常导致的结果就是进程被强令关闭。

一旦出错的页通过了验证,MMU 随即更新,建立新的虚拟到物理的映射(如有必要,中断被移出页的映射),用户进程得以继续。造成页错误的用户进程对此不会有丝毫察觉,一切都在不知不觉中进行。

文件I/O

文件 I/O 属文件系统范畴,文件系统与磁盘迥然不同。磁盘把数据存在扇区上,通常一个扇区512 字节。磁盘属硬件设备,对何谓文件一无所知,它只是提供了一系列数据存取窗口。在这点上,磁盘扇区与内存页颇有相似之处:都是统一大小,都可作为大的数组被访问。

文件系统是更高层次的抽象,是安排、解释磁盘(或其他随机存取块设备)数据的一种独特方式。您所写代码几乎无一例外地要与文件系统打交道,而不是直接与磁盘打交道。是文件系统定义了文件名、路径、文件、文件属性等抽象概念。

文件系统把一连串大小一致的数据块组织到一起。有些块存储元信息,如空闲块、目录、索引等的映射,有些包含文件数据。单个文件的元信息描述了哪些块包含文件数据、数据在哪里结束、最后一次更新是什么时候,等等。

当用户进程请求读取文件数据时,文件系统需要确定数据具体在磁盘什么位置,然后着手把相关磁盘扇区读进内存。老式的操作系统往往直接向磁盘驱动器发布命令,要求其读取所需磁盘扇区。而采用分页技术的现代操作系统则利用请求页面调度取得所需数据。

操作系统还有个页的概念,其大小或者与基本内存页一致,或者是其倍数。典型的操作系统页从 2,048 到 8,192 字节不等,且始终是基本内存页大小的倍数。

采用分页技术的操作系统执行 I/O 的全过程可总结为以下几步:

  • 确定请求的数据分布在文件系统的哪些页(磁盘扇区组)。磁盘上的文件内容和元数据可能跨越多个文件系统页,而且这些页可能也不连续。
  • 在内核空间分配足够数量的内存页,以容纳得到确定的文件系统页。在内存页与磁盘上的文件系统页之间建立映射。
  • 为每一个内存页产生页错误。
  • 虚拟内存系统俘获页错误,安排页面调入,从磁盘上读取页内容,使页有效。
  • 一旦页面调入操作完成,文件系统即对原始数据进行解析,取得所需文件内容或属性信息。

需要注意的是,这些文件系统数据也会同其他内存页一样得到高速缓存。对于随后发生的 I/O请求,文件数据的部分或全部可能仍旧位于物理内存当中,无需再从磁盘读取即可重复使用。

大多数操作系统假设进程会继续读取文件剩余部分,因而会预读额外的文件系统页。如果内存争用情况不严重,这些文件系统页可能在相当长的时间内继续有效。这样的话,当稍后该文件又被相同或不同的进程再次打开,可能根本无需访问磁盘。这种情况您可能也碰到过:当重复执行类似的操作,如在几个文件中进行字符串检索,第二遍运行得似乎快多了。

类似的步骤在写文件数据时也会采用。这时,文件内容的改变(通过 write( ))将导致文件系统页变脏,随后通过页面调出,与磁盘上的文件内容保持同步。文件的创建方式是,先把文件映射到空闲文件系统页,在随后的写操作中,再将文件系统页刷新到磁盘。

标准文件访问方式

standard-file-io

当应用程序调用read接口时,操作系统检查在内核的高速缓存有没有需要的数据,如果已经缓存了,那么就直接从缓存中返回,如果没有,则从磁盘中读取,然后缓存在操作系统的缓存中。

应用程序调用write接口时,将数据从用户地址空间复制到内核地址空间的缓存中,这时对用户程序来说,写操作已经完成,至于什么时候再写到磁盘中,由操作系统决定,除非显示调用了sync同步命令。

内存映射

Linux内核提供一种访问磁盘文件的特殊方式,它可以将内存中某块地址空间和我们要指定的磁盘文件相关联,从而把我们对这块内存的访问转换为对磁盘文件的访问,这种技术称为内存映射(Memory Mapping)。这种方式的目的同样是减少数据从内核空间缓存到用户空间缓存的数据复制操作,因为这两个空间的数据是共享的。当大量数据需要传输的时候,采用内存映射方式去访问文件会获得比较好的效率。

使用内存映射文件处理存储于磁盘上的文件时,将不必再对文件执行I/O操作,这意味着在对文件进行处理时将不必再为文件申请并分配缓存,所有的文件缓存操作均由系统直接管理,由于取消了将文件数据加载到内存、数据从内存到文件的回写以及释放内存块等步骤,使得内存映射文件在处理大数据量的文件时能起到相当重要的作用。

file-memory-map

有两种类型的内存映射,共享型和私有型,前者可以将任何对内存的写操作都同步到磁盘文件,而且所有映射同一个文件的进程都共享任意一个进程对映射内存的修改;后者映射的文件只能是只读文件,所以不可以将对内存的写同步到文件,而且多个进程不共享修改。显然,共享型内存映射的效率偏低,因为如果一个文件被很多进程映射,那么每次的修改同步将花费一定的开销。

直接IO

一些较复杂的应用,比如数据库服务器,它们为了充分提高性能,希望绕过内核缓冲区,由自己在用户态空间实现并管理I/O缓冲区,包括缓存机制和写延迟机制等,以支持独特的查询机制,比如数据库可以根据更加合理的策略来提高查询缓存命中率。另一方面,绕过内核缓冲区也可以减少系统内存的开销,因为内核缓冲区本身就在使用系统内存。

应用程序直接访问磁盘数据,不经过操作系统内核数据缓冲区,这样做的目的是减少一次从内核缓冲区到用户程序缓存的数据复制。这种方式通常是在对数据的缓存管理由应用程序实现的数据库管理系统中。
直接I/O的缺点就是如果访问的数据不在应用程序缓存中,那么每次数据都会直接从磁盘进行加载,这种直接加载会非常缓慢。通常直接I/O跟异步I/O结合使用会得到较好的性能。Linux提供了对这种需求的支持,即在open()系统调用中增加参数选项O_DIRECT,用它打开的文件便可以绕过内核缓冲区的直接访问,这样便有效避免了CPU和内存的多余时间开销。

顺便提一下,与O_DIRECT类似的一个选项是O_SYNC,后者只对写数据有效,它将写入内核缓冲区的数据立即写入磁盘,将机器故障时数据的丢失减少到最小,但是它仍然要经过内核缓冲区。

file-direct-io

零拷贝(sendfile)

普通的网络传输步骤如下:

  1. 操作系统将数据从磁盘复制到操作系统内核的页缓存中
  2. 应用将数据从内核缓存复制到应用的缓存中
  3. 应用将数据写回内核的Socket缓存中
  4. 操作系统将数据从Socket缓存区复制到网卡缓存,然后将其通过网络发出

socket-io

  1. 当调用read系统调用时,通过DMA(Direct Memory Access)将数据copy到内核模式
  2. 然后由CPU控制将内核模式数据copy到用户模式下的 buffer中
  3. read调用完成后,write调用首先将用户模式下 buffer中的数据copy到内核模式下的socket buffer中
  4. 最后通过DMA copy将内核模式下的socket buffer中的数据copy到网卡设备中传送。

从上面的过程可以看出,数据白白从内核模式到用户模式走了一圈,浪费了两次copy,而这两次copy都是CPU copy,即占用CPU资源。

zero-copy-1

通过sendfile传送文件只需要一次系统调用,当调用 sendfile时:

  1. 首先通过DMA copy将数据从磁盘读取到kernel buffer中
  2. 然后通过CPU copy将数据从kernel buffer copy到sokcet buffer中
  3. 最终通过DMA copy将socket buffer中数据copy到网卡buffer中发送

sendfile与read/write方式相比,少了一次模式切换一次CPU copy。但是从上述过程中也可以发现从kernel buffer中将数据copy到socket buffer是没必要的。

zero-copy-2

为此,Linux2.4内核对sendfile做了改进:

  1. DMA copy将磁盘数据copy到kernel buffer中
  2. 向socket buffer中追加当前要发送的数据在kernel buffer中的位置和偏移量
  3. DMA gather copy根据socket buffer中的位置和偏移量直接将kernel buffer中的数据copy到网卡上。

经过上述过程,数据只经过了2次copy就从磁盘传送出去了。(事实上这个Zero copy是针对内核来讲的,数据在内核模式下是Zero-copy的)。

FileChannel.transferTo(Java中的零拷贝)把当前通道中的数据传送到目标通道target中,在支持Zero-Copy的linux系统中,transferTo()的实现依赖于 sendfile()调用。

对于请求较小的静态文件,sendfile发挥的作用便显得不那么重要,通过压力测试,我们模拟100个并发用户请求151字节的静态文件,是否使用sendfile的吞吐率几乎是相同的,可见在处理小文件请求时,发送数据的环节在整个过程中所占时间的比例相比于大文件请求时要小很多,所以对于这部分的优化效果自然不十分明显。

文件锁定

文件锁定机制允许一个进程阻止其他进程存取某文件,或限制其存取方式。通常的用途是控制共享信息的更新方式,或用于事务隔离。在控制多个实体并行访问共同资源方面,文件锁定是必不可少的。数据库等复杂应用严重信赖于文件锁定。

“文件锁定”从字面上看有锁定整个文件的意思(通常的确是那样),但锁定往往可以发生在更为细微的层面,锁定区域往往可以细致到单个字节。锁定与特定文件相关,开始于文件的某个特定字节地址,包含特定数量的连续字节。这对于协调多个进程互不影响地访问文件不同区域,是至关重要的。

文件锁定有两种方式:共享的和独占的。多个共享锁可同时对同一文件区域发生作用;独占锁则不同,它要求相关区域不能有其他锁定在起作用。

共享锁和独占锁的经典应用,是控制最初用于读取的共享文件的更新。某个进程要读取文件,会先取得该文件或该文件部分区域的共享锁。第二个希望读取相同文件区域的进程也会请求共享锁。两个进程可以并行读取,互不影响。但是,假如有第三个进程要更新该文件,它会请求独占锁。该进程会处于阻滞状态,直到既有锁定(共享的、独占的)全部解除。一旦给予独占锁,其他共享锁的读取进程会处于阻滞状态,直到独占锁解除。这样,更新进程可以更改文件,而其他读取进程不会因为文件的更改得到前后不一致的结果。

文件锁有建议使用和强制使用之分。建议型文件锁会向提出请求的进程提供当前锁定信息,但操作系统并不要求一定这样做,而是由相关进程进行协调并关注锁定信息。多数 Unix 和类 Unix 操作系统使用建议型锁,有些也使用强制型锁或兼而有之。

强制型锁由操作系统或文件系统强行实施,不管进程对锁的存在知道与否,都会阻止其对文件锁定区域的访问。微软的操作系统往往使用的是强制型锁。假定所有文件锁均为建议型,并在访问共同资源的各个应用程序间使用一致的文件锁定,是明智之举,也是唯一可行的跨平台策略。依赖于强制文件锁定的应用程序,从根子上讲就是不可移植的。


参考资料:

  • http://www.cnblogs.com/Anker/p/3269106.html
  • https://segmentfault.com/a/1190000007692223
  • http://matt33.com/2017/08/06/unix-io/
  • https://my.oschina.net/xianggao/blog/663655
-------------本文结束感谢您的阅读-------------
坚持分享,您的支持将鼓励我继续创作!
0%