深入分析Linux内核File cache机制

一、什么是File cache?

1. File cache概述

Linux File cache机制,每次动笔想写到该知识点的时候,我心里总会犹豫迟疑,众所周知内存管理是Linux系统的比较难啃的子系统之一,而内核文件缓存机制是内存管理框架中难度较大的知识点。其中包括文件缓存预读取流程、写流程、回收流程等,希望我们这次将其一探究竟。

讨论Linux File cache前,先看下什么是Linux cache机制呢?

我们在使用Linux系统的时候,经常会发现系统的空闲内存(后文以memfree代替)经常处于一个较低的状态,有时8G的手机刚开机memfree就低于2G,而此时可能并无启动多少应用。仔细查看发现,此时系统的cached可能达到3G以上【图1:meminfo@1】,这时很多用户会有疑问:cached是什么?是内存泄露吗?显然不是。cached表示系统的缓存内存大小,当用户需要读取文件中的数据时,操作系统会先分配内存,然后将数据从存储器读入到内存中,最后将内存中的数据分发给用户;当用户需要往文件中写数据时,操作系统会先分配内存接收用户的数据,然后再将数据从内存写到磁盘中。而Linux cache机制就是对这些由操作系统内核分配,并用来存储文件数据的内存进行管理。

那么可能有人会问:Cache机制为什么会缓存这么大?是否会被回收?

如果系统内存充足,缓存在内存中的文件数据是可以在内存中长时间驻留的,如果有其他的进程访问这部分的数据,就不需要访问磁盘,我们知道内存访问速度比磁盘访问速度要快,该机制可以避免用户因为磁盘访问导致的长时间等待。所以在内存充足的情况下,系统的cache大小是会越来越大的;当系统的内存不足,Linux内存回收机制就会把cache的内存进行回收,以缓解内存压力。

在Linux内核中,cache主要主要包括:(对应【图1:meminfo@2】)

  • 普通文件数据的页面;
  • 目录等信息的页面;
  • 直接从块设备文件读出来的数据页;
  • swapcache;
  • 特殊文件系统的页面,例如shm文件系统;

cache中大部分是文件缓存,即本文讨论的File cache,其包含活跃和非活跃的部分,对应如下:Active(file)和Inactive(file)【图1:meminfo@3@4】。

【图1:meminfo】

2. File cache机制框架

(1)系统层面下的File cache机制

【图2:Linux I/O操作流程图】

当用户发起一个读或者写文件请求时,流程如【图2】,整体的流程如下:

  • 系统调用read()会调用到VFS(Virtual file system,后文以VFS简称)相应的函数,传递参数有文件描述符合文件偏移量等;
  • 接着缓存机制(Buffer page Cache)确定请求的数据是否已经在内存缓冲区;如果在缓存区并且数据是最新的,那就不用发起IO操作,返回数据给用户。
  • 若数据不在内存中,确定如何执行读操作,通过具体的文件系统(Disk System)和通用块设备层(Generic Block layer),确定数据在物理设备中的位置,构建IO请求;
  • 在通用块设备之下是IO调度层(IO Scheduler Layer),根据内核的调度策略,对等待的IO等待队列进行排序;
  • 最后,块设备驱动(Block Device Driver)通过向磁盘控制器发送相应的命令,执行真正的数据传输;

VFS用于与系统调用read/write等接口进行交互,通过VFS后可以通过DIRECT_IO直接与具体的文件系统进行交互,如果没有DIRECT_IO,则会通过cache机制与具体的文件系统交互。具体的文件系统例如ext3/ext4等,通过Generic block layer和IO schedule layer与具体的块设备驱动交互。

所以理论上cached的机制的设计逻辑在于具体的具体文件系统之上,VFS之下,即上图中“Buffer page Cache”部分。

(2)File cache机制内部框架梳理

File cache机制,从内部框架简单分为两部分:File cache的产生和回收。学习文件缓存按照下面的框架进行由浅至深进行分析,更加容易抓住设计的逻辑。

  • File cache产生流程
  • 读文件:读文件流程以及预读机制,包括read和mmap发起的文件读取流程;
  • 写文件:写文件流程;
  • File cahce回收流程

以下的分析基于Linux-4.19,并且基于不讨论DIRECT_IO模式。

二、read发起读文件流程分析

1. read函数生命周期

