linux如何感知通过mmap进行的文件修改

一、问题
对于mmap将内容映射到地址空间,从而让应用程序可以像操作内存一样来操作文件内容,这是操作系统为用户态程序提供的一个便利,它的确可以将繁琐的文件操作转换为码农喜闻乐见的内存操作,更重要的是它可以将文件内容的读写达到按需加载,只有在真正使用到文件内容的时候才会触发文件内容的读取,当然写回也是如此。
和文件的读取相比,写入的实现想起来可能更加复杂一些(如果你理解这个功能的底层实现基础并曾经考虑过这样问题的话):我们考虑这样的一个场景,用户通过mmap将文件的内容映射到自己的地址空间,访问文件中某个位置的数据,修改地方的内容。在这个过程中,第一次触发的读操作会引起操作系统的缺页异常,并导致操作系统按需将这个页面的内容读入内存,建立页面的映射,这个看起来还比较直观。但是在写入的时候呢?此时应用程序是通过内存操作的,而这个内存映射已经建立,此时并不会触发操作系统的缺页异常,这个地方真正的做到了让用户感觉到是在访问内存,并且连操作系统也有这个错觉(相当于入戏太深,最后把自己也感动了),操作系统如果对这个写入没有任何感知的话,操作系统怎么知道这个mmap的文件内容哪里被修改(进而需要写回磁盘)了呢?
二、如何识别write系统调用的修改内容
write系统调用本身就指明文件要修改的位置、长度和内容,通过这个接口来修改文件的内容对操作系统来说是一种喜闻乐见的朴素文件修改方式。依然以最简单的ext2文件系统为例,其中对于文件的写入接口经过一番暂时忽略的辗转,走到我们关心的路径为__generic_file_aio_write_nolock===>>>generic_file_buffered_write===>>>generic_commit_write===>>>__block_commit_write===>>>mark_buffer_dirty,在这个路径中,操作系统可以明确的感知到一个文件的哪些buffer被修改,从而在进行特定操作的时候(close,fsync、pdflush等)写回硬盘,所以说对于通过write接口修改的文件内容操作系统可以轻松识别,也就是我们通常所说的vanilla水平。
三、mmap修改的内容
为了说明这里的问题,我使用mmap的man手册自带的例子简单修改了下,就是下面的代码:
tsecer@harry: cat mmapwriteback.cpp 
#include <sys/mman.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
 
#define handle_error(msg) \
   do { perror(msg); exit(EXIT_FAILURE); } while (0)
 
