文件随机或顺序读写原理深入浅出

一、文件读写的用户程序、操作系统、磁盘交互原理

  最近为了彻底搞懂文件读写原理,我特意查询了很多资料,包括Java读写文件的API代码、操作系统处理文件以及磁盘硬件知识等。由于网上现存技术文章,几乎没有找到一篇能够彻底综合讲明白这个原理的文章。心中还是有很多疑问。且有不少文章包括书籍所阐述的随机/顺序读写原理讲述的都是错误或误导性的。所以我综合了一下我能查阅到的所有资料,深入细节知识,给大家彻底讲明白这事。原创文章,转发请保留第一作者著作权。谢谢!
  如下图所示。我们编写的用户程序读写文件时必须经过的OS和硬件交互的内存模型。

1、读文件

  用户程序通过编程语言提供的读取文件api发起对某个文件读取。此时程序切换到内核态,用户程序处于阻塞状态。由于读取的内容还不在内核缓冲区中,导致触发OS缺页中断异常。然后由OS负责发起对磁盘文件的数据读取。读取到数据后,先存放在OS内核的主存空间,叫PageCache。然后OS再将数据拷贝一份至用户进程空间的主存ByteBuffer中。此时程序由内核态切换至用户态继续运行程序。程序将ByteBuffer中的内容读取到本地变量中,即完成文件数据读取工作。

2、写文件

  用户程序通过编程语言提供的写入文件api发起对某个文件写入磁盘。此时程序切换到内核态用户程序处于阻塞状态,由OS负责发起对磁盘文件的数据写入。用户写入数据后,并不是直接写到磁盘的,而是先写到ByteBuffer中,然后再提交到PageCache中。最后由操作系统决定何时写入磁盘。数据写入PageCache中后,此时程序由内核态切换至用户态继续运行。
用户程序将数据写入内核的PageCache缓冲区后,即认为写入成功了。程序由内核态切换回用于态,可以继续后续的工作了。PageCache中的数据最终写入磁盘是由操作系统异步提交至磁盘的。一般是定时或PageCache满了的时候写入。如果用户程序通过调用flush方法强制写入,则操作系统也会服从这个命令。立即将数据写入磁盘然后由内核态切换回用户态继续运行程序。但是这样做会损失性能,但可以确切的知道数据是否已经写入磁盘了。

一、文件读写详细过程

1、读文件

  如下所示为一典型Java读取某文件内容的用户编程代码。接下来我们详细解说读取文件过程。
            // 一次读多个字节
            byte[] tempbytes = new byte[100];
            int byteread = 0;
            in = new FileInputStream(fileName);//
            ReadFromFile.showAvailableBytes(in);
            // 读入多个字节到字节数组中,byteread为一次读入的字节数
            while ((byteread = in.read(tempbytes)) != -1) { //
                System.out.write(tempbytes, 0, byteread);
            }
View Code
  首先通过位置①的代码发起一个open的系统调用,程序由用户态切换到内核态。操作系统通过文件全路径名在文件目录中找到目标文件名对应的文件iNode标识ID,然后用这个iNode标识ID在iNode索引文件找到目标文件iNode节点数据并加载到内核空间中。这个iNode节点包含了文件的各种属性(创建时间,大小以及磁盘块空间占用信息等等)。然后再由内核态切换回用户态,这样程序就获得了操作这个文件的文件描述。接下来就可以正式开始读取文件内容了。
  然后再通位置②,循环数次获取固定大小的数据。通过发起read系统调用,操作系统通过文件iNode文件属性中的磁盘块空间占用信息得到文件起始位的磁盘物理地址。再从磁盘中将要取得数据拷贝到PageCache内核缓冲区。然后将数据拷贝至用户进程空间。程序由内核态切换回用户态,从而可以读取到数据,并放入上面代码中的临时变量tempbytes中。
  整个过程如下图所示。
  至于上面说到的操作系统通过iNode节点中的磁盘块占用信息去定位磁盘文件数据。其细节过程如下图所示。