用户读取的文件,可以有不同的实现方法,但是普遍是通过read系统和mmap接口进行读取,该章节介绍read的读取流程分析:

ssize_t read(int fd, void *buf, size_t count);

用户调用该接口会调用内核的sys_read接口,并最终会通过VFS调用到具体文件系统的读文件接口,并经过内核“六个阶段”,最终调用块设备驱动程序的接口,通过向磁盘控制器发送相应的命令,执行真正的数据传输;

【图3:Linux read函数生命周期图】

从图3,我们可以看出filecache的设计逻辑集中“file cache”方框,这部分在具体文件系统之上,在VFS之下。

从图3我们也可以看出具体的文件系统(EXT2/3/4等)负责在文件cache和存储设备之间交换数据,而VFS负责应用程序和文件cache之间通过read()/write()等接口交换数据。

2. 预读机制

从图3中,当文件缓存读取的过程主要是通过page_cache_sync_readhead()和page_cache_async_readahead()两部分,从函数的名字可以看出两个函数的作用分别是“同步预读”和“异步预读”。但是从代码的逻辑看,其实page_cache_sync_readhead()这个名字取得并不准确,因为同步预读的语义应该是进程同步等待直至读取文件内容成功,但是后面分析我们会发现这两个函数仅仅做到事情如下,并非真正的等到文件内容读取完成。

  • 将文件读取需求提交给具体文件系统,后者做成bio提交给Generic block layer;
  • 将分配的文件缓存加入到搜索树和zone lruvec中;

说回预读(read ahead)机制,就是在数据真正访问之前,从普通文件或者块设备文件批量的读取更多的连续文件页面到内存中。内核为了提供IO性能,当用户要求读取文件页面时,会通过预读算法计算是否将相邻的文件页面提前从磁盘读入到内存中。

(1)预读机制的优势和风险

预读机制有两个优势:

  • 减少磁盘控制器处理的命令数,每次IO请求读取多个扇区,提升系统的IO吞吐量,提高磁盘的性能;
  • 提前预判用户即将访问的文件,减少用户读取文件同步等待的时间,提高用户的执行速度;

当然预读也是存在风险,特别是随机读的时候,预读对系统是有害的,因为对于随机读取这种场景,预读的文件页面被用户访问的概率偏低。如果被提前预读的文件页面没有被用户访问,该场景会浪费系统的物理内存,并且会造成阶段性的IO负载。

(2)Linux内核预读规则

为了规避上面提到的预读风险,Linux内核对预读的机制秉承着的规则:(这里不讨论用户调用madvise等系统调用指定特定区域即将被访问的场景,读者可以自行分析源码)

  • 只对顺序读进行预读,随机读不进行预读。
  • 如果进程不断发起顺序读操作,那么接下来单次预读的量会不断增加,直至达到设置的单次最大预读值;
  • 如果文件的所有的页面都已经在缓存在内存中了,那么停止预读;

(3)Linux内核预读机制设计模式

内核对预读的设计是通过“窗口”来实现的,有两个窗口:当前窗口和前进窗口。

  • 当前窗口:已经读取的文件页面,包括用户要求的页面以及提前预读在缓存中的页面;
  • 前进窗口:即将为用户提前预读的页面;

简单理解窗口表示一些页面的集合,例如图4,当用户要求读取1个文件页面时,其实系统总共预读4个页面,当前窗口表示用户要求读取的页面,前进窗口表示提前预读的3个页面,不管这三个文件页面是否完整读入到内存中了。

【图4:预读窗口解析1】

如果经过一段时间,前进窗口已经成功提前将文件页面读入到内存中,并且用户命中了预读的文件页面,则此时图4中的前进窗口会转变为当前窗口,并重新构建前进窗口,如图5:

【图5:预读窗口解析2】

所以如果说预读不断命中,前进窗口是不断转换为当前窗口的,当然如果预读没有命中,例如随机读场景,那么预读机制就会被关闭,此时就不存在前进窗口了,但是当前窗口总是存在的。最理想的状态,就是当前窗口的页面被用户访问完后,前进窗口的页面也预读完成,并且接下来被用户读取命中,此时前进窗口转为当前窗口,并且重新构建新的前进窗口进行预读。

“窗口”的概念,Linux通过“struct file_ra_state”进行抽象,定义在include/linux/fs.h:

这里需要注意的“窗口”的概念是针对文件个体设定的,即不同文件对应着各自的窗口实体,所以如果连续打开不同的文件,不同文件之间的预读大小是不会互相影响的。系统打开的每个文件,都有一个file_ra_state实例:

每个文件被打开时会对该文件的file_ra_state结构体进行初始化,默认状态file_ra_state的成员状态如下:

  • start = size = async = mmap_miss = 0;
  • prev_pos = -1;
  • ra_pages = 单次最大的预读页面数;

3. generic_file_buffered_read

明白了总体框架后,那就跟随者那句经典的“read the f***ing source code”,来看看代码generic_file_buffered_read流程,该函数定义在mm/filemap.c:

@0:函数一开始,首先拿到该文件的窗口实例,如果是第一次读取那么该实体就是初始状态,如果非第一次读取页面,该实体就是上一次预读的状态,本次读取会根据上次的预读状态和本次是否命中调整窗口实例。另外这里有两个局部变量需要关注一下:

  • index:表示用户要求读取的第一个页面在文件中的页面序号,如果是文件头那么该值为0.
  • last_index:表示用户要求读取的最后一个页面在文件中的页面序号;

last_index减去index表示用户要求读取的页面数目。

该函数会执行同步预读和异步预读两个部分,这里分开分析:

(1)同步预读

@1调用find_get_page根据mapping和index查看一个文件页面是否在缓存中了,其实就是在文件缓存树中进行查找。【文件缓存树】此时有两种结果:在文件缓存树中找到页面或者找不到页面。

  • 在文件缓存找不到page的情况:第一次读取肯定会走到这里。

@2调用page_cache_sync_readahead()函数进行同步预读,对于该函数需要注意的两点:

  • 仅仅是分配好页面,调用具体文件系统的接口封装好bio请求发给Gerneric block layer,也就说该函数并没法保证能把文件数据读取到文件中;
  • 该函数分配内存gfp_mask调用的是__GFP_NORETRY | __GFP_NOWARN,所以该函数在内存紧张时是很可能分配不到内存的,所以才有@3的第二次判断;

@3重新判断是否分配好页面并加入到缓存树中,这里有两个结果:在文件缓存中找到页面或者找不到页面;

  • 找的到页面:

@5通过PageUptodate()判断页面是否读取到最新数据,如果不是最新的数据没有读取完成,就会调用wait_on_page_locked_killable()->io_schedule()进行等待,这就是systrace 中read进程Block IO的原因。这里可能有人会问题PG_uptodate和PG_locked是在哪里设置的?

当分配内存并将页面插入到缓存树以及zone lruvec中前会通过add_to_page_cache_lru()->__SetPageLocked()设置页面的PG_locked,而PG_uptodate内存申请时默认不设置。当发起IO请求,并且IO操作完成时会及时将页面的PG_locked清除,并设置PG_uptodate。

所以wait_on_page_locked_killable(page)函数此刻就能起到同步等待的数据读取完成的作用,而并非是在page_cache_sync_readahead()同步等待,该函数命名比较迷惑。

@6表示一个页面已经更新完数据了,此时会做几件事:

1) 将读取的页面发送拷贝给用户;

2)记录当前读取的数据对应的页面序号到在prev_index中;以便@9更新到窗口中,用于下一次页面读取判断用户是否是顺序读;

3)然后更新index,记录要读取的下一个文件页面序号;

4)通过iov_iter_count判断是否已经读取完成,完成则执行@9更新当前窗口的状态,并退出;否则根据index,读取下一个页面;

  • 找不到的页面:

@3找不到页面的情况,即此时可能是因为不支持预读或者页面分配没有成功等原因,此时就需要改变内存分配的标志,并且等到该文件更新完数据。

@7通过page_cache_alloc分配页面,分配标志没有__GFP_NORETRY | __GFP_NOWARN,表示内存紧张会进入慢速路径,分配成功后将页面插入到缓存树和zone lruvec中。

@8调用文件系统的readpage进行文件数据读取,并同步等待读取完成;读取完成后就执行@6进行下一个页面读取或者退出本次读取过程;

  • 在文件缓存找得到page的情况

如果一开始就在缓存树中找到了页面,那么就直接执行@5的流程执行,等待页面的数据读取完成,后续流程跟上面一致。

所以同步预读核心因素是page_cache_async_readahead函数,定义在mm/readahead.c中,该函数仅仅是ondemand_readahead的封装,该函数在“4.ondemand_readahead”分析。

(2)异步预读

异步预读的处理集中在@4,先通过PageReadahead(page)判断页面的是否设置了PG_readahead,如果该页面设置该标志,表示本地当前窗口读取的文件页面命中了上一个前进窗口预读的页面,此时就要通过异步预读操作发起一个新预读。

关于PG_readahead是在预读时标记的,规则如图6,当用户要求读入一个文件页面,系统预读的其后连续的3个文件页面,那么第一个预读的页面就会被标记PG_readahead;

【图6:PG_readahead设置规则】

page_cache_async_readahead()函数的参数和同步预读一样,只多一个struct page结构体,作用是将该page的PG_readahead的标志清空,接着也是调用ondemand_readahead()函数。我们发现generic_file_buffered_read()发起的同步预读和异步预读最终都是调用ondemand_readahead()函数,区别是第四个传参hit_readahead_marker为true或false。

4. ondemand_readahead

ondemand_readahead()函数定义在mm/readahead.c中,总共6个参数,这里主要关注4个参数:

  • ra:窗口的抽象结构体;
  • hit_readahead_marker:区分是sync readahead还是async readahead;
  • offset:表示读取的第一个页面在文件中的页面序号,个人觉得命名为index比较符合语义;
  • req_size:表示用户请求读取的页面数;

函数一开始先获取该窗口单次预读最大的页面数。该函数分为两种场景:从文件头开始读取或者非文件头读取。

@1:判断当前读取是否从文件头开始读?offset表示读取的第一个页面在文件中的页面序号,为0表示为从文件头读取。

(1)从文件头开始读

从文件头部开始读,代码流程如下:

@1:如果该页面是文件中的第一页面,即从头开始读,那么就判断为顺序读,开始初始化当前的窗口;

@2:先把读取的第一个页面序号赋值给ra->start;然后调用get_init_ra_size()函数根据用户要求读取的页面数和单次最大允许的预读页面数,得到本次窗口的预读页面数;

如果本次读取的页面数大于用户请求读取的页面数,则将多预读的页面数记录到ra->async_size,这部分页面表示异步读取;

get_init_ra_size()函数定义在mm/readahead.c 中,该函数的参数:

  • size:表示用户要求读取的页面数目;
  • max:表示单次预读最多可以读取的页面数目;

对于内核这种固定数值,又没给出注释的方式的公式,个人觉得不是很“优雅”。

这套计算公式分别用最大预读数128Kbytes和512Kbytes,推导结果如下:(req_size表示用户要求读取的页面数,new_size表示实际预读的页面数)。从这个结果可以得出,设置单次最大的预读页面数目,影响不仅仅是最大的预读页面数,对预读的每个环节都有影响。

@3:调用ra_submit()发起读页面请求,该函数定义在mm/internal.h,是对__do_page_cache_readahead的封装,传入本次预读的起始页面序号,预读页面数,异步预读页面数。

__do_page_cache_readahead()函数,比较关键的三个参数:

  • offset:表示本地预读的第一个页面在文件中的序号,个人觉得叫做index比较妥当;
  • nr_to_read:发起的预读大小;
  • lookahead_size:异步预读的大小;

__do_page_cache_readahead()逻辑是比较简单的,这里不做过多阐述,这里需要注意:

@1:该函数分配的页面是带__GFP_NORETRY的,也就是内存紧张时不会进入分配慢速路径。

@2:如果一个文件所有数据读取完成,必须停止剩下的预读;

@3:对第一个异步预读的页面标志PG_readahead,对应【图4:PG_readahead设置规则】。

@4:调用具体文件系统的readpages接口发起IO流程,并将page加入到缓存树和zone lru(请查阅文件缓存回收流程解析章节);

(2)非文件头读取

回到ondemand_readahead函数,如果是非文件头读取文件页面,有几种可能 :

@4:顺序读情况处理:如果请求的第一个页面序号与上次预读的最后一个页面时相邻的(page(hit2)),或者刚好是上次第一个异步预读的页面(page(hit1)),则表示此时读取是顺序读,增加预读页面数进行预读。【窗口连续读请求则加速预读】假设用户上次要求读取一个页面,加上预读总共读取了4个页面,如果此次我们读取到page(hit1)或者page(hit2),则表示顺序读,此时直接增加预读数,最后走到@3通过ra_submit发起预读就完成了。

