文章目录
- 文件定义
-
- 文件系统的组成
-
- 虚拟文件系统
- 文件的使用
- 文件的存储
-
- 空闲空间管理
-
- 文件系统的结构
- 目录存储
- 软链接硬链接
- 文件I/O
-
- 缓冲与非缓冲I/O
- 直接与非直接I/O
- 阻塞与非阻塞I/O
- 同步与异步I/O
- 小结
文件定义
文件属性
- 文件名:文件名有创建文件的用户决定,同一个目录下不允许存在相同的文件名
- 文件类型
- 位置:文件存放位置
- 大小:文件大小
- 标识符:一个系统内的各个文件都有一个唯一的标识符。该标识符对用户而言毫无意义,仅仅是系统用于区分文件。
- 保护信息:文件访问修改权限
- 文件所有者信息,创建时间,修改时间,占用空间属性等
对于Linux系统可以总结为两个内容:
- 索引节点(inode):用于记录文件元信息,比如inode编号,文件大小,访问权限,创建时间,修改时间,数据在磁盘中的位置等。索引节点是文件的唯一标识,它们之间一一对应,也同样都会被存储在硬盘中,所以索引节点同样占用磁盘空间
- 目录项:用于记录文件名称,索引节点指针以及与其它目录项的,就会形成目录结构,但它与索引节点不同的是,目录项是由内核维护的一个数据结构,不存放于磁盘,而是缓存在内存。
由于一个文件可能含有多个别名,所以目录项和文件是属于多对一的关系。
注意:
- 目录也是文件,也是用索引节点唯一标识,和普通文件不同的是,普通文件在磁盘里面保存的是文件数据,而目录文件在磁盘里面保存子目录或文件。
- 目录和目录项是不同的,目录是一个文件,也拥有自己的索引节点,存放在硬盘中。而目录项存放在内存中,是一个数据结构。
关系图如上,可以看到:
1、 目录项中包含了文件名和inode节点
2、 找到inode节点后,inode节点中包含了该文件的数据块位置信息
索引节点是存储在硬盘上的数据,那么为了加速文件的访问,通常会把索引节点加载到内存中。
文件系统的组成
在已知的操作系统中,它们分别支持多种不同的文件系统。Windows 下支持 FAT、FAT32、NTFS,Linux 支持十多种的文件系统,例如:EXT2、EXT3、EXT4、NFS、NTFS等。
在计算机中,文件系统控制数据是如何存取的。如果没有文件系统,放置在存储介质中的数据将是一个庞大的数据主体,无法分辨一个数据从哪里停止,下一个数据又从哪里开始。通过将数据分为一块一块的,并为每一块都赋予一个名字,数据将会很容易隔离和确定。
通过结构和逻辑规则来管理一组数据和名字,就被称为文件系统。
Linux 支持的文件系统也不少,根据存储位置的不同,可以把文件系统分为三类:
- 磁盘的文件系统,它是直接把数据存储在磁盘中,比如 Ext 2/3/4、XFS 等都是这类文件系统。
- 内存的文件系统,这类文件系统的数据不是存储在硬盘的,而是占用内存空间,我们经常用到的 /proc 和 /sys 文件系统都属于这一类,读写这类文件,实际上是读写内核中相关的数据数据。
- 网络的文件系统,用来访问其他计算机主机数据的文件系统,比如 NFS、SMB 等等。
文件系统首先要先挂载到某个目录才可以正常使用,比如 Linux 系统在启动时,会把文件系统挂载到根目录。
磁盘
- 磁盘进行格式化的时候,会被分成三个存储区域,分别是超级块、索引节点区和数据块区;
- 超级块,用来存储文件系统的详细信息,比如块个数、块大小、空闲块等等。
- 索引节点区,用来存储索引节点;
- 数据块区,用来存储文件或目录数据;
我们不可能把超级块和索引节点区全部加载到内存,这样内存肯定撑不住,所以只有当需要使用的时候,才将其加载进内存,它们加载进内存的时机是不同的
- 超级块:当文件系统挂载时进入内存;
- 索引节点区:当文件被访问时进入内存;
索引节点inode
目录项
文件
虚拟文件系统
文件系统种类众多,而操作系统希望对用户提供一个统一的接口,于是在用户层和文件系统层之间引入了一个中间层,这个中间层就是虚拟文件系统(Virrual File System, VFS)
文件的使用
fd = open(name, flag);
...
write(fd,...);
...
close(fd);
上图展示了向文件写入数据的调用流程:
- 使用open系统调用打开文件,open的参数包含文件的路径名和文件名;
- 使用write写数据,其中write使用open所返回的文件描述符,并不使用文件名。
- 使用完文件后,使用close系统调用关闭文件,避免资源泄露
打开一个文件后,操作系统会跟踪进程打开的所有文件(操作系统为每个进程维护一个打开文件表,文件表中的每一项代表文件描述符)。
打开文件表中包含文件的状态和信息:
- 文件指针:跟踪上次读写位置作为当前文件位置执政,这种指针对打开文件的某个进程来说是唯一的。
- 文件打开计数器:文件关闭时,操作系统必须重用其打开文件表条目,否侧表内空间不够用。因为多个进程可能打开统一文件,所以系统在删除打开文件条目前,必须等待最后一个进程关闭文件。
- 文件磁盘位置:绝大多数文件操作都要求系统修改文件数据,该信息保存至内存中,一面每个操作都从磁盘读取
- 访问权限:每个进程打开文件都需要一个访问模式
文件系统的基本操作单位是数据块
文件的存储
连续空间存储方式
- 文件存放在磁盘【连续的】物理空间。这种模式下,文件数据都是紧密相连的,读写效率高,只需要一次磁盘寻道就可以读取到整个文件。
- 前提:必须知道文件的大小,这样才能根据文件大小分配磁盘物理空间。
- 因此文件头需要指定起始块的位置和长度。所谓文件头,就类似于Linux的inode
- 缺点:磁盘空间碎片和文件长度不易扩展
如果文件 B 被删除,磁盘上就留下一块空缺,这时,如果新来的文件小于其中的一个空缺,我们就可以将其放在相应空缺里。但如果该文件的大小大于所有的空缺,但却小于空缺大小之和,则虽然磁盘上有足够的空缺,但该文件还是不能存放。当然了,我们可以通过将现有文件进行挪动来腾出空间以容纳新的文件,但是这个在磁盘挪动文件是非常耗时,所以这种方式不太现实。
另外一个缺陷是文件长度扩展不方便,例如上图中的文件 A 要想扩大一下,需要更多的磁盘空间,唯一的办法就只能是挪动的方式,前面也说了,这种方式效率是非常低的。
非连续空间存储
非连续空间存放方式分为「链表方式」和「索引方式」。
链表方式
- 无法直接访问数据块,必须按顺序一次访问。
- 数据块中预留存放下一个数据块位置的指针空格键,消耗了一定的存储空间。
- 稳定性较差,如果某个数据块出现问题,比如指针丢失或损坏,会导致文件数据丢失。
- 显式链接
显式链接取出每个数据块的位置信息,存储到一个内存的表中,这个表称之为文件分配表(File Allocation Table, FAT)。
该表在整个磁盘仅设置一张,每个表项中存放链接指针,指向下一个数据块号
对于显式链接的工作方式,我们举个例子,文件 A 依次使用了磁盘块 4、7、2、10 和 12 ,文件 B 依次使用了磁盘块 6、3、11 和 14 。利用下图中的表,可以从第 4 块开始,顺着链走到最后,找到文件 A 的全部磁盘块。同样,从第 6 块开始,顺着链走到最后,也能够找出文件 B 的全部磁盘块。最后,这两个链都以一个不属于有效磁盘编号的特殊标记(如 -1 )结束。
优点: 查找动作在内存中进行,提高了检索速度,减少了访问磁盘的次数。
缺点:由于整个表都存在磁盘中,因此不适用于大磁盘。
索引方式
索引的实现是为每个文件创建一个「索引数据块」,里面存放的是指向文件数据块的指针列表,说白了就像书的目录一样,要找哪个章节的内容,看目录查就可以
文件头需要包含指向「索引数据块」的指针,这样就可以通过索引数据块找到对应的文件数据块了。
创建文件时,索引块的所有指针都设为空。当首次写入第 i 块时,先从空闲空间中取得一个块,再将其地址写到索引块的第 i 个条目。
如图所示,可以通过索引数据块得到该文件使用的数据块分别为5,7,9,14且顺序为1,2,3,4
优点:
- 文件创建,增大和缩小方便
- 不会有碎片问题
- 支持顺序读和随机读写
- 索引数据存放在磁盘,大文件可以承受
缺点:索引数据存放在磁盘,若需要的存储内容很小,仍旧需要占用一个块,带来额外存储开销
另一个例子:
- 因为逻辑块号的长度(比如 4byte)是固定的,那么逻辑块号可以通过计算得出,而不需要显式的记录。比如,第一个 4byte 则表示第一个逻辑块号对应的物理块号,第二个 4byte 表示第二个逻辑块号对应的物理块号,以此类推
- 如何转换逻辑块号到物理块号呢?
用户给出需要访问的逻辑块号 i,操作系统需要找出所需访问的文件的目录项(FCB)。在 FCB 中可以找到索引块的物理块号,通过该物理块号,可以找到索引表,将索引表读入内存,并在索引表中查找需要的逻辑块 i 对应的物理块即可。
混合模式
产生原因:假设一个磁盘块的大小为 512byte,一个索引项为 4byte,那么一个磁盘块只能记录 128 个索引项,但是如果一个文件的大小超过了 128 个磁盘块。故而若只有一个索引块,无法满足大文件的存储要求。
小结
早期 Unix 文件系统是组合了前面的文件存放方式的优点,如下图:
它是根据文件的大小,存放的方式会有所变化:
- 如果存放文件所需的数据块小于 10 块,则采用直接查找的方式;
- 如果存放文件所需的数据块超过 10 块,则采用一级间接索引方式;
- 如果前面两种方式都不够存放大文件,则采用二级间接索引方式;
- 如果二级间接索引也不够存放大文件,这采用三级间接索引方式;
那么,文件头(Inode)就需要包含 13 个指针:
- 10 个指向数据块的指针;
- 第 11 个指向索引块的指针;
- 第 12 个指向二级索引块的指针;
- 第 13 个指向三级索引块的指针;
所以,这种方式能很灵活地支持小文件和大文件的存放:
对于小文件使用直接查找的方式可减少索引数据块的开销;
对于大文件则以多级索引的方式来支持,所以大文件在访问数据块时需要大量查询;
这个方案就用在了 Linux Ext 2/3 文件系统里,虽然解决大文件的存储,但是对于大文件的访问,需要大量的查询,效率比较低。
空闲空间管理
前面说到的文件的存储是针对已经被占用的数据块组织和管理,接下来的问题是,如果我要保存一个数据块,我应该放在硬盘上的哪个位置呢?难道需要将所有的块扫描一遍,找个空的地方随便放吗?
那这种方式效率就太低了,所以针对磁盘的空闲空间也是要引入管理的机制,接下来介绍几种常见的方法:
空闲表法
空闲表法就是为所有空闲空间建立一张表,表内容包括空闲区的第一个块号和该空闲区的块个数,注意,这个方式是连续分配的。如下图:
当请求分配磁盘空间时,系统依次扫描空闲表里的内容,直到找到一个合适的空闲区域为止。当用户撤销一个文件时,系统回收文件空间。这时,也需顺序扫描空闲表,寻找一个空闲表条目并将释放空间的第一个物理块号及它占用的块数填到这个条目中。
这种方法仅当有少量的空闲区时才有较好的效果。
因为,如果存储空间中有着大量的小的空闲区,则空闲表变得很大,这样查询效率会很低。另外,这种分配技术适用于建立连续文件。
空闲链表法
我们也可以使用「链表」的方式来管理空闲空间,每一个空闲块里有一个指针指向下一个空闲块,这样也能很方便的找到空闲块并管理起来。如下图:
当创建文件需要一块或几块时,就从链头上依次取下一块或几块。反之,当回收空间时,把这些空闲块依次接到链头上。
这种技术只要在主存中保存一个指针,令它指向第一个空闲块。其特点是简单,但不能随机访问,工作效率低,因为每当在链上增加或移动空闲块时需要做很多 I/O 操作,同时数据块的指针消耗了一定的存储空间。
空闲表法和空闲链表法都不适合用于大型文件系统,因为这会使空闲表或空闲链表太大。
位图法
位图是利用二进制的一位来表示磁盘中一个盘块的使用情况,磁盘上所有的盘块都有一个二进制位与之对应。
当值为 0 时,表示对应的盘块空闲,值为 1 时,表示对应的盘块已分配。它形式如下:
1111110011111110001110110111111100111 …·
在 Linux 文件系统就采用了位图的方式来管理空闲空间,不仅用于数据空闲块的管理,还用于 inode 空闲块的管理,因为 inode 也是存储在磁盘的,自然也要有对其管理。
文件系统的结构
- Linux使用位图的方式管理空闲空间,用户再创建一个新文件的时候,Linux内核会通过inode的位图找到空闲可用的inode,并进行分配。存储数据时,Linux内核通过块的位图找到空闲的块,并分配。
- 数据块的位图存放在磁盘中,假设放在一个块中,一个块4K,每位表示一个数据块,则一个可以表示
4 * 1024 * 8 = 2^15
个空闲块。由于一个数据块是4K,故而最大可以表示的空间为2^15 * 4 * 1024 = 2^27
个byte,也就是128M。
- 也就是说如果按照上述模式,[一个块的位图 + 一系列块 + 一个块的inode位图 + 一些列inode结构]最大也就能表示128M的文件大小。明细不合理。
- 在Linux中采用块组来表示,即采用N多个块组就可以表示足够大的文件。
- 下图给出了 Linux Ext2 整个文件系统的结构和块组的内容,文件系统都由大量块组组成,在硬盘上相继排布:
最前面是一个引导块,在系统启动时用于启动引导,接着后面就是一个一个连续的块组了,块组内容如下:
- 超级块:包含文件系统的重要信息,比如inode总个数,块总个数,每个块组的inode个数,每个块组的块个数。
- 块组描述符表:包含文件系统中各个块组的状态,比如块组中空闲块和inode的数目等,每个块组都包含了文件系统中所有块组的块组描述信息
- 数据位图和inode位图:对应的数据块和inode块的状态是空闲还是使用
- inode列表:包含了块组中所有的inode,inode用于保存文件系统中与各个文件和目录相关的所有元数据
- 数据块:包含文件的有用数据
你可以会发现每个块组里有很多重复的信息,比如超级块和块组描述符表,这两个都是全局信息,而且非常的重要,这么做是有两个原因:
- 如果系统崩溃破坏了超级块或块组描述符,有关文件系统结构和内容的所有信息都会丢失。如果有冗余的副本,该信息是可能恢复的。
- 通过使文件和管理数据尽可能接近,减少了磁头寻道和旋转,这可以提高文件系统的性能。
不过,Ext2 的后续版本采用了稀疏技术。该做法是,超级块和块组描述符表不再存储到文件系统的每个块组中,而是只写入到块组 0、块组 1 和其他 ID 可以表示为 3、 5、7 的幂的块组中。
目录存储
- 和普通文件不同的是,普通文件的块里面保存的是文件数据,而目录文件的块里面保存的是目录里面一项一项的文件信息。
- 在目录文件的块中,最简单的保存格式就是列表,就是一项一项地将目录下的文件信息(如文件名、文件 inode、文件类型等)列在表里。
- 列表中每一项就代表该目录下的文件的文件名和对应的 inode,通过这个 inode,就可以找到真正的文件。
- 通常,第一项是「.」,表示当前目录,第二项是「…」,表示上一级目录,接下来就是一项一项的文件名和 inode
- 保存目录中文件的格式改成了哈希表,对文件名进行哈希计算,把哈希值保存起来,这样提高了查询到效率。
- Linux 系统的 ext 文件系统就是采用了哈希表,来保存目录的内容,这种方法的优点是查找非常迅速,插入和删除也较简单,不过需要一些预备措施来避免哈希冲突。
- 目录查询是通过在磁盘上反复搜索完成,需要不断地进行 I/O 操作,开销较大。所以,为了减少 I/O 操作,把当前使用的文件目录缓存在内存,以后要使用该文件时只要在内存中操作,从而降低了磁盘操作次数,提高了文件系统的访问速度。
软链接硬链接
- 硬链接:多个目录项中的索引节点指向同一个文件,也就是指向同一个inode,但是由于inode是不能跨文件系统的。所以硬链接是不可用于跨文件系统的。由于多个目录项都是指向同一个inode,那么只有删除文件的所有硬链接以及原文件时,系统才彻底删除了这个文件。
- 软链接:相当于重新创建了一个文件,这个文件拥有独立的inode,但是这个文件的内容是另一个文件的路径,所以访问软链接的时候,实际上是访问了另一个文件。软连接是可以跨文件系统的,甚至目标文件删除了,链接文件还是在的,只不过指向的文件找不到了而已。
文件I/O
缓冲与非缓冲I/O
- 文件操作的标准库是可以实现数据缓存的,因此可以按是否缓存分成两类:
- 缓冲I/O:利用标准库的缓存实现文件的加速访问,而标准库再通过系统调用访问文件
- 非缓冲I/O:直接通过胸痛调用访问文件,不经过标准库缓存
比如:很多程序遇到换行时才真正输出,而换行前的内容,其实就是被标准库暂时缓存起来了,这样可以减少系统调用的次数(系统调用是又CPU上下文切换开销的)
直接与非直接I/O
- 磁盘I/O速度低,因此Linux内核为了减少磁盘I/O次数,在系统调用后,会把用户数据拷贝到内核中缓存起来,这个内核缓存给空间也就是页缓存,只有当缓存满足某些条件的时候,才发起磁盘I/O的请求。
- 直接I/O:不会发生内核缓存和用户程序数据复制,直接访问磁盘
- 非直接I/O:读操作时,数据从内核缓存中拷贝给用户程序,写操作时,数据从用户程序拷贝给内核缓存,再由内核写入磁盘
- 若在使用文件操作类的系统调用函数时,指定O_DIRECT标志,则表示使用直接I/O,否则默认使用非直接I/O
以下几种场景会触发内核缓存的数据写入磁盘:
- 在调用 write 的最后,当发现内核缓存的数据太多的时候,内核会把数据写到磁盘上;
- 用户主动调用 sync,内核缓存会刷到磁盘上;
- 当内存十分紧张,无法再分配页面时,也会把内核缓存的数据刷到磁盘上;
- 内核缓存的数据的缓存时间超过某个时间时,也会把数据刷到磁盘上;
阻塞与非阻塞I/O
- 阻塞I/O:当用户执行read时,线程被阻塞,等待 内核数据准备好,并把数据从内核拷贝到应用程序的缓冲区,当拷贝完成,read才会返回
- 重点在于两个过程:内核数据准备好,数据从内核拷贝到用户态。
- 非阻塞I/O:read在数据未准备好的情况下立即返回,可以继续执行,此时程序不断轮询内核,直到数据准备好,内核将数据拷贝到应用程序缓冲区,read才能取到所需的数据
- 最后一次read调用,获取数据的过程,是一个同步的过程,是需要等待的过程。这里的同步值得是内核态的数据拷贝到用户程序缓存区这个过程
举个例子,访问管道或 socket 时,如果设置了 O_NONBLOCK 标志,那么就表示使用的是非阻塞 I/O 的方式访问,而不做任何设置的话,默认是阻塞 I/O。
同步与异步I/O
- 应用程序每次轮询内核的 I/O 是否准备好,感觉有点傻乎乎,因为轮询的过程中,应用程序啥也做不了,只是在循环
- 为了解决这种傻乎乎轮询方式,于是 I/O 多路复用技术就出来了,如 select、poll,它是通过 I/O 事件分发,当内核数据准备好时,再以事件通知应用程序进行操作
- 下图是使用 select I/O 多路复用过程。注意,read 获取数据的过程(数据从内核态拷贝到用户态的过程),也是一个同步的过程,需要等待
- 实际上,无论是阻塞 I/O、非阻塞 I/O,还是基于非阻塞 I/O 的多路复用都是同步调用。因为它们在 read 调用时,内核将数据从内核空间拷贝到应用程序空间,过程都是需要等待的,也就是说这个过程是同步的,如果内核实现的拷贝效率不高,read 调用就会在这个同步过程中等待比较长的时间。
- 异步I/O
- 真正的异步 I/O 是「内核数据准备好」和「数据从内核态拷贝到用户态」这两个过程都不用等待。
- 当我们发起 aio_read 之后,就立即返回,内核自动将数据从内核空间拷贝到应用程序空间,这个拷贝过程同样是异步的,内核自动完成的,和前面的同步操作不一样,应用程序并不需要主动发起拷贝动作。过程如下图:
小结
在前面我们知道了,I/O 是分为两个过程的:
- 数据准备的过程
- 数据从内核空间拷贝到用户进程缓冲区的过程
- 阻塞 I/O 会阻塞在「过程 1 」和「过程 2」
- 非阻塞 I/O 和基于非阻塞 I/O 的多路复用只会阻塞在「过程 2」,所以这三个都可以认为是同步 I/O。
- 异步 I/O 则不同,「过程 1 」和「过程 2 」都不会阻塞。
举个你去饭堂吃饭的例子,你好比用户程序,饭堂好比操作系统。
阻塞 I/O 好比,你去饭堂吃饭,但是饭堂的菜还没做好,然后你就一直在那里等啊等,等了好长一段时间终于等到饭堂阿姨把菜端了出来(数据准备的过程),但是你还得继续等阿姨把菜(内核空间)打到你的饭盒里(用户空间),经历完这两个过程,你才可以离开。
非阻塞 I/O 好比,你去了饭堂,问阿姨菜做好了没有,阿姨告诉你没,你就离开了,过几十分钟,你又来饭堂问阿姨,阿姨说做好了,于是阿姨帮你把菜打到你的饭盒里,这个过程你是得等待的。
基于非阻塞的 I/O 多路复用好比,你去饭堂吃饭,发现有一排窗口,饭堂阿姨告诉你这些窗口都还没做好菜,等做好了再通知你,于是等啊等(select 调用中),过了一会阿姨通知你菜做好了,但是不知道哪个窗口的菜做好了,你自己看吧。于是你只能一个一个窗口去确认,后面发现 5 号窗口菜做好了,于是你让 5 号窗口的阿姨帮你打菜到饭盒里,这个打菜的过程你是要等待的,虽然时间不长。打完菜后,你自然就可以离开了。
异步 I/O 好比,你让饭堂阿姨将菜做好并把菜打到饭盒里后,把饭盒送到你面前,整个过程你都不需要任何等待。
参考文件:
https://juejin.cn/post/6847902224098525192#heading-7
https://segmentfault.com/a/1190000023615225