在 Kafka 的存储层这部分代码时,看到了很多地方使用操作系统的共享内存机制,Kafka 中所有日志文件的索引都是使用了 mmap 做内存映射,mmap 这块刚好也是一个值得深入学习的知识点,于是就就深入地看了一下、做了一下总结,本文的内容主要来自《深入理解操作系统》第三版 9.8 存储器映射部分。

存储器映射

Linux 通过将一个虚拟存储器区域与一个磁盘上的对象关联起来,以初始化这个虚拟存储器区域的内容,这个过程就被称为 存储器映射(memory mapping),虚拟存储器区域可以映射到下面两种类型的对象中的一种:

  • Unix 文件系统的普通文件:一个区域可以映射到一个普通磁盘文件的连续部分,例如一个可执行的目标文件。文件区会被分成了页大小的片,每一片包含一个虚拟页面的初始内容。因为按需进行页面调度,所以这些虚拟页面没有实际交换进入物理存储器,直到 CPU 第一次引用页面(如果区域比文件区要大,那么就用零来填充这个区域的余下部分);
  • 匿名文件:一个区域也可以映射到一个匿名文件,匿名文件是由内核创建的,包含的是二进制零。CPU 第一次引用这样一区域内的虚拟页面时,内核就在物理存储器中找到一个合适的牺牲页面,如果该页面被修改过,就将这个页面换出来,用二进制零覆盖牺牲页面并更新页表,将这个页面标记为是驻留在存储器中的,但是要注意的是在磁盘和存储器之间并没有实际的数据传输,因为这个原因,映射到匿名文件区域中的页面有时也叫做 请求二进制零的页(demand-zero page)

上面这个是存储器映射的基础内容,理解完这部分之后,我们再来看共享对象(共享内存)和 mmap。

共享对象

存储器映射的出现,它是为了要解决是什么问题呢?先看一下对于操作系统来说,没有存储器映射的话面临的情况:

在操作系统中,进程这一抽象能够为每个进程提供自己私有的虚拟地址空间,可以免受其他进程的错误读写,但是,对于操作系统的每一个进程,它们都有同样的只读文本区域,如:每个 C 程序都需要调用一些标准的 C 库函数、需要程序需要访问只读运行时库代码的相同拷贝等等。那么如果每个进行都在物理存储器中保持这些常用代码的复制拷贝,那就是极端的浪费了。

而存储器映射机制的出现,就给我们提供了一种清晰的机制,用来 控制多个进程如何共享对象

一个对象可以被映射到虚拟存储器的一个区域,要么作为共享对象,要么作为私有对象:

  • 如果是作为共享对象,那么这个进程对这个区域的任何写操作,对于那些也会把这个共享对象映射到它们虚拟存储器的其他进程而言也是可见的,而且这些变化,也会反映在磁盘上的原始对象中;
  • 如果是作为私有对象,这样的改变,对于其他进程来说是不可变的,并且进程对这个区域所做的任何写操作都不会反映在磁盘上的对象中。

关于共享对象,举一个例子,如下图所示:

一个共享对象

假设进程1将一个共享对象映射到它的虚拟地址存储器的一个区域中,如上图左边所示,现在假设进程2将同一个共享对象映射到它的地址空间(与进程1虚拟地址空间并不一定一样),如右边所示。因为每个对象都有一个唯一的文件名,内核可以迅速地判断进程1已经映射了这个对象,而且可以使进程2中的页表条目指向相应的物理页面。关键点在于即使对象被映射到了多个共享区域,物理存储器中也只需要存放共享对象的一个拷贝。

私有对象的 copy-on-write

在私有对象中,操作系统是使用了一种叫做 写时拷贝(copy-on-write) 的巧妙技术将其映射到虚拟存储器中的。一个私有对象开始生命周期的方式基本上与共享对象一样,在物理存储器中只保存有私有对象的一份拷贝。

  • 如下图的左边部分所示,其中两个进程将一个私有对象映射到它们虚拟存储器的不同区域,但是却共享这个对象同一个物理拷贝。这时,对于每个映射私有对象的进程,相应私有区域的页表条目都被标记为只读,并且区域结构被标记为私有的写时拷贝,只要没有进程试图写它自己的私有区域,它们就可以继续共享物理存储器中对象的一个单独拷贝;
  • 如果只有有一个进程试图写私有区域的某个页面,那么这个写操作就会触发一个保护故障:如下图右边所示,该故障处理程序触发的原因是由于进程试图写私有的写时拷贝区域的一个页面引起的,它就会在物理存储器中创建这个页面的一个新拷贝,更新页表条目指向这个新拷贝,然后恢复这个页面的写权限,当故障处理程序返回时,CPU 重新执行这个写操作,现在在新创建的页面上这个写操作就可以正常执行了。

一个写时拷贝对象

copy-on-write 最充分地使用了稀有的物理存储器。

