Chapter 15. Link15.1 Overview15.2 ConceptsStep 1. Symbol ResolutionStep 2. Relocation15.3 Linking Details15.4 Post-linking: Load Executable File into Memory (Load-time)15.5 Libraries & Link15.5.1 Static Libraries & Link15.5.2 Shared Libraries & Link15.6 Library Interpositioning15.6.1 Overview15.6.2 Usage: Compile-time Interpositioning15.6.3 Usage: Link-time Interpositioning15.6.4 Usage: Load / Run-time InterpositioningChapter 16. Virtual Memory16.1 Design a Memory Allocator16.1.1 Prerequisites & Issues16.1.2 Implementation16.2 Improve Allocator's Performance16.2.1 Method 1: Explicit Free Lists16.3 Garbage Collection16.4 Virtual Memory: From Hardware's Perspective16.4.1 How to Design Memory16.4.2 Page Table16.4.3 Address Translation16.4.4 Multi-level Page Table16.4.5 Cute Trick for Speeding Up L1 Access16.4.5 General View of Page Table & TLB16.5 Virtual Memory: From OS's perspective (Linux)16.5.1 How to manage OS Kernel ?16.5.2 How to Organize Virtual Memory in OS's View?16.5.3 How to deal with Page Fault in OS's view?16.6 Miscellenous16.6.1 Put it Togerther16.6.2 Application: execve()16.6.3 Sharing & Private Virtual Memory16.6.4 User-Level Memory Mapping: mmap()16.6.5 Application II: fork()16.7 Replacement Policy16.7.1 Metrics16.7.2 PoliciesChapter 17. Network17.1 Foundation for Network: Hardware-level17.2 Network Usage: System & Application Level17.2.1 Socket InterfaceConnection17.2.2 Details of RIO Package17.3 Web ServerChapter 18. Concurrency18.1 Overview18.2 Thread18.2.1 Introduction to Thread18.2.2 补充:Memory Sharing & Isolation of Thread18.2.3 Semaphores: 线程间信号量18.2.4 Applications of SemaphoreProducer-Consumer ProblemReaders-Writers Problem18.2.3 补充:GDB 多线程调试和多进程调试18.3 I/O Multiplexing

Chapter 15. Link

15.1 Overview

15.2 Concepts

详细认识 link 之前,先从一个例子说起。假设现在有两个如下源文件 main.csum.c

如果上面的程序都在一个文件中,那么 GCC 将其编成二进制文件的过程很简单,就像我们在 Arch Lab 中做的一样:

  1. 先用 cpp (C preprocessor)程序将 *.c 转译为 ASCII 中间文件 *.i

  2. 再用 cc1(GNU Compiler Collection 中的 C 编译器)将 *.i 编译为 ASCII 汇编文件 *.s

  3. 最后 as (assembler,汇编器)将 *.s 汇编语言一一对应地翻译为二进制文件 *.o又被称为可重定向目标文件,Relocatable Object File),这个文件如果不依赖其他库的话(实际上即便是一个最基本的 hello world 程序都依赖 libc 库,除非你手写汇编),理论上直接能被机器执行。

但是当这个代码被分到不同文件中,那么第三步获得的 *.o 就相互依赖,无法直接被执行。GCC 就需要做另一件事情:链接,来按一定规则组装这些 *.o,最终获得机器地址无关的可执行文件(Position-Independent Executable file),如下图所示:

好,既然说链接是 “来按一定规则组装这些 *.o,最终获得可执行文件”,那么链接器所做的这个过程的细节究竟是什么呢?

我们先从宏观上看,链接的任务主要分为两个步骤:

Step 1. Symbol Resolution

这一步中,链接器大致会做几件事:

Step 2. Relocation

这一步中,链接器会:


接下来,为了更进一步详细了解上面的步骤的具体内容,我们需要一点前置知识。

1. object file(目标文件,*.o)的种类

我们知道,预处理器+编译器+汇编器最终生成的是 relocatable object file,其实将它们链接后,可执行文件还是一种 object file。但是根据是否需要链接(里面有没有没有解析、重定向的符号)、是否有主函数入口,它们可以有不同的类型:

2. object file 的格式:Executable and Linkable Format(ELF)

其实上面还有一些 section,例如字符串表 .strtab,但在这里用不到,以后再讨论

 

3. 链接器符号(linker symbol)的种类

我们发现,在前面的描述中,似乎链接器对不同的符号处理方法不同。例如:

这些不一样主要是因为语法解析、 symbol resolution 和 relocation 的要求所致。

因此链接器将它遇到的符号分为 3 类:

⚠️⚠️ 请注意:程序的局部变量不是局部符号,它甚至不是链接器符号!因为它会在编译阶段就被编译器用寄存器或栈管理起来了。 ⚠️⚠️

但是程序的 静态局部变量 也属于局部符号,因为它的语义要求它不能存储在运行时栈上,必须存放在 .bss / .data 段内,因此它也作为一个链接器的局部符号,不会被编译器用寄存器或者栈的位置替代。

事实上,编译器对于静态局部变量,会和静态全局变量一样,被分配 .data / .bss 段中,并且由编译器赋予它们符号名称

你可能会想,为什么编译器要亲自为静态局部变量取名?用本身的名字不行吗?

还真不行。考虑一种情况:在同一个文件中两个静态局部变量同名。

 

15.3 Linking Details

现在我们可以讨论链接过程的所有细节了。重新拾起之前的例子,并做一些改装:

第一步是 symbol resolution:

当然,现在每个 object file 间没有关联,因此有些外部符号的定义是不知道的(例如 main.o 还缺少 sum 符号引用的 relocation、sum.o 还缺少 printf 符号引用的 relocation),链接器会对这种情况也会做记录。为此,编译器会留出相应空间,等待链接器在 relocation 阶段填入。

链接器是如何记录上述信息的?我们知道它放在符号表(symbol table)中,这个表在那么符号表的详细结构是什么?

符号表数据结构中每个 entry 的结构如上声明。

为了使符号表的长度确定,因此符号名统一存放在 .strtab 字符串表中,使用就用偏移索引即可(详见 18.4)。

type 区分这个符号是 函数符号 还是 数据符号;

binding 区分这个符号是否为局部符号(是否是被 static 修饰的变量、函数);

reserved 会存一些其他数据(例如 visibility);

section 表示当前这个符号最终应该位于哪个段(以索引表示);在 Relocatable Object File 有几个 pseudo sections:ABS(这个符号不应该被 relocate,即这个符号与绝对地址绑定)、UNDEF(当前这个符号的定义不在这个 relocatable object file 中,也许是外部符号,所以也不知道)、COMM(当前这个符号是全局的,并且没有被初始化。)


等一等!之前说过一个问题,可能编码的人无意重复定义了一些全局符号(局部符号不会出现这个问题!因为在一个文件作用域内,编译器就能发现为错误),链接器可能会抛出错误,那么这个机制是如何进行的呢?

针对符号的重复定义,链接器遵循一些规则来处理这种情况。

在此之前还要再定义一些内容:

  • Strong symbols(强符号):指已经定义和初始化的链接器 全局符号;

  • Weak symbols(弱符号):指未初始化的链接器符号;

Rule 1:不允许有多个同名的强符号。

Rule 2:允许存在一个强符号和多个同名弱符号,但链接器将认准强符号的定义;

Rule 3:如果同时存在多个同名弱符号而没有强符号的定义,则随机选择一个弱符号作为定义。

注:在 GCC 10 及以上版本中,链接器已经默认打开选项 -fno-common ,这意味着 如果链接器遇到多个弱符号定义,但没有强符号,则直接抛出错误

如果你想要仍然使用这个特性,那么在 GCC 10 及以上的版本中需要手动添加 -fcommon 选项。

 

虽然有这样的规则,但是真的不建议你定义同名的链接器符号!除非你知道你在做什么。有时候如果多个弱符号,的数据类型不同的时候,就会出大问题(甚至有强符号也不行!),因为链接器不会做类型检查!举个例子:

这个例子中,我们知道最终链接器肯定会选择强符号定义 int x = 1;,但是编译器编译时并不知道!因为编译器先编译单个文件,所以在 p2 所在的文件中,编译器会翻译成:将 x 当作 8 bytes 的内存数据结构写入。

