Cache
为了提到CPU访问指令和数据的速率,计算机常常在CPU和主存之间设置一种小容量的高速缓冲存储器,叫做高速缓存,英文为Cache。
现代系统中缓存随处可见,CPU芯片、操作系统、分布式文件系统中和万维网上都使用了缓存。而各种各样的硬件和软件的组合构成和管理着缓存。
存储器层次结构
早期的计算机系统的存储器层次结构只有三层,即CPU寄存器,DRAM主存储器和磁盘存储。随着计算机技术的发展,CPU和主存之间的性能差异越拉越大,设计者被迫需要在CPU寄存器堆和主存之间插入一个小的SRAM高速缓存存储器,后来就是L1高速缓存(一级缓存),其访问速率几乎和寄存器一样快。CPU与主存之间的性能差异继续拉大,系统设计者在L1高速缓存和主存之间又插入了一个更大的高速缓存,称为L2高速缓存;有些现代系统还包括更大一点的L3高速缓存。最后存储器层次结构大致呈现如下形态:
同时我们也可以了解一下Intel Core i7处理器的Cache结构:
Cache的经典结构
如果我们考虑一个有
m
m
m位存储器地址的计算机系统,其形成了
M
=
2
m
M=2^m
M=2m个不同的地址。此时,这样的一个机器的高速缓存常常被组织成一个有
S
=
2
s
S=2^s
S=2s个高速缓存组(cache set)的“数组”,每一组包含
E
E
E(相联度)个高速缓存行/槽(cache line),每一行常常由一个
B
=
2
k
B=2^k
B=2k字节的数据块(Block)+
t
t
t个标记位(tag bit)+
1
1
1个有效位(valid bit)组成。
那么我们如何通过数据在
m
m
m位存储器中的地址来找到数据在Cache中的位置呢?参数
S
S
S(对应set)和
B
B
B(对应Block)将这
m
m
m位地址划分成了三段,如下图所示,分别为标记位、组索引位和块偏移位:
根据上述地址的划分,我们就可以不用进入主存,直接找到Cache中对应位置的数据了。
当然,这其中涉及到很多相关的问题,如分组的大小与多少(Cache与主存块之间的映射规则)、Cache中主存块的替换算法、Cache的一致性问题等等,在此便不再一一赘述。最后以Intel Core i7 高速缓存层次结构的特性来直观感受一下各级缓存的特点:
文件系统与Cache
特别地,在操作系统中,我们也会采用高速缓存的思路来优化我们的文件系统:
最常用的磁盘访问次数技术是块高速缓存(block cache)或缓冲区高速缓存(buffer cache)。这些高速缓存与主板中的高速缓存不太一样,但是功能有相似之处。这些高速缓存逻辑上属于磁盘,但实际上基于性能的考虑被维护在内存中。
管理高速缓存的常用算法是:检查全部的读请求,查看高速缓存中是否有所需要的块,如果存在,可以在不访问磁盘的情况下执行读操作;否则,需要先将其读入高速缓存,再复制到所需的地方;如果高速缓存已满,则可用页面置换算法(如FIFO,LRU等等)进行处理。
高速缓存存在的一个问题是:如果对一个块做出了修改,它很可能只是被放在了高速缓存中,但没有及时地写入磁盘中。因此,如果计算机突然崩溃,这可能导致数据的丢失。UNIX系统采用了一个名为sync
的系统调用来解决这一问题, 它会强制性地把全部修改过的块立即写回磁盘。系统启动时,后台会运行一个update
的程序,它再无限循环中不断执行sync
调用,每两次调用之间休眠30s。Windows目前也有一个等价的系统调用FlushFileBuffer
(过去没有)。之前Windows采取的策略是:只要被写进高速缓存,就把被修改的块写进磁盘,这称之为通写高速缓存(write-through cache)。较之于非通写高速缓存,这种方式需要更多的磁盘I/O。
在Linux中,使用的是Page cache缓存文件的逻辑内容,即文件的页数据,从而加快对磁盘上映像和数据,即加速对文件内容的访问;Buffer cache缓存物理磁盘上的磁盘块,即块设备的块数据,加速对磁盘的访问。如果我们通过文件系统去操作文件,那么文件将被缓存到Page Cache,而如果需要刷新文件的时候,Page Cache将交给Buffer Cache去进行处理。但是,块设备大多是磁盘,磁盘上的数据又大多通过文件系统来组织,这种设计导致很多数据被缓存了两次,浪费内存。所以在 2.4 版本Linux内核之后,两块缓存近似融合在了一起:如果一个文件的页加载到了 Page Cache,那么同时 buffer cache 只需要维护块指向页的指针就可以了。只有那些没有文件表示的块,或者绕过了文件系统直接操作的块,才会真正放到 buffer cache 里。因此,我们现在提起 Page Cache,基本上都同时指 Page Cache 和 buffer cache 两者。
首先,我们可以使用cat /proc/meminfo
来实时获取系统内存情况(截图不完整):
实际上有公式:
Page Cache = Buffers + Cached + SwapCached
= Active(file) + Inactive(file) + Shmem + SwapCached
实际上可以使用
/proc
文件系统找出一般的系统属性,或者某个特殊的进程使用的内存段;2.6版本的Linux内核还引入了/sys
文件系统,输出关于系统总线和设备的额外的底层信息。
也可以使用free
指令获取当前内存使用情况:
total | 系统总的可用物理内存大小 |
---|---|
used | 已被使用的物理内存大小 |
free | 还有多少物理内存可用 |
shared | 被共享使用的物理内存大小 |
buff/cache | 被 buffer cache 和 page cache 使用的物理内存大小 |
available | 还可以被应用程序使用的物理内存大小 |
sync
指令能够将所有未写的系统缓冲区写到磁盘中,以确保文件系统的完整性。
至于释放缓存区内存的方法,大家可以自行查询相关资料,在此便不多赘述。
参考
[1] 《计算机组成与系统结构(第三版)》袁春风, 唐杰, 杨若瑜, 李俊
[2] 《深入理解计算机系统》兰德尔 E. 布莱恩特, 大卫 R. 奥哈拉伦
[3] 《现代操作系统》安德鲁 S. 塔嫩鲍姆, 赫伯特 · 博斯
[4] https://zhuanlan.zhihu.com/p/436313908
[5] https://zhuanlan.zhihu.com/p/35277219
缺页异常
缺页异常(page fault,又名硬错误、硬中断、分页错误、寻页缺失、缺页中断、页故障等)指的是物理内存(DRAM)缓存不命中的情况,每当系统所要访问的页不在内存中的时候便产生一个缺页异常,请求操作系统将所缺失的页调入内存。
虚拟内存
如果我们考虑主存被组织成一个由
M
M
M个连续字节大小的单元组成的数组,那么很明显,每个字节都有一个唯一的物理地址(Physical Address),我们之后用 PA 表示,而CPU直接利用物理地址对主存进行访问的方式称为物理寻址。早期的计算机的确是使用物理寻址的,不过现代处理器常常使用的是一种被称为虚拟寻址的方式访问主存,这时主存则被抽象为虚拟内存(Virtual Memory,简称VM)。
在虚拟寻址方式中,CPU首先会生成一个虚拟地址(Virtual Address,用VA表示),然后通过其上的一个叫做内存管理单元(Memory Management Unit,即MMU)的专用硬件将虚拟地址转换为(翻译为)恰当的物理地址,这一步骤需要CPU硬件和操作系统之间紧密地配合。其中内存管理单元对虚拟地址进行翻译的时候会利用到存放在主存中的查询表来动态翻译虚拟地址,这个表的内容就是由操作系统进行管理的。
虚拟地址形式上扩大了地址空间。
值得一提的是,在32位的x86机器上每个Linux进程有4GB的进程地址空间,内核态下的这4GB的地址空间全都可以访问,但是在用户态时只能访问**0x0000_0000~0xbfff_ffff**
的地址空间,其中只读代码区域从0x0804_8000
开始增长,而剩下0xc000_0000 ~ 0xffff_ffff
的地址空间只能在内核态下访问。而对于64位的x86机器最多有48位用于寻址,其中最开始的只读代码区域由0x0040_0000
开始,用户空间的最大地址为**0x7fff_ffff_fffff**
,也就是说整个用户空间的大小为
2
47
2^{47}
247字节,即128TB;内核空间在**0x8000_0000_0000 ~ 0xffff_ffff_ffff**
,也是128TB,总共为256TB(即
2
48
2^{48}
248字节)。
注:图中下面一半为用户空间。关于内核空间的相关内容可参考:https://www.kernel.org/doc/Documentation/x86/x86_64/mm.txt
页和页表
虚拟内存实际上将真正由DRAM构成的主存看成了外部存储器的缓存,一种常见的实现方式是分页式。虚拟内存系统将虚拟内存划分为大小固定的块,被称为虚拟页(Virtual Page,简写为VP),每个虚拟页的大小为 P = 2 p P=2^p P=2p字节。类似地,物理内存也被分给成物理页(Physical Page,简写为PP,又称实页、页帧、页框),大小也为 P P P字节。下面给出一些体系结构下的页长度:
体系结构 | PAGE_SHIFT | 页大小 |
---|---|---|
arm | 12, 14, 15 | 4KB, 16KB, 32KB |
mips | 12 | 4KB |
x86 | 12 | 4KB |
注:PAGE_SHIFT表示从最右端开始屏蔽多少位可以得到该地址对应页的页号。
虚拟内存管理采用所谓“请求分页”的思路,每次仅将需要访问的页面从外部存储调入到主存的一个页框(物理页)中,而当访问所在页不在主存中的某个信息时则发生缺页异常,操作系统接管进程,将需要的页从外部存储中读出并装载进入内存,并建立虚拟页与物理页之间的映射关系,而这个映射关系由内存中一个叫做页表(page table)的结构来维护,页表的一行称为页表项(Page Table Entry,简称PTE),维护了一个虚拟页到一个物理页的映射关系。
比较典型的页表项结构包括了以下内容:
- 页框号
- “在/不在”位
- 保护(protection)位:保护位指出一个页面允许什么类型的访问。比较先进的方法是使用三个二进制位分别控制读、写、执行的权限。
- 修改(modified)位:用于确定一个页面有没有被修改过,如果没有被修改过,回收对应页框直接丢弃即可,否则需要先把其中的内容写回磁盘。这一位也被称为脏位(dirty bit)。
- 访问(referenced)位:用于帮助操作系统确定发生缺页中断时要被淘汰的页面。显然应该优先淘汰不再使用的页面。
- 高速缓存禁止位:用于禁止该页面被高速缓存访问。有些机器没有独立的I/O空间,因而使用内存映射I/O,因而有些页面被映射到设备寄存器,需要保证硬件是不断从设备中读取数据而不是访问一个旧的被高速缓存的副本。
需要注意的是,如果某个页面不在内存中,那么用于保存该页面的磁盘地址不是页表的一部分(也就缺页了)。
缺页异常处理的细节
缺页异常发生时的事件顺序如下:
- 硬件陷入内核,在堆栈中保存程序计数器。大多数机器将当前指令的各种状态信息保存在特殊的CPU寄存器中。
- 启动一个汇编代码例程保存通用寄存器和其他易失信息以免被操作系统破坏。这个例程将操作系统作为一个函数来调用。
- 当操作系统发现一个缺页中断时,尝试发现需要哪个虚拟页面。通常一个硬件寄存器包含了这一信息,如果没有,操作系统必须检索程序计数器,并用软件分析其中的指令以确定它在缺页中断时正在做什么。
- 得知需要的虚拟页面之后,操作系统检查该地址是否有效,并检查存取与保护是否一致。若不一致,像进程发出一个信号或杀掉该进程。如果地址有效且没有保护错误发生,系统则检查是否有空闲页框。若没有,执行页面置换算法寻找一个页面回收。
- 如果选择的页框是被修改过的,将其写回,并发生一次上下文切换,挂起产生缺页中断的进程,让其他进程运行直至磁盘传输结束。无论如何,该页框被标记为忙,以免因为其他原因而被其他进程占用。
- 一旦页框“干净”之后,操作系统查找所需页面在磁盘上的地址,通过磁盘操作将其装入,该页面被装入后,产生缺页中断的进程仍被挂起,并且如果有其他可运行的用户进程,则选择另一个用户进程运行。
- 当磁盘中断发生时,表明该页已经被装入,页表已经更新可以反映它的位置,页框也被标记为正常状态。
- 恢复发生缺页中断指令以前的状态,程序计数器重新指向这条指令。
- 调度引发缺页中断的进程,操作系统返回调用它的汇编语言例程。
- 该例程恢复寄存器和其他状态信息,返回到用户空间继续执行,就好像缺页中断没有发生过一样。
参考
[1] 《深入理解计算机系统》兰德尔 E. 布莱恩特, 大卫 R. 奥哈拉伦
[2] 《计算机组成与系统结构(第三版)》袁春风, 唐杰, 杨若瑜, 李俊
[3] 《现代操作系统》安德鲁 S. 塔嫩鲍姆, 赫伯特 · 博斯
[4] https://cloud.tencent.com/developer/article/1683163
[5] 《庖丁解牛Linux内核分析》孟宁, 娄嘉鹏, 刘宇栋