再看 fork 函数

学习了虚拟存储器映射进制后,回头再看 Linux 中的 fork 函数,就会明白 fork 函数是如何创建一个带有自己独立虚拟地址控制的新进程。

  1. 当 fork 函数被当前进程调用时,内核为新进程创建各种数据结构,并分配给它一个唯一的 PID;
  2. 为了给新进程创建虚拟存储器,它创建了当前进程的 mm_struct、区域结构和页表的原样拷贝,它将两个进程的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有的写时拷贝;
  3. 当 fork 函数在新进程中返回时,新进程现在的虚拟存储器刚好和调用 fork 时存在的虚拟存储器相同;
  4. 当两个进程中的任一个进行写操作时,写时拷贝机制就会创建新页面,因此,也就为每个进程保持了私有地址空间的抽象概念。

mmap 函数

经过前面的介绍,既然在操作系统中有了存储器映射的机制,那么我们应该怎么使用呢?这就需要引入 UNIX 中另一个重要的函数 —— mmap,它是用来创建新的虚拟存储器区域,并将对象映射到这些区域中。

1
void *mmap(void *start, size_t length, int prot, int flags, int fd, off_t offset);

成功执行时,mmap() 返回指向映射区的指针,失败时,mmap() 返回 MAP_FAILED(-1), error被设为以下的某个值:

1
2
3
4
5
6
7
8
9
10
11
EACCES:访问出错
EAGAIN:文件已被锁定,或者太多的内存已被锁定
EBADF:fd不是有效的文件描述词
EINVAL:一个或者多个参数无效
ENFILE:已达到系统对打开文件的限制
ENODEV:指定文件所在的文件系统不支持内存映射
ENOMEM:内存不足,或者进程已超出最大内存映射数量
EPERM:权能不足,操作不允许
ETXTBSY:已写的方式打开文件,同时指定MAP_DENYWRITE标志
SIGSEGV:试着向只读区写入
SIGBUS:试着访问不属于进程的内存区

mmap 要求内核创建一个新的虚拟存储器区域,最好是从地址 start 开始的一个区域,并将文件描述符 fd 指定的对象的一个连续的片(chunk)映射到这个新的区域,连续的对象片大小为 length 字节,从距文件开始偏移量为 offset 字节的地方开始,start 地址仅仅是一个暗示,通常设置为 NULL,如下图所示。

mmap 函数解释

参数 port 包含描述新映射的虚拟存储器区域的访问权限(在相应区域结构中的 vm_port 位):

  • PORT_EXEC:这个区域内的页面由可以被 CPU 执行的指令组成;
  • PORT_READ:这个区域内的页面可读;
  • PORT_WRITE:这个区域内的页面可写;
  • PORT_NONE:这个区域内的页面不能被访问。

参数 flags 指定映射对象的类型,映射选项和映射页是否可以共享(下面列出的只是其中一部分)

  • MAP_ANON:表示被映射的对象就是一个匿名对象,而相应的虚拟页面是请求二进制零的;
  • MAP_PRIVATE:表示被映射的对象是一个私有的、写时拷贝的对象;
  • MAP_SHARED:表示是一个共享对象。

示例如:bufp = Mmap(-1, size, PORT_READ, MAP_PRIVATE|MAP_ANON, 0, 0);,让内核创建一个新的包含 size 字节的只读、私有、请求二进制零的虚拟存储器区域。

删除虚拟存储器区域,使用 int munmap(void *start, size_t length);,若成功返回0,若出错返回 -1.

下面看一个示例:使用 mmap 实现一个功能:将一个任意大小的磁盘文件拷贝到 stdout,输入文件的名字必须作为一个命令行参数来传递。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#include"csapp.h"
void mmapcopy(int fd,int size){
char *bufp;
bufp =(char *)mmap(NULL,size,PROT_READ,MAP_PRIVATE,fd,0);//在进程空间中创建一个新的虚拟存储器区域,将磁盘文件映射到这个区域中
write(1,bufp,size);//将信息写入标准输出
//POSIX 定义了 STDIN_FILENO、STDOUT_FILENO 和 STDERR_FILENO 来代替 0、1、2。这三个符号常量的定义位于头文件 unistd.h。
munmap(bufp,size);//删除虚拟存储器区域
return;
}

int main(int argc,char **argv){
struct stat _stat; //文末附上关于这个结构体详细内容的链接
int fd;
if(argc != 2){
printf("usage :%s <filename>",argv[0]);
exit(0);
}
fd = open(argv[1],O_RDONLY,0);
//fd1 = open(argv[2],O_RDWR|O_APPEND,0); 以“读写+追加”模式打开一个额外的文件,将在函数里尝试向它追加信息。

fstat(fd,&_stat); //fstat将文件标识符fd所标识的文件状态,复制到结构体stat中
mmapcopy(fd,_stat.st_size);

return 0;
}

参考文献: