文件IO


在计算机中,IO有很多种,常见的磁盘IO,网络IO,我们常用的鼠标键盘这些也都是IO设备。
今天主要介绍下磁盘IO

文件读取

先简单看下我们的应用是怎么读取到文件的:

在这里插入图片描述
在我们的内存中,主要被分为了两块区域,一块OS内核的专有内存,另外一块是我们运行的其他程序的内存。磁盘的读与写,都是由kernel统一管理,针对文件这块,kernel会对文件虚拟出一个文件目录树,当需要读取文件时,会将文件内容加载进n多个page cache,一个page cache一般4k左右大小。另外kernel还维护有对文件的修改 以及刷盘(flush)机制。

在应用层,读取文件,实际读取的是对应的那多个page cache,每一个app都会维护一个seek指针,标识自己需要从哪个page的哪个位置开始读取数据。

文件加载

下面我们以Linux/Unix系统为例,简单介绍下文件是如何被加载进内存的。
在这里插入图片描述
我们的物理磁盘一般会有很多个分区,第一个分区一般会有几百M大小,里面装的精简版的Kernel,当我们计算机通电启动后,会根据BIOS去读取第一个分区数据,也就是将Kernel的镜像装在进内存,此时也即相当于在内存中运行了一个kernel了。如何再去加载我们自定义的分区。如图当加载了磁盘分区n,则可以读取出root目录下的user,system,bin等文件,进而构建出一棵虚拟的文件目录系统VTS。

虚拟目录:

默认情况下,这颗虚拟目录树,是根据我们磁盘里文件的父子排列,如磁盘目录路径:
/user/test/test.txt
那在这颗目录树下,从root节点 / 开始依次寻找user目录,test目录,最终可以找到test.txt目录。
但是我们需要注意到,这是一颗虚拟的目录树,既然是虚拟出来的,也即意味着目录树的结构是可以随意变动的。如我们可以从另一个磁盘分区上找到某一个文件,添加到这颗树上,比如我们添加目录:/user/test2/test2.txt,实际对应到磁盘root目录下,根本就没有test2这个目录存在,test2实际在磁盘的另一个分区上,这种便是文件的挂载,反之便是文件的卸载

文件描述符(FD)

下面我们简单介绍下Linux和Unix下很重要的一个文件概念:文件描述符。
在Linux和Unix中遵循一个原则:一切皆文件,不论是我们的读操作,还是写操作,还是其他计算操作等,这些在Linux/Unix系统中都被定义为文件,一切皆文件之后,便可将这些操作都统一视为IO。

那么什么是文件是文件描述符?
在这里插入图片描述
通过系统命令,我们可以看到在FD栏目下有不少值,有0,1,2
Type栏目下也有不少,这个其实标识的文件的类型,有目录类型,字符类型,可执行文件类型等待
DEVIECE标识文件所处的位置
OFFSET:标识当前文件的偏移量

我们在当前目录下新增一个文件text.txt,新增一个文件描述符 5 来读取这个文件,再次执行命令:
在这里插入图片描述
我们可以看到,在当前的bash线程下,出现了FD 5,后面的“r”代表"read",当前线程的offset偏移量是33,读取的文件是text.txt

所以文件描述符,描述的是当前线程可执行的某种动作,以及该动作已经执行到某种程度的状态。我们来看下当前线程的fd文件下有哪些文件:
在这里插入图片描述
在Linux/Unix中,有3个系统限定的文件描述符:
0:标准输入
1:标准输出
2:标准报错

如图,我们自定义了文件描述符5,用于从磁盘读取文件,于是在当前线程的fd目录下,即出现了文件"5",代表当前线程拥有文件描述符5,作用是从磁盘读取文件test.txt,且当前线程读取文件的偏移量offset已经到了33的位置。

FD的概念一定要梳理清楚,这个对于我们后面学习NIO的实现原理很重要。
其中FD目录存放地址,
Linux:/ proc / $$ / fd
mac os: /dev/fd,注:这个地址不一定准确,网上Mac os能查到的资料相当少,自己本地尝试查找目录,结合实际文件里的数据内容推测,是在这个路径,不一定正确。