①根据文件路径从文件目录中找到iNode ID。

  用户读取一个文件,首先需要调用OS中文件系统的open方法。该方法会返回一个文件描述符给用户程序。OS首先根据用户传过来的文件全路径名在目录索引数据结构中找到文件对应的iNode标识ID。目录数据是存在于磁盘上的,在OS初始化时就会加载到内存中,由于目录数据结构并不会很庞大,一次性加载驻留到内存也不是不可以或者部分加载,等需要的时候在从磁盘上调度进内存也可以。根据文件路径在目录中查找效率应该是很高的,因为目录本身就是一棵树,应该也是类似数据库的树形索引结构。所以它的查找算法时间复杂度就是O(logN)。具体细节我暂时还没弄清楚,这不是重点。
  iNode就是文件属性索引数据了。磁盘格式化时OS就会把磁盘分区成iNode区和数据区。iNode节点就包含了文件的一些属性信息,比如文件大小、创建修改时间、作者等等。其中最重要的是还存有整个文件数据在磁盘上的分布情况(文件占用了哪些磁盘块)。

②根据iNode ID从Inode索引中找到文件属性。

  得到iNode标识的ID后,就可以去iNode数据中查找到对应的文件属性了,并加载到内存,方便后续读写文件时快速获得磁盘定位。iNode数据结构应该类似哈希结构了,key就是iNode标识ID,value就是具体某个文件的属性数据对象了。所以它的算法时间复杂度就是O(1)。具体细节我暂时还没弄清楚,这不是重点。
  我们系统中的文件它的文件属性(iNode)和它的数据正文是分开存储的。文件属性中有文件数据所在磁盘块的位置信息。

③根据文件属性中的磁盘空间块信息找到需要读取的数据所在的磁盘块的物理位置

  文件属性也就是iNode节点这个数据结构,里面包含了文件正文数据在磁盘物理位置上的分布情况。磁盘读写都是以块为单位的。所以这个位置信息其实也就是一个指向磁盘块的物理地址指针。
  其结构图如下。
  文件属性里就包含了文件正文数据占有磁盘所有信息。但是由于文件属性大小有限制,而文件大小没有限制。这样会导致磁盘块占用信息超出限制。所以最后一个磁盘数据项设计为特殊的作用。它是一个指向更多磁盘占用信息数据的指针。这些更多信息存放在普通的数据区。这样当文件iNode加载到内存后,可以把其他更多磁盘块信息一起加载进来。这样就避免了iNode索引文件太大的问题。
后续的文件读写系统调用,由用户态切换至内核态。操作系统就可以根据文件数据的相对位置(偏移量)快速从iNode中的磁盘块占用数据结构中找到其对应的磁盘物理位置在哪里了。很明显这个数据结构类似哈希结构,其算法复杂度就是O(1)。
  比如我们现在讨论的读取数据。每次用户代码的api调用read方法时。由于时从头开始读取,所以OS就从上图中“磁盘块0”数据项开始迭代,获取对应的物理磁盘块起始地址开始读取数据并拷贝至PageCache缓冲区,再拷贝至用户进程缓冲区。这样用户代码就可以获取这些数据了。
考虑到另外一种随机读的场景。我们并不是把整个文件从头开始读一遍。而是需要直接定位到文件的中间某个位置开始 读取部分内容。如下所示。
  RandomAccessFile raf=new RandomAccessFile(new File("D:\\3\\test.txt"), "r");   
            //获取RandomAccessFile对象文件指针的位置,初始位置是0  
            System.out.println("RandomAccessFile文件指针的初始位置:"+raf.getFilePointer());  
            raf.seek(pointe);//移动文件指针位置  
            byte[]  buff=new byte[1024];  
            //用于保存实际读取的字节数  
            int hasRead=0;  
            //循环读取  
            while((hasRead=raf.read(buff))>0){  
                //打印读取的内容,并将字节转为字符串输入  
                System.out.println(new String(buff,0,hasRead));       
            }  
