物理内存也称为主存,大多数计算机用的主存都是动态随机访问内存(DRAM)。只有内核才可以直接访问物理内存。那么,进程要访问内存时,该怎么办呢?Linux 内核给每个进程都提供了一个独立的虚拟地址空间,并且这个地址空间是连续的。这样,进程就可以很方便地访问内存,更确切地说是访问虚拟内存。
虚拟地址空间的内部又被分为内核空间和用户空间两部分,不同字长(也就是单个 CPU 指令可以处理数据的最大长度)的处理器,地址空间的范围也不同。
还记得进程的用户态和内核态吗?进程在用户态时,只能访问用户空间内存;只有进入内核态后,才可以访问内核空间内存。虽然每个进程的地址空间都包含了内核空间,但这些内核空间,其实关联的都是相同的物理内存。这样,进程切换到内核态后,就可以很方便地访问内核空间内存。
既然每个进程都有一个这么大的地址空间,那么所有进程的虚拟内存加起来,自然要比实际的物理内存大得多。所以,并不是所有的虚拟内存都会分配物理内存,只有那些实际使用的虚拟内存才分配物理内存,并且分配后的物理内存,是通过内存映射来管理的。
而当进程访问的虚拟地址在页表中查不到时,系统会产生一个缺页异常,进入内核空间分配物理内存、更新进程页表,最后再返回用户空间,恢复进程的运行。
malloc() 是 C 标准库提供的内存分配函数,对应到系统调用上,有两种实现方式,即 brk() 和 mmap()。对小块内存(小于 128K),C 标准库使用 brk() 来分配,也就是通过移动堆顶的位置来分配内存。这些内存释放后并不会立刻归还系统,而是被缓存起来,这样就可以重复使用。而大块内存(大于 128K),则直接使用内存映射 mmap() 来分配,也就是在文件映射段找一块空闲内存分配出去。(都发生在用户空间)
当这两种调用发生后,其实并没有真正分配内存。这些内存,都只在首次访问时才分配,也就是通过缺页异常进入内核中,再由内核来分配内存。(还是那句话,只有内核才有权限操作内存)、
mmap 的功能非常强大,根据参数的不同,它可以用于创建共享内存,也可以创建文件映射区域用于提升 IO 效率,还可以用来申请堆内存。决定它的功能的,主要是 prot, flags 和 fd 这三个参数
prot
可以是以下四个常量的组合:
PROT_EXEC,表示这块内存区域有可执行权限,意味着这部分内存可以看成是代码段,它里面存储的往往是 CPU 可以执行的机器码。PROT_READ,表示这块内存区域可读。
PROT_WRITE,表示这块内存区域可写。
PROT_NONE,表示这块内存区域的页面不能被访问。
flags (根据是否有关联文件区分匿名映射还是文件映射)
MAP_SHARED:创建一个共享映射的区域,多个进程可以通过共享映射的方式,来共享同一个文件。这样一来,一个进程对该文件的修改,其他进程也可以观察到,这就实现了数据的通讯。
MAP_PRIVATE:创建一个私有的映射区域,多个进程可以使用私有映射的方式,来映射同一个文件。但是,当一个进程对文件进行修改时,操作系统就会为它创建一个独立的副本,这样它对文件的修改,其他进程就看不到了,从而达到映射区域私有的目的。MAP_ANONYMOUS:创建一个匿名映射,也就是没有关联文件。使用这个选项时,fd 参数必须为空。
MAP_FIXED:一般来说,addr 参数只是建议操作系统尽量以 addr 为起始地址进行内存映射,但如果操作系统判断 addr 作为起始地址不能满足长度或者权限要求时,就会另外再找其他适合的区域进行映射。如果 flags 的值取是 MAP_FIXED 的话,就不再把 addr 看成是建议了,而是将其视为强制要求。如果不能成功映射,就会返回空指针。
我们再来看参数 fd。当参数 fd 不为 0 时,mmap 映射的内存区域将会和文件关联,如果 fd 为 0,就没有对应的相关文件,此时就是匿名映射,flags 的取值必须为 MAP_ANONYMOUS。
父子通信举例
#include <stdlib.h>
#include <stdio.h>
#include <unistd.h>
int main() {
pid_t pid;
char* shm = (char*)mmap(0, 4096, PROT_READ | PROT_WRITE,
MAP_SHARED | MAP_ANONYMOUS, -1, 0);
if (!(pid = fork())){
sleep(1);
printf("child got a message: %s\n", shm);
sprintf(shm, "%s", "hello, father.");
exit(0);
}
sprintf(shm, "%s", "hello, my child");
sleep(2);
printf("parent got a message: %s\n", shm);
return 0;
}
我们先是用 mmap 方法创建了一块共享内存区域,命名为 shm(第 9 行代码),接着,又通过 fork 这个系统调用创建了子进程。从第 13 行到第 16 行代码是子进程的执行逻辑,具体来讲,子进程休眠一秒后,从 shm 中取出一行字符并打印出来,然后又向共享内存中写入了一行消息(第 15 行)。
在子进程的执行逻辑之后,是父进程的执行逻辑(第 19 行以后):父进程先写入一行消息,然后休眠两秒,等待子进程完成读取消息和发消息的过程并退出后,父进程再从共享内存中取出子进程发过来的消息。这就是共享匿名映射在父子进程间通信的运用。
文件映射
在内核中,如果有一个进程打开了一个文件,PCB 中就会有一个 struct file 结构与这个文件对应。struct file 结构是与进程相关,假如进程 A 与进程 B 都打开了文件 f,那么进程 A 中就会有一个 struct file 结构,进程 B 中也会有一个。
Linux 的文件系统中有一个叫做 inode 的结构,这个结构与具体的磁盘上的文件是一一对应的,也就是说对于同一个文件,整个内核中只会有一个 inode 结构。所以进程 A 与进程 B 的 file struct 结构都有一个指针指向 inode 结构,这就将 file struct 与 inode 结构联系起来了。
如果文件是只读的话,那这个文件在物理页的层面上其实是共享的。也就是进程 A 和进程 B 都有一页虚拟内存被映射到了相同的物理页上。
但如果要写文件的时候,私有文件映射因为这一段内存区域的属性是私有的,所以内核就会做一次写时复制,为写文件的进程单独地创建一份副本。这样,一个进程在写文件时,并不会影响到其他进程的读。
在私有文件映射的基础上,共享文件映射就很简单了:对于可写的页面,在写的时候不进行复制就可以了。这样的话,无论何时,也无论是读还是写,多个进程在访问同一个文件的同一个页时,访问的都是相同的物理页面。
VMA(描述已分配的内存区域)
描述线性空间已分配的内存区域的结构对于内存管理至关重要,我们先来看一下这个结构。在 Linux 源码中,负责这个功能的结构是 vm_area_struct,后面简称 vma。内核将每一段具有相同属性的内存区域当作一个单独的内存对象进行管理。
struct vm_area_struct {
unsigned long vm_start; // 区间首地址
unsigned long vm_end; // 区间尾地址
pgprot_t vm_page_prot; // 访问控制权限
unsigned long vm_flags; // 标志位
struct file * vm_file; // 被映射的文件
unsigned long vm_pgoff; // 文件中的偏移量
...
}
mmap 并不真正分配物理内存,它只是分配了一段虚拟内存,也就是说只在 PCB 中创建了一个 vma 结构而已。这就导致 fork 在复制页表的时候,页表中共享匿名映射区域都是未映射状态。
当指令真正访问到内存的时候,由于页表被清空,这时会产生缺页中断,然后,内核就使用 vma 中的文件映射关系,去磁盘上读取相应的内容,将它放到物理页中,最后建立好虚拟地址到物理地址的映射。这是一种按需加载的机制。
从上面这张图我们看到,用户空间从低到高依次是代码区、数据区、堆、共享库与 mmap 内存映射区、栈、环境变量。其中堆向高地址增长,栈向低地址增长。请注意用户空间上还有一个共享库和 mmap 映射区,Linux 提供了内存映射函数 mmap, 它可将文件内容映射到这个内存区域,用户通过读写这段内存,从而实现对文件的读取和修改,无需通过 read/write 系统调用来读写文件,省去了用户空间和内核空间之间的数据拷贝,Java 的 MappedByteBuffer 就是通过它来实现的;用户程序用到的系统共享库也是通过 mmap 映射到了这个区域。(mmap是在用户空间,不是在内核空间?mmap看起来就像是一次批量操作,一次性把整个文件映射到用户空间,实际上还是在内核空间,只是映射避免了用户态切换?应该是实现方式用的还是内核的pageCache,但可以不需要管实现方式。通过内存操作避免了多次从用户态切换到内核态的read,write的系统调用。通过内存映射避免了内核态到用户态的拷贝。如果改内存就相当于直接改文件,那么等待时间也是恐怖的,也和direct io没区别。所以实现上还是缓存批量修改)
mmap其实只负责将文件与内存页对应起来。至于这个页是缺页状态,还是在Cache里,或者在swap区域,都是操作系统关心的,mmap不需要知道这些的。有很多开源组件使用mmap来加速IO,这是很常见的技巧。更常见的是驱动开发里使用这个技巧。
Page Cache 的本质是由 Linux 内核管理的内存区域。我们通过 mmap 以及 buffered I/O 将文件读取到内存空间实际上都是读取到 Page Cache 中。
在用户空间看来,通过 mmap 机制以后,磁盘上的文件仿佛直接就在内存中,把访问磁盘文件简化为按地址访问内存。这样一来,应用程序自然不需要使用文件系统的 write(写入)、read(读取)、fsync(同步)等系统调用,因为现在只要面向内存的虚拟空间进行开发。