一切皆文件

上面已经提及过,在Linux/Unix系统中,一切皆文件。由于Mac os相关资料太少,查找验证相对麻烦,用Windows做服务器的相对较少,选择性忽略,下面以Linux进行示例。

在Linux下,是支持多线程的,每个线程可以执行多种不同的任务,如读写数据,建立网络连接,网络数据传输,CPU计算等,这些是动作是如何体现“一切皆文件”原则的呢?

上面也已经提到了一个路径:/proc,下面我们来看下这个目录下都有些什么:
在这里插入图片描述
可以看出,在/proc目录下,有很多以数字开头的文件,这些数字代表啥呢?每一个线程都有一个唯一的线程编号PID,这些数字也就是线程的PID,也就是说每一个线程都有一个自己的文件目录,目录的名称就是线程自己的PID。

在这里插入图片描述
再进入fd目录,便会看到0,1,2三个基本文件描述符。如果当前线程需要读取IO流,实际触发的便是0号FD。

另外次目录是只有os启动之后,才会被写入数据,当关机等操作后,磁盘上是不存在这个目录数据的。

/proc/$$/fd,这是具体的目录路径,可以查看当前线程所有的文件描述符,都以文件形式存在。
lsof -op 可以线程fd的详情。

文件IO

现在我们回到前面提及的文件读取,假设现在我们的APP需要读取IO数据(假设从磁盘文件读取),这个过程是什么样的呢?
在这里插入图片描述
当我们APP运行到读取file的方法时,实际是由用户线程调用kernel,触发kernel的read方法,也即 FD(0),文件描述符0,此时会给CPU下发 int 0X80指令,其中int是CPU指令,0X80是指令号,即bit7(1000 0000) =128,对应于系统中断向量表中的callback,此时线程需要从用户态切换到内核态,用户态线程开始挂起,等待CPU切换到用户态。此时内核read线程开始工作,也即 FD(0),校验当前维护的pageCache是否有相应数据,如果没有,则通过协处理器DMA,从磁盘中读取数据写入pageCache,读取到数据之后,通知CPU从内核态切换到用户态,将数据copy进工作线程的buffer。

这期间需要先从磁盘copy进DMA,再从DMA copy进pageCache,再从pageCache copy进用户态线程buffer,中间需要经历多次copy。而且这段操作,需要用户态和内核态多次切换,又会涉及到线程调度以及线程的快照保存、恢复快照等操作,同时工作线程会block住,等待返回结果,这就是为什么IO操作是效率瓶颈的原因。

pageCahce

通过上面的介绍,可以看到我们的IO操作,依赖于pageCache这个中间对象,那这个pageCache又是如何具体提供服务的呢?
在这里插入图片描述
pageCache的作用,是将磁盘数据加载进内存,减少磁盘IO操作。

pageCache一般默认是4K大小,OS每次以4K为单位从磁盘读取数据写入pageCache。如图所示,app1需要从磁盘读取2*3个page的数据,需要6个page,同样的app2读取另外一个文件也需要6个page,app3需要同时读取这两个文件,需要12个page。

在物理内存中,每个文件的page不一定都是连续存储的。假设现在app1先来读取文件1,在一个CPU时间片内读取了3个page内容,写入了3个pageCache,此时线程切换到app2,app2线程在这轮的时间片内读取了3个page内容,写入3个pageCache,再下一轮时间片切换到app1,又写入3个pageCache,此时在物理内存中实际存储的数据,对于单个文件来讲,数据是不连续的。

对于每个app而言,在app眼里它取到的数据实际是连续的,因为os对每个app都维护有一个虚拟内存,在这个虚拟内存中地址是连续的,只是通过mmu虚拟映射表,映射到实际物理内存上时,数据是不连续。

dirty

假设我们现在已经从磁盘加载数据进入某一个page页了,现在我们程序需要对该数据进行修改,最终又是如何实现的呢?
与读取数据调用FD(0)类似,写数据是调用FD(1)给os,os将该pageCache中的数据进行修改,一旦pageCache中数据被修改,该page就被标记为dirty,即为脏页,依据刷盘规则,在一定条件下,将该page数据回写进磁盘。