View Code
  程序代码调用seek方法直接定位到某个文件相对位置开始读取内容。实际上就是调用了OS管理文件的系统调用seek函数。这个系统调用需要传递一个文件相对位置也就是偏移量,不是指磁盘的物理位置。文件的相对位置偏移量是从0开始的,结束位置和文件的大小字节数相等。操作系统拿到这个偏移量后,就可以计算出文件所属的逻辑块编号。因为每个块是固定大小的,所以能计算出来。通过文件属性的逻辑磁盘块信息就能得到磁盘块的物理位置。从而可以快速直接定位到磁盘物理块读取到需要的数据。这里说的逻辑块和物理块的概念是有区别的。逻辑块属于当前的文件从0开始编号,物理块才是磁盘真正的存放数据的区域,属于全局的。编号自然不是从0开始的。

2、写文件

  写文件的过程和前面阐述的差不多,相关的知识点也在读文件中已经顺带描述了。就不在赘述了。这里就说些特别需要注意的点就行。

  ③根据空闲块索引找到可以写入的物理位置并写入

  如上图所示,OS写文件内容时首先要访问磁盘空闲块索引表。这是个什么东西呢?由于磁盘很大,不可能每次写数据时,都让磁头从头到尾遍历一次才能找到空闲位置。这样效率可想而知的差劲。所以OS会把磁盘上的空闲块索引起来存放在磁盘某个位置上。后续磁盘存储和删除文件内容时都通过这个空闲块索引表快速定位,同时删除数据也会更新索引表增加空闲块。
空闲块记录索引的实现常用有两种,一种是我们熟悉的链表结构,还有一种是位图结构。这里就不详细讨论了。

  ④写入数据后更新iNode里的磁盘占用块索引

  数据写入后,那么这个空闲块就被占用了,自然也就需要更新下iNode文件属性里的磁盘占用块索引数据了。
我们前面说的写文件都是只讲了尾部追加这种方式。但是实际上我们可以通过RandomAccessFile类实现文件随机位置写功能。但是我们同时也有一些困惑。为啥不能直接在中间某个位置插入我们要写的内容,而是要先把插入位置后面的内容截取放入临时文件中。插入新内容后,再把临时文件内容尾部追加到原来的文件中来实现文件修改?代码如下所示。
public static void insert(String fileName, long pos, String insertContent) throws IOException{
        File file = File.createTempFile("tmp", null);
        file.deleteOnExit();
        RandomAccessFile raf = new RandomAccessFile(fileName, "rw");
        FileInputStream fileInputStream = new FileInputStream(file);
        FileOutputStream fileOutputStream = new FileOutputStream(file);
        raf.seek(pos);
        byte[] buff = new byte[64];
        int hasRead = 0;
        while((hasRead = raf.read(buff)) > 0){
            fileOutputStream.write(buff);
        }
        raf.seek(pos);
        raf.write(insertContent.getBytes());
        //追加文件插入点之后的内容
        while((hasRead = fileInputStream.read(buff)) > 0){
            raf.write(buff, 0, hasRead);
        }
        raf.close();
        fileInputStream.close();
        fileOutputStream.close();
    }