但实际上,最终链接器将 x 这个符号链接到了一个 4 bytes(int)大小的内存地址上,这就会导致紧接着 x 定义的 y 全局变量被改变(因为都是全局变量,并且代码中紧挨着,因此会被编译器安排在相邻的 .data 段)!这是个非常隐晦的越界读写错误,极难以调试!

 

这些教训总结下来就是,尽量少用全局变量。或者如果要使用,就自己模块用的话及时修饰 static,给其他模块用的话尽量在定义同时初始化(让链接器能判断到错误)并且使用方要用 extern 声明

C 毕竟也不是脚本语言,限制变量的作用域范围总是件好事,不会出现上面的错误。


好,再回到本节开始的例子 😂

我们知道,main.o 中包含了 main 全局符号的定义、array 全局符号的定义,sum.o 包含了 sum 全局符号的定义、times 局部符号的定义。

main.o 还缺少 sum 符号引用的 relocation、sum.o 还缺少 printf 符号引用的 relocation。

现在链接器要进行下一步动作 —— 第二步是 relocation。

链接器会先将各个文件合并到一个二进制文件中,这个合并只是按照特定顺序来组合各个目标文件(包括你要编译的目标文件、程序的系统引导代码、之前记录的符号数据等等),例如:

这个时候其实各个模块间还是 relocatable 的(相对地址),这意味着操作系统仍然无法知道应该将这个程序载入到内存具体的什么位置执行。那么这个时候链接器下一步动作肯定是计算并且向未知地址的位置上填入地址(例如 main 函数、数据地址的位置)。但是这个位置是怎么得到的呢?

众所周知,在编译阶段,各个 object file 还没有相互联系的时候,外部符号,甚至同一个模块中针对数据的符号,编译器都是没办法确定符号的最终地址的。

因此编译器秉持着:能用相对地址就用相对地址;不能用相对地址,就为数据留出空间,并添加链接器指令,告诉链接器此处应该 relocation 后填上 的原则。例如对于之前的 main.o,汇编器输出结果对应的汇编代码是:

因此在 main.o 中,我们注意到几个点:

注:其中 R_X86_64_PC32 表示需要 linker 填写的是 32 bits 的数据,内容是 array - 0x4 / sum - 0x4,至于为什么减 0x4,是因为现在使用的是 PC(相对于 %rip)的地址,当 PC 读完该语句的时候,PC 应该在这个数据后面 4 bytes。

以上的 relocation entry 都由汇编器生成,记录在 relocation table 中。链接器只要读就行。

而对于 sum.o,汇编器输出结果如下:

对于 sum.o 我们还注意到了一些点:

 

于是,我们可以总结出链接器对于符号地址处理的原则:

以上的规则,即便程序使用静态、动态链接库,也是遵守的。只不过涉及动态链接库的时候,有些链接器符号的定义、引用即便是 linker 结束也不会填写,而是留给动态链接器(dynamic linker)来填写。以后涉及动态链接库再详细叙述。

 

至此,链接器的工作完成了。但是有个问题,程序还需要加载到内存中执行,那么放在 executable file 中的各个段是如何加载到内存里的?要 copy 多少、抛弃多少?如何给这些 链接器符号定义 一个最终的绝对地址?

15.4 Post-linking: Load Executable File into Memory (Load-time)

理论上,编译器、链接器已经帮我们用相对地址处理好了绝大多数的符号引用,只需要 OS 在 load-time 将程序载入内存时,指定一些关键符号定义在虚拟内存中的绝对地址就行。

那么 OS 是如何将 ELF 的 Executable Object File 加载到内存中去的?

要理解这一点,我们还要知道从 多个 ELF Relocatable Object File 链接为 一个 ELF Executable Object File 后的文件结构,如下图:

这个时候,注意到几个变更点:

然后,OS 将 Executable Object File 加载到 Memory 的过程就比较方便了:


最终,我们以 main 函数中一句调用 sum 函数为例,看看它在链接前后、加载前后的变化:

在编译汇编结束、链接前(main.o):

在链接结束、装载运行前(program.d):

在装载运行时(gdb dissasembly):

 

考虑一种情况,当我们想使用外部库来链接的时候,总不能要开发人员在编译工具上包含源文件再编译吧?那这就没法发挥链接的优势了。

人们在早期使用一种 静态链接 的技术,将外部库打包成一个 archive file(*.a,在 Windows MSVC 上是 *.lib),方便人们在编译好自己的源代码后,直接链接使用,如下图所示:

静态链接库的本质上,就是将编译好的 *.o relocatable object files 以一种特定的格式打包起来,头部含有每个 relocatable object file 的信息(例如名称、字节偏移量),在链接时,链接器看你所引入的外部函数在哪个 relocatable object file 中,然后链接特定的 *.o 即完成链接过程。

也就是说,静态链接库只支持 link-time linkage,不支持 load-time 和 run-time loading

注:GCC 打包所使用的套件是 ar,一般你调用 gcc 时如果要打包为静态链接库,那么它就会自动派上用场。

你还可以用 ar -t <archive file name> 来查看一个 archive 中打包了哪些 relocatable object file;

它的缺点显而易见,如果你想更新静态链接库,那么不仅要重新编译这个静态链接库,其他所有用到这个静态链接库的其他程序也全部需要重新编译(因为只是用了 archive 里面的 relocatable object file)。

重要补充:那么,如何告诉 gcc 的链接器,指定链接某些 static libraries 呢?

事实上,你可以使用 gcc-L<libDir> 指定链接器要首先额外查找链接库的目录(相当于 CMaketarget_link_directories),也可以使用 -l<libName> 指定特定的链接库。

(另注:libName 不是文件名,而是库名,如果你没有修改库的默认前后缀规则的话,大部分的库前缀是 lib,静态链接库的后缀是 *.a/lib,例如 libmime.a 的库名是 mime

但是,命令行的顺序很重要!原因是 gcc 命令行的搜索算法:它如果遇到一个程序中有 undefined references,才会记录到运行时表中,此后遇到一个 -l 包含了一个库,就更新一下这个列表。当扫描完成后,如果还有 undefined references,那么抛出错误。

因此如果你把主程序放在命令行参数后面、要链接的库放在前面,就会抛出 undefined references 错误,你还以为链接的没问题……

 

更新一点才设计出的打包和链接的方法是使用动态链接库。这种方法规避了 static libraries 中的一些缺点(例如:更新库的版本的话所有使用的程序就要重新编译、每一个使用了库的程序都会在 executable 中保留一份 *.o 占用空间……);

与静态链接库不同,共享库(动态链接库)既允许 link-time 链接库(!!但不是 relocation 到 executable file 中 !!具体怎么做下面讨论),又允许 load-time 和 run-time 才加载库

优点是,不需要占用主程序大小,而且更新动态链接库只要不改 API,就无需重编译使用它的文件。

那么前面有个遗留的问题就出现了。既然你能在 load-time,甚至 run-time 才加载这个库,那么链接器怎么 relocation 你用到的外部符号?

答案是,普通链接器(如 gcc 中的 ld)会在使用到这些存在于动态链接库的引用 仍然留空,交给 dynamic linker(动态链接器,在 Linux 系统上依靠 ld-linux.so)来运行时 / 加载时填写。

那么如果是 link-time 链接库,链接器因为不会提取当中的 *.o,所以链接器对动态链接库的做法和对静态链接库的处理方法肯定不一样啊!

因此,链接了动态链接库的程序,即便是在链接完成后形成了 executable file,它还是 partially linked 的状态,有些地方仍然留空,并且记录在 section header table 中(回忆 ELF 的结构),让 load-time 操作系统、run-time 动态链接器来处理这个填写地址的问题。

补充:这下你能明白,为什么别人给你一个可以开发的 SDK 库的时候,不仅要给你 *.a/so,还有给你头文件了吧?

因为你需要外部符号声明来让链接器知道你要用、你能用哪些链接库内的函数(提示:#include 只是在 C preprocessor 中简单的将文件内容包含进来,做简单的插入扩展和处理)。

如下图所示,这是个 load-time 加载动态链接库的例子:

如果你在程序中使用了 dlopen,那么就是 run-time 加载动态链接库了。

这样比较抽象,我们举两个例子。

假设我们用来编译动态链接库的源文件是这个:

编译指令:

得到动态链接库后,接下来,首先是 load-time 使用动态链接库的例子:

这个例子应该这么编译:

这种方法一定要把 testShared-sum.so 放到 /etc/ld.so.conf 指定的目录下,或者你可以向 /etc/ld.so.conf 或 向 LD_LIBRARY_PATH 环境变量加入这个动态链接库,不然操作系统在加载时找不到它

另一个是 run-time 使用动态链接库的例子:

这个例子编译方法类似:


以第一个例子 load-time 加载动态链接库为例,我们将链接完成的 executable file 反汇编,这个时候会发现:

即便是 executable file,它在对动态链接库中的外部符号引用的时候,你会发现 sumprintf 一样,它们不在 .text 段,而是都放在 .plt 段,并且符号后面有 @plt 标识,如下:

main 函数中的 sum 和之前处理外部符号的规则一样(indirect location 相对位置),不过指向的是 .plt 段的函数:

 

15.6 Library Interpositioning

15.6.1 Overview

在链接库的技术中,有一种强大的技术叫 “库打桩”,它可以让开发者 截获对库的任何函数调用,在此基础上进行其他操作。

你可以理解成 JavaPythonTypeScript 中的装饰器。

在 C 中,库打桩的应用有:debugging(gdb 就采用了这种技术)、profiling(某个函数调用次数或时长)、检查内存泄漏和追踪地址调用等等。

(以及软件优(po)化(jie)方面

15.6.2 Usage: Compile-time Interpositioning

如果是编译时库打桩,那么使用方法很朴实,就是定义一个包装函数 wrapper,然后用宏替换原来的函数,不过记得名字写的和要调用的库头文件名字一致。

最后记得指定 -I 参数(相当于 CMaketarget_include_directories),这样标准库就会被自己写的 “包装函数库” 覆盖了。

 

如果情况不允许在编译时库打桩(即不能重新编译原程序,但可以重新链接原程序),还可以链接时库打桩。

这时候需要用到 GCC 的一些链接选项。例如如果想包装 malloc/free 函数,那么在源文件中这么做:

在最后链接时,使用链接器选项 -Wl

其中 , 会被 GCC 自动解析为同一个参数中的空格。

这个 -Wl,--wrap,malloc 的意思是,将链接符号表中所有 malloc 符号全部解析为 __wrap_malloc,原 malloc 符号会被解析为 __real_malloc(这样不会冲突或者导致符号消失,free 同理)。

 

15.6.4 Usage: Load / Run-time Interpositioning

甚至也可以在加载时、运行时库打桩。

这个时候,你需要生成的自制链接库中需要包含一个与想要替换的库函数的签名相同的函数,但是在你的函数中,使用 dlsym(RTLD_NEXT, "<funcName>"),表示在目前栈上的动态链接库区域,查找下一个叫 funcName 的函数的函数地址(也就是被你覆盖的函数地址),进行相应操作即可(无需 dlclose)。

最后,在加载前,修改 LD_PRELOAD 环境变量(这样才能让操作系统加载时找到,并且先于其他库加载,达到运行时覆盖的目的),表示先加载的库,指定为你打桩的库路径就大功告成了。

 

Chapter 16. Virtual Memory

16.1 Design a Memory Allocator

16.1.1 Prerequisites & Issues

以上函数调用返回的一定是 double-word alignment(8 bytes 对齐)的地址。因为要适应所有数据结构的内存创建操作。而结构体又需要对齐(结构体对齐的原因见前),最大对齐要求是 8 bytes,所以 malloc 也会在分配时对齐 8 bytes;


内存分配器的要求:

内存分配器的性能目标(对于一串分配指令 R0,R1,,Rn):

两种碎片:Internal / External Fragmentation

内存分配器实现细节阐明:

16.1.2 Implementation

按照上面的问题约定,我们可以设计出如下的内存分配器管理的堆列表结构,如下图所示:

注意到,对于一个堆列表结构而言:

 

16.2 Improve Allocator's Performance

16.2.1 Method 1: Explicit Free Lists

 

16.3 Garbage Collection

内存管理器如何判断已分配的部分是应该被回收的(以后不会被用到)?

经典的方法是判断这块内存的 reachable(可达性)。

那么怎么判断这块内存的可达性?在 C/C++ 中可以通过当前程序中是否有指向这块内存的指针判断。但是由于 C/C++ 允许强制类型转换,因此 C/C++ 如果要有 GC,就是保守(conservative)的,把所有的整型都看作指针。

 

16.4 Virtual Memory: From Hardware's Perspective

分时复用 CPU 资源:进程轮流使用 CPU 核心(Context Switch,高频切换)、由 OS 决定;

进程一章介绍了抽象:Logical Control Flow。每个进程都感觉自己独占了计算机资源,不用担心寄存器、CPU、内存等的重要数据被更改;

之前从 Exception Control Flow 的机制介绍了进程间的 CPU、寄存器等 CPU Context 的隔离方法。

接下来的几个小节会介绍一种内存的隔离机制:虚拟内存,来保证进程间内存数据隔离,确保安全性、方便性。

16.4.1 How to Design Memory

为了隔离各个进程的内存状态,那么应该怎么设计?有一些思路:

上面两种方法都不行。我们吸取教训,还是先总结一些必须要遵守的条件:

也就是说,操作系统为了将离散、有限的资源抽象为连续、近乎无限的资源给应用程序使用,并将各个程序隔离开,就需要采用一种措施,这个措施就是 虚拟内存抽象

虚拟内存的抽象是操作系统和硬件配合完成的。操作系统维护了一个从物理内存(简称 PM)到虚拟内存(简称 VM)的映射(还记得 Memory Hierarchy 最后介绍的 TLB cache 吗?就是为这个准备的),将主存(main memory)上离散的空间映射到连续的虚拟内存空间上

这个 “映射” 存在处理器芯片的 MMU(Memory Managing Unit,硬件层面) 中,这个映射的数据结构被称为 页表。页表由 OS(软件层面)来设置和管理。

一个页是固定大小 4K(212)bytes;

为了充分利用主存(物理内存)的空间,页表将 PM 和 VM 切分为很小的块(大家能懂往瓶子里装石子、沙子、水的道理吧?),这些很小的数据块被称为 ,而页表就是将这些虚拟内存页映射到物理内存页,如下:

那么这样能拥有如下优点:

每个应用程序所能看到的就是完整的虚拟内存,其中有独立的运行时栈。运行在 CPU 上的应用程序也直接使用虚拟地址,因为 VMA 出 CPU 前会经过 MMU 转换为物理地址,再向主存硬件请求。

但是!为了节约空间,操作系统不会一次性将全部的虚拟内存(地址 0 ~ FFFFFFFF)全部用页表映射上物理内存(一来大小不够,二来浪费资源),只是先为虚拟内存的必要部分(例如程序栈的 data 段、code 段、shared libraries 段、stack 段等)分配物理内存、记录在页表上。其余部分被称为未被分配的段

未被分配的段又分为 Uncached area 和 Unallocated area;

Uncached area 的出现可以分成两种情况:

Unallocated area 就是应用程序没申请、OS 也没挂载到物理内存的虚拟内存区域。

16.4.2 Page Table

那么页表的结构是什么样子的?

注意到,每个页会被编号(在虚拟内存上的页被称为虚拟页,即 VP / Virtual Page,对应物理内存上的页被称为物理页,即 PP / Physical Page)。所以对 64 位机器(假设真的有 64 位物理内存)而言,这个编号大小是 64 - 12 = 52 位(减去 12 是页的大小 212 bytes);

而页表只是将 VPN(Virtual Page Number)翻译到 PPN(Physical Page Number),因为是一页一页地映射。

MMU 在翻译虚拟地址时,只会看 valid 位,如果有效就直接翻译,无效则抛出 ECF Page Fault;

由于 OS 对于 uncached area 的处理是放到磁盘里(page table entry 中记录磁盘地址),并且 valid = 0,这就是为什么应用程序访问 uncached area 和 unallocated area(这里的 page table entry 是 NULL,不指向磁盘地址)都会导致 page fault;

在 CMU 的书上一开始的部分,都是这么描述的:

  • 如果是 uncached area,entry 一定指向一个磁盘地址(即便 OS 要懒加载,也至少要填磁盘地址);

  • 如果是 unallocated area,entry 直接是 null;

  • 如果是 allocated area,entry 一定填写了一个物理页号;

这种解释非常简单,也通常很有效。

但实际上现代的 OS 并不是这么做的。大部分的情况下,OS 应该是这么处理 page table 的:

所有的页都分为 文件背景页(file-backed page)和 匿名页(anonymous page)

比如进程的 .code/.datammap 映射的文件都是 文件背景的,而进程的堆、栈都是不与文件相对应的、就属于匿名页。

而 OS 对于虚拟内存到物理内存的映射是懒加载(demand paging)的:

  • 假设当前机器的物理空间充足(足够多空闲的物理页)时,

    • 对于匿名页,例如如果程序想要在栈 / 堆上分配一段 比较大的、不需要初始化的 虚拟内存页 到物理内存页,那么 OS 不会立即将这段虚拟页映射到物理页,而是几乎不作任何操作。只是在内核中标记这段内存是应用程序申请的;等应用程序真正访问的时候,再触发 page fault,OS 再动态地为一个虚拟页分配一个物理页。

      怎么标记的?通过 VMA 数据结构。后面详细介绍。

      例如,我们如果在内存中申请了一个超大数组,占用了 8 个虚拟页,OS 不会立即分配 8 个物理页,而是什么都不做(也不填 entry),只是知道已经给程序分配了这段虚拟页(匿名页)。等到真正访问其中一个虚拟页时,会触发 page fault,此时 OS 才在将这个虚拟页挂载上物理页;

      再例如,我们使用 malloc 在堆上申请了相当大的一段空间,OS 也不会填页表。这种策略被称为 demand paging

    • 对于文件背景页,例如如果程序加载时,想从可执行程序加载到内存中。我们以 .data 段为例,假设有一大段数组空间被分配了数据,OS 加载时也不会立即将这个 ELF 文件中的数据需要的空间分配物理页、并全部复制到虚拟内存中(都是 demand paging 吗?),等访问这块空间、硬件抛出 page fault 时,再分配物理页、将磁盘的该部分数据复制到对应虚拟页中。

      再例如,假设程序执行中,执行了 mmap 将很大的文件(1G+)动态映射到虚拟内存中,OS 甚至不会向页表中填入磁盘地址,因为虚拟内存页实在太多了。OS 会在内核其他地方为这个地方标记一下,等访问到这个区间的时候再挂载物理页(挂载到 page cache 上,因为 OS 会在内核中进行 page cache,cache 在一块物理内存中)。

  • 假设当前机器的物理空间不足(没有足够多空闲的物理页)时,类似缓存的 evict,

    • 对于匿名页,磁盘上没有对应的文件,只能常驻内存。但是如果分了 swap 分区,那么 OS 可以将不活跃的页交换到硬盘中(swap分区可以当做针对匿名页伪造的文件背景),缓解内存紧张(称为 swap-out);

    • 对于文件背景页,直接将这个不活跃虚拟页对于的物理页内容重新存到这个磁盘文件中,将 page table entry 改成磁盘地址(称为 page-out),这样就能空出物理页给其他虚拟页使用;

但是前者能被 OS 恢复(swap in,OS 从页表 valid = 0 entry 存的磁盘地址取出页的内容,随便放到一个空闲物理页上,再在页表上建立 valid = 1 的 entry),而后者 OS 认为应用程序自己有错,会向应用发送 SIGSEGV(软件层面的信号)停止程序。

可以看到,Recoverable Page Fault 非常耗费时间(从磁盘搬运),但是由于程序的 temporal locality,程序不会经常使用 uncached area 的内容,所以就能维持性能;

但是,如果当前所有程序的虚拟内存总的 working set 大小大于物理内存的大小,就会频繁存在 Recoverable Page Fault(也就是从 swap 磁盘空间加载、存放的交换过程),则会导致 thrashing(性能抖动);

此外,还需要补充两点。

一点是,除了上面叙述的好处以外,这种虚拟页和物理页的抽象还有几个好处:

另一点是,一个 page table 的 entry 中大致内容是什么:

这不是全部的内容。详细内容在下下节讨论。

 

16.4.3 Address Translation

地址翻译完全由硬件和 OS 配合完成。

由于虚拟内存是按页映射的,并且地址翻译时都是整个页进行翻译(即翻译 Virtual Page Number 到 Physical Page Number),而页内的偏移量是相同的(VPO = PPO,Virtual Page Offset 和 Physical Page Offset);

所以翻译地址的方法如下:

于是 CPU 只需要将虚拟地址的前面一部分进行翻译,后面的一部分进行复制。

这个地址翻译规则放在页表中(OS 维护),而 virtual page number 会作为页表的索引,利用存放在 page table base register(PTBR,x86 放在 CR3 寄存器,这不可能放虚拟地址,因为它要用来翻译虚拟地址,显然也是 OS 维护);

页表在内存中保存(进程页表位于一个进程的 PCB 块中),page table base register 则存放当前进程的 page table 的起始位置。

所以,目前翻译过程如下:

当 CPU 根据指令请求一个虚拟地址,会被先传到 MMU 中,MMU 找到 virtual page number,并且根据 page table base register 计算出 page table entry 的物理地址,向物理内存读 page table entry,并检查 valid 和 permission bits:

现在举个例子,以一个小的地址为例,如果一台机器:

那么可以推出,它的地址 layout 如下:

 

假设有虚拟地址 0x3D40011 1101 0100),则 PO = 010100VPN = 1111 = 0xFPPN(page hit) = 0x0D = 1101,翻译出的物理地址为:PMA = 11 0101 0100 = 0x354


我们发现一个问题,在上面的步骤中, MMU 无论是取 Page Table Entry,还是按物理地址向主存通知要取指定位置的数据给 CPU,都是直接与主存交互的。这样的效率可能会很慢,也不符合实际情况。

真实情况是,我们应该考虑到在前几章节介绍的缓存,因为 Memory Hierarchy,CPU 对主存的访问间还有一层 Cache(高速缓存器,通常是 L1、L2、L3 Cache),于是更完整的步骤应该是这样的:

  1. 当 CPU 根据指令请求一个虚拟地址,会被先传到 MMU 中,MMU 找到 virtual page number,并且根据 page table base register 计算出 page table entry 的物理地址PTEA),向 cache memory 请求 PTE(page table entry)的具体内容(其中可能发生 cache hit/miss,但最终得到 PTE),MMU 检查 valid 和 permission bits:

    • 如果有效,则直接取 PTE 中 Physical Page Number 翻译完成;

    • 如果无效,则抛出 Page Fault,利用 exception base register 进入 OS 的 handler 进行处理;

      • 如果是 recoverable page fault(包括 swap-in/page-in),则会加载要求的 Page 到内存中,即修改 Page Table Entry 的 valid 位 并 挂载对应的物理页。必要时选取 victim page 存回磁盘;

  2. 翻译完成后,MMU 将得到的物理地址通知 cache memory 传给 CPU;

Tips.

我们发现,上面的步骤中,传给 Cache 的 PTEA 是物理地址。现在主流的机器的 cache 也都是使用物理地址来做缓存。

如果使用虚拟地址索引 Cache,那么意味着 Cache 是和进程相关的(不同进程虚拟地址对应的内容不一样),所以每次 context switch 时,cache 都需要清空。

所以如果使用虚拟内存地址索引的 cache 的 context switch 代价很大。这也是为什么现在的机器大多是物理地址索引 cache。

但是用物理地址索引的 cache 也有问题。就像上面的步骤。

如果采用物理地址索引,则相对于使用虚拟地址索引的 cache,后者在 cache hit 的情况下甚至不需要进行地址翻译,降低了操作的 latency。


在看上面的步骤,我们发现页表和普通内存数据的 cache 都共用一个 cache。而地址翻译作为一个很基本的步骤,能不能像 “指令和数据区分 cache(L1 中有 data cache 和 instruction cache,见缓存一章)” 一样,我们为页表单独设计一个 cache?

实际上,主流的机器是会做这个事情的!因为研究表明单独为 page table 设置 cache,其 cache miss 的概率几乎没有!于是人们在 MMU 内部设置了一个专门用于缓存 Page Table Entry 的硬件 TLB(Translation Lookaside Buffer)。

切记:TLB 存放的是从 VPN 到 PPN 的翻译结果(VPN 对应的 PTE)!

缓存到 TLB 的实际上是整个 PTE,包括了权限位信息。

我们知道,请求 cache 的时候,是使用请求的地址来解析缓存内容的位置的。根据缓存设计的规格参数,把请求的地址切成几段再对缓存查找。这个方法无论是对 TLB 还是普通的 Cache 都是一样的。

这样的话,翻译的总体步骤就又改变了:

  1. 当 CPU 根据指令请求一个虚拟地址,会被先传到 MMU 中,MMU 找到其中的 Virtual Page Number,直接分成 TLB 的 set index、tag(注意设计 block 正好一块 PTE 因此不存在 block offset)请求 TLB;

    • 如果 TLB hit,则直接得到 PTE

    • 如果 TLB miss,则还要将 VPN 借助 page table base register 计算得到 PTEA 物理地址,向 Cache 请求 PTE;找到后直接放入 TLB;

      PTEA 的计算很简单,只需要取得 PTBR 中的 Page Table 起始地址(肯定是物理地址),加上 VPN x Page Table Entry Size (64 位下 8 bytes) 就能得到 PTEA 物理地址。

    然后无论是 TLB hit 还是 TLB miss,MMU 都会将得到的 PTE 检查 valid 和 permission bits,得到 Physical Page Number;如果也没找到,说明 Page Fault,MMU 抛出 Page Fault 并且进入 OS Handler 进行相关处理。否则继续下一步;

  2. MMU 将 PPN 与 Page Offset 拼接得到物理地址,通知 cache memory 传给 CPU;

现在同样以一个小地址的机器为例:

当前的 TLB 状态如下:

当前 L1 Cache 状态如下:

那么可以推得:

虚拟地址 0x3D40011 1101 0100):TLB set index = 11 = 0x3TLB tag = 0011 = 0x03

于是 TLB hit:PPN = 0x0D = 1101PA = 1101010100 = 0x354

Cache set index = 0101 = 0x5Cache block offset = 00 = 0x0Cache tag = 001101 = 0x0D

因此 cache hit,取出的 1 byte data 是 0x36

 

16.4.4 Multi-level Page Table

现在考虑另一个问题。我们知道页表放在内存中,那按照上面的设计,一个页表的大小是多少?

以 x86-64 机器为例,一个页表要对应 48-byte 的地址空间,由于一个页表 4K(4096 bytes),每个 entry 8 bytes,那么我们需要:248/212=236 个页表 entry,页表总大小 236×8 bytes=512 GB,这是一个应用程序所不能接受的!

如果我只要用几个 KB 的内存,还要花 512 GB 建立页表?

所以真实的页表肯定不能像前面几节一样建立。

但在一个页表中,我们又不能用多少开多少页表项,因为 MMU 对页表的索引是数组索引式的。

这时候,人们就引入了多级页表的设计(硬件规定),有效降低了页表的内存占用:

举个例子,对于一个 64 位的虚拟地址 0xFFFF FF80 8060 4FFF,它的四层页表索引分别是什么?

按照上面的拆分方法,各级页表索引是:

L1 0x1FFL2 0x2L3 0x3L4 0x4


另外一个值得讨论的问题是,四级页表的 entry 究竟存放什么数据?

我们讨论单级页表时,说过一个 PTE 大小 8 bytes,不仅用于存放 Page Offset(4K -> 12 bits),再除去开头的硬件规定区域限制的 16 bits 信息,剩下的 52 - 16 = 36 bits 的数据可以用来可以用来存放足够多的数据。

对于第 1~3 级页表而言,它们的结果稍稍有些不同。我们就介绍常用的一些 bit:

对于第 4 级页表而言,它和 1~3 级页表很类似。

16.4.5 Cute Trick for Speeding Up L1 Access

16.4.5 General View of Page Table & TLB

实际的一个处理器中 TLB 在哪里、究竟有哪些类型,我们可以从下图看到:

而虚拟地址翻译的总体流程如下图所示(注意,TLB miss 后取页表的过程也是向 cache 申请的):

 

16.5 Virtual Memory: From OS's perspective (Linux)

16.5.1 How to manage OS Kernel ?

再次强调一下,CSAPP 3e 有一点是错误的。

实际上,一个操作系统上的虚拟内存中的 OS Kernel 使用 Global Page,并且地址映射是完全相同的(这是少有的各个进程共享一些物理页的情况)!

你可能会好奇,为什么即便有 ASLR,OS Kernel 的空间也能共享物理页呢?

首先,每个进程有一个独立的页表,也就是说有独立的第一级页表(CR3 寄存器值不同)。其实在不同的进程中,由于 OS Kernel 占用的是高地址的部分,因此它通常从第一级页表的最后一项开始映射。

于是有一个很好的映射 OS Kernel 的方式:将每个进程的第一级页表的后面部分的 entry 写入相同的地址,这样所有进程的 page table 的高地址翻译到的物理内存是共享的 kernel 内存。

所以,这样设计可以在 context switch 时翻译规则不改变,这样也就无需进行 TLB 的刷新,解释了前面章节为什么 OS Kernel 可以使用 Global Page,进行多进程共享物理页的问题。

16.5.2 How to Organize Virtual Memory in OS's View?

我们知道,虚拟内存和物理内存映射的机制是 OS 和硬件共同完成的。

但是我们到目前为止,了解的都是硬件层面对于 Virtual Memory 和 Physical Memory 关联的抽象,解释了比 OS 更底层的处理虚拟内存和物理内存映射的机制。

现在,以 Linux OS 为例。我们想从 OS 的角度(更高级的层级)看一看这个地址映射的过程是如何进行的。

还记得很早之前对 虚拟内存和物理内存映射机制的 Overview 一节,我们说:

…… 那么 OS 不会立即将这段虚拟页映射到物理页,而是几乎不作任何操作。只是在内核中标记这段内存是应用程序申请的;等应用程序真正访问的时候,再触发 page fault,OS 再动态地为一个虚拟页分配一个物理页。

你知道这里 “在内核中标记这段内存是应用程序申请的”,这个标记的机制是什么吗?

或者说,操作系统怎么记住哪些是一个进程的 uncached area(应用程序自己要求分配,但是还没有真正在物理页上挂载),哪些是 unallocated area?

这就是内核中的 VMA Struct(Virtual Memory Area 结构体) 的作用了。

我们知道,目前几乎所有主流的 OS,它在管理各个进程的时候,使用的几乎都是 PCB(进程控制块)数据结构。其中包括了超级多的关于这个进程的信息,想要进一步了解的可以跳到 18.2.2 节 一探究竟。

在 Linux 中,PCB 这个数据结构是由一个 C 结构体实现的,它的名字叫 task_struct。它的字段很多,不过我们这里只需要关注 mm 成员字段。

task_struct::mm 是一个指向另一个结构体 mm_struct 的指针,用来管理进程的内存分配的相关信息。

mm_struct 中有两个重要的字段,能够解释、呈现我们目前学到现在虚拟内存和物理内存映射关系在 OS 视角下的样貌。

结构体的第一个字段是 pgd,大家回忆一下四级页表的名称 —— 没错,是 Page Global Directory(一级页表),也就是说这里存放的是这个进程的第一级页表的基地址,在 Context Switch 时会被 Kernel 填入 CR3(Page Table Base Register)中。

中间还有一个字段,是 mmap 指针,它指向了本节的主角:vm_area_structVMA Struct)。

这个 VMA Struct 在 Linux 中的组织形式是红黑树,在某些 case 下有良好性能。但是这里为了解释方便,我们以链表为例说明。

它是怎么表示 OS 为一个进程究竟分配了哪些空间的呢(同时包括 uncached area 和 allocated area)?

其中有一些字段 vm_endvm_start 标识了分配空间的起止地址,vm_prot 标识了这段空间的读写权限。

vm_flag 标识了这段空间是否是共享的内存空间(这个 “共享” 的含义和 global page 有些区别,之后详细讨论)。

如下图所示:

16.5.3 How to deal with Page Fault in OS's view?

接下来我们在来看看,OS 层面是如何处理硬件抛出的 Page Fault 的异常,并进行相应的识别和处理的。

首先,在 CPU 执行一段代码时,遇到读取/写入的地址就是虚拟地址,它会传给 MMU 解析出物理地址并进行请求。

MMU 如果在解析时发现 P bit 无效,或者 permission bit 不符合行为,或者其他的 attribute 冲突,都会抛出一个异常,按照 Exception Table Base Register 的地址索引找到 OS Kernel 中处理 Page Fault 的 Handler,然后传递 PC 就是传递了控制流。