刷盘

  • vm.dirty_background_ratio 是内存可以填充脏数据的百分比。这些脏数据稍后会写入磁盘。

如:假设现在内存容量10G,设置vm.dirty_background_ratio=80,即修改的dirty page容量如果不超过8G(总容量的80%),则不会触发OS的自动写入磁盘的操作;当dirty page容量超过8G了,此时才会由os启动后台线程执行刷盘操作,此操作不会阻塞前台线程的执行。

  • vm.dirty_ratio 是可以用脏数据填充的绝对最大系统内存量,当系统到达此点时,必须将所有脏数据提交到磁盘,同时所有前台新的I/O块都会被阻塞,直到脏数据被写入磁盘。这通常是长I/O卡顿的原因,但这也是保证内存中不会存在过量脏数据的保护机制。

如:vm.dirty_ratio=80,即当前台进程不间断产生数据容量超过8G后,阻塞前台进程强制将page数据写入磁盘

  • vm.dirty_writeback_centisecs 指间隔多长时间,由os触发将dirty page数据回写如磁盘
    如:vm.dirty_writeback_centisecs=5000,即每隔5s,os会自动触发回写磁盘操作

  • vm.dirty_expire_centisecs 指dirty page可以存在多久后必须写入磁盘
    如:vm.dirty_expire_centisecs=3000,即dirty page在3s之后,不论是否修改容量达到了8G,都必须异步写入磁盘

通过上面的介绍,我们可以发现一个问题,OS上面时候进行刷盘,依赖于我们对os的这几项的配置,这也表明,我们对磁盘数据的修改,很大概率会出现数据丢失。
假如我们这几项数值设置的比较大,10G总内存容量,触发回写的阈值是90%,dirty page存活时间是1h。假设我们现在经过了30min修改数据,总dirty数量是7G,此时还不符合任何一种触发刷盘的条件,但是此刻突然断电,前面30min修改的数据由于没有来得及写入磁盘,将会全部丢失。这也是前面章节讲Redis,MySql在刷盘时不默认使用OS缓存的原因。

当dirty page被刷盘后,该page不再被标记为dirty。

LRU

在这里插入图片描述
如图,假设现有内存容量剩余1k,已经有3个page被加载了,此时app需要读取另一块数据,os需要将另一块数据的page加载进内存,担忧由于内存仅剩1k容量,不足以存放新的page,os需要执行LRU策略。
总体策略是将最先加载,不活跃的page移除,如page1,最开始被加载进来,且访问频次较低,则优先将page1从内存中移除。但是需要注意,page1此时被标记为dirty,说明该page的改动还没有回写进磁盘,需要先触发刷盘,然后再讲page1移除。

buffer

在这里插入图片描述
如图小demo,分别使用OutPutStream和BufferedOutPutStream分别写入相同次数的相同数据,结果耗时天壤之别,为什么使用了buffer之后效率会相差这么多?

我们追踪下这两次的系统调用:
在这里插入图片描述
在这里插入图片描述

我们可以看到如果直接使用OutPutStream,每次进行一次系统调用write写入磁盘,每次只写入了10个字节的数据,但是buffer每进行一次系统调用,一次性写入了8k的数据,大大减少了系统调用的次数,减少了用户态和内核态切换的次数。由此也可见,buffer默认维护了一个大小为8k的数组。

file NIO

在这里插入图片描述
这边先简单介绍下MappedByteBuffer和ByteBuffer使用:
在这里插入图片描述
如图,图中的Java线程,是Java本身的运行线程,而非基于Java开发的引用程序。

针对byteBuffer,有两种分配模式,allocate是在JVM 堆上分配空间,allocateDirect是在JVM堆外分配空间,如果是分配是堆内,最终还是需要先将数据copy到堆外,然后再基于channel.write/channel.read 进行系统调用,将数据读取/更新到对应的page,最后再进行os刷盘。
针对MappedByteBuffer,基于mmap,将内存和文件建立映射,当我们使用put时,由于有内存映射,可以将内容直接写入文件,而不需要进行系统调用。

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值