虚拟内存

早期的操作系统允许每个进程自由地读取和修改它们想要的任何内存区域,包括为其他进程分配的那些区域。虽然这让事情变简单,但也带来了一些问题:

  • 如果某个进程存在bug,或者完全存粹是恶意的(病毒),我们应该如何防止它修改分配给其他进程的内存,同时仍然保持通过内存进行进程间通信的可能性?
  • 我们如何应对内存碎片化问题?假设我们有4MB的内存,进程A分配了开头的1MB给自己,然后进程B声明了下一个2MB,然后A结束并释放了它的内存,这时进程C启动并请求一个连续的2MB空间——但是无法得到,因为我们只有两个分离的1MB空间。重新启动进程B或者以某种方式暂停它并将其所有数据和指针向前移动1MB似乎并不是一个好的解决方案。
  • 我们如何访问非RAM类型的存储?我们该如何插入闪存并从中读取特定文件呢?

对于某些特定类型的计算机系统,例如图形处理器(GPU),这些问题并不是那么关键,因为你通常一次只解决一个任务,并完全掌控计算过程。但是对于现代的多任务操作系统,这些问题绝对是不可避免的,而这些系统通过使用一种名为虚拟内存(virtual memory)的技术来解决所有这些问题。

内存分页

虚拟内存使每个进程有一种”错觉“,即它完全控制了一块连续的内存区域,但实际上,这个连续的区域可能被映射到包括主内存(RAM)和外部存储器(HDD,SSD)在内的物理内存的多个较小的块上。

为了实现这个目标,内存地址空间被划分为许多页面(pages,通常每页大小为4KB),这些页面是程序可以向操作系统申请的最基本的内存单位。内存系统将维护一个特殊的硬件数据结构,称为页表(page table),它包含了虚拟页地址到物理页地址之间的映射关系。当一个进程试图通过虚拟内存地址来访问数据时,内存系统会先计算出这个地址对应的页号(如果页的大小为4096=2124096=2^{12},则可通过右移1212位来计算),然后在页表中找到这个页号所对应的物理地址,最后将这个读或写请求转发到数据实实在在存储的物理内存位置。由此,虚拟内存为进程提供了一种看似连续的、无需关心实际内存管理的操作接口,极大地简化了编程的复杂性。

每次内存请求都需要进行地址转换,而内存页的数量本身可能很大(例如,16G RAM / 4K 页面大小 = 4M 页面),所以地址转换本身就是一个困难的问题。一种加快其速度的方法是使用一个特殊的缓存来存放(近期查询过的)页面表,这就是所谓的地址变换高速缓存(Translation Lookaside Buffer,TLB),另一种方法是增大页面的大小,这样可以减少内存页面的总数,但代价是牺牲了一些颗粒度。

映射外部存储

虚拟内存的机制也允许我们非常透明地使用外部内存类型。现代操作系统支持内存映射,这让你可以打开一个文件并使用其内容,就如同它们在主存中一样。

// open a file containing 1024 random integers for reading and writing
int fd = open("input.bin", O_RDWR);
// map it into memory size allow reads and writes write changes back to the file
int* data = (int*) mmap(0, 4096, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
// sort it like if it was a normal integer array
std::sort(data, data + 1024);
// changes are eventually propagated to the file

这里是一个4K大小的文件,我们可以把它映射到仅需一个内存页面的位置。但当我们打开更大的文件时,只有在我们需要某个页面时,系统才会“懒惰地”去进行读取(延迟加载);而对于写入操作,系统会将其缓存,并在确定时刻(通常来说是程序终止时,或系统运行出现内存不足时)将数据提交到文件系统。

一种具有相同运作原理但目的相反的技术是交换文件(swap file),这种技术允许操作系统在没有足够的实际RAM时,自动使用SSD或HDD的部分作为主内存的扩展。这使得当系统内存不足时不至于会崩溃,而只是会严重的变慢。

主内存和外部存储的无缝整合实际上把RAM变成了外部存储的“L4缓存”,这样在设计算法时,可以将这个过程看作是一个多级缓存的模型,更方便进行资源管理和优化设计。