前言:本节课算是一点文件系统的补充内容。 但是说是文件系统的补充内容,其实我们也可以把这篇文章当作linux下的内存管理的文章来看待。 因为博主会从内存管理的角度, 将进程打开文件、写入数据的流程, 以非常底层的角度, 给剖析出来。 ——本篇文章可能讲的可能不会很细致。 但是博主相信友友们如果看完本篇文章后,一定会对于文件打开, 文件写入和磁盘之间的关联有一个很清晰的认识!!!
ps:注意本篇内容非常抽象, 非常难以理解。没有前面文件系统、文件管理、进程管理的铺垫可能无法学习本节。建议友友们了解对应只是后再来学习本节内容!!!
目录
页框和页帧
页框
对于物理内存来说, 内存可以分成一块一块的, 并且这些一块一块的内存是连续的, 也就可以被当成一个线性的逻辑结构, 如下图:
这物理内存中一块一块的4kb的块, 就叫做页框。 ——这里为什么是4kb, 不太重要, 而且很简单, 这里先卖关子。
页帧
不只只物理内存的逻辑结构可以是线性的。 我们也知道磁盘的逻辑结构也是线性的。 并且里面的存储内容是以块为单位的!!! 那么我们的程序, 在磁盘中保存的时候, 其实就是一块一块的分开保存的。 如下图:
注意, 这里的块是连续的, 因为一个程序就是一个文件, 而我们说过, 一个文件的块是连续的。 这里的这一个个的块, 就叫做页帧。
数据的交换
我们知道, 在进行IO的时候, 物理内存和磁盘的数据是来回加载过来加载过去的。 而之所以上面的块都是4kb, 就是因为方便交换, 这个4kb就是交换数据的单位。(为什么是4kb, 因为是大佬们规定的。这也就回答了物理内中的页框是4kb一个。)
在物理内存和磁盘进行数据交换的时候, 会以4kb的大小进行加载。 这个时候, 即便我们都要加载的数据只有1字节有效内容, 也只能将连带1字节在内的4kb一起加载进来!!!
为什么会这样呢? ——因为我们的磁盘是机械设备,而机械设备的速度相对于计算机内部的速度(光电速度)是很慢的。 那么就说明磁盘存储数据或者拿数据向内存中加载数据是很慢的, 那么我们一次性向内存中加载4kb数据和一次性向内存中加载1kb, 但是加载4次相比肯定要快的多得多。 ——这个过程只需要定位磁头, 定位磁道, 定位扇区, 将数据连续的写到磁盘上即可。
不知道友友们是否知道局部性原理——也就是我们一般访问代码和数据的时候, 有非常大的概率, 在该正在访问的代码区域的附近, 也会被访问。 ——所以我们在加载内存的时候, 即便只有1字节, 也要把这一字节的上下文全部加载进来。 这就是——基于局部性原理的预加载机制。
所以, 综上, 一次性加载4kb这样做有什么好处呢?
- 减少IO的次数——减少访问外设的次数——硬件层面
- 进行预加载——有较大概率在软件层面上是实现效率提升
操作系统如何管理内存?
这里我们需要提前知道的是——我们从内存的学习中了解到了虚拟地址空间, 而操作系统想要管理物理地址。 那么就要能够看到虚拟地址, 同时也要看到物理地址。 ——为什么会这样?
因为操作系统给我们的虚拟地址利用页表进行映射物理内存, 分配物理空间, 如果操作系统看不到物理内存, 如何给进程分配物理空间呢? 如果看不到虚拟地址, 进程如何实现我们看到的显示器上面的数字呢?
所以,操作系统是能够看到内存的物理地址的!!!那么如果操作系统想, 是不是就能够管理物理地址了?——那么操作系统是如何管理物理地址的呢?
具体操作系统如何管理地址, 这对于学习了如何进行文件管理, 如何进行进程管理的我们来说太简单了——也就是先描述,再组织!!!(如果友友不知道,那么可能需要先学习一下前面的知识, 因为这六个子是整个计算机世界进行管理的本质)
先描述
结构体的名称是page, 并且描述的是物理内存的一个个的页框。
struct page
{
//page必要的属性信息。
}//描述的是一个个页框
再组织
如果这里我们的物理内存一共有4GB, 那么如果按照一个页框4kb来算, 物理内存就有大概一百万个页框, 计算后其实是1,048,576个页框。
一个struct page对象用来描述一个页框, 那么操作系统要将内存组织起来就要创建一个一百万大小的struct page对象组成的数组。 如下:
struct page page_arrar[1,048,576];//创建一个结构体数组进行组织
所以, 我们操作系统对内存的管理, 就转化为了对于数组的管理!!!
又因为数组天然是有下表的, 所以页框就有了下标!也就有了页号这样的概念!(了解即可, 本篇文章并没有用到页号)
那么这里就可以得出结论了——我们要访问一个内存 ,只需要先直接找到这个对应的4kb对应的page, 就能在系统中找到对应的物理页框(在操作系统中, 所有申请内存的动作, 其实都是在访问内存page数组)。
我们查看一下linux下面的page结构体, 这里先看一部分, 一个flags, 一个_count
这两个字段, 我们虽然没有学过内存管理, 但是我们已经学习了虚拟地址空间, 学习了页表。 应该也能够看出来——flag代表映射的物理内存的状态, 是否是只读, 是否是只写等等。而_const就是引用计数, 什么是引用计数呢? 是否还记得我们在学习父子进程的时候, 说过:子进程刚刚被创建出来的时候, 我们的父子进程的页表可能会指向同一块代码和数据。而这里的引用计数其实就是有多少页表会指向这块空间。
操作系统是如何发现缺页中断, 是如何发现要执行写时拷贝的呢?——其实非常简单, 我们知道, 我们的子进程在创建出来的时候, 指向的物理空间应该是只读的, 并且这个时候, 这块物理空间的引用计数应该是2, 那么我们如果看到某一块的物理内存的flags是为只读, 并且这块物理空间内的引用计数大于2, 那么就要执行写时拷贝!!!
page里面还有一个链表结构——lru(这是个数据结构, 友友们可能学过), 也就是最近最少使用, 我们的操作系统要把我们的内存中不太使用的, 不长使用的刷新出去。因为如果一个一个遍历, 那么就太慢了,所以就用到了一个lru的数据结构。
其实对于操作系统内存管理的上层还有伙伴系统算法, slab分派器。 这里解释一下slab分派器
slab分派器就是一种对于垃圾数据结构回收的机制——就比如我们创建进程, 必须要有虚拟地址空间, 也就要有mm_struct对应, 也要有struct page对象。 当我们释放掉这些对象的时候, 我们的操作系统就可能不去释放了, 而是直接将这些对象保存在了一个队列, 或者一个数组等等数据结构里缓存起来。 等到下次创建对象的时候就直接拿过来用, 就不用再重新向操作系统中申请内存了!!!
从用户层到内存
我们电脑开机的时候, 基本上会将文件系统的前四个信息内容, 加载到内存中, 也就是下面这四个内容:
那么问题就来了, 我们的磁盘里面可不止有一个分区, 而是由很多个分区。 这些分区可能文件系统不一样, 也就是super block的内容不一样。 那么怎么办呢? 没关系, 其实操作系统就是使用一个链表结构, 将所有分区的super block进行链接起来。 然后在操作系统的层面上就知道了我们分区的位置, 然后还有文件系统的样子等等。如下图:
重新理解文件管理
struct inode
现在我们来回忆一下文件fd
我们在今天这节课之前理解的文件fd其实就是下面这一张图
今天, 我们要重新理解一下这张图了。
其实, 我们在加载inode属性信息的时候, 不是将磁盘中对应的inode属性直接加载到struct file里面。 而是先创建另一个结构体对象, 这个结构体对象叫做struct inode, 然后将大部分文件的属性保存到这个结构体里面。 然后struct file里面包含一个指向这个结构体的指针, 如下图:
然后我们的上层, 也就是应用层。 我们是如何打开的一个文件? ——这里以前讲过, 这里相当于重新复习。 这里只是简单的说一下流程。
首先我们要知道这个文件的路径+文件名。 ——》这就相当于知道了当前目录, 然后我们就可以读取当前目录的数据块, 找到对应目录的inode, 进而找到对应的struct file 对象的inode, 然后查看已经加载到内存中的inode bitmap, 看这个inode是否存在, 如果存在, 再讲磁盘中等等属性信息加载到内存中。
adress_space
另外其实在struct file里面, 存在一个数据结构的指针, 这个数据结构叫做adress_space(地址空间)。 如下图:
adress_space这个数据结构里面包含一颗radix树, 这棵树叫做page_tree
radix_tree这个结构的定义如下:
这是整颗树的结构体定义。这棵树是一颗多叉树:
这个叶子结点, 其实就是上面的radix_tree_node类型的对象。
真正的树节点的结构是下面这张图:
radix_tree_node这个结构体里面, 我们看一下slots指针数组。 这个指针数组其实就是当前节点的叶子节点的指针。 上图中有RADIX_TREE_MAP_SIZE个数组元素, 说明这是一颗除叶子结点外,每个节点都有RADIX_TREE_MAP_SIZE个孩子节点的多叉树。
那么可能有的友友们会疑惑了, 这颗树的叶子结点连接的是什么呢?是不是指向空呢?——答案是指向的是我们的page!!!——我们知道, 我们使用fopen, fprintf本质上就是先向内存中写入数据, 再将内存中的数据刷新到磁盘里面。而page就是物理内存, page现在被一颗树指向了!!那么是不是也就是说, 我们的用户层, 在使用fopen, fprintf向文件中打印的时候, 一定会先按照一种流程, 这种流程从上到下贯穿整个文件管理, 一直进入地址空间, 然后穿过整颗radix树, 最后找到page, 将数据写到物理内存里面!!!——而这, 就是整个的fopen, fprintf写入数据的过程。 这, 也是内存进行管理管理的过程。 这里的page,这颗树连接的所有的page, 组成的这个page_array[]数组, 其实就是文件页缓冲区, 也是我们所说的内核级缓冲区!!!
画成图就是这样子:
这其实就是用户层到内存的整个流程, 也可以说是结论。
radix_tree解析——补充知识
radix_tree到底是一棵什么样的树呢?
其实, 这棵树叫做基数树 或者 基数——》本质上就是一棵字典树。
这棵树的节点的结构大致如下:
struct node
{
int level; //层数
void* slot[NUM]; //这个NUM可以是很多数, 可以是26, 可以是3, 可以是5
}
我们这里假设NUM是3, 也就是说这棵树每一个节点都有3个子节点。 并且, 字典树的层数一般和这个NUM是相同的。 也就是说, 每一个节点有几个孩子节点, 这棵树就有几层。 假设NUM为3, 那么这棵树就有三层, 并且每个节点的子节点都是三个。
上面这就是一颗字典树,最后叶子节点的slot指向的是要保存的数据. 也就是说, 一个叶子节点, 可以保存三个数据块。 那么如何找到某个数据呢?——如果我们要找bca这个数据, 那么就先找第一个节点的b子节点, 再找b子节点的cc子节点。 以此类推, 直到找到最后要查找到的数据, 上图中用红线画的就是整个查找流程。
文件的内容按照4kb是有偏移量的, 加入今天我有一个10MB的大小的文件。 如下图:
所以这里块的个数就是10 * 1024 * 1024 / 1024 / 4 = 2560
然后每一个数据块的下标乘以4kb就能计算出这个数据在原始内容中的偏移量。
所以, 如果我们提前构建出一颗字典树, 那么我们如果将我们的文件内容的下标(下表是int类型), 以比特位为单位, 按照某种规则构成特定的几个区域, 在字典树当中找到对应的偏移量和page的映射关系。 就能通过偏移量等等直接查找物理内存了。
那么未来我们读写文件, 都是通过偏移量, 我们一旦有了偏移量, 根据这棵字典树里面的映射关系, 我们就可以找到对应的page。 ——这个偏移量的具体细节博主也不清楚, 知道就行
偏移量不太重要, 但是下面的这个结论是非常重要的!
这里的这个文件, 是有自己的文件页缓冲区的。 其实, 在linux中, 我们的每一个进程, 打开的每一个文件, 都要有自己的文件页缓冲区!!!而这个文件页缓冲区, 就是字典树能够找到的所有的page结构体对象!!!
从内存到磁盘
上面我们已经很清楚的讲解了数据如何从上层用户写到内存中。 那么接下里的一步就是如何从内存中写到磁盘里面。
其实我们从这张图里面可以看到, 进程是不关心文件管理如何向内存中写入数据的。并且, 写到了物理内存之后, 文件管理也不管物理内存了。 那么从物理内存到磁盘, 到底是怎么管理的呢?——其实, 之后的任务就是驱动程序的工作了, 这个也叫做IO子系统。
而要管理好未来我们的大量的IO请求, 操作系统就要对这些请求进行描述, 再组织起来。
而描述的结构体是一个驱动级别的结构体(知道就行,博主也不理解什么是驱动级别的结构体)
struct request
{
//这里面包含了与请求相关的各个page;
//这里面也包含了LBA, 也就是包含了要在磁盘的哪个位置写入数据!!!
}
描述了之后, 系统如果想要将这些IO请求组织起来, 用的是大量的request 队列——为什么使用队列, 这是因为IO也需要排队的。
但是, 这里有一个问题——可能我们的这个队列里面存在了这样一个情况, 比如进程1访问1号扇区, 进程2访问6号扇区, 但是进程3访问3号扇区等等。 就是说磁盘在进行访问的时候, 如果直接按照顺序访问队列的进程, 那么磁头就可能访问不到连续的区域。 而磁头的重新定位会花费大量的时间(相对于计算机内部), 所以我们再进行定位的时候要尽可能让我们的请求访问同一个盘面的, 同一个磁道的, 连续的扇区。 这样效率才能保证最高。 所以, 我们的操作系统就可以对上面的队列进行IO排序, 然后再IO合并。 以此来保证能够访问连续的扇区。
经过操作系统的组织之后, 将request对象拿给磁盘驱动, 再由磁盘驱动去执行, 就能够将数据从内存写到我们的磁盘里面了!!!
那么上面就是数据从内存到磁盘到整个过程。
————————以上, 就是本节的全部内容, 下面为本节课笔记