文件mmap读写和普通读写

Posted by LML on May 18, 2020

前言

本文主要介绍文件读写的相关问题:

  • 虚拟文件系统
  • mmap读写文件
  • 普通读写文件机制

分析上述两种方案的原理、区别、各自的优势

用户空间(user space)和内核空间(kernel space)

进程的虚拟地址空间可分为两部分,内核空间和用户空间。内核空间中存放的是内核代码和数据,而进程的用户空间中存放的是用户程序的代码和数据。不管是内核空间还是用户空间,它们都处于虚拟空间中,都是对物理地址的映射。
应用程序中对文件的操作过程就是典型的系统调用过程。
多个进程的各自的内核空间(虚拟地址)其实映射到同一块物理内存

虚拟文件系统

几个重要概念

超级块(super_block)

用于保存一个文件系统的所有元数据,相当于这个文件系统的信息库,为其他的模块提供信息。因此一个超级块可代表一个文件系统。文件系统的任意元数据修改都要修改超级块。超级块对象是常驻内存并被缓存的。

目录项模块

管理路径的目录项。比如一个路径 /home/foo/hello.txt,那么目录项有home, foo, hello.txt。目录项模块,存储的是这个目录下的所有的文件的inode号(指向inode模块)和文件名等信息。其内部是树形结构,操作系统检索一个文件,都是从根目录开始,按层次解析路径中的所有目录,直到定位到文件。

inode模块

管理一个具体的文件,是文件的唯一标识,一个文件对应一个inode。通过inode模块可以方便的找到文件在磁盘扇区的位置。同时inode模块可链接到address_space模块,方便查找自身文件数据是否已经缓存。

address_space模块

它表示一个文件在页缓存中已经缓存了的物理页。内部维护了一个树结构来指向所有的物理页结构page,同时维护了一个host指针指向inode来获得文件的元数据。
这个树结构指向的所有的物理页结构page,就是已经缓存的page

打开文件列表模块

包含所有内核已经打开的文件。已经打开的文件对象由open系统调用在内核中创建,也叫文件句柄。打开文件列表模块中包含一个列表,每个列表表项是一个结构体struct file,结构体中的信息用来表示打开的一个文件的各种状态参数。 且有个指针指向目录项模块 也就是说 open 会对对应的文件生产一个struct file结构体,存储文件的各种状态参数,保存在打开文件列表模块。通过struct_file 结构体可以找到文件的node,进而找到在磁盘和缓存页的位置

file_operations模块

这个模块中维护一个数据结构,是一系列函数指针的集合,其中包含所有可以使用的系统调用函数,例如open、read、write、mmap等。每个打开文件(打开文件列表模块的一个表项)都可以连接到file_operations模块,从而对任何已打开的文件,通过系统调用函数,实现各种操作。

进程和虚拟文件系统交互

  • 内核使用task_struct来表示单个进程的描述符,其中包含维护一个进程的所有信息。task_struct结构体中维护了一个 files的指针来指向结构体files_struct,files_struct中包含文件描述符表和打开的文件对象信息。
  • file_struct中的文件描述符表实际是一个file类型的指针列表(和“已打开文件列表”上的表项是相同的指针),可以支持动态扩展,每一个指针指向虚拟文件系统中文件列表模块的某一个已打开的文件。
  • file_struct中的file结构和打开文件泪奔中维护的structfile结构一致,该结构
    • 一方面可从f_dentry链接到目录项模块以及inode模块,获取所有和文件相关的信息
    • 另一方面链接file_operations子模块,其中包含所有可以使用的系统调用函数,从而最终完成对文件的操作
    • 这样,从进程到进程的文件描述符表,再关联到已打开文件列表上对应的文件结构,从而调用其可执行的系统调用函数,实现对文件的各种操作
  • 多个进程可以同时指向一个打开文件对象(文件列表表项),例如父进程和子进程间共享文件对象;

  • 一个进程可以多次打开一个文件,生成不同的文件描述符,每个文件描述符指向不同的文件列表表项。但是由于是同一个文件,inode唯一,所以这些文件列表表项都指向同一个inode。通过这样的方法实现文件共享(共享同一个磁盘文件);

小结

值得注意的是:无论是普通文件读取还是mmap文件读取,上面介绍的文件虚拟系统是基础,二者均在虚拟文件系统的基础上进行后续的操作的:
二者共有的操作

  • 进程中已知文件路径+名字
  • open
  • 进程的taskstruct 中的文件文件描述符表中的file_struct中结构
    • file_struct中的的指针找到目录模块
    • 找到node模块:可确认磁盘+缓存位置

普通文件读写机制

缓存(Page Cache)

为什么上面的read 和 write 除去进程的 Databuffer(malloc stack上的内容)和磁盘外,还会有个页缓存的概念?

  • 缓存是用来减少高速设备访问低速设备所需平均时间的组件,文件读写涉及到计算机内存和磁盘,内存操作速度远远大于磁盘,如果每次调用read,write都去直接操作磁盘,一方面速度会被限制,一方面也会降低磁盘使用寿命,因此不管是对磁盘的读操作还是写操作,操作系统都会将数据缓存起来。
  • 有了缓存的存在,文件读写不会在直接读写磁盘,而是
    • 读取:磁盘——缓存——读取缓存;
    • 写入:写入缓存——缓存——磁盘

这样提高了效率

struct page 结构

struct page结构标志一个物理内存页,具有如下成员

  • 标志位flags来记录该页是否是脏页,是否正在被写回等等;
  • mapping指向了地址空间address_space,表示这个页是一个页缓存中页,和一个文件的地址空间对应;
  • index记录这个页在文件中的页偏移量;

上面介绍过:

  • 已知 inode 可确认这个文件所有的块block的块号,通过对文件偏移量offset取模可以很快定位到这个偏移量所在的文件系统的块号,磁盘的扇区号
  • 通过对文件偏移量offset进行取模可以计算出偏移量所在的页的偏移量。

一个文件inode对应一个地址空间address_space。而一个address_space对应一个页缓存基数树。页缓存基数树的每个节点存储的是struct page结构,每个struct page结构可以定位一个缓存页。页缓存实际上就是采用了一个基数树结构将一个文件的内容组织起来存放在物理内存struct page中它们之间的关系如下:

小结

  • 文件虚拟系统打好基础
    • 目录项模块可定位一个文件的node
    • 一个文件的node可定位
      • 指定地址在磁盘的空间
      • 指定地址在页的空间
        • 一个node对应一个 address_space,一个address_space 对应一个页缓存基数树,一个页缓存基数树里面多个struct page,数结构可快速定位该页是否在 缓存(内存中)

Dirty Page

页缓存对应文件中的一块区域,如果页缓存和对应的文件区域内容不一致,则该页缓存叫做脏页(Dirty Page)。对页缓存进行修改或者新建页缓存,只要没有刷磁盘,都会产生脏页

读写文件的流程

读文件

  • 进程调用库函数向内核发起读文件请求;
  • 内核通过检查进程的文件描述符定位到虚拟文件系统的已打开文件列表表项;
  • 调用该文件可用的系统调用函数read()
  • read()函数通过文件表项链接到目录项模块,根据传入的文件路径,在目录项模块中检索,找到该文件的inode;
  • 在inode中,通过文件内容偏移量计算出要读取的页;
  • 通过inode找到文件对应的address_space;
  • 在address_space中访问该文件的页缓存树,查找对应的页缓存结点:
    • 如果页缓存命中,那么直接返回文件内容;
    • 如果页缓存缺失,那么产生一个页缺失异常,创建一个页缓存页,同时通过inode找到文件该页的磁盘地址,读取相应的页填充该缓存页;重新进行上一步查找页缓存;
  • 文件内容读取成功。
    ssize_t read(int filedes, void *buf, size_t nbytes);
    返回值:读取到的字节数;0(读到 EOF);-1(出错)
    read 函数从 filedes 指定的已打开文件中读取 nbytes 字节到 buf 中
    

写文件

  • 进程调用库函数向内核发起读文件请求;
  • 内核通过检查进程的文件描述符定位到虚拟文件系统的已打开文件列表表项;
  • 调用该文件可用的系统调用函数read()
  • read()函数通过文件表项链接到目录项模块,根据传入的文件路径,在目录项模块中检索,找到该文件的inode;
  • 在inode中,通过文件内容偏移量计算出要读取的页;
  • 通过inode找到文件对应的address_space;
    • 如果页缓存命中,直接把文件内容修改更新在页缓存的页中。写文件就结束了。这时候文件修改位于页缓存,并没有写回到磁盘文件中去。
    • 如果页缓存缺失,那么产生一个页缺失异常,创建一个页缓存页,同时通过inode找到文件该页的磁盘地址,读取相应的页填充该缓存页。此时缓存页命中,进行上一步。
  • 一个页缓存中的页如果被修改,那么会被标记成脏页。脏页需要写回到磁盘中的文件块。有两种方式可以把脏页写回磁盘:
    • 手动调用sync()或者fsync()系统调用把脏页写回
    • pdflush进程会定时把脏页写回到磁盘
    • 同时注意,脏页不能被置换出内存,如果脏页正在被写回,那么会被设置写回标记,这时候该页就被上锁,其他写请求被阻塞直到锁释放。
ssize_t write(int filedes, const void *buf, size_t nbytes);
返回值:写入文件的字节数(成功);-1(出错)
write 函数向 filedes 中写入 nbytes 字节数据,数据来源为 buf 。返回值一般总是等于 nbytes,否则就是出错了。常见的出错原因是磁盘空间满了或者超过了文件大小限制。

mmap

mmap 原理

mmap是一种内存映射文件的方法,即将一个文件或者其它对象映射到进程的地址空间,实现文件磁盘地址和进程虚拟地址空间中一段虚拟地址的一一对映关系。实现这样的映射关系后,进程就可以采用指针的方式读写操作这一段内存,而系统会自动回写脏页面到对应的文件磁盘上。
mmap内存映射的实现过程,总的来说可以分为三个阶段:

  • 进程启动映射过程,并在虚拟地址空间中为映射创建虚拟映射区域
    • 进程在用户空间调用库函数mmap,原型:void *mmap(void *start, size_t length, int prot, int flags, int fd, off_t offset);
    • 在当前进程的虚拟地址空间中,寻找一段空闲的满足要求的连续的虚拟地址
    • 为此虚拟区分配一个vm_area_struct结构,接着对这个结构的各个域进行了初始化
    • 将新建的虚拟区结构(vm_area_struct)插入进程的虚拟地址区域链表或树中
      • 内核使用vm_area_struct结构来表示一个独立的虚拟内存区域,由于每个不同的虚拟内存区域功能和内部机制都不同,因此一个进程使用多个vm_area_struct结构来分别表示不同类型的虚拟内存区域。各个vm_area_struct结构使用链表或者树形结构链接,方便进程快速访问
      • vm_area_struct结构中包含区域起始和终止地址以及其他相关信息,同时也包含一个vm_ops指针,其内部可引出所有针对这个区域可以使用的系统调用函数。这样,进程对某一虚拟内存区域的任何操作需要用要的信息,都可以从vm_area_struct中获得。
    • mmap函数就是要创建一个新的vm_area_struct结构,并将其与文件的物理磁盘地址相连
  • 调用内核空间的系统调用函数mmap(不同于用户空间函数),实现文件物理地址和进程虚拟地址的一一映射关系
    • 为映射分配了新的虚拟地址区域后,通过待映射的文件指针,在文件描述符表中找到对应的文件描述符,通过文件描述符,链接到内核“已打开文件集”中该文件的文件结构体(struct file),每个文件结构体维护着和这个已打开文件相关各项信息
    • 通过该文件的文件结构体,链接到file_operations模块,调用内核函数mmap,其原型为:int mmap(struct file *filp, struct vm_area_struct *vma),不同于用户空间库函数。
    • 内核mmap函数通过虚拟文件系统inode模块定位到文件磁盘物理地址
    • 通过remap_pfn_range函数建立页表,即实现了文件地址和虚拟地址区域的映射关系。此时,这片虚拟地址并没有任何数据关联到主存中
  • 进程发起对这片映射空间的访问,引发缺页异常,实现文件内容到物理内存(主存)的拷贝(注:前两个阶段仅在于创建虚拟区间并完成地址映射,但是并没有将任何文件数据的拷贝至主存。真正的文件读取是当进程发起读或写操作时。)
    • 进程的读或写操作访问虚拟地址空间这一段映射地址,通过查询页表,发现这一段地址并不在物理页面上。因为目前只建立了地址映射,真正的硬盘数据还没有拷贝到内存中,因此引发缺页异常。
    • 缺页异常进行一系列判断,确定无非法操作后,内核发起请求调页过程
    • 调页过程先在交换缓存空间(swap cache)中寻找需要访问的内存页,如果没有则调用nopage函数把所缺的页从磁盘装入到主存中
    • 之后进程即可对这片主存进行读或者写的操作,如果写操作改变了其内容,一定时间后系统会自动回写脏页面到对应磁盘地址,也即完成了写入到文件的过程。
    • 修改过的脏页面并不会立即更新回文件中,而是有一段时间的延迟,可以调用msync()来强制同步, 这样所写的内容就能立即保存到文件里了。

⚠️:上面过程略去了一个过程:只描述了虚拟地址空间与磁盘空间的映射,其实是除去虚拟地址空间——磁盘空间映射;还有在建立进程的时候已经建立好了的虚拟地址空间与主存的映射。因为磁盘的内存加载进来最终是存在主存的,与macho的加载与建立映射关系类似。

相关API

int (*map)(struct file *filp, struct vm_area_struct *vma);
映射文件或设备到内存中
成功执行时,mmap()返回被映射区的指针,munmap()返回0。失败时,mmap()返回MAP_FAILED[其值为(void *)-1],munmap返回-1。errno被设为以下的某个值。

其实读取文件的mmap建立映射关系,与 macho 文件的mmap形式原理相同:
macho 文件建立了两个映射关系

  • 虚拟内存与物理内存映射关系
    • 运行中,通过指针实现跳转等,这里指针是虚拟内存指针。根据虚拟内存与物理内存映射关系,确定要访问的物理内存(无论是否mmap这个步骤必须)
  • 物理内存与磁盘中的macho映射关系
    • 如果上步骤物理内存不存在,则缺页,需要根据物理内存与磁盘中的macho映射关系将磁盘内容读取到物理内存,然后上述步骤再次读取

mmap读写文件

写文件

先讲下mmap写文件的几个坑

  • mmap不能更改文件大小,所以如果你不提前扩展文件的大小,会导致无法追加内容到文件
  • lseek只是挪动指针,没有扩大文件大小,下面代码中的write完成了文件大小的扩充
  • 文件空洞
- (void)clickButton1{
    //测试下mmap 耗时
    int fd = open("/Users/langminglang/Desktop/2.txt", O_CREAT|O_RDWR,0666);  //fd 打开文件, 失败返回-1
    if(-1 == fd)
    {
        perror("文件打开错误");
    }
    char *inserted = "##inserted##\n"; // this str will be inserted to the file
    //获取文件大小
    off_t length  = lseek(fd, 0, SEEK_END);
    
    // 扩展文件大小,因为mmap不能更改文件大小,导致如果我们不扩展文件,会造成无法追加内容到文件
    off_t lengthNew  = lseek(fd, length + strlen(inserted), SEEK_SET);
    if (write(fd, "", 1) == -1)
    {
        close(fd);
        perror("Error writing last byte of the file");
        exit(EXIT_FAILURE);
    }
    //mmap
    char *addr = (char *)mmap(NULL, lengthNew, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);

    memcpy(addr + length, inserted, strlen(inserted));
    
    //下面方法调用非必须,因为closed 文件也会写入磁盘
    if (msync(addr, lengthNew, MS_SYNC) == -1)
    {
        perror("Could not sync the file to disk");
    }
    
    if (munmap(addr, lengthNew) == -1)
    {
        close(fd);
        perror("Error un-mmapping the file");
        exit(EXIT_FAILURE);
    }
    close(fd);  //关闭文件
}

读文件

参考https://gist.github.com/marcetcheverry/991042

二者对比

相同点

  • 都建立在虚拟文件系统的基础上
  • 前面几点利用虚拟文件系统定位到具体文件的是都需要做的
    • 进程struct_task结构,确认文件描述符列表,找到对应的文件指针
    • 目录项模块
    • node模块

不同点

到确认了node模块之后,普通读且和mmap开始不同: 普通读写:

  • 先去访问address_space,进而确认tree——是否找到page_cache,找到,copy到用户内存,没有去磁盘——这个tree——copy到用户内存

mmap:

  • 访问磁盘,建立映射关系
  • 后续page fault,根据映射关系直接确认需要copy的磁盘的地址

⚠️:mmap不需要在去建立使用address_space 的后续步骤;普通读写不需要连续的虚拟内存空间,也不需要建立映射关系,copy就行了。

参考

从内核文件系统看文件读写过程

认真分析mmap:是什么 为什么 怎么用