当前位置: 首页 > 科技观察

页缓存、内存和文件之间的那些事

时间:2023-03-21 10:58:50 科技观察

上一篇文章,我们学习了内核如何为一个用户进程管理虚拟内存,没有提到文件和I/O。在这篇文章中我们将专门谈谈这个重要的话题——页面缓存。文件和内存之间的关系通常知之甚少,但它们对系统性能的影响可能是巨大的。在面对文件的时候,有两个很重要的问题需要操作系统去解决。第一个是相对于内存,尤其是磁盘寻道而言,硬盘驱动器慢得令人抓狂。二是文件的内容需要一次性加载到物理内存中,这样文件的内容就可以在程序之间共享。如果您使用Windows中的ProcessExplorer查看其进程,您会看到每个进程中加载??了大约15MB的常用DLL。现在我的Windows机器上运行着大约100个进程,所以如果不共享,这些常见的DLL单独使用多达~1.5GB的物理内存。如果真是这样,那就太糟糕了。同样,几乎所有的Linux进程都需要ld.so和libc,再加上其他常用的库,它们占用的内存量也不是小数目。幸运的是,这两个问题都可以通过一个解决方案解决:页面缓存——保存在内存中的页面大小的文件块。为了以图形方式说明页面缓存,我编写了一个名为render的Linux程序,它打开文件scene.dat,一次读取512字节,并将文件内容存储在堆上分配的块中。第一次读取的过程如下:读取和页面缓存渲染请求从偏移量0开始的512字节的scene.dat。内核在页面缓存中寻找4kb的scene.dat块来满足请求。假设数据没有被缓存。内核分配一个页框,发起I/O请求,将scend.dat从偏移量0开始的4kb复制到分配的页框。内核将请求的512字节从页面缓存复制到用户缓冲区,然后read()系统调用结束。读取12KB文件内容后,render程序的堆及相关页框如下图所示:Non-mappedfileread看起来很简单,但实际上这个过程做了很多事情。首先,虽然这个程序使用了正常的读取调用,但是页面缓存中已经有三个4KB的页面框架保存了文件scene.dat的一部分。虽然有时看起来令人惊讶,但这就是正常文件I/O通过页面缓存的方式。在基于x86的Linux上,内核将文件视为一系列4KB块。如果您从文件中读取单个字节,则包含该字节的整个4KB块将从磁盘读取到页面缓存中。这是可以理解的,因为磁盘通常是连续的吞吐量,程序一般不会只从磁盘区域读取几个字节。页面缓存知道文件中每个4KB块的位置,如上图中的#0、#1等所示。Windows使用256KB的视图大小,类似于Linux页面缓存中的页面。不幸的是,在正常的文件读取过程中,内核必须将页面缓存的内容复制到用户缓冲区,这不仅会消耗CPU时间并影响CPU缓存,而且在复制数据时还会浪费物理内存。如上图所示,scene.dat的内存存储了两次,程序的每个实例都需要另外一次存储内容。虽然我们解决了从磁盘读取文件慢的问题,但它在其他方面造成了更痛苦的问题。内存映射文件是解决这种痛苦的一种方法:映射文件读取当您使用文件映射时,内核将您程序的虚拟页面直接映射到页面缓存上。这可以带来显着的性能提升:Windows系统编程报告在运行相对普通的文件读取时性能提升高达30%,而Unix环境中的高级编程报告文件映射在Linux和Solaris上具有类似的效果。根据您的应用程序类型,通过使用文件映射,您可以节省大量物理内存。追求高性能是永恒的目标,度量很重要,内存映射应该是程序员应该经常使用的工具。这个API提供了一个非常好的实现,允许您在内存中逐字节访问文件,而不会为此牺牲代码的可读性。在类Unix系统上,您可以使用mmap查看您的地址空间,在Windows上,您可以使用CreateFileMapping,或者在高级编程语言中有更多可用的包装器。当你映射一个文件的内容时,它不会一次将所有内容映射到内存中,而是通过页面错误按需映射。在获取所需文件内容的页面框架后,页面错误处理程序将您的虚拟页面映射到页面缓存。如果文件内容没有首先缓存,这也将涉及磁盘I/O。现在有一个突发情况,假设我们渲染程序的最后一个实例退出了。是否应立即释放页面缓存中包含scene.dat内容的页面?人们通常会这么想,但这样做并不是一个好主意。你应该想到我们经常在一个程序中创建一个文件,退出程序,然后在第二个程序中使用这个文件。页面缓存正好可以处理这种情况。如果考虑更多的情况,为什么内核会清除pagecache的内容呢?请记住,磁盘读取比内存慢5个数量级,因此安装页面缓存是一个巨大的回报。因此,只要有足够的物理内存,缓存就应该保持满状态。而且,这个原则适用于所有流程。如果您在一周后运行渲染并且scene.dat的内容仍然被缓存,恭喜!这就是内核缓存越来越大直到达到最大限制的原因。并不是因为操作系统设计的太“垃圾”,浪费了你的内存。其实这是一个很好的行为,因为释放物理内存是一种“浪费”。(LCTT译注:释放物理内存会导致pagecache被清除,下次运行程序所需的相关数据需要重新从磁盘读取,会“浪费”CPU和I/O资源)最好方法是尽可能使用缓存。由于页面缓存架构,当程序调用write()时,字节被简单地复制到页面缓存中,页面被标记为“脏”。磁盘I/O通常不会立即发生,因此您的程序不会因等待磁盘写入而阻塞。副作用就是如果这时候电脑死机了,你的写入就无法完成。因此,对于关键文件,例如数据库事务日志,必须执行fsync()(仍然需要担心磁盘控制器的缓存未命中问题),另一方面,读取将被您的程序阻塞,直到数据可用。内核采用预加载来缓解这种矛盾。它一般会预先预取若干页,并将它们加载到页面缓存中,供您以后阅读。当您计划执行顺序或随机读取时(参见madvise()、readahead()、Windows缓存提示),您可以通过提示帮助内核调整这种预加载行为。Linux会预读内存映射文件,但我不确定Windows的行为。当然,它可能会在Linux上使用O_DIRECT来跳过预读,或者在Windows上使用NO_BUFFERING来跳过预读,就像一些数据库软件经常做的那样。文件映射可以是私有的或共享的。当然,这仅适用于内存内容的更新:在私有内存映射上,更新不会提交到磁盘或对其他进程可见,而对于共享内存映射,情况恰恰相反,对它的任何更新都会提交到磁盘并且对其他进程可见。内核使用写时复制(CoW)机制,通过页表项(PTE)实现这种私有映射。在下面的示例中,render和另一个名为render3d的程序私下映射到scene.dat。然后render去写映射文件的虚拟内存区域:Copy-On-Write机制两个程序私自映射scene.dat,内核误导他们把他们映射到pagecache中,却把那个页表项设为只读。render试图写入虚拟页面映射的scene.dat,并且处理器页面出现故障。内核分配一个页框,将scene.dat的第二块复制进去,将故障页映射到新的页框。继续执行。该程序就好像什么都没发生过一样。上面显示的只读页表条目并不意味着映射是只读的,它只是一个内核技巧,直到最后一刻才共享物理内存。你可以认为“私有”这个词有点用词不当,你只需要记住这个“私有”只在更新的情况下使用。这种设计的重要性在于,为了看到映射文件的变化,其他程序只能读取它的虚拟页面。一旦发生“copy-on-write”,其他地方就看不到变化了。但是,内核不保证此行为,因为它是在x86中实现的,从API的角度来看这是有意义的。相反,共享映射只是将其映射到页面缓存。所有进程都可以看到更新并将其写入磁盘。最后,如果上述映射是只读的,页面错误将触发内存段故障而不是写入副本。动态加载的库通过文件映射集成到程序的地址空间中。这并不奇怪,它通过普通API为您提供与私有文件映射相同的效果。下面的示例显示了文件映射渲染程序的两个实例运行的地址空间的一部分,以及物理内存,试图将我们已经看到的许多概念结合在一起。将虚拟内存映射到物理内存这是内存架构系列第三部分的结论。我希望本系列文章有所帮助,并为理解操作系统的这些主题提供了良好的思维模型。