int
main(int argc, char *argv[])
{
   char *addr;
   int fd;
   struct stat sb;
   off_t offset, pa_offset;
   size_t length;
   ssize_t s;
 
   if (argc < 3) {
       fprintf(stderr, "%s file offset [length]\n", argv[0]);
       exit(EXIT_FAILURE);
   }
 
   fd = open(argv[1], O_RDWR);
   if (fd == -1)
       handle_error("open");
 
   if (fstat(fd, &sb) == -1)           /* To obtain file size */
       handle_error("fstat");
 
   offset = atoi(argv[2]);
   pa_offset = offset & ~(sysconf(_SC_PAGE_SIZE) - 1);
       /* offset for mmap() must be page aligned */
 
   if (offset >= sb.st_size) {
       fprintf(stderr, "offset is past end of file\n");
       exit(EXIT_FAILURE);
   }
 
   if (argc == 4) {
       length = atoi(argv[3]);
       if (offset + length > sb.st_size)
           length = sb.st_size - offset;
               /* Can't display bytes past end of file */
 
   } else {    /* No length arg ==> display to end of file */
       length = sb.st_size - offset;
   }
 
   addr = (char*)mmap((void*)NULL, length + offset - pa_offset, PROT_READ | PROT_WRITE,
               MAP_SHARED, fd, pa_offset);
   if (addr == MAP_FAILED)
       handle_error("mmap");
       
        close(fd);
   s = write(STDOUT_FILENO, addr + offset - pa_offset, length);
   if (s != length) {
       if (s == -1)
           handle_error("write");
 
       fprintf(stderr, "partial write");
       exit(EXIT_FAILURE);
   }
        *(addr + offset - pa_offset) = 'A';
        sleep(1000);
   exit(EXIT_SUCCESS);
}
这里代码需要注意的是:在将文件映射到地址空间之后,应用通过s = write(STDOUT_FILENO, addr + offset - pa_offset, length);以读取的形式访问了这个映射后的地址空间,在这次访问之后,被映射的文件内容会被读入内存,并建立起这个页面和应用地址空间之间的映射,此后应用进程对该地址(所在的页面的所有内容)访问都不会触发访问异常。那么问题来了:在接下来的 *(addr + offset - pa_offset) = 'A';语句中修改了内存地址,进而是修改了文件内容,那么操作系统如何知道我修改了这个地方的内容了呢
四、2.6.21内核版本的x86实现
事实上,在之前的讨论中,我忽略了CPU对于这种情况的支持,对于386系列的CPU来说,它的MMU单元对于这种情况在页表项中提供了支持。具体来说,当一个页面被写入时,CPU会在这个页面对应的PTE(Page Table Entry)中设置一个dirty标志位来表示对应的页面内容被修改了。
即使如此,操作系统在什么时候来识别这个寄存在PTE中的页面标志位呢?这个我想到两个场景:一个是系统需要内存页面从而尝试将某些页面换出到磁盘的时候,另一个是这个修改内容的落地问题。这两个都是最为基础的重要功能,可以说是一个操作系统的基本功能。
1、页面回收时的处理
由于此时的页面dirty标志是放在CPU写入的PTE中,而不是放在内核的页面管理结构(struct page)中,所以通过常规的PageDirty接口并不能判断处这个文件的内容被修改了。在页面回收的时候,如果此处判断有误,那么前面例子中修改的内容将会随着页面的回收而丢失,所以说操作系统肯定会处理这种情况的。具体的流程在shrink_page_list===>>>try_to_unmap===>>>try_to_unmap_file===>>>try_to_unmap_one
/* Move the dirty bit to the physical page now the pte is gone. */
if (pte_dirty(pteval))
set_page_dirty(page);
这里在映射断链的时候将pte中保存的dirty状态保存在页面(struct page)中,从而保证了页面回收时脏页面的识别。
2、进程退出时的处理
回收时的逻辑判断只是一个可能的场景,在一些生存期较短的进程中可能永远也不可能出现(就好像电影中的罗曼蒂克一样),而更为常见、也是关键的是在常规情况下,这些脏页面是如何落地的。
大家都知道,在进程创建的时候需要有不少工作要准备,这一点连用户态的代码也有感知,比方说fork、文件描述符的操作等,但是,更为复杂、同时也是更容易被大家遗忘的是回收工作,这个道理在很多场景下同样成立。例如,设计一个内存分配算法比较简单,但是考虑到回收之后的碎片整理就比较麻烦;开发(并污染)一个环境比较简单,治理就比较麻烦;讲一个段子比较简单,冷场的时候hold住场面就比较麻烦。同样,即使用户觉得通过fork/exec系统调用来创建一个进程比较麻烦,事实上一个进程的退出更加麻烦。在进程退出的时候,需要大量的资源释放和回收(更不要说一个进程如何“释放”自己的task_struct这种高难度动作,可以想像尝试自己把自己埋葬到棺材里、并立上墓碑这个行为),这些回收包括文件、信号量、线程组、模块等,而这些大的模块可能又包含了一些小的模块,例如socket的关闭、robus futex的退出、共享内存的引用计数减少等。
在这些所有的释放操作中,和我们这里讨论的问题有联系的就是这里的exit_mm===>>>mmput===>>>exit_mmap===>>>unmap_vmas===>>>unmap_page_range===>>>zap_pud_range===>>>zap_pmd_range===>>>zap_pte_range
if (PageAnon(page))
anon_rss--;
else {
if (pte_dirty(ptent))
set_page_dirty(page);
这是将pte中的dirtry转换到内核通用的page中的最后步骤。
五、如何平滑落地
基于上面的分析,一个mmap的文件内容只会在内存尝试页面回收或者进程退出的时候才会写回磁盘。不过实际测试上面的代码并不是这个效果,其中的语句为
        *(addr + offset - pa_offset) = 'A';
        sleep(1000);
也就是在通过内存修改文件内容之后进程进行休眠,不退出进程。在我现在使用的3.11内核版本中执行这个实验可以发现,经过一段时间之后,即使sleep没有退出,这个修改还是体现在了磁盘上,这说明还有其它的路径会将修改内容写回磁盘(从代码上看,感觉2.6.21版本应该没有这个功能,不过没有环境没法测试,所以也就不确定)。下面是我测试时使用的环境
tsecer@harry: uname -a
Linux localhost.localdomain 3.11.10-301.fc20.x86_64 #1 SMP Thu Dec 5 14:01:17 UTC 2013 x86_64 x86_64 x86_64 GNU/Linux
tsecer@harry: 
其实想一下也可以知道:系统不应该将这么多的修改都累积到进程退出的时候落地,而应该分批、平滑写回磁盘。这样可以减少IO操作的峰值压力,而且更为重要的是不会在系统突然断电的时候丢失所有的应用修改内容,这一点对于需要长时间运行的服务程序来说尤为重要。
六、如何第一时间感知页面修改
其实,之前的分析也并没有错,只是内核使用了更为复杂的策略:为了实时感知用户对于页面的写操作,在建立页目录的时候先屏蔽掉可写属性,从而在写操作的时候触发一次访问异常,进而在访问异常中处理脏页面的写回问题。具体的实现依然在do_mmap_pgoff函数中:
if (vma_wants_writenotify(vma))
vma->vm_page_prot =
protection_map[vm_flags & (VM_READ|VM_WRITE|VM_EXEC)];
这里映射的内容是取消了vm_flags 属性中的VM_SHARED属性,而这个属性对应的protection_map数组中没有MAP_SHARED的将不会具有PROT_WRITE属性,从而写操作将会触发异常。下面是protection_map数组的内容
/* description of effects of mapping type and prot in current implementation.
 * this is due to the limited x86 page protection hardware.  The expected
 * behavior is in parens:
 *
 * map_type prot
 * PROT_NONE PROT_READ PROT_WRITE PROT_EXEC
 * MAP_SHARED r: (no) no r: (yes) yes r: (no) yes r: (no) yes
 * w: (no) no w: (no) no w: (yes) yes w: (no) no
 * x: (no) no x: (no) yes x: (no) yes x: (yes) yes
 *
 * MAP_PRIVATE r: (no) no r: (yes) yes r: (no) yes r: (no) yes
 * w: (no) no w: (no) no w: (copy) copy w: (no) no
 * x: (no) no x: (no) yes x: (no) yes x: (yes) yes
 *
 */
pgprot_t protection_map[16] = {
__P000, __P001, __P010, __P011, __P100, __P101, __P110, __P111,
__S000, __S001, __S010, __S011, __S100, __S101, __S110, __S111
};
以比较新的3.12.6版本为例(很可能2.6.26版本已经有这个功能了),这个写异常的触发函数为filemap_page_mkwrite,其中有我们最为关注的set_page_dirty操作,就是在这里感知了页面的修改行为。
int filemap_page_mkwrite(struct vm_area_struct *vma, struct vm_fault *vmf)
{
……
set_page_dirty(page);
wait_for_stable_page(page);
out:
sb_end_pagefault(inode->i_sb);
return ret;
}
七、多次修改的处理及效率问题
在filemap_page_mkwrite执行之后,会取消掉页面的写保护属性,也就是将页面修改为可写状态,然后内核线程会定时将这些脏数据写回磁盘。但是接下来的问题是,把页面写回磁盘之后,如果用户再次修改页面内容,此时操作系统将如何感知这个修改呢?注意:此时页面的写保护状态已经解除,不能在写保护异常中感知页面修改。当然此时直观的做法就是始终保留页面的写保护,但是这样对于系统压力很大,每次修改触发一次页面访问异常,操作系统的大部分时间都用在了异常处理,内核相对于用户程序是喧宾夺主了。
所以内核采用的是一种更见友好的方式,就是在页面首次触发写保护之后取消页面写保护,从而减少页面访问异常的次数,然后在内核线程将页面写回磁盘之后再次把页面写保护开启,从而周而复始。这个操作的大致流程如下面关系链所示,其中的pte_wrprotect就是再次开启页面的保护,从而再下次修改的时候再次感知页面修改。
do_writepages===>>>ext4_writepages===>>>mpage_prepare_extent_to_map===>>>mpage_process_page_bufs===>>>mpage_submit_page===>>>clear_page_dirty_for_io===>>>page_mkclean===>>>page_mkclean_file===>>>page_mkclean_one
if (pte_dirty(*pte) || pte_write(*pte)) {
pte_t entry;
 
flush_cache_page(vma, address, pte_pfn(*pte));
entry = ptep_clear_flush(vma, address, pte);
entry = pte_wrprotect(entry);
entry = pte_mkclean(entry);
set_pte_at(mm, address, pte, entry);
ret = 1;
}

posted on 2019-03-07 10:00  tsecer  阅读(2542)  评论(0编辑  收藏  举报

导航