View Code
  按照我们上面的阐述,写入的文件内容完全可以存入磁盘上的一个新块,然后更新下iNode属性里的占用磁盘块索引数据即可。也不需要真的去移动磁盘上的所有数据块。看上去成本也很小,可为啥我们的编程api却都不支持呢?
  我想答案可能是这样的。假如允许我们上面那种操作,如果一个很大的文本文件。你现在只是编辑了文本中间某个位置的一个字,即插入了一个文本字符。那么此时这个新增的文本内容就得在磁盘上找到一个新的块存储下来。这样是不是有点浪费空间呢?因为磁盘上的一个块只能分配给一个文件使用,块大小如果是64kb的话,一个字符也就占用了2个字节的空间。更要命的是这样一搞,使得原本满存状态的块,出现很多不连续的空洞。这样就会使得读取文件时数据是不连续的,系统需要额外信息记录这些中间的存储空洞。加大了读取难度。这就是我猜测的原因。实际上操作系统层面也没有这种操作插入的系统调用函数。故编程语言层面也就没法支持了。
  操作系统层面给上层应用程序提供了写文件的两个系统调用。write和append,其中append是write的限制形式,即只能在文件尾部追加。而write虽然提供了随机位置写,但是并不是将新内容插入其中,而是覆盖原有的数据。我们平时使用Word文本编辑软件时,如果对一个很大的文件进行编辑,然后点击保存,你会发现很慢。同时你还能看到文件所在的目录下生成了一个新的处于隐藏状态的临时文件。这些现象也能说明我们上面的观点。即编辑文件时,需要一个成本很高的过程。如下图所示。
 

三、常见认知误区澄清

1、磁盘上文件存储数据结构是链表,每一块文件数据节点里有一个指针指向下一块数据节点。理解错误!

  很多人都知道磁盘存储一个文件不可能是连续分配空间的。而是东一块西一块的存储在磁盘上的。就误以为这些分散的数据节点就像链表一样通过其中一个指针指向下一块数据节点。如下图所示。
  怎么说呢?这种方案以前也是有一些文件系统实现过的方案,但是现在常见的磁盘文件系统都不再使用这种落后的方案。而是我前面提到的iNode节点方案。也就是说磁盘上存储的文件数据块就是纯数据,没有其他指针之类的额外信息。之所以我们能顺利定位这些数据块,都全靠iNode节点属性中磁盘块信息的指针。

2、append文件尾部追加方法是顺序写,也就是磁盘会分配连续的空间给文件存储。理解错误!

  这种观点,包括网上和某些技术书籍里的作者都有这种观点。实际上是错误的。或许是他们根本没有细究文件存储底层OS和磁盘硬件的工作原理导致。我这里就重新总结纠正一下这种误导性观点。
前面说过,append系统调用是write的限制形式,即总是在文件末尾追加内容。看上去好像是说顺序写入文件数据,因为是在尾部追加啊!所以这样很容易误导大家以为这就是顺序写,即磁盘存储时分配连续的空间给到文件,减少了磁盘寻道时间。
  事实上,磁盘从来都不会连续分配空间给哪个文件。这是我们现代文件系统的设计方案。前面介绍iNode知识时也给大家详细说明了。所以就不再赘述。我们用户程序写文件内容时,提交给OS的缓冲区PageCache后就返回了。实际这个内容存储在磁盘哪个位置是由OS决定的。OS会根据磁盘未分配空间索引表随机找一个空块把内容存储进去,然后更新文件iNode里的磁盘占用块索引数据。这样就完成了文件写入操作。所以append操作不是在磁盘上接着文件末尾内容所在块位置连续分配空间的。最多只能说逻辑上是顺序的。
  那么逻辑上的随机写write是不是会慢呢?根据前面介绍的原理,应该是相同的效率。我们可以做如下测试验证。使用RandomAccessFile 实现,因为只有这个类支持随机位置写入,其他写文件类都只提供尾部追加方式。打开一个20M的文本文件“test.txt”。分别采用如下两个方法,随机位置写入和尾部追加写入做100000次操作,多次测得大概的平均耗时数据。