此外,MMU 会将具体翻译出错的虚拟地址,传递到 CR2(Control Register 2)中,以供 OS 在错误处理时检查出错原因。

在 Page Fault Handler 中,OS 会首先检查虚拟地址是不是 unallocated area。它通过在 VMA Struct 中查找这个地址是否落在这些结构体指定分配的区域内。

 

16.6 Miscellenous

16.6.1 Put it Togerther

现在把这个过程连起来想一想。假设 CPU 要执行一条汇编指令:

这条指令可能发生几次 Page Fault?

实际上可能发生 0 次、1 次、2 次都有可能。

那么如果指令是这样的呢:

这个时候 jmp 指令可能出现几次 Page Fault?

我们注意到这个时候 movq 指令位于页表对齐的地址,因此只要能执行到 jmp,那么 jmp 一定不会因为取指令而发送 page fault;

还要注意的一点是,即便 %rbx 存放的是无效地址,也不一定会导致 jmp 指令的错误。假设在执行 jmp 时,%rbx 中存放的是 0,那么访问非法地址也是在 jmp 结束后的下一条取指令的时候发生,不算 jmp 本身的 page fault;

所以这条指令一定不会 page fault 吗?并不是!

考虑一个问题,因为 CPU 会进行 context switch(分时复用),导致各个指令出现 interleaving。在 context switch 的时候,TLB 会被清空,而且同时可能会发生 page evict。所以在 jmp 指令发生 page fault 是有可能的事情。

16.6.2 Application: execve()

现在我们回忆下多进程一章节中介绍的系统调用 execve,它是用来在当前进程中执行指定的程序,通常和 fork 一起使用。

那么 execve 底层要做的事情,很大一部分就涉及到了本章讨论的 OS 对虚拟内存和物理内存的管理和映射。在一个程序进程中执行 execve,会做以下的事情:

16.6.3 Sharing & Private Virtual Memory

你还记得在 vm_area_struct 中有个字段叫 vm_flags 吗?我们说这个字段用来标识这块内存区域是共享的(shared)还是私有的(private);

注意,这个 “共享” 不是我们说过的 global page。

我们考虑一个情况假设一个 Linux 可执行文件在两个 shell 中执行了 2 次,现在 OS 的 shell 会为这个可执行文件开辟两个进程。

这个时候,我们发现 OS 要映射诸如 .text/.data 之类的 file-backed 的虚拟页的时候,可能会遵循 demanding page 的策略,不立即分配一个物理页并写在页表里;而是在内核中向这个进程的 VMA 结构体集合中加入几段标识分配的新结构体,然后什么都不做。

这个结构体比较复杂,除了之前提到的几个字段,它可能还会记录它对应的 fd,如果 fd == -1 就代表了 anonymous page;

这个时候,我们想,它们既然对应同一段磁盘上的文件,那么假设其中一个进程对 .data 段更新了数据,为什么另一个进程不会受到影响?或者说,为什么磁盘上的可执行文件没有被更新

这就归功于分配的这页是 private(私有属性)的,这个信息是在 OS 分配这段虚拟内存时就决定,并且记录在 VMA 结构体的 vm_flags 中。

页的私有属性决定了,这个页即便是 file-backed 并且有可写的属性,一个进程书写的信息也不会真正地更新到文件中。

再考虑一个比较迷惑的点:程序的 shared libraries 段对应的页的属性其实是 private 的。因为 shared libraries 中可能存在 .data 等可写的部分(由一些全局变量产生,例如 libc 中的 errno)。我们在运行不同程序的时候,显然不希望其他进程能更改自己的 shared libraries 中的一些值。

所以 “共享库” 实际上说的是共享的磁盘上同一个 file-backed 的位置,和不同进程运行同一个可执行文件是一个道理。

另一种相反的情况是,程序使用 mmap 主动读入、更改磁盘上的文件,这个时候 OS 分配的空间就可能不是私有的了,因为数据会更新到磁盘中,详细内容我们在 16.6.5 中讨论。

16.6.4 User-Level Memory Mapping: mmap()

其实,除去 OS 自动会进行的分配工作、除了 malloc 扩展堆的大小时,间接让 OS 为程序在 VMA 上分配一段虚拟内存以外,还有一种方法是程序可以主动让 OS 为自己分配一片虚拟内存。这就是系统调用 mmap()

所以说,mmap 的实质就是在 VMA 结构体中加入了一项分配记录,允许应用程序之后访问这段虚拟内存,这和 malloc 很像,或者不如这么说:

malloc 在分配小空间时底层调用 brk 增长堆,分配大空间时底层调用 mmap 来直接向 OS 的 VMA 添加记录,避免直接写页表,实现了 demand paging,提升了程序性能

这就解释了早在 Chapter 7 中,我们发现 Linux memory layout 在 malloc 分配小空间时,从堆的地方增长,而分配大数据时,是从 shared libraries 的下方开始反向增长。因为底层使用的系统调用不一样!

你看,mmap 有这么多好处,它具体被用在哪些地方呢?

 

 

16.6.5 Application II: fork()

现在我们有了基本的虚拟内存的知识,再回来看一下 fork() 的具体行为。

fork 的作用是 clone 一个新的进程,那么它会 clone 原来进程的哪些内容?回忆一下我们已经知道的:

那么父子进程的虚拟内存的情况,fork 是如何处理的?

fork直接将父进程的 VMA Struct(回忆一下,虚拟内存分配信息)、mm_struct(页表信息)、page tables(所有页表)全部完整复制给子进程

这里的 “完整复制” 是 deep copy 的意思。父子进程的 mm_struct 和 page tables 本质上已经位于不同的物理内存空间了!

你可能会好奇,这样不会导致父子进程完全共享虚拟内存空间吗?怎么保证隔离性?

为了理解这个问题,我们需要更详细地理解内存区域 private / shared 的深层含义和行为;

所以 fork 采用了一种技术:Copy-On-Write(COW);

刚执行 fork 时,父子进程的虚拟内存的映射情况确实完全一样,但是 fork 会做一些额外的工作:

父子进程对自己虚拟地址进行读操作时,什么事都不会发生,因为尽管这个时候它们共享了虚拟内存的映射,但是因为没有修改,所有也不需要变化。

只有当子进程对虚拟地址进行写操作时,MMU 发现读写权限错误,通过 CR2 和 ECF 通知 OS,OS 在 Page Fault handler 中检查到读写权限问题,但是!它又看到了 COW 被标记,因此它知道现在的虚拟块可能是可写的,只不过因为 COW 的机制延迟分配物理页,暂时借用了父进程的物理页。

于是 OS 现在找一块空闲的物理页,并且重新给这块虚拟内存挂载,修改 VMA Struct 和页表记录,还原这块虚拟内存在页表中的读写权限。

再考虑一些具体的问题:

如果是本来就是只读的页,但是 fork 之后向里面写入,会有几次 page fault?

fork 之后,pgd 怎么办?

带着问题,我们来看一个例子:

假设有个进程的虚拟内存到物理内存的映射如上所示。映射关系,也就是 虚拟页号-物理页号联系的箭头 存在页表中,缓存在 TLB 中。因此 VMA 结构体如图所示。

注意到 VMA 结构体保存的是连续的虚拟内存映射(OS 会做合并操作),所以 VMA 是 2 个。

考虑一个问题,当 process 1 调用 fork() 时,发生的变化:

下一节,我们就详细讨论,OS 是如何选择这个 evict 页的。

16.7 Replacement Policy

再考虑更详细的场景。我们知道当空闲的物理页数量不足时,OS 会选择一个物理页 evict 到磁盘中,并且给申请方分配物理页。这个过程就是 swap out / page out;

这节我们就来详细讨论缓存 page 的 evict 策略。

这部分在 CSAPP 中没有,是 OSTEP 的补充内容。

16.7.1 Metrics

首先引入一些 metrics:

可以知道 AMAT=(PhitTM)+(PmissTD)

可以由数量级发现,PhitPmiss)对 AMAT 影响很大(主要因为磁盘访问时间很长)。

16.7.2 Policies

首先我们知道 page evict 策略有一个理论上的最优上界,我们称为 Best Possible Replacement Policy。它的思路如下:

每次 evict 选择在未来的请求序列中,离当前最远会访问的物理页。

在实际情况中,这几乎不可能实现,因为没有 OS 能够预测所有程序未来要申请的页的情况。但在性能研究中,可以作为一个模拟上界,与其他设计出的策略形成对照。

首先我们考虑第一种替换策略:FIFO(先进先出)。这种策略的实现非常简单,就是为页表维护一个普通队列,即可完成既定任务。

但是这种策略的效果并不好,主要是因为:没有考虑到经常使用的页的信息,可能导致频繁替换的现象。不仅如此,FIFO 还会发生 Belady's Anomaly,也就是说,这种缓存策略甚至会出现随着缓存大小的增大,发生 evict 次数增大的反常、反直觉的现象

其次我们考虑第二种缓存策略:Random Replacement(随机替换)。这种策略实现也非常简单,而且它还有一个优点,就是不会受到某些 corner case 的业务情况影响,总不至于一直出现极低的性能。

实践证明,在某些 benchmark 下 Random Replacement 有近 40% 的概率可以达到 Best Possible Replacement Policy(理论上界);

还有两种常见的策略:LFU(Least Frequently Used)& LRU(Least Recently Used)。

LRU 和 LFU 的实现方式有些差别:对于 LRU 而言,直接维护一个可变队列,最近访问的元素(无论是否 hit)都会被直接放到队尾;对于 LFU 而言,更加严格,需要维护一个访问次数数据,以及一个关于访问次数的优先级队列。

LFU 和 LRU 策略都满足一个重要的特性:Stack Property(栈特性);大小为 N+1 的 cache 一定包含大小为 N 的 cache 的所有内容。

数学上可以证明,满足栈特性的策略一定不会发生 Belady's Anomaly;显然 Random Replacement Policy 和 FIFO 都不满足栈特性,目前只有 LFU/LRU 满足。

现在,我们从不同的使用场景(负载业务场景)来评估上面的策略的优劣。考虑下面 3 中不同类型的负载:

 

讨论了上面的一些情况,我们还是发现,在页访问的业务逻辑下,不经常出现 loop sequential 的情况,因此决定采用 LRU 的策略。但是我们如何实现 LRU 呢?

如果要做 LRU,那么就需要各个页打上时间戳,并且需要按时间戳排序。但是 OS 中有上万个页表,平均情况大致映射 4 GB 数据。而无论是打很多时间戳(空间占用),还是要遍历一遍页表(时间占用)对 OS 和硬件而言都是是不可接受的。于是人们想出了一种算法来逼近 LRU 的效果:Clock Algorithm

还记得之前介绍的多级页表结构吗?

在硬件层面,保留了 A bit(access bit),相当于给每个页都留了一个 “最近一轮是否访问过” 的标记。将物理页号环形组织,想象有个指针从某个位置出发遍历:

我们发现 “针臂” 和 access bit 的操作不同步。这主要是因为 “针臂” 是 OS 维护的,在出现 swap in / page in 才会更新;而 access bit 是硬件维护,只要访问了就会更新。

这个缓存策略还有一些补充的措施:

除了上面的策略,OS 在进行页的换入时,还会考虑另一个策略:clustering(grouping)。也就是说,OS 会设置两个水位线(low/high water mark):

OS 还会采取一些措施,来防止频繁地换页造成性能抖动:

 

 

Chapter 17. Network

17.1 Foundation for Network: Hardware-level

上面介绍的都是一个局域网内的网络传输机制,仅涉及局域网中的 IP(其内的 IP 命名使用 DHCP 或其他软件协议完成,可变),或者称为私网 IP。不涉及公网 IP。

实际上存在公网和私网的转换(公网相当于许多子网集群组成的网络),需要通过 NAT(Network Address Translation)等机制完成。但是在 CSAPP 中用不到,无需进一步关注,感兴趣学习计算机网络。

在公网 IP 中,建立了 DNS(Domain Name Server),将公网 IP 与 域名(字符串)联系起来。

17.2 Network Usage: System & Application Level

17.2.1 Socket Interface

Socket 对 OS 来说是连接的一端,对 Linux 使用者来说,与普通文件无异。

数据结构定义如下:

Connection

  1. 将指定 网络层协议、port、传输层协议 转换为 程序可用的 sockaddr 结构体。主要用来查找任何能连接的 socket 信息(hints 存放查找条件)。

ai_flags 是个 16 进制掩码,可以取:

ai_family 指定查找的 socket 遵循的网络层协议(AF_INET IPv4,AF_INET6 IPv6,AF_UNSPEC 任何协议);

ai_socktype 指定查找的 socket 遵循的传输层协议(SOCK_STREAM TCP、SOCK_DGRAM UDP、SOCK_RAW 仅作为文件);

ai_canonname:一个指向规范主机名(如果设置了 AI_CANONNAME 标志)的指针。不常用。

ai_protocol 常用 0 作为接受任何协议号;

ai_addrlen 保存了维护的 sockaddr 结构体的大小;

 

  1. 将程序可用的 sockaddr 结构体转换为用户可读的 网络层协议、Port、传输层协议。主要用来查看当前操作的 socket 的信息。

sasalen 参数不解释。就是之前我们获得的 sockaddr 结构体以及结构体大小信息;

host 是外部提供给这个函数的 buffer,hostlen 表示外界给的 buffer 的大小;

service 也是外部提供给这个函数的 buffer,不再赘述;

flags:用于指定一些标志,影响解析的行为。常见的标志包括:

flag 常用前面两个值;

 

 

于是 socket 程序总体的业务逻辑如下图所示:

17.2.2 Details of RIO Package

本节详细解释 RIO 包的原理和作用。

网络套接字文件(socket)和普通文件读写都有短读的问题,那么为什么不直接用已有标准的 fread/fwrite 来操纵文件呢?实际上,二者还是有些区别的。

在网络套接字方面,写的问题不大,和普通文件的写一样。由于 RIO 包基于操作系统的 read 接口,因此不可避免地会受到信号中断的影响(触发中断后 read/write 错误并设置 errnoEINTR),所以 RIO 包对于写的唯一包装就是:循环写(防止短写)直至写完,中途如果出现 EINTR 则当前轮写入不算,而且不需要写缓存:

在读的方面,网络套接字文件就比普通的文件麻烦一点。注意到读网络套接字可能被以下因素中断:

对于前两种情况,和写操作差不多处理就行,但是最后一种情况不行——因为网络原因,除非读够用户指定的字节数,否则遇到 EOF 不应该认为真正读到文件末尾了。因此 RIO 包的读操作需要阻塞!

为了解决前面两个问题,我们先使用一层没有读缓冲的函数包装一下:

这个函数会对各种外部中断处理(帮助在 EINTR 出现时循环、其他错误抛出),但是没有针对网络套接字完善(因为遇到 EOF 的处理是直接短读,普通文件是没问题的,但套接字不行),因此需要外部自行处理;

RIO 包的另一个做法是使用一个内置的 buffer 结构读,并且读到内置 buffer 中,规定:

这样做是不够的,因为短读没有解决,但是这个包装解决了有 buffer 情况下的 EINTR 和其他中断的问题:

在这个基础上,我们为了彻底解决网络套接字随时 EOF 的问题,在含有 buffer 的 rio_read 基础上包装了一个必须等待用户指定的字节数 n 全部读完的函数 rio_readnb,它同时解决了两个问题:

这样一定能保证 rio_readn 返回时,除非出现无法恢复的 read 错误,用户 buffer 一定会被填入 n 字节的套接字内数据(可能会通过阻塞来达成这个目的)。

正因如此,这个 RIO 包不应该用在普通文件上:因为假设文件内容长度小于用户指定的 nrio_readnb 会永远卡死,除非外部进程 / 线程添加了内容。

rio_readlinebrio_readnb 思路一样,只不过它会以 \n 和用户给定的 maxline 作为结束判据。

 

17.3 Web Server

 

Chapter 18. Concurrency

 

18.1 Overview

然而,写并发程序很难。主要是因为:

 

18.2 Thread

18.2.1 Introduction to Thread

Rules:

POSIX API:

 

Compared with Process:

MetricsThreadProcess
Own Logical Control Flow
Concurrency
Context Switch
Memory Isolation (Virtual Memory)
Overhead (Create & Reap)~10 K Cycles~20 K Cycles