【图7:顺序读】

更新到ra->start到page(hit1)或者page(hit2)的序号,然后通过get_next_ra_size()获取下一次的预读的大小:

根据上面的规则,大多数情况都是上次预读页面数目的两倍。我们看下最大预读数分配为128Kbytes和512Kbytes的情况下,用户需要命中多少轮才能达到最大的预读页面数。【最大预读窗口128page】

@5:异步预读命中处理:如果是page_cache_async_readahead()函数调用进来,hit_readahead_marker为true,这种情况已经确认命中PG_readahead的页面,所以肯定增大预读页面数,再次发起预读。首先查找[index+1, max_pages]这个文件区间内第一个没有在缓存树中的页面,以此页面为新的起点,增加好预读数,并构建好前进窗口,最后跳到@5:ra_submit发起预读请求;【增大预读窗口,从下一个未加载页面发起预读】

@6:该场景有两种,其一是此时读取的页面和上一次访问的页面相同;其二是如果用户要求读入多个页面,如果预读来不及处理多个页面,那么就会出现多个页面连续进来预读的情况,如图8读取到page*。这两种情况需要重新初始化预读状态,并将第一个读取页面序号指向当前读取页面;【预读条件:命中预读数据,连续读多个page】

【图8】

@7的场景是通过预读历史判断是否继续预读;

@8随机读场景:表示系统判断此时页面读取是随机读,这种场景会关闭预读,__do_page_cache_readahead()的nr_to_read参数传入req_size,表示只读取用户要求的文件页面。

三、File cache写流程分析

1. write函数生命周期

用户写文件没有像读文件类似的预读模式,所以整个过程是比较简单的,以下不考虑Direct_IO的模式:

ssize_t write(int fd, void *buf, size_t count);

内核调用的流程如下,其中ext4_write_begin会判断需要写的页面是否在内存中,如果不在会分配内存并通过add_to_page_cache_lru()将页面插入到缓存树和zone lru中;ext4_write_end会发起IO操作。由于篇幅的原因,本文就不再贴出具体的分析过程,如果有兴趣可以跟着源码细读。【缓存树、zone lru】

【图9:Linux write函数生命周期图】

四、mmap读文件流程涉及缺页中断

一、mmap及缺页中断读文件流程分析

上面篇幅,“什么是Filecache”篇幅介绍了文件缓存框架,走读了read()流程中涉及文件缓存的操作流程。对于用户来说,读过程一般是同步读的过程(当然也有异步读的接口,其思想跟文件缓存异步预读机制类同,不再阐述),其执行完接口调用后所需数据就已经在内存中了,而操作的时候就通过write()等接口进行读写。

除了以上的接口,文件缓存的读取和使用是否还有其他方法?答案是肯定的,例如mmap()等接口可以实现将文件映射到用户地址空间,进程可以像访问普通内存一样对文件进行访问。mmap接口从内核的归属上是内存接口,Linux系统对于用户内存分配总是苛刻的,当用户分配内存时,内核优先只给虚拟内存(MAP_POPULATE等模式本文不作讨论),只有使用时才分配物理内存,通过mmap接口进行文件映射属于内核内存的管理框架内,自然也符合这个管理思想。

那么系统怎么及时地给用户分配物理内存呢?这里就引出缺页中断的概念:

当用户访问虚拟地址时发现地址没有映射到具体的物理内存空间,则触发中断进入内核态,进行物理内存填充,本文只关注文件页面的缺页中断。

对于文件缓存的缺页中断,另外一种触发的场景也非常常见:文件缓存因为内存紧张被系统回收了,因为回收的过程会进行虚拟内存解映射,此时用户再次访问到之前分配的虚拟地址时,同样也需要进入缺页中断流程进行数据的填充。关于文件缓存的回收,放到“Filecache 回收流程分析”章节,读者有兴趣可以进行查阅。

本篇幅通过分析mmap/munmap调用执行流程和文件缓存的缺页中断流程,了解该路径下文件缓存的读取过程。

1. mmap函数生命周期

mmap系统调用用于将用户空间的一端内存区域映射到内核空间,根据传递参数的不同,其可以实现共享内存、单独分配匿名内存以及映射文件页面等。本文主要阐述映射文件缓存,普通文件被映射到进程地址空间后,进程可以像访问普通内存一样对文件进行访问,而不需要调用read()/write()接口。

addr:是否指定映射的内存区域的起始地址,NULL表示由内核指定;

length:要映射的内存区域大小;

prot:内存保护权限的标志;

flags:执行映射对象的类型,例如是否共享、是否锁定映射内存、是否匿名映射(不与文件关联)等;

fd:文件描述符,内核通过该句柄来寻找具体的文件,如果要映射文件,必须正确地传递该参数。另外调用返回后,fd可以关闭,但是映射依然有效;

offset:表示映射对象(文件)的内容的位置;

对于具体的参数使用可以通过man mmap进行查阅。对于文件映射,其mmap的生命周期如下:

mmap系统调用最终调用到内核的ksys_mmap_pgoff接口,文件映射则通过文件描述符得到file结构体,然后最终调用到do_mmap函数,MAP_POPULATE本文不阐述。

do_mmap执行可以简单归纳如下:

get_unmmaped_area:获取虚拟地址;

mmap_region

vm_area_alloc:为虚拟地址申请vma抽象结构体;

初始化vma结构体,包括其实虚拟地址、vm_flags(权限)、vm_file赋值文件结构体指针等和文件映射相关的成员;

call_mmap:调用文件系统的mmap函数集;

get_unmmaped_area用于在进程虚拟地址中找到一块合适的空闲地址,内核中是通过红黑树进行管理和查找,对于虚拟内存的管理框架,如果读者有兴趣,可以查阅文章《进程内存管理初探》。(虚拟内存框架是相对抽象的子系统,包括vma、vm_struct等抽象结构体以及操作函数集合、地址管理逻辑等,如果读者有兴趣,可以在评论区留言出专篇)

下面走读一下mmap_region函数,一探mmap的核心逻辑,函数定义于mm/mmap.c中:

函数的逻辑主干很清晰,就不在细述。最后调用call_mmap函数通过mapping->a_ops->mmap回调文件系统操作函数集,以ext4文件系统为例,定义在fs/ext4/file.c中:

至此mmap函数调用就结束了,可能读者会很疑惑:mmap构建了一个vma结构体后什么都没做?其实非MAP_POPULATE分支的mmap函数就是构建vma结构体,该结构抽象管理一段虚拟地址。如本章节篇头提及,Linux内核对用户内存分配是很苛刻的,从内核的角度:因为物理内存是公共资源且比较紧缺,用户申请分配物理内存可能不用,避免共用资源浪费,所以就只先给虚拟内存,因为虚拟内存是属于进程内部独立的,只影响进程内部,对系统的影响仅仅是增加了vma等管理结构体的内存占用开销。

2. 文件缓存的缺页中断处理流程

用户经过mmap接口后,得到一段虚拟内存,其没有物理载体,即没有承载任何用户需要的数据,那么当用户访问该虚拟内存后,怎么获取数据?答案就是Linux 的缺页中断机制,简单概括:

用户访问虚拟地址,MMU发现虚拟地址没有映射到物理内存;

触发中断进行缺页处理流程;

根据vma结构体成员判断是哪种缺页的类型,进行物理页面分配以及数据填充;

Linux根据内存用途分成多种页面,缺页中断也根据页面类型会走不同的分支。本文只讨论文件缺页中断,其他类型可根据相同思路走读代码。以arm处理器,其处理流程如下:

其核心处理阶段包括:

平台的缺页中断;

内存核心框架,衔接到文件系统的fault回调函数;

文件缓存管理框架进行文件页面预读;

通用块设备层、IO调度层和设备驱动层进行数据读取;

本文走读前三部分,最后一部分放在IO和文件系统篇幅,后续更新。

(1)缺页中断

缺页中断跟正常的中断处理流程相似,硬件触发经过汇编代码后跳转到do_DataAbort函数处理,定义在arch/arm/mm/fault.c,arm平台有两个寄存器用于记录中断的信息:

FSR:失效状态寄存器

FAR:失效地址寄存器

内核使用fsr_info结构体抽象异常处理操作集合:

以二级页表架构为例子,定义于arch/arm/mm/fsr-2level.h中:

通过do_page_fault()调用到__do_page_fault函数:通过当前进程的mm_struct和异常的地址得到vma结构体,然后调用handle_mm_fault函数。

handle_mm_fault函数定义在mm/memory.c中,此时已经进入内存管理框架内,该函数主要分配页表,然后通过handle_pte_fault函数,针对不同的页面类型,进行分支处理:

处理流程也很清晰,根据不同的情况走不同的分支,do_fault函数用于处理文件页面的缺页中断,当然文件页面的缺页中断也有不同的原因:

本文走读do_read_fault流程,其他分支读者可以自行分析,其逻辑大同小异。

__do_fault函数通过 vma->vm_ops->fault()回调到具体文件系统的操作函数集合,前文已知vm_ops函数操作集是在具体的文件系统赋值的,以ext4文件系统为例,ext4_filemap_fault通过filemap_fault函数进行文件页面读取。

(2)filemap_fault

filemap_fault是文件缺页中断的核心处理函数,定义于mm/memory.c中,其逻辑作用:

判断页面是否在文件缓存中,如存在则判断是否需要异步预读;

如不存在则进行同步预读;

其主要的逻辑还是集中在预读窗口的构建。

函数主干简化后如上,逻辑很清晰就不再细述。关注一下此时的同步预读和异步预读策略跟read分析的预读流程,是否存在不同。

  • do_async_mmap_readahead函数

首先是异步预读do_async_mmap_readahead函数:

@1中PG_Readahead标志,表示该页面上次是提前异步读取的第一个页面,在“read发起读文件流程分析:4.1章节”的__do_page_cache_readahead中有分析。当前如果访问到这个文件表示,上次提前预读的页面被命中,那么可能是顺序读,此时需继续进行异步预读,page_cache_async_readahead已经在“read发起读文件流程分析”章节分析,这里不再累述。

do_sync_mmap_readahead函数

ra_submit通过__do_page_cache_readahead函数进行预读,该函数在“read发起读文件流程分析”章节都已经分析。

3. munmap函数生命周期

munmap系统调用逻辑非常简单:

内核根据虚拟地址找到vma结构体;

unmap_region进行解映射,并释放页表;

remove_vma_list将vma从用户进程的mm_struct中剥离;

这里需要注意的是,调用munmap接口后,是否文件缓存就会一定马上被回收?答案是否定的:

首先文件页面可能被其他进程映射使用;

其次Linux系统对待文件缓存还是比较“博爱”的,除非用户主动调用drop_cache等接口将文件缓存从内存刷出,不然内核会尽量将文件缓存保存在内存中,避免下次用户使用时需要再次从磁盘中读取。既然是“尽量”,那就得有限度,该限度就是内核空闲内存是否低于LOW_WATERMARK水位线?如果低于该值,就会进行内存回收,其中就包括文件缓存的回收。

5 文件Cache相关API及其实现

Linux内核中与文件Cache操作相关的API有很多,按其使用方式可以分成两类:一类是以拷贝方式操作的相关接口, 如read/write/sendfile等,其中sendfile在2.6系列的内核中已经不再支持;另一类是以地址映射方式操作的相关接口,如mmap等。

第一种类型的API在不同文件的Cache之间或者Cache与应用程序所提供的用户空间buffer之间拷贝数据,其实现原理如图7所示。

第二种类型的API将Cache项映射到用户空间,使得应用程序可以像使用内存指针一样访问文件,Memory map访问Cache的方式在内核中是采用请求页面机制实现的,其工作过程如图8所示。

首先,应用程序调用mmap(图中1),陷入到内核中后调用do_mmap_pgoff(图中2)。该函数从应用程序的地址空间中分配一段区域作为映射的内存地址,并使用一个VMA(vm_area_struct)结构代表该区域,之后就返回到应用程序(图中3)。当应用程序访问mmap所返回的地址指针时(图中4),由于虚实映射尚未建立,会触发缺页中断(图中5)。之后系统会调用缺页中断处理函数(图中6),在缺页中断处理函数中,内核通过相应区域的VMA结构判断出该区域属于文件映射,于是调用具体文件系统的接口读入相应的Page Cache项(图中7、8、9),并填写相应的虚实映射表。经过这些步骤之后,应用程序就可以正常访问相应的内存区域了。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值