// 文件随机位置写入 耗时:1000ms
public static void randomWrite1(String path,String content) throws Exception {
RandomAccessFile raf=new RandomAccessFile(path,"rw");
Random random=new Random();
for(int i=0;i<100000;i++){
raf.seek(random.nextInt((int)raf.length())); // 在文件随机位置写入覆盖
raf.write((i+content+System.lineSeparator()).getBytes());
}
raf.close();
}
// 文件尾部位置写入 耗时:800ms
public static void randomWrite2(String path,String content) throws Exception {
RandomAccessFile raf=new RandomAccessFile(path,"rw");
for(int i=0;i<100000;i++){
raf.seek(raf.length()); // 总是在文件尾部追加
raf.write((i+content+System.lineSeparator()).getBytes());
}
raf.close();
}
View Code
  看上去采用尾部追加性能略高。实际上也相差不大。多出的200ms只是生成随机数消耗的。因为如果说随机位置写需要某些人认为的磁盘磁头来回反复移动,则性能不可能只差这么一丢丢。实际上我去掉随机数生成的代码,改用固定中间位置写入,这两个方法耗时几乎没有区别了。这说明无论是尾部追加还是随机位置写入方式,性能都是一样的。因为根据前面介绍的原理,OS通过iNode中的磁盘占用块哈希表,可以快速定位到目标磁盘物理位置,其算法时间复杂度是O(1)。所以尾部追加也是一样的定位效率。
可能也有些人想说怎么不试试BufferWriter、FileWriter等写入类,效率高几个数量级比RandomAccessFile 。我也的确测试过,但是这些类都是采用尾部追加模式,无法和其随机位置写入做比较。所以没法拿出来测试说明。但是这些类写入性能之所以远高于直接用RandomAccessFile 尾部追加。我想是因为api方法做了用户程序层面的优化,比如批量写入,批量转化成Byte之类的。而RandomAccessFile 可能就是最原始的直接对接OS系统调用层的API了。

3、mmap内存映射技术之所以快,是因为直接把磁盘文件映射到用户空间内存,不走内核态。理解错误!

  这也是一种常见的认知误区,实际上这个技术是操作系统给用户程序提供的一个系统调用函数。它把文件映射到OS内核缓冲区空间,同时共享给用户进程,也可以共享给多个用户进程。映射过程中不会产生实际的数据从磁盘真正调取动作,只有用户程序需要的时候才会调入部分数据。总之也是和普通文件读取一样按需调取。那么mmap技术为什么在读取数据时会比普通read操作快几个数量级呢?
上面我们讲述了普通读写操作的内存模型。用户程序要读取到磁盘上的数据。要经历4次内核态切换以及2次数据拷贝操作。那么mmap技术由于是和用户进程共享内核缓冲区,所以少了一次拷贝操作(数据从内核缓冲区到用户进程缓冲区)。从而大大提高了性能。如下图所示。

4、mmap内存映射技术写文件快是因为顺序写磁盘。理解错误!

  上面的问题基本已经让我们理解了mmap技术的内存模型。同样的,我们写文件时,由于也少了一次数据从用户缓冲区到内核缓冲区的拷贝操作。使得我们的写效率非常的高。并不是很多人认为的数据直达磁盘,中间不经过内核态切换,并且连续在磁盘上分配空间写入。这些理解都是错误的。

5、随机读写文件比顺序读写文件慢,是因为磁盘移动磁头来回随机移动导致。理解错误!

  这也是一种常见的误区。我看过很多文章都是这样认为的。其实所有的写操作在硬件磁盘层面上都是随机写。这是由现代操作系统的文件系统设计方案决定的。我们用户程序写入数据提交给OS缓冲区之后,就与我们没关系了。操作系统决定何时写入磁盘中的某个空闲块。所有的文件都不是连续分配的,都是以块为单位分散存储在磁盘上。原因也很简单,系统运行一段时间后,我们对文件的增删改会导致磁盘上数据无法连续,非常的分散。
  当然OS提交PageCache中的写入数据时,也有一定的优化机制。它会让本次需要提交给磁盘的数据规划好磁头调度的策略,让写入成本最小化。这就是磁盘调度算法中的电梯算法了。这里就不深入讲解了。
  至于读文件,顺序读也只是逻辑上的顺序,也就是按照当前文件的相对偏移量顺序读取,并非磁盘上连续空间读取。即便是seek系统调用方法随机定位读,理论上效率也是差不多的。都是使用iNode的磁盘占用块索引文件快速定位物理块。

 

posted @ 2021-10-25 23:22  月光冷锋  阅读(3133)  评论(2编辑  收藏  举报