18.2.2 补充:Memory Sharing & Isolation of Thread

你可能开始疑惑,线程究竟有哪些数据能相互共享、哪些数据是线程自己私有的?

回忆一下,一个进程的虚拟内存中有哪些成分?

现在告诉你,其实 OS Kernel 中还含有一块数据结构,称为 TCB(Thread Control Block,线程控制块),Kernel 中可以有多个 TCB,因为可以有多个线程。一个 TCB 包含以下数据:

上面的 PCB 和 TCB 在 OS 管理进程(例如 Context Switch)的过程中扮演重要角色。

所以,这下你清楚了吧?

线程共享的数据就是 TCB 中不单独存储的、内存中的数据。有:文件描述符表、页表、所在进程的所有信息、全部的用户态空间

线程不共享的数据就是 TCB 中存放的数据。有:各种线程信息、SP、PC、CPU 寄存器值

对于共享的数据,我们需要关心的是文件描述符以及栈上的数据传递问题:

例如下面这段程序:

 

特别地,我们将 “变量线程共享” 定义为:一个变量 x 被多个线程共享,当且仅当多个线程至少可以对同一个 x 的实例(内存地址)引用。

所以,上面的例子中,msgs 是共享的(即便它位于 main 栈帧)、ptr 是共享的、cnt 是共享的;

i/tid/myid 都不是共享的。

 

18.2.3 Semaphores: 线程间信号量

现在看起来我们只要知道了线程间变量共享的情况、知道了在线程间参数传递的危险性,不就可以正确地编写多线程程序了,对吗?

可惜的是,还有一个相当严重的问题。我们现在知道了不同线程有变量共享的情况,似乎没啥问题,但问题是各个线程是并发的,执行先后顺序是不确定的。那如果它们同时操作共享在内存上的变量会发生什么?

你可能会说,没问题啊?操作就是啦,反正最后结果不是共享在内存上咯。

问题在于,线程只是共享了内存,而没有共享 CPU 状态

再加上程序对内存的更改并不直接在内存上完成:在汇编中可以看到,大致经历了 load(从内存到 CPU 寄存器)、update(在 CPU 寄存器内更新数据)、store(将 CPU 寄存器数据写回内存)这 3 步。

如果一次 Context Switch 恰好在 Load + Update 和 Store 之间出现,并且切换到的同一进程、另一个线程正好要读同一个内存,这就会发生 脏读(dirty read)。

回忆数据库的知识,如果两个连接对同一个记录进行查询、更改,如果没有事务的保护,可能出现脏读、不可重复读、幻读这 3 种情况。

这里的情况出现的原因和线程访问共享变量道理是一样的。

线程访问共享变量也会遇到:脏读、不可重复读(程序中不存在变量增删的情况)。

根据程序的局部性原理、context switch 的随机性,出现这种情况几乎是必然的。我们一定要避免这种情况的发生!

怎么避免?我们首先要讨论上面 “脏读”、“不可重复读” 出现的准确时机。

我们简化一下讨论的情形,假设我们讨论 “两个线程向全局变量累加一亿次” 的情形:

我们可以将 thread routine 中的操作分成之前说的 load、update、store 3 步:

接下来,我们引入一种更清晰的表述各阶段执行顺序的图:process graph,来描述可能的并发情况。

那么,“脏读”、“不可重复读” 的执行先后序列在这幅图中怎么表示?

显然,在 load / update 间、update / store 间任意一个间隙出现 context switch,都会发生脏读的情况。于是,我们将 load-update-store 规定为 critical section,这 3 个操作需要是原子性的,否则就会出现问题。

而被 critical section 围住的状态区域称为 unsafe region,一切经过 unsafe region 的执行序列(迹)一定会发生脏读

表述出了这种情况,我们就可以想办法解决:不允许程序执行序列进入 unsafe region,就可以避免脏读情况的发生。图灵奖得主 Dijkstra 就设计了一种方法:semaphores(线程间信号量,原意是火车信号灯)。

他定义,semaphore 就是一个用于同步信息的非负整型。并且定义了 2 个针对 semaphore 的操作:

这两个操作会绝对保证原子性(期间不发生 context switch)。

这可以做些什么呢?这可以保证限制资源访问者的数量。意味着,同一时间,如果 s 减为 0,那么执行 P 的程序将一直等待下去,直至另一个并发的程序调用了 Vs 增加。

这个效果就相当于调用 V 的程序 “唤醒了” 正在等待 semaphore 的另一个程序。另一个程序在等到 semaphore 前,绝不继续执行,就像给下面要执行的代码 “上锁” 了一样。

如果我们让 s 初始化为 1,并且在操作 cnt 的前后加上 P(等待 semaphore)、V(释放 semaphore),就像这样:

我们会惊喜地发现,所有 unsafe region 区域的 semaphore 都是 -1!因为我们限制 semaphore 是非负整数,因此 unsafe region 永远不可达,相当于在 unsafe region 周围建立了一个围墙。彻底解决了访问不安全的问题:

如果我们改变 s 的初始值会发现它的意义:s 的初始值限制了 能同时访问在 semaphore 保护下的代码 并发执行的线程数量。

s = 1 的情况比较常见,就是只允许一个线程并发执行受保护的代码(在上面的例子中,就是 cnt++ 改变内存变量的代码)。

这个情况下,semaphore 被称为 binary semaphore。

Binary Semaphore 和 互斥锁mutex)有些微妙的区别。

Mutex 相比 binary semaphore 增加了所有权的概念一只锁住的 Mutex 只能由给它上锁的线程解开,只有系铃人才能解铃。Mutex 的功能也就因而限制在了构造 unsafe region 的 “围墙” 上。

Binary semaphore 则可以由任一线程解开。比如某进程读取磁盘并进入睡眠,等待中断读取盘块结束之后来唤醒它,而这种情况 Mutex 是解决不了的。

这是因为 semaphore 的语义有两个功能:保护资源 + 通知。除了限制资源并发数量,semaphore 的释放还能通知等待 semaphore 的线程。

Mutex 相较 semaphore 的优势在于,Mutex 职责更单一,语义更清晰,实现的效率稍微高一点。

那么怎么用 semaphore 呢?在 POSIX 标准中,C 库里有这些方法:

这些 P/V 操作的原子性由 OS 保证。

 

18.2.4 Applications of Semaphore

我们引入了 semaphore 成功解决了线程的并发访问内存数据的问题。

那么哪些典型的情况会出现 “并发访问内存数据” 呢?也就是说,semaphore 的使用场景有哪些?

先考虑一个经典模型:生产者-消费者模型。

Producer-Consumer Problem

假设有两个线程,一个是产生数据的线程(称为生产者),另一个是读取数据的线程(称为消费者),它们交换数据的方式是通过访问内存中的一段共享 buffer 来实现的:

对于 1-element buffer 而言,我们只需要维护 2 个 semaphores:一个提示 buffer 空(让 producer 用 P 等待这个信号量),另一个提示 buffer 满(让 consumer 用 P 等待这个信号量);

对于 N-element buffer 而言呢?思路需要转变一下。

 

Readers-Writers Problem

资源访问互斥问题的一种泛化。

假设还是有两个线程,一个线程只读对象(称为读者),另一个线程只写对象(称为写者)。

这种模型是读者-写者模型,广泛应用于航线监测系统、多线程 web proxy 缓存系统等等场景。

我们根据线程可能出现并发写内存的问题,判断 同一时刻只能有最多一个写者访问特定的对象,但是可以有任意多个读者读对象,因为只是读内存不会造成并发的问题。

那么现在有两种解决方案:

 

以 Favors Readers 为例,我们可以这么设计:

如果是 Favors Writers 呢?情况有些不同。和 Readers 不一样,Writers 本身还要等待之前正在进行的 Writers,直到它们结束任务。所以我们需要这么设计:

 

18.2.3 补充:GDB 多线程调试和多进程调试

 

18.3 I/O Multiplexing

readfds 中存在能读的 fd,那么将能读 fd 位置置为 1,其他位置为 0,并返回当前能读的 fd 数量。

为了方便操作,C 提供了一系列操作这个 readfds 的宏:

于是使用 select(I/O Multiplexer)编写简单的逻辑并发程序的主要步骤如下:

对于复杂的 I/O 复用的场景,例如 I/O 复用以提升网络服务器性能的场景,我们需要: