存储管理
内存管理(Memory Management) 和 虚拟内存(Virtual Memory) 是操作系统最核心、最精妙的机制之一,它们共同解决了程序如何高效、安全地使用物理内存的问题。
一、内存管理的目标
操作系统内存管理的核心目标:
| 目标 | 说明 |
|---|---|
| 抽象 | 给每个进程提供“独占内存”的假象 |
| 保护 | 防止进程非法访问其他进程或内核内存 |
| 共享 | 允许多个进程共享代码或数据(如共享库) |
| 高效利用 | 最大化内存利用率,支持运行比物理内存更大的程序 |
| 简化编程 | 程序员无需关心物理内存布局 |
✅ 虚拟内存是实现这些目标的关键技术。
二、什么是虚拟内存(Virtual Memory)?
🎯 定义
虚拟内存是一种内存抽象机制,它为每个进程提供一个独立的、连续的地址空间,这个地址空间是“虚拟”的,不直接对应物理内存。
🔑 核心思想
- 每个进程认为自己独占整个内存空间(如 32 位系统有 4GB 地址空间)
- 操作系统通过 地址翻译(Address Translation) 将虚拟地址 → 物理地址
- 使用 分页(Paging) 或 分段(Segmentation) 实现(现代系统主要用分页)
三、虚拟内存的三大核心机制
1. 分页(Paging)
原理
- 将虚拟内存和物理内存都划分为固定大小的块:
- 页(Page):虚拟内存的块(通常 4KB)
- 页框(Page Frame):物理内存的块
- 通过 页表(Page Table) 记录虚拟页 → 物理页框的映射关系
页表结构(单级)
| 虚拟页号(VPN) | → | 页表 | → | 物理页框号(PFN) | + | 页内偏移 |
|---|---|---|---|---|---|---|
| 0x1000 | → | [0] | → | 0x3 | + | 0x000 = 0x3000 |
✅ 优点:避免外部碎片,管理简单 ❌ 缺点:页表可能很大(32 位系统需 4MB 页表)
多级页表(Multi-level Page Table)
- 将页表分级(如两级、三级),只加载使用的部分
- 节省内存(页表本身也分页存储)
CR3 → 页目录 → 页表 → 物理页框
✅ 现代系统(x86-64)使用 4 级或 5 级页表
2. 按需分页(Demand Paging)
原理
- 进程启动时,不加载所有页面到内存
- 只在访问某个页面时,才将其从磁盘(如交换区)加载到物理内存
- 如果访问的页不在内存中,触发 缺页中断(Page Fault)
缺页中断处理流程
1. CPU 访问虚拟地址
2. MMU 查页表 → 发现页不在内存(Valid 位 = 0)
3. 触发 Page Fault 中断
4. 内核处理:
a. 找一个空闲物理页框(或换出一个页)
b. 从磁盘读取所需页到内存
c. 更新页表项(PFN、Valid 位)
d. 重新执行被中断的指令
✅ 实现“运行比物理内存更大的程序”
3. 页面置换(Page Replacement)
当物理内存不足时,需要将某些页换出(Swap Out) 到磁盘,腾出空间。
经典算法
| 算法 | 说明 | 是否可实现 |
|---|---|---|
| OPT(最优) | 换出最久不会被使用的页 | ❌ 理论算法,无法预知未来 |
| FIFO | 换出最先进入内存的页 | ✅ 简单,但可能踢出常用页(Belady 异常) |
| LRU(最近最少使用) | 换出最久未访问的页 | ✅ 效果好,但实现开销大(需记录访问时间) |
| Clock(时钟算法) | 近似 LRU,用“使用位”和循环指针 | ✅ Linux 使用改进版(如 Working Set) |
Linux 的页面回收
- 使用 LRU 列表(活跃/非活跃)
- 结合 工作集模型 和 页面访问频率
- 支持 透明大页(THP) 和 内存压缩(Zswap)
四、虚拟内存布局(以 32 位 Linux 为例)
0xFFFFFFFF ┌─────────────────┐
│ 内核空间 │
│ (1GB, 共享) │
0xC0000000 ├─────────────────┤
│ 栈(Stack) │ ← 向下增长
│ │
│ │
│ 堆(Heap) │ ← 向上增长
│ │
│ 全局数据、BSS │
│ 代码段(Text) │
0x08048000 ├─────────────────┤
│ 保留/未映射 │
│ (防止空指针访问) │
0x00000000 └─────────────────┘
- 用户空间:0x00000000 ~ 0xC0000000(3GB)
- 内核空间:0xC0000000 ~ 0xFFFFFFFF(1GB,所有进程共享)
- 栈:每个线程一个,存放局部变量、函数调用
- 堆:动态分配(
malloc/new) - 代码段:可执行指令
- 未映射区:保护空指针访问
64 位系统地址空间极大(如 48 位寻址),布局更灵活。
五、内存管理单元(MMU)与 TLB
1. MMU(Memory Management Unit)
- 硬件组件,负责虚拟地址 → 物理地址翻译
- 查页表、检查权限(读/写/执行)
- 触发缺页中断
2. TLB(Translation Lookaside Buffer)
- 页表项的高速缓存
- 避免每次地址访问都查内存中的页表(太慢)
- 类似 CPU 的 L1/L2 缓存
TLB 命中 Vs 未命中
- 命中:直接得到物理地址,快
- 未命中:查页表(可能多级),慢,然后更新 TLB
✅ TLB 性能对系统整体性能影响巨大
六、共享与保护机制
1. 共享
- 共享库(Shared Libraries):多个进程共享
libc.so的代码页 - 内存映射文件(mmap):文件直接映射到虚拟内存,多个进程可共享
- 进程间共享内存:
shm_open、mmapwithMAP_SHARED
2. 保护
- 页表项权限位:
Read、Write、Execute(NX 位,防 shellcode)User/Supervisor(用户态不能访问内核页)
- 地址空间布局随机化(ASLR):随机化栈、堆、库的地址,防攻击
- W^X(Write XOR Execute):内存页不能同时可写和可执行(防代码注入)
七、现代扩展技术
1. 大页(Huge Pages)
- 使用更大的页(如 2MB、1GB)
- 减少页表项数量,提高 TLB 命中率
- 适用于数据库、高性能计算
echo 20 > /proc/sys/vm/nr_hugepages
2. 透明大页(THP, Transparent Huge Pages)
- 内核自动将多个 4KB 页合并为大页
- 无需应用程序修改
3. KSM(Kernel Samepage Merging)
- 合并多个进程的相同内存页(如虚拟机镜像)
- 节省内存
4. NUMA 内存管理
- 多处理器系统中,内存访问有“局部性”
- 调度器尽量让进程使用本地内存
八、内存分配器(用户态)
操作系统提供 brk/sbrk 和 mmap 系统调用,但用户程序通常使用内存分配器:
| 分配器 | 特点 |
|---|---|
| glibc malloc(ptmalloc) | 基于 sbrk 和 mmap,支持多线程 |
| tcmalloc(Google) | 线程本地缓存,高性能 |
| jemalloc(FreeBSD) | 减少碎片,适合多核 |
✅ 分配器在
mmap的大块内存上实现malloc/free的细粒度管理。
九、总结:虚拟内存的“魔法”
| 传统问题 | 虚拟内存的解决方案 |
|---|---|
| 程序太大,放不下 | 按需分页 + 交换 |
| 内存碎片严重 | 分页避免外部碎片 |
| 程序位置不确定 | 重定位(虚拟地址) |
| 进程互相干扰 | 地址隔离 + 权限保护 |
| 无法共享代码 | 共享页(如共享库) |
✅ 最终结论
- 虚拟内存是操作系统最伟大的抽象之一,它让每个进程“以为”自己独占整个内存。
- 分页 + 按需分页 + 页面置换 是三大核心技术。
- MMU 和 TLB 是硬件支持,确保地址翻译高效。
- Linux 使用 CFS 调度 + 多级页表 + LRU 页面回收 + THP 构成现代内存管理。
- 理解虚拟内存 是掌握性能调优、安全防护(如 ASLR、NX)、并发编程的基础。
💡 正如 Linus Torvalds 所说:“虚拟内存是操作系统的核心,没有它,现代计算就不可能存在。”
虚拟内存
为什么需要虚拟内存?内存技术发展历程
- 1、无存储器抽象
早期计算机没有存储器抽象,每一个程序都是直接访问物理内存。这种情况下在内存中同时运行两个程序是不可能的,因为如果第一个程序比如在内存 2000 的位置写入一个新的值,那么第二个程序运行的时候就会擦掉第一个程序的值。
但是即使没有抽象概念,运行两个程序也是可能的。。。
操作系统把当前内存中的内容保存在磁盘文件中,然后把下一个程序读入到内存中再运行就行,即某个时间内内存只有一个程序在运行就不会发生冲突。(最早期的交换的概念)
还有就是借助硬件来实现并发运行多个程序,比如 IBM360 中内存被划分为 2KB 的块,每个块有一个 4 位的保护键,保护键存储在 CPU 的特殊寄存器中。一个运行中的程序如果访问的保护键与操作系统中保存的不相符,则硬件会捕获到该异常,由于只有操作系统可以修改保护键,因此这样就可以防止用户进程之间、用户进程和操作系统间的干扰。
- 2、地址空间(存储抽象)
把物理地址暴露给进程会带来几个问题:
- 如果用户进程可以寻址内存中的每一个字节,就可以很容易的破坏操作系统,从而时电脑出故障。
- 想要同时运行多个程序比较困难
在之前所说的解决多个程序在内存中互不影响的办法主要有两个:保护和重定位,也就是说并不是所有内存你都能访问,因此出现了地址空间的概念。
避免死锁的方法是一个进程可用于寻址内存的一套地址的集合。每个进程都有自己的地址空间,并且这个地址空间独立于其他进程的地址空间
最开始的地址空间的解决办法是动态重定位,就是简单地把每个进程的地址空间映射到物理内存的不同部分。但是也有问题,即每个进程都有内存,如果同时运行多个进程,那所需要的内存是很庞大的,这个 ram 数远远超过存储器支持的。比如在 Linux 和 Windows 中,计算机完成引导后会启动 50-100 个进程,那你需要的空间可大了去了。
处理上述内存超载问题有两个办法,就是交换技术和虚拟内存。
交换技术就是把一个进程完整的调入内存,使得该进程完整运行一段时间后在调入磁盘。但是这样的话,你把一个完整的进程调入进去,可能很大程度只会使用一部分内存,这样是不划算的。
所以有了虚拟内存,每个进程的一部分调入内存。
什么是虚拟内存
根据上面所说,虚拟内存就是来解决“地址空间”,即如何不把程序内存全部装进去。
程序大于内存的问题。当程序引用到一部分在物理内存中的地址空间时,由硬件立刻执行映射。当程序引用到一部分不在物理内存中的地址空间时,由操作系统负责将缺失部分装入物理内存并重新执行指令。
虚拟内存的基本思想是每个程序都拥有自己的地址空间,这些空间被分割成多个块儿。每一块儿被称作一页或者页面。每一个页面有连续的地址范围。这些页面被映射到物理内存,但是并不是一个程序的所有的页面都必须在内存中才能运行 使得应用程序认为它拥有连续的可用的内存(一个连续完整的地址空间),而实际上,它通常是被分隔成多个物理内存碎片,还有部分暂时存储在外部磁盘存储器上,在需要时进行数据交换。与没有使用虚拟内存技术的系统相比,使用这种技术的系统使得大型程序的编写变得更容易,对真正的物理内存(例如 RAM)的使用也更有效率。
第一阶段:分段
程序是由若干个逻辑分段组成的,如可由代码分段、数据分段、栈段、堆段组成。不同的段是有不同的属性的,所以就用分段的形式
分段机制下的虚拟地址由两部分组成,虚拟内存和段选择子。
-
段选择子就保存在段寄存器里面。段选择子里面最重要的是段号,用作段表的索引。段表里面保存的是这个段的基地址、段的界限和特权等级等。
-
虚拟地址中的段内偏移量应该位于 0 和段界限之间,如果段内偏移量是合法的,就将段基地址加上段内偏移量得到物理内存地址。
虚拟地址是通过段表与物理地址进行映射的,分段机制会把程序的虚拟地址分成 4 个段,每个段在段表中有一个项,在这一项找到段的基地址,再加上偏移量,于是就能找到物理内存中的地址,如下图:
如果要访问段 3 中偏移量 500 的虚拟地址,我们可以计算出物理地址为,段 3 基地址 7000 + 偏移量 500 = 7500。
分段的办法很好,解决了程序本身不需要关心具体的物理内存地址的问题,但它也有一些不足之处:
- 第一个就是内存碎片的问题。
-
外部内存碎片,也就是产生了多个不连续的小物理内存,导致新的程序无法被装载;
-
内部内存碎片,程序所有的内存都被装载到了物理内存,但是这个程序有部分的内存可能并不是很常使用,这也会导致内存的浪费;
- 第二个就是内存交换的效率低的问题。
解决外部内存碎片的问题就是内存交换。这个内存交换空间,在 Linux 系统里,也就是我们常看到的 Swap 空间,这块空间是从硬盘划分出来的,用于内存与硬盘的空间交换。对于多进程的系统来说,用分段的方式,内存碎片是很容易产生的,产生了内存碎片,那不得不重新 Swap 内存区域,这个过程会产生性能瓶颈。因为硬盘的访问速度要比内存慢太多了,每一次内存交换,我们都需要把一大段连续的内存数据写到硬盘上。所以,**如果内存交换的时候,交换的是一个占内存空间很大的程序,这样整个机器都会显得卡顿。**为了解决内存分段的内存碎片和内存交换效率低的问题,就出现了内存分页。
第二阶段:分页
在没有虚拟内存的计算机上,系统直接将虚拟地址送到内存总线上,读写操作使用具有同样地址的物理内存字。在使用虚拟内存的情况下,虚拟地址不是直接被送到内存总线上的,而是被送往内存管理单元 MMU,然后 MMU 把虚拟地址映射为物理内存地址
在这里 MMU 是作为 CPU 芯片的一部分,通常就是这样的。
段内偏移量
上图可以看到虚拟地址被划分成多个页面组成的单位,而物理内存地址中对应的是页框的概念。页面和页框的大小通常是一样的,在这里是 4KB 作为例子,实际系统中可能是 512KB-1GB。上图虚拟地址空间有 64KB,物理内存有 32KB,因此由 16 个虚拟页面和 8 个页框。请记住,ram 和也磁盘之间的交换总是以整个页面为单位进行的。
比如当程序访问地址 0 的时候,其实是访问虚拟地址,然后将虚拟地址 0 送到 MMU,MMU 根据映射关系发现虚拟地址 0 在页面 0-4095 这个页面上,然后根据映射找到实际物理内存地址是第二个页框,即 8192,然后把地址 8192 送到总线上。内存对 MMU 是一无所知的,他只看到了一个读写 8192 的请求并执行。
当程序访问了一个未被映射的页面,即虚拟地址没有对应的页框索引。此时 MMU 注意到该页面没有映射,使 CPU 陷入到操作系统,即缺页中断或者缺页错误(page fault)。随后操作系统找到了一个很少使用的页框并把该页框内容写入磁盘,然后把需要访问的页面读到刚才被回收的那个页框上,修改映射关系,重新启动引起中断的指令就好。
例如如果操作系统放弃页框 1,即重新映射页框 1,那么重新改变映射关系,将页面 8 装入物理地址 4096(页框 1),并且对 MMU 映射做两处修改:①由于页框 1 之前被页面 1 映射,因此要将页面 1 标记为未映射,使得以后对页面 1 的访问都将导致缺页中断。②把虚拟页面 8 的叉号改为 1,表明虚拟页面 8 映射到页框 1 上去,当指令重新启动时候就会产生新的映射。
具体页表中虚拟地址与物理内存地址之间的映射关系如下:
输入虚拟地址 8196 的二进制在最底下即 0010000000000100,用 MMU 映射机进行映射,这 16 位虚拟地址被分解成 4 位的页号 +12 位的偏移量。4 位页号表示 16 个页面,是页面的索引,12 位的位偏移可以为一页内的全部 4096 个字节编址。
第二阶段:分表
虚拟地址到物理地址的映射可以概括如下:虚拟地址被分成虚拟页号 + 偏移量。虚拟页号是一个页表的索引,可以有该索引即页面号找到对应的页框号,然后将该页框号放到 16 位地址的前 4 位,替换掉虚拟页号,就形成了送往内存的地址。可以参考上面那个图中,两个二进制字符串的替换形式。如下图:
页表的目的是将虚拟页面映射为页框,从数学角度说页表是一个函数,输入参数是虚拟页号,输出结果是物理页框号。
页表的结构如下:
页表项中最重要的字段就是 页框号(Page frame number)。毕竟,页表到页框最重要的一步操作就是要把此值映射过去。下一个比较重要的就是 在/不在 位,如果此位上的值是 1,那么页表项是有效的并且能够被 使用。如果此值是 0 的话,则表示该页表项对应的虚拟页面 不在 内存中,访问该页面会引起一个 缺页异常(page fault)。保护位(Protection) 告诉我们哪一种访问是允许的,啥意思呢?最简单的表示形式是这个域只有一位,MMU 的内部操作:。修改位(Modified) 和 访问位(Referenced) 会跟踪页面的使用情况。当一个页面被写入时,硬件会自动的设置修改位。修改位在页面重新分配页框时很有用。如果一个页面已经被修改过(即它是 脏 的),则必须把它写回磁盘。如果一个页面没有被修改过(即它是 干净 的),那么重新分配时这个页框会被直接丢弃,因为磁盘上的副本仍然是有效的。这个位有时也叫做 脏位(dirty bit),因为它反映了页面的状态。访问位(Referenced) 在页面被访问时被设置,不管是读还是写。这个值能够帮助操作系统在发生缺页中断时选择要淘汰的页。不再使用的页要比正在使用的页更适合被淘汰。这个位在后面要讨论的 页面置换 算法中作用很大。最后一位用于禁止该页面被高速缓存,这个功能对于映射到设备寄存器还是内存中起到了关键作用。通过这一位可以禁用高速缓存。具有独立的 I/O 空间而不是用内存映射 I/O 的机器来说,并不需要这一位。
分页带来的问题
分页系统中要考虑两个问题:
- 虚拟地址到物理地址的映射必须非常快(速度问题)
由于每次访问内存都需要进行虚拟地址到物理地址的映射,所有的指令最终都必须来自内存,并且很多指令也会访问内存中的操作数。因此每条指令进行多次页表访问是必要的。如果执行一条指令需要 1ns,则页表查询必须在 0.2ns 之内完成,以避免映射成为主要的瓶颈。
- 如果虚拟地址空间很大,页表也会很大(空间问题)
现代计算机使用的虚拟地址至少为 32 位,而且越来越多的 64 位。假设一个页面大小为 4KB,则 32 位的地址空间将有 100 万页,那么 64 位地址空间更多了。一个页表有 100 万条表项,你个存储开销就很大。而且每个进程都有自己的页表还
所以我们接下来主要解决的就是这两个问题。
解决速度问题
大多数优化技术都是从内存中的页表开始的,因为这里面会存在这有巨大影响的问题。比如一条 1 字节指令要把一个寄存器中的数据复制到另一个寄存器,在不分页的情况下这条指令只访问一次内存。有了分页机制之后,会因为要访问页表而引起多次的内存访问。同时,CPU 的执行速度会被内存中取指令执行和取数据的速度拉低,所以会使性能下降。解决方案如下:
由于大多数程序总是对少量页面进行多次访问,因此只有很少的页表项会被反复读取,而其他页表项很少会被访问。针对这个问题,为计算机设置一个小型的硬件设备,将虚拟地址直接映射到物理地址,而不必再去访问页表通过 MMU 得到物理地址,这个设备叫做0 表示可读可写,1 表示的是只读又叫做转换检测缓冲区。通常在 MMU 中,包含少量的表项,实际中应该有 256 个,每一个表项都记录了一个页面相关信息,即虚拟页号、修改为、保护码、对应的物理页框号,如下表所示:
| 有效位 | 虚拟页面号 | 修改位 | 保护位 | 页框号 |
| :----: | :--------: | :----: | :----: | :----: |
| 1 | 140 | 1 | RW | 31 |
| 1 | 20 | 0 | R X | 38 |
| 1 | 130 | 1 | RW | 29 |
| 1 | 129 | 1 | RW | 62 |
| 1 | 19 | 0 | R X | 50 |
| 1 | 21 | 0 | R X | 45 |
| 1 | 860 | 1 | RW | 14 |
| 1 | 861 | 1 | RW | 75 |
TLB 其实就是一种内存缓存,用于减少访问内存所需要的时间,它就是 MMU 的一部分,TLB 会将虚拟地址到物理地址的转换存储起来,通常可以称为 地址翻译缓存(address-translation cache)。TLB 通常位于 CPU 和 CPU 缓存之间,它与 CPU 缓存是不同的缓存级别。下面我们来看一下 TLB 是如何工作的。
当一个 MMU 中的虚拟地址需要进行转换时,硬件首先检查虚拟页号与 TLB 中所有表项进行并行匹配,判断虚拟页是否在 TLB 中。如果找到了有效匹配项,并且要进行的访问操作没有违反保护位的话,则将页框号直接从 TLB 中取出而不用再直接访问页表。如果虚拟页在 TLB 中但是违反了保护位的权限的话(比如只允许读但是是一个写指令),则会生成一个 保护错误(protection fault) 返回。上面探讨的是虚拟地址在 TLB 中的情况,那么如果虚拟地址不再 TLB 中该怎么办?如果 MMU 检测到没有有效的匹配项,就会进行正常的页表查找,然后从 TLB 中逐出一个表项然后把从页表中找到的项放在 TLB 中。当一个表项被从 TLB 中清除出,将修改位复制到内存中页表项,除了访问位之外,其他位保持不变。当页表项从页表装入 TLB 中时,所有的值都来自于内存。
下面给出流程图:
快表(TLB)
直到现在,我们假设每台电脑都有可以被硬件识别的页表,外加一个 TLB。在这个设计中,TLB 管理和处理 TLB 错误完全由硬件来完成。仅仅当页面不在内存中时,才会发生操作系统的 陷入(trap)。
但是有些机器几乎所有的页面管理都是在软件中完成的。
在这些计算机上,TLB 条目由操作系统显示加载。当发生 TLB 访问丢失时,软件 TLB 管理。操作系统必须找到该页,把它从 TLB 中移除(移除页表中的一项),然后把新找到的页放在 TLB 中,最后再执行先前出错的指令。然而,所有这些操作都必须通过少量指令完成,因为 TLB 丢失的发生率要比出错率高很多。
无论是用硬件还是用软件来处理 TLB 失效,常见的方式都是找到页表并执行索引操作以定位到将要访问的页面,在软件中进行搜索的问题是保存页表的页可能不在 TLB 中,这将在处理过程中导致其他 TLB 错误。改善方法是可以在内存中的固定位置维护一个大的 TLB 表项的高速缓存来减少 TLB 失效。通过首先检查软件的高速缓存,操作系统 能够有效的减少 TLB 失效问题。
TLB 软件管理会有两种 TLB 失效问题,当一个页访问在内存中而不在 TLB 中时,将产生 软失效(soft miss),那么此时要做的就是把页表更新到 TLB 中(我们上面探讨的过程),而不会产生磁盘 I/O,处理仅仅需要一些机器指令在几纳秒的时间内完成。然而,当页本身不在内存中时,将会产生 硬失效(hard miss),那么此时就需要从磁盘中进行页表提取,硬失效的处理时间通常是软失效的百万倍。在页表结构中查找映射的过程称为 页表遍历(page table walk)。如图:
上面的这两种情况都是理想情况下出现的现象,但是在实际应用过程中情况会更加复杂,未命中的情况可能既不是硬失效又不是软失效。一些未命中可能更 软 或更 硬。比如,如果页表遍历的过程中没有找到所需要的页,那么此时会出现三种情况:
-
所需的页面就在内存中,但是却没有记录在进程的页表中,这种情况可能是由其他进程从磁盘掉入内存,这种情况只需要把页正确映射就可以了,而不需要在从硬盘调入,这是一种软失效,称为
次要缺页错误(minor page fault)。 -
基于上述情况,如果需要从硬盘直接调入页面,这就是
严重缺页错误(major page falut)。 -
还有一种情况是,程序可能访问了一个非法地址,根本无需向 TLB 中增加映射。此时,操作系统会报告一个
段错误(segmentation fault)来终止程序。只有第三种缺页属于程序错误,其他缺页情况都会被硬件或操作系统以降低程序性能为代价来修复
解决内存太大问题
上面引入 TLB 加快虚拟地址到物理地址的转换,另一个要解决的问题就是处理巨大的虚拟空间,有两种解决方法:多级页表和倒排页表。
多级页表
从整体来过一遍虚拟地址的概念,虚拟存储器的基本思想是:程序、数据和堆栈的总大小可能超过可用的物理内存的大小。由操作系统把程序当前使用的那些部分保留在主存中,而把其他部分保存在磁盘上。例如,对于一个 16MB 的程序,通过仔细地选择在每个时刻将哪 4MB 内容保留在内存中,并在需要时在内存和磁盘间交换程序的片段,这样这个程序就可以在一个 4MB 的机器上运行。
由程序产生的地址被称为虚拟地址,它们构成了一个虚拟地址空间。在使用虚拟存储器的情况下,虚拟地址不是被直接送到内存总线上,而且是被送到内存管理单元 (Memory Management Unt,MMU),MMU 把虚拟地址映射为物理内存地址。
虚拟地址空间以页面为单位划分。在物理内存中对应的单位称为页帧。页面和页帧的大小总是一样的。
- 不再是由 MMU 到页表中查找并取出需要的页表项,而是生成一个 TLB 失效并将问题交给操作系统解决
任何进程的切换都会更换活动页表集,Linux 中为每一个进程维护了一个 tast_struct 结构体(进程描述符 PCB),其中 tast_struct→mm_struct 结构体成员用来保存该进程页表。在进程切换的过程中,内核把新的页表的地址写入 CR3 控制寄存器。CR3 中含有页目录表的物理内存基地址,如下图:
- 页表在哪儿
假如每个进程都有 4GB 的虚拟地址空间,而显然对于大多数程序来说,其使用到的空间远未达到 4GB,何必去映射不可能用到的空间呢?一级页表覆盖了整个 4GB 虚拟地址空间,但如果某个一级页表的页表项没有被用到,也就不需要创建这个页表项对应的二级页表了,即可以在需要时才创建二级页表。
如果每个页表项都存在对应的映射地址那也就算了,但是,绝大部分程序仅仅使用了几个页,也就是说,只需要几个页的映射就可以了,如下图(左),进程 1 的页表,只用到了 0,1,1024 三个页,剩下的 1048573 页表项是空的,这就造成了巨大的浪费,为了避免内存浪费,计算机系统开发人员想出了一个方案,多级页表。
)
)
下面计算一下上图(右)的内存占用情况,对于一级页表来说,假设一个表项是 4B,则
一级页表占用:1024 * 4 B= 4K
2 级页表占用 = (1024 * 4 B) * 2 = 8K。
总共的占用情况是 12K,相比只有一个页表 4M,节省了 99.7% 的内存占用。
为什么省空间?
- 因此引入多级页表的原因就是避免把全部页表一直保存在内存中。
假设 32 位的虚拟地址被划分为 10 位的 PT1 域、10 位的 PT2 域和 12 位的偏移量域,工作过程如下:当一个虚拟地址被送到 MMU 时,MMU 首先提取 PT1 域并把该值作为访问顶级页表的索引。由于虚拟空间为 32G,顶级页表有 2^10(PT1)^=1024 个表项,则二级页表有 2^10(PT2)^=1024 个表项,每一个二级页表都含有虚拟地址对应物理地址的页框号,该页框号与偏移量结合便形成物理地址,放到地址总线送入内存中。
如果没有多级页表,则 32 位虚拟内存应该有 100 万个页面,这个存储开销是很大的,而有了多级页表,则实际只需要 4 个页表,顶级页表、0、1、1024 这四个,顶级页表中的其他表项的“在/不在”为被设置为 0,访问他们是强制产生一个缺页中断。
倒排页表
针对不断分级的页表的替代方案是**《深入理解计算机操作系统》 中的解释**,实际内存中的每个页框对应一个表项,而不是每个虚拟页面对应一个表项。
对于 64 位虚拟地址,4KB 的页,4GB 的 RAM,一个倒排页表仅需要 1048576 个表项。
由于 4KB 的页面,因此需要 2^64^/2^12^=2^52^个页面,但是 1GB 物理内存只能有 2^18^个 4KB 页框
虽然倒排页表节省了大量的空间,但是它也有自己的缺陷:那就是从虚拟地址到物理地址的转换会变得很困难。当进程 n 访问虚拟页面 p 时,硬件不能再通过把 p 当作指向页表的一个索引来查找物理页。而是必须搜索整个倒排表来查找某个表项。另外,搜索必须对每一个内存访问操作都执行一次,而不是在发生缺页中断时执行。解决这一问题的方式时使用 TLB。当发生 TLB 失效时,需要用软件搜索整个倒排页表。一个可行的方式是建立一个散列表,用虚拟地址来散列。当前所有内存中的具有相同散列值的虚拟页面被链接在一起。如下图所示
如果散列表中的槽数与机器中物理页面数一样多,那么散列表的冲突链的长度将会是 1 个表项的长度,这将会大大提高映射速度。一旦页框被找到,新的(虚拟页号,物理页框号)就会被装在到 TLB 中。
逻辑地址到物理地址的转换
页式存储管理的逻辑地址分为两部分:页号和页内地址。物理地址分为两部分:块号+页内地址;
逻辑地址= 页号+页内地址
物理地址= 块号+页内地址;
倒排页表
某虚拟存储器的用户编程空间共 32 个页面,每页为 1KB,内存为 16KB。假定某时刻一用户页表中已调入内存的页面的页号和物理块号的对照表如下,则逻辑地址 0A5C(H) 所对应的物理地址是什么?要求:写出主要计算过程。
| 页号 | 物理块号 |
| ---- | -------- |
| 0 | 3 |
| 1 | 7 |
| 2 | 11 |
| 3 | 8 |
举例子
用户编程空间共 32 个页面, 得知页号部分占5位,由“每页为 1KB”,,可知内页地址占 10 位。
由“内存为 16KB”, 得知块号占 4 位。
然后找页号部分,转换成二进制查表,得到物理块号,然后转为二进制拼接页内地址就 ok 了。
逻辑地址 0A5C(H)所对应的二进制表示形式是:0000101001011100,后十位1001011100是页内地址,
00010为为页号,页号化为十进制是2,在对照表中找到2对应的物理块号是11,11转换二进制是1011,即可求出物理地址为10111001011100,化成十六进制为2E5C;
即则逻辑地址 0A5C(H) 所对应的物理地址是2E5C;
页面置换算法
**缺页中断:**一个进程所有地址空间里的页面不必全部常驻内存,在执行一条指令时,如果发现他要访问的页没有在内存中(即存在位为 0),那么停止该指令的执行,并产生一个页不存在的异常,对应的故障处理程序可通过从物理内存加载该页的方法来排除故障,之后,原先引起的异常的指令就可以继续执行,而不再产生异常。
- 最佳页面置换算法(OPT)(理想算法)
最佳页面置换算法基本思路是,置换在「未来」最长时间不访问的页面。所以,该算法实现需要计算内存中每个逻辑页面的「下一次」访问时间,然后比较,选择未来最长时间不访问的页面。这很理想,但是实际系统中无法实现,因为程序访问页面时是动态的,我们是无法预知每个页面在「下一次」访问前的等待时间。所以,最佳页面置换算法作用是为了衡量你的算法的效率,你的算法效率越接近该算法的效率,那么说明你的算法是高效的。
- 先进先出置换算法(FIFO)
选择在内存驻留时间很长的页面进行中置换
- 最近最久未使用的置换算法(LRU)
发生缺页时,选择最长时间没有被访问的页面进行置换,也就是说,该算法假设已经很久没有使用的页面很有可能在未来较长的一段时间内仍然不会被使用。
虽然 LRU 在理论上是可以实现的,但代价很高。为了完全实现 LRU,需要在内存中维护一个所有页面的链表,最近最多使用的页面在表头,最近最少使用的页面在表尾。困难的是,在每次访问内存时都必须要更新「整个链表」。在链表中找到一个页面,删除它,然后把它移动到表头是一个非常费时的操作。所以,LRU 虽然看上去不错,但是由于开销比较大,实际应用中比较少使用。
- 时钟页面置换算法(Lock)
把所有的页面都保存在一个类似钟面的「环形链表」中,一个表针指向最老的页面。
当发生缺页中断时,算法首先检查表针指向的页面:
-
如果它的访问位位是 0 就淘汰该页面,并把新的页面插入这个位置,然后把表针前移一个位置;
-
如果访问位是 1 就清除访问位,并把表针前移一个位置,重复这个过程直到找到了一个访问位为 0 的页面为止;
-
最不常用置换算法(LFU)
当发生缺页中断时,选择「访问次数」最少的那个页面,并将其淘汰。
要增加一个计数器来实现,这个硬件成本是比较高的,另外如果要对这个计数器查找哪个页面访问次数最小,查找链表本身,如果链表长度很大,是非常耗时的,效率不高。
对缺页中断的处理
-
硬件陷入内核,在堆栈中保存程序计数器。大多数机器将当前的指令,各种状态信息保存在特殊的 CPU 寄存器中。
-
启动一个汇编代码保存通用寄存器和其他易失信息,防止被操作系统破坏
-
当操作系统收到缺页中断信号后,定位到需要的虚拟页面。
-
找到发生缺页中断的虚拟地址,操作系统检查这个地址是否有效,并检查存取与保护是否一致。
如果不一致则杀掉该进程
如果地址有效且没有保护错误发生,系统会检查是否有空闲页框。如果没有空闲页框就执行页面置换算法淘汰一个页面。
-
如果选择的页框对应的页面发生了修改,即为“脏页面”,需要写回磁盘,并发生一次上下文切换,挂起产生缺页中断的进程,让其他进程运行直至全部把内容写到磁盘。
-
一旦页框是干净的,则 OS 会查找要发生置换的页面对应磁盘上的地址,通过磁盘操作将其装入。在装入该页面的时候,产生缺页中断的进程仍然被挂起,运行其他可运行的进程
-
当发生磁盘中断时表明该页面已经被装入,页表已经更新可以反映其位置,页框也被标记为正常状态。
-
恢复发生缺页中断指令以前的状态,程序计数器重新指向引起缺页中断的指令
-
调度引发缺页中断的进程
-
该进程恢复寄存器和其他状态信息,返回用户空间继续执行。
Linux 可执行文件如何装载进虚拟内存
源代码通过预处理,编译,汇编,链接后形成可执行文件,因为计算机的操作系统的启动程序是写死在硬件上的,每次计算机上电时,都将自动加载启动程序,之后的每一个程序,每一个应用,都是不断的 fork 出来的新进程。那么我们的可执行文件,以 linux 系统为例,也是由 shell 进程 fork 出一个新进程,在新进程中调用 exec 函数装载我们的可执行文件并执行。
-
execve()。当 shell 中敲入执行程序的指令之后,shell 进程获取到敲入的指令,并执行 execve() 函数,该函数的参数是敲入的可执行文件名和形参,还有就是环境变量信息。execve() 函数对进程栈进行初始化,即压栈环境变量值,并压栈传入的参数值,最后压栈可执行文件名。初始化完成后调用 sys_execve()
-
sys_execve()。该函数进行一些参数的检查与复制,而后调用 do_execve()
-
do_execve()。该函数在当前路径与环境变量的路径中寻找给定的可执行文件名,找到文件后读取该文件的前 128 字节。读取这 128 个字节的目的是为了判断文件的格式,每个文件的开头几个字节都是魔数,可以用来判断文件类型。读取了前 128 字节的文件头部后,将调用 search_binary_handle()
-
search_binary_handle()。该函数将去搜索和匹配合适的可执行文件装载处理程序。Linux 中所有被支持的可执行文件格式都有相应的装在处理程序。以 Linux 中的 ELF 文件为例,接下来将会调用 elf 文件的处理程序:load_elf_binary()
-
load_elf_binary()。
该函数执行以下三个步骤:
a)创建虚拟地址空间:实际上指的是建立从虚拟地址空间到物理内存的映射函数所需要的相应的数据结构。(即创建一个空的页表)
b)读取可执行文件的文件头,建立可执行文件到虚拟地址空间之间的映射关系
c)将 CPU 指令寄存器设置为可执行文件入口(虚拟空间中的一个地址)
load_elf_binary() 函数执行完毕,事实上装载函数执行完毕后,可执行文件真正的指令和数据都没有被装入内存中,只是建立了可执行文件与虚拟内存之间的映射关系,以及分配了一个空的页表,用来存储虚拟内存与物理内存之间的映射关系。
- 程序返回到 execve() 中。此时从内核态返回到用户态,且寄存器的地址被设置为了 ELF 的入口地址,于是新的程序开始启动,发现程序入口对应的页面并没有加载(因为初始时是空页面),则此时引发一个缺页错误,操作系统根据可执行文件和虚拟内存之间的映射关系,在磁盘上找到缺的页,并申请物理内存,将其加载到物理内存中,并在页表中填入该虚拟内存页与物理内存页之间的映射关系。之后程序正常运行,直至结束后回到 shell 父进程中,结束回到 shell。
说一说内存对齐
- 由一个例子引出内存对齐
//32位系统
#include<stdio.h>
struct{
int x;
char y;
}s;
int main()
{
printf("%d\n",sizeof(s); // 输出8
return 0;
}
上述代码实际得到的值是 8byte,这就是内存对齐所导致的。
从理论上来讲,任何类型的变量可以从任意地址开始存放。但是实际上计算机对基本数据类型在内存中的存放位置有限制,会要求数据首地址的值是某个数(通常是 4 或 8)的整数倍,这就是内存对齐。
- 为什么要内存对齐呢?
计算机的内存是以字节为单位的,但是大部分处理器并不是按照字节块来存取内存的。计算机有个内存存取粒度的概念,就是说一般以 2 字节,4 字节,8 字节这样的单位来存取内存。
性能原因:加入没有内存对齐机制,数据可以任意存放。地址排列为 01234567 现在读取一个 int 型变量,这个变量存放地址从 1 开始,那我们从 0 字节开始找,找到 1 后读取第一个 4 字节,把 0 扔掉,然后从地址 4 开始读下 4 个字节,扔掉 567 地址,int 变量就存储在 1234 这里,可以看到这样很浪费开销,因为访问了两次内存。
- 内存对齐的规则
对其规则如下:
-
基本类型的对齐值就是 sizeof 值。如果该成员是 c++ 自带类型如 int、char、double 等,那么其对齐字节数=该类型在内存中所占的字节数;如果该成员是自定义类型如某个 class 或者 struct,那个它的对齐字节数 = 该类型内最大的成员对齐字节数
-
结构体。结构体本身也要对齐,按照最大成员长度来参照的。
-
编译器可以设置最大对齐值,gcc 中默认是#pragma pack(4)。但是类型的实际对齐值与默认对齐值取最小值来
-
如果设置了对齐字节数,就另说。①如果定义的字节数为 1,就是所有默认字节数直接相加。②定义的字节数大于任何一个成员大小时候,不产生任何效果。如果定义的对齐字节数大于结构体内最小的,小于结构体内最大的话,就按照定义的字节数来计算
内存空间的堆和栈的区别是什么?
-
程序内存布局场景下,堆与栈表示两种内存管理方式;
-
栈是由操作系统自动分配的,用于存放函数参数值,局部变量。存储在栈中的数据的生命周期随着函数的执行结束而结束。栈的内存生长方向与堆相反,由高到低,按照变量定义的先后顺序入栈。
-
堆是由用户自己分配的。如果用户不回收,程序结束后由操作系统自动回收。堆的内存地址生长方向与栈相反,由低到高。
解答
操作系统有一个记录空闲内存地址的链表,当系统收到程序的开辟内存申请时候,会遍历该链表,寻找第一个空间大于所申请内存空间的节点。接着把该节点从空闲链表中删除,同时将该空间分配出去给程序使用。同时大多数系统会在内存空间中的首地址记录此次分配的大小,这样 delete 才能正确释放内存空间。由于找到的节点所对应的内存大小不一定正好等于申请内存的大小,OS 会自动的将多余的部分放入空闲链表。
- 堆与栈区别的总结
-
管理方式不同
-
空间大小不同
-
分配方式不同。堆都是动态分配。栈的静态分配由操作系统完成,回收也是自动的。栈的动态分配有 alloca 函数分配,由操作系统自动回收。
-
分配效率不同。栈由操作系统自动分配,会在硬件层级对栈提供支持:分配专门的寄存器存放栈的地址,压栈出栈都有专门的指令执行,这就决定了栈的效率比较高。堆则是由 C/C++ 提供的库函数或运算符来完成申请与管理,实现机制较为复杂,频繁的内存申请容易产生内存碎片。显然,堆的效率比栈要低得多。
- 数据结构场景下,堆与栈表示两种常用的数据结构。
栈是线性结构
堆是一种特殊的完全二叉树
堆上分配内存的过程:
程序的结构是前部是栈,中部是程序体,后部是堆。前部和中部是链接的时候由编译器算好了的,执行过程中是固定不变的。而后部则可以随着程序的执行而改变长度。
栈是用来存储程序初期设定的变量的,这些变量在程序执行之前就要准备好。因此栈要放在程序的前部并且固定位置,否则程序就不知道该到那里去找这些变量。而程序的运行结果往往是不能预先确定的,所以把堆放在后部以便可以提供足够的内存保存运算结果。
进程虚拟空间是怎么布局的?进程内存模型
linux 进程在 32 位处理器下的虚拟空间内存布局,从高地址到低地址
-
内核空间,从 C000000-FFFFFFFF
-
栈区。有以下用途
-
存储函数局部变量
-
记录函数调用过程的相关信息,成为栈帧,主要包括函数返回地址,一些不适合放在寄存器中的函数参数。
-
临时存储区,暂存算术表达式的计算结果和 allocation 函数分配的栈内存。
-
内存映射段(mmap)。内核将硬盘文件的内容直接映射到内存,是一种方便高校的文件 I/O 方式,用于装在动态共享库。
-
堆。分配的堆内存是经过字节对齐的空间,以适合原子操作。堆管理器通过链表管理每个申请的内存,由于堆申请和释放是无序的,最终会产生内存碎片。堆内存一般由应用程序分配释放,回收的内存可供重新使用。若程序员不释放,程序结束时操作系统可能会自动回收。
-
BSS 段。通常存放以下内容:
-
未初始化的全局变量和静态局部变量
-
初始化值为 0 的全局变量和静态局部变量
-
未定义且初值不为 0 的符号
- 数据段。通常存放程序中已经初始化且初值不为 0 的全局变量。数据段属于静态存储区,可读可写。
数据段和 BSS 段的区别如下:
-
BSS 段不占用物理文件尺寸,但占用内存空间(不在可执行文件中)。数据段在可执行文件中,也占用内存空间。
-
当程序读取数据段的数据时候,系统会发生缺页故障,从而分配物理内存。当程序读取 BSS 段数据的时候,内核会将其转到一个全零页面,不会发生缺页故障,也不会为期分配物理内存。
-
bss 是不占用.exe 文件(可执行文件)空间的,其内容由内存为什么要分堆栈在编程里,要是全部只用堆或者全部只用栈可以吗(清零);而 data 却需要占用,其内容由操作系统初始化,因此造成了上述情况。
-
代码段。代码段也称正文段或文本段,通常用于存放程序执行代码 (即 CPU 执行的机器指令)。一般 C 语言执行语句都编译成机器代码保存在代码段。通常代码段是可共享的,因此频繁执行的程序只需要在内存中拥有一份拷贝即可。
-
保留区。位于虚拟地址空间的最低部分,未赋予物理地址。任何对它的引用都是非法的,用于捕捉使用空指针和小整型值指针引用内存的异常情况。
系统调用和进程上下文切换的区别
系统调用过程中,并不会涉及到虚拟内存等进程用户态的资源,也不会切换进程。这跟我们通常所说的进程上下文切换是不一样的:
进程上下文切换,是指从一个进程切换到另一个进程运行;而系统调用过程中一直是同一个进程在运行。
上下文切换
程序初始化
- CPU 寄存器是 CPU 内置的容量小、但速度极快的内存。
- 程序计数器则是用来存储 CPU 正在执行的指令位置、或者即将执行的下一条指令位置。
什么是 CPU 上下文切换
就是先把前一个任务的 CPU 上下文(也就是 CPU 寄存器和程序计数器)保存起来,然后加载新任务的上下文到这些寄存器和程序计数器,最后再跳转到程序计数器所指的新位置,运行新任务。而这些保存下来的上下文,会存储在系统内核中,并在任务重新调度执行时再次加载进来。这样就能保证任务原来的状态不受影响,让任务看起来还是连续运行。
CPU 上下文切换的类型
- CPU 寄存器和程序计数器就是 CPU 上下文,因为它们都是 CPU 在运行任何任务前,必须的依赖环境。
-
.切换页目录以使用新的地址空间
-
切换内核栈
-
切换硬件上下文
-
刷新 TLB
-
系统调度器的代码执行
- 进程上下文切换
线程上下文切换
前后两个线程属于不同进程。此时,因为资源不共享,所以切换过程就跟进程上下文切换是一样。
两个线程属于不同进程
线程上下文切换和进程上下文切换一个最主要的区别是线程的切换虚拟内存空间依然是相同的,
但是进程切换是不同的。这两种上下文切换的处理都是通过操作系统内核来完成的。
内核的这种切换过程伴随的最显著的性能损耗是将寄存器中的内容切换出。
另外一个隐藏的损耗是上下文的切换会扰乱处理器的缓存机制。
简单的说,一旦去切换上下文,处理器中所有已经缓存的内存地址一瞬间都作废了。
- 两个线程属于相同进程
为了快速响应硬件的事件,中断处理会打断进程的正常调度和执行,转而调用中断处理程序,响应设备事件。而在打断其他进程时,就需要将进程当前的状态保存下来,这样在中断结束后,进程仍然可以从原来的状态恢复运行。
中断上下文切换。即便中断过程打断了一个正处在用户态的进程,也不需要保存和恢复这个进程的虚拟内存、全局变量等用户态资源。只需要关注内核资源就行,CPU 寄存器,内核堆栈,硬件中断参数啥的。
- 中断上下文切换并不涉及到进程的用户态
-
时间片轮转技术下,该进程分配到的时间片耗尽,就会被系统挂起,切换到其他进程
-
进程在系统资源不足(比如内存不足)时,要等到资源满足后才可以运行,这个时候进程也会被挂起,并由系统调度其他进程运行。
-
当进程通过睡眠函数 sleep 这样的方法将自己主动挂起时,自然也会重新调度。
-
当有优先级更高的进程运行时,为了保证高优先级进程的运行,当前进程会被挂起,由高优先级进程来运行
-
发生硬件中断时,CPU 上的进程会被中断挂起,转而执行内核中的中断服务程序。
什么是大端字节,什么是小端字节?如何转换字节序?
大端序,小端序是计算机存储数据的两种方式。
大端字节序:高位字节在前,低位字节在后,符合人类读写数值的习惯(参考普通的十进制数)
小端字节序:低位字节在前,高位字节在后。
- 为什么要有大小端字节序?统一不行吗?
答:①内存的低地址处存放低字节,所以在强制转换数据时不需要调整字节的内容(注解:比如把 int 的 4 字节强制转换成 short 的 2 字节时,就直接把 int 数据存储的前两个字节给 short 就行,因为其前两个字节刚好就是最低的两个字节,符合转换逻辑);②CPU 做数值运算时从内存中依顺序依次从低位到高位取数据进行运算,直到最后刷新最高位的符号位,这样的运算方式会更高效。但是大端序更符合人类的习惯,主要用在网络传输和文件存储方面,符号位在所表示的数据的内存的第一个字节中,便于快速判断数据的正负和大小。
其各自的优点就是对方的缺点,正因为两者彼此不分伯仲,再加上一些硬件厂商的坚持,因此在多字节存储顺序上始终没有一个统一的标准
- 转换字节序
主要是针对主机字节序和网络字节序来说的。我们用的 x86 架构的处理器一般都是小端序存储数据,但是网络字节序是 TCP/IP 中规定好的数据表示格式,RFC1700 规定使用“大端”字节序为网络字节序,独立于处理器操作系统。
在 Linux 网络编程中,会使用下列 C 标准库函数进行字节之间的转换
#include <arpa/inet.h>
uint32_t htonl(uint32_t hostlong); //把uint32_t类型从主机序转换到网络序
uint16_t htons(uint16_t hostshort); //把uint16_t类型从主机序转换到网络序
uint32_t ntohl(uint32_t netlong); //把uint32_t类型从网络序转换到主机序
uint16_t ntohs(uint16_t netshort); //把uint16_t类型从网络序转换到主机序
- 如何判断本机是大端序还是小端序?
int i=1;
char *p=(char *)&i;
if(*p == 1)
printf("小端模式"); //小端序是低位字节在前,高位字节在后
else // (*p == 0)
printf("大端模式");
mmap(内存映射)
什么是 mmap?
mmap 是一种内存映射文件的方法,即将一个文件或者其它对象映射到进程的地址空间,实现文件磁盘地址和进程虚拟地址空间中一段虚拟地址的一一对映关系。实现这样的映射关系后,进程就可以采用指针的方式读写操作这一段内存,而系统会自动回写脏页面到对应的文件磁盘上,即完成了对文件的操作而不必再调用 read,write 等系统调用函数。相反,内核空间对这段区域的修改也直接反映用户空间,从而可以实现不同进程间的文件共享。
Mmap 的过程—原理
-
进程启动映射过程,并在虚拟地址空间中为映射创建虚拟映射区域
-
调用内核空间的系统调用函数 mmap(不同于用户空间函数),实现文件物理地址和进程虚拟地址的一一映射关系
-
进程发起对这片映射空间的访问,引发缺页异常,实现文件内容到物理内存(主存)的拷贝
Mmap 和常规文件操作的区别
常规文件的操作如下:
-
进程发起读文件请求。
-
内核查找文件描述符,定位到内核已打开的文件信息,找到文件的 inode。
-
查看文件页是否在缓存中,如果存在则直接返回这片页面
-
如果不存在,缺页中断,需要定位到该文件的磁盘地址处,将数据从磁盘复制到页缓存中,然后发起页面读写过程,将页缓存中的数据发送给用户
常规文件需要先将文件页从磁盘拷贝到页缓存中,由于页缓存处在内核空间,不能被用户进程直接寻址,所以还需要将页缓存中数据页再次拷贝到内存对应的用户空间中。这样,通过了两次数据拷贝过程,才能完成进程对文件内容的获取任务。
而使用 mmap 操作文件中,创建新的虚拟内存区域和建立文件磁盘地址和虚拟内存区域映射这两步,没有任何文件拷贝操作。而之后访问数据时发现内存中并无数据而发起的缺页异常过程,可以通过已经建立好的映射关系,只使用一次数据拷贝,就从磁盘中将数据传入内存的用户空间中,供进程使用。
共享内存的原理
共享内存可以说是最有用的进程间通信方式,也是最快的 IPC 形式。两个不同进程 A、B 共享内存的意思是,同一块物理内存被映射到进程 A、B 各自的进程地址空间。进程 A 可以即时看到进程 B 对共享内存中数据的更新,反之亦然。由于多个进程共享同一块内存区域,必然需要某种同步机制,互斥锁和信号量都可以。
这种 IPC 机制无需内核介入!
采用共享内存通信的一个显而易见的好处是效率高,因为进程可以直接读写内存,而不需要任何数据的拷贝。对于像管道和消息队列等通信方式,则需要在内核和用户空间进行四次的数据拷贝 (用户空间 buf 到内核,内核把数据拷贝到内存,内存拷贝到内核,内核到用户空间),而共享内存则只拷贝两次数据 (一次从输入文件到共享内存区,另一次从共享内存区到输出文件。)
实际上,进程之间在共享内存时,并不总是读写少量数据后就解除映射,有新的通信时,再重新建立共享内存区域。而是保持共享区域,直到通信完毕为止,这样,数据内容一直保存在共享内存中,并没有写回文件。共享内存中的内容往往是在解除映射时才写回文件的。因此,采用共享内存的通信方式效率是非常高的。
进程上下文切换的场景:
不同进程之间共享的内存通常为同一段物理内存。进程可以将同一段物理内存连接到他们自己的地址空间中,所有的进程都可以访问共享内存中的地址。
共享内存位于进程空间的什么位置?
单个共享内存段最大字节数,一般为 32M
共享内存段最大个数,一般为 4096
系统中共享内存页总数默认值:2097152*4096=8GB
共享内存段最大限制是多少?
注意共享内存和内存映射是不同的
共享内存和内存映射的区别
-
共享内存可以直接创建,内存映射需要磁盘文件(匿名映射除外)
-
共享内存效率更高
-
内存。
共享内存,所有的进程操作的是同一块共享内存。
内存映射,每个进程在自己的虚拟地址空间中有一个独立的内存。
- 数据安全
进程突然退出时,共享内存还存在,内存映射区消失
运行进程的电脑死机,宕机时。在共享内存中的数据会消失。内存映射区的数据 ,由于磁盘文件中的数据还在,所以内存映射区的数据还存在。
- 生命周期
内存映射区:进程退出,内存映射区销毁
共享内存:进程退出,共享内存还在,标记删除(所有的关联的进程数为 0),或者关机
如果一个进程退出,会自动和共享内存进行取消关联。
Malloc 是如何实现内存管理的
C 语言中使用 malloc 可以分配一段连续的内存空间。在 c/c++ 开发中,因为 malloc 属于 C 标准库函数,经常会使用其分配内存。malloc 是在堆中分配一块可用内存给用户。作为一个使用频繁的基础函数,理解清楚其实现原理很有必要,因此本文主要探讨 malloc 的具体实现原理,以及在 linux 系统中这该函数的实现方式。
内存分配涉及到的系统调用
共享内存不保证同步,可以使用信号量来保证共享内存同步
上图是一个堆内存的分布,其实在堆内存有三段空间,第一段是映射好的,指针可以访问的;第二段是未映射的地址空间,访问这段空间会报错;第三段是无法使用的空间。其中映射好的空间和未映射的空间由一个 brk 指针分割。所以如果要增加实际堆的可用大小,就可以移动 brk 指针。
跟 brk/sbrk/mmap 这三个系统调用函数有关
要增加一个进程实际的可用堆大小,就需要将 break 指针向高地址移动。Linux 通过**brk() 和 sbrk()**操作 break 指针。两个系统调用的原型如下:
#include <unistd.h>
int brk(void *addr);
void *sbrk(intptr_t increment);
brk 函数将 break 指针直接设置为某个地址,而 sbrk 将 break 指针从当前位置移动 increment 所指定的增量。所以我们使用 sbrk 获得 brk 的地址。brk 在执行成功时返回 0,否则返回 -1 并设置 errno 为 ENOMEM;sbrk 成功时返回 break 指针移动之前所指向的地址,否则返回 (void *)-1。
**mmap 函数:**mmap 函数第一种用法是映射磁盘文件到内存中;而 malloc 使用的 mmap 函数的第二种用法,即匿名映射,匿名映射不映射磁盘文件,而是向映射区申请一块内存。当申请小内存的时,malloc 使用 sbrk 分配内存;当申请大内存时,使用 mmap 函数申请内存;但是这只是分配了虚拟内存,还没有映射到物理内存,当访问申请的内存时,才会因为缺页异常,内核分配物理内存。
Malloc 实现方案
由于 brk/sbrk/mmap 属于系统调用,如果每次申请内存,都调用这三个函数中的一个,那么每次都要产生系统调用开销(即 cpu 从用户态切换到内核态的上下文切换,这里要保存用户态数据,等会还要切换回用户态),这是非常影响性能的;其次,这样申请的内存容易产生碎片,因为堆是从低地址到高地址,如果低地址的内存没有被释放,高地址的内存就不能被回收。鉴于此,brk 和 sbrk 系统调用,malloc 内存池实现方式更类似于 STL 分配器和 memcached 的内存池,先申请一大块内存,然后将内存分成不同大小的内存块,然后用户申请内存时,直接从内存池中选择一块相近的内存块即可。
如上图,内存池保存在 bins 这个长 128 的数组中,每个元素都是一双向个链表。
malloc 将内存分成了大小不同的 chunk,然后通过 bins 来组织起来。malloc 将相似大小的 chunk(图中可以看出同一链表上的 chunk 大小差不多)用双向链表链接起来,这样一个链表被称为一个 bin。malloc 一共维护了 128 个 bin,并使用一个数组来存储这些 bin。数组中第一个为malloc 采用的是内存池的实现方式,数组编号前 2 到前 64 的 bin 为unsorted bin,同一个 small bin 中的 chunk 具有相同的大小,两个相邻的 small bin 中的 chunk 大小相差 8bytes。small bins 后面的 bin 被称作small bins。large bins 中的每一个 bin 分别包含了一个给定范围内的 chunk,其中的 chunk 按大小序排列。large bin 的每个 bin 相差 64 字节。
malloc 除了有 unsorted bin,small bin,large bin 三个 bin 之外,还有一个large bins。一般的情况是,程序在运行时会经常需要申请和释放一些较小的内存空间。当分配器合并了相邻的几个小的 chunk 之后,也许马上就会有另一个小块内存的请求,这样分配器又需要从大的空闲内存中切分出一块,这样无疑是比较低效的,故而,malloc 中在分配过程中引入了 fast bins,不大于 max_fast(默认值为 64B) 的 chunk 被释放后,首先会被放到 fast bins 中,fast bins 中的 chunk 并不改变它的使用标志 P。这样也就无法将它们合并,当需要给用户分配的 chunk 小于或等于 max_fast 时,malloc 首先会在 fast bins 中查找相应的空闲块,然后才会去查找 bins 中的空闲 chunk。在某个特定的时候,malloc 会遍历 fast bins 中的 chunk,将相邻的空闲 chunk 进行合并,并将合并后的 chunk 加入 unsorted bin 中,然后再将 unsorted bin 里的 chunk 加入 bins 中。
unsorted bin 的队列使用 bins 数组的第一个,如果被用户释放的 chunk 大于 max_fast,或者 fast bins 中的空闲 chunk 合并后,这些 chunk 首先会被放到 unsorted bin 队列中,在进行 malloc 操作的时候,如果在 fast bins 中没有找到合适的 chunk,则 malloc 会先在 unsorted bin 中查找合适的空闲 chunk,然后才查找 bins。如果 unsorted bin 不能满足分配要求。 malloc 便会将 unsorted bin 中的 chunk 加入 bins 中。然后再从 bins 中继续进行查找和分配过程。从这个过程可以看出来,fast bin(其实感觉在这里还利用了局部性原理,常用的内存块大小差不多,从 unsorted bin 这里取就行了,这个和 TLB 之类的都是异曲同工之妙啊!)
除了上述四种 bins 之外,malloc 还有三种内存区。
-
当 fast bin 和 bins 都不能满足内存需求时,malloc 会设法在**unsorted bin 可以看做是 bins 的一个缓冲区,增加它只是为了加快分配的速度。**中分配一块内存给用户;top chunk 为在 mmap 区域分配一块较大的空闲内存模拟 sub-heap。(比较大的时候) >top chunk 是堆顶的 chunk,堆顶指针 brk 位于 top chunk 的顶部。移动 brk 指针,即可扩充 top chunk 的大小。当 top chunk 大小超过 128k(可配置) 时,会触发 malloc_trim 操作,调用 sbrk(-size) 将内存归还操作系统。
-
当 chunk 足够大,fast bin 和 bins 都不能满足要求,甚至 top chunk 都不能满足时,malloc 会从 mmap 来直接使用内存映射来将页映射到进程空间,这样的 chunk 释放时,直接解除映射,归还给操作系统。(极限大的时候)
-
Last remainder 是另外一种特殊的 chunk,就像 top chunk 和 mmaped chunk 一样,不会在任何 bins 中找到这种 chunk。当需要分配一个 small chunk,但在 small bins 中找不到合适的 chunk,如果 last remainder chunk 的大小大于所需要的 small chunk 大小,last remainder chunk 被分裂成两个 chunk,其中一个 chunk 返回给用户,另一个 chunk 变成新的 last remainder chunk。(这个应该是 fast bins 中也找不到合适的时候,用于极限小的)
由之前的分析可知 malloc 利用 chunk 结构来管理内存块,malloc 就是由不同大小的 chunk 链表组成的。malloc 会给用户分配的空间的前后加上一些控制信息,用这样的方法来记录分配的信息,以便完成分配和释放工作。chunk 指针指向 chunk 开始的地方,图中的 mem 指针才是真正返回给用户的内存指针。
Malloc 内存分配流程
- 如果分配内存<512 字节,则通过内存大小定位到 smallbins 对应的 index 上 (floor(size/8))
-
如果 smallbins[index] 为空,进入步骤 3
-
如果 smallbins[index] 非空,直接返回第一个 chunk
- 如果分配内存>512 字节,则定位到 largebins 对应的 index 上
-
如果 largebins[index] 为空,进入步骤 3
-
如果 largebins[index] 非空,扫描链表,找到第一个大小最合适的 chunk,如 size=12.5K,则使用 chunk B,剩下的 0.5k 放入 unsorted_list 中
-
遍历 unsorted_list,查找合适 size 的 chunk,如果找到则返回;否则,将这些 chunk 都归类放到 smallbins 和 largebins 里面
-
index++ 从更大的链表中查找,直到找到合适大小的 chunk 为止,找到后将 chunk 拆分,并将剩余的加入到 unsorted_list 中
-
如果还没有找到,那么使用 top chunk
-
或者,内存<128k,使用 brk;内存>128k,使用 mmap 获取新内存
此外,调用 free 函数时,它将用户释放的内存块连接到空闲链上。到最后,空闲链会被切成很多的小内存片段,如果这时用户申请一个大的内存片段,那么空闲链上可能没有可以满足用户要求的片段了。于是,malloc 函数请求延时,并开始在空闲链上翻箱倒柜地检查各内存片段,对它们进行整理,将相邻的小空闲块合并成较大的内存块。
内存碎片
free 释放内存时,有两种情况:
-
chunk 和 top chunk 相邻,则和 top chunk 合并
-
chunk 和 top chunk 不相邻,则直接插入到 unsorted_list 中
如上图示: top chunk 是堆顶的 chunk,堆顶指针 brk 位于 top chunk 的顶部。移动 brk 指针,即可扩充 top chunk 的大小。当 top chunk 大小超过 128k(可配置) 时,会触发 malloc_trim 操作,调用 sbrk(-size) 将内存归还操作系统。
以上图 chunk 分布图为例,按照 glibc 的内存分配策略,我们考虑下如下场景 (假设 brk 其实地址是 512k):
malloc 40k 内存,即 chunkA,brk = 512k + 40k = 552k malloc 50k 内存,即 chunkB,brk = 552k + 50k = 602k malloc 60k 内存,即 chunkC,brk = 602k + 60k = 662k free chunkA。
此时,由于 brk = 662k,而释放的内存是位于 [512k, 552k] 之间,无法通过移动 brk 指针,将区域内内存交还操作系统,因此,在 [512k, 552k] 的区域内便形成了一个内存空洞即内存碎片。 按照 glibc 的策略,free 后的 chunkA 区域由于不和 top chunk 相邻,因此,无法和 top chunk 合并,应该挂在 unsorted_list 链表上。
信号量和互斥量的区别?
我觉得主要区别在两方面:top chunk
- 先说第一个所有权。
解铃还须系铃人,一个锁住临界区的锁必须由上锁的线程解开,因此 mutex 的功能也就限制在了构造临界区上。
对于信号量来说,任意多线程都可以对信号量执行 PV 操作。
- 第二个是用途
也就是同步和互斥的用处。
互斥很好说了,当我占有使用权的时候别人不能进入,独占式访问某段程序和内存。
同步就是第一方面是所有权的概念,第二个方面是用途。,即一些线程生产一些线程消费,让生产和消费线程保持合理执行顺序。因为 semaphore 本意是信号灯,含量了通知的意味,只要是我的信号量值大于等于 1,那么就可以有线程或者进程来使用。
『同步』这个词也可以拆开看,一侧是等待数据的『事件』或者『通知』,一侧是保护数据的 『临界区』,所以同步也即调度线程。信号量可以满足这两个功能,但是可以注意到两个功能的应用场景还是蛮大的,有 同步 + 互斥 的空间。linux 内核曾将 semaphore 作为同步原语,后面代码变得较难维护,刷了一把 mutex 变简单了不少还变快了,需要『通知』 的场景则替换为了 completion variable。
- 举个例子
公司的咖啡机,互斥就是我一个人独占咖啡机,做完了咖啡然后撤走,咖啡机可以被别人使用。
信号量是同步 + 互斥,有一个人不停地生产咖啡,每个人排队拿,如果有了就拿,没有了就阻塞等待。等有咖啡了唤醒阻塞的线程拿咖啡。
所以更像是 mutex+condition_variable
- 英文的解释
A mutex
is essentially the same thing as a binary semaphore and sometimes uses
the same basic implementation. The differences between them are in how
they are used. While a binary semaphore may be used as a mutex, a mutex
is a more specific use-case, in that only the thread that locked the
mutex is supposed to unlock it. This constraint makes it possible to
implement some additional features in mutexes:
- Since only the thread that locked the mutex is supposed to unlock
it, a mutex may store the id of thread that locked it and verify the
same thread unlocks it.
- Mutexes may provide priority inversion
safety. If the mutex knows who locked it and is supposed to unlock it,
it is possible to promote the priority of that thread whenever a
higher-priority task starts waiting on the mutex.
- Mutexes may also provide deletion safety, where the thread holding the mutex cannot be accidentally deleted.
- Alternately, if the thread holding the mutex is deleted (perhaps due
to an unrecoverable error), the mutex can be automatically released.
- A mutex may be recursive: a thread is allowed to lock it multiple times without causing a deadlock.
写时拷贝底层原理
在 Linux 系统中,调用 fork 系统调用创建子进程时,并不会把父进程所有占用的内存页复制一份,而是与父进程共用相同的内存页,而当子进程或者父进程对内存页进行修改时才会进行复制 —— 这就是著名的 写时复制 机制。如果有多个调用者同时请求相同资源(如内存或磁盘上的数据存储),他们会共同获取相同的指针指向相同的资源,直到某个调用者试图修改资源的内容时,系统才会真正复制一份专用副本(private copy)给该调用者,而其他调用者所见到的最初的资源仍然保持不变。
传统的 fork 系统调用直接把所有资源复制给新创建的进程,这种实现过于简单并且效率低下,如果父进程中有很多数据的话依次复制个子进程,那么 fork 函数肯定是非常慢的。因此使用写时拷贝会快很多。
do one thing and do it best
首先要了解一下内存共享机制。不同进程的 虚拟内存地址 映射到相同的 物理内存地址,那么就实现了共享内存的机制。我们可以用用这种思想来实现写时拷贝。fork() 之后,kernel 把父进程中所有的内存页的权限都设为 read-only,然后子进程的地址空间指向父进程。当父子进程都只读内存时,相安无事。当其中某个进程写内存时,CPU 硬件检测到内存页是 read-only 的,于是触发页异常中断(page-fault),陷入 kernel 的一个中断例程。中断例程中,kernel 就会把触发的异常的页复制一份,于是父子进程各自持有独立的一份。这样父进程和子进程都有了属于自己独立的页。
子进程可以执行 exec() 来做自己想要的功能。
栈区分配内存快还是堆区分配内存快 ?
毫无疑问,显然从栈上分配内存更快,因为从栈上分配内存仅仅就是栈指针的移动而已
在堆区上申请与释放内存是一个相对复杂的过程,因为堆本身是需要程序员 (内存分配器实现者) 自己管理的,而栈是编译器来维护的,堆区的维护同样涉及内存的分配与释放,但这里的内存分配与释放显然不会像栈区那样简单,一句话,这里是原理,按需进行内存的分配与释放,这是由程序员决定的,我倾向于把内存动态分配释放想象成去停车场找停车位。
申请内存时底层发生了什么?
就是 malloc 如何实现内存管理的
Cache
什么是 Cache
Cache 存储器,是位于 CPU 和主存储器 DRAM 之间的一块高速缓冲存储器,规模较小,但是速度很快,通常由 SRAM(静态存储器)组成。Cache 的功能是提高 CPU 数据输入输出的速率。Cache 容量小但速度快,内存速度较低但容量大,通过优化调度算法,可以让系统的性能大大改善,感觉就像是有了主存储器的内存,又有了 Cache 的访问速度。
工作方式
CPU 在访问存储器的时候,会同时把虚拟地发送给 MMU 中的 TLB 以及 Cache,CPU 会在 TLB 中查找最终的 RPN(Real Page Number),也就是真实的物理页面,如果找到了,就会返回相应的物理地址。同时,CPU 通过 cache 编码地址中的 Index,也可以很快找到相应的 Cache line 组,但是这个 cache line 中存储的数据不一定是 CPU 所需要的,需要进一步检查,前面我们说了,如果 TLB 命中后,会返回一个真实的物理地址,将 cache line 中存放的地址和这个转换出来的物理地址进行比较,如果相同并且状态位匹配,那么就会发生 cache 命中。如果 cache miss,那么 CPU 就需要重新从存储器中获取数据,然后再将其存放在 cache line 中。
多级 Cache 存储结构
cache 的速度在一定程度上同样影响着系统的性能。一般情况下 cache 的速度可以达到 1ns,几乎可以和 CPU 寄存器速度媲美。但是,这就满足了人们对性能的追求了吗?并没有。当 cache 中没有缓存我们想要的数据的时候,依然需要漫长的等待从主存中 load 数据。为了进一步提升性能,引入多级 cache。前面提到的 cache,称为 L1 cache(第一级 cache)。我们在 L1 cache 后面连接 L2 cache,在 L2 cache 和主存之间连接 L3 cache。等级越高,速度越慢,容量越大。 但是速度和主存相比而言,依然很快。
内存屏障
现在大多数现代计算机为了提高性能而采取乱序执行,这可能会导致程序运行不符合我们预期,内存屏障就是一类同步屏障指令,是 CPU 或者编译器在对内存随机访问的操作中的一个同步点,只有在此点之前的所有读写操作都执行后才可以执行此点之后的操作。
Fork 函数相关知识
fork()函数通过系统调用创建一个与原来进程几乎完全相同的进程,也就是两个进程可以做完全相同的事,但如果初始参数或者传入的变量不同,两个进程也可以做不同的事。
一个进程调用 fork()函数后,系统先给新的进程分配资源,例如存储数据和代码的空间。然后把原来的进程的所有值都复制到新的新进程中,只有少数值与原来的进程的值不同。相当于克隆了一个自己。
本质在于堆区中每一块被分配出去的内存其生命周期都不一样
fork 调用的一个奇妙之处就是它仅仅被调用一次,却能够返回两次,它可能有三种不同的返回值:
-
在父进程中,fork 返回新创建子进程的进程 ID;
-
在子进程中,fork 返回 0;
-
如果出现错误,fork 返回一个负值;
返回值
fork 时子进程获得父进程数据空间、堆和栈的复制,所以变量的地址(当然是虚拟地址)也是一样的。
子进程与父进程之间除了代码是共享的之外,堆栈数据和全局数据均是独立的。
包含内容
fork 子进程完全复制父进程的栈空间,也复制了页表,但没有复制物理页面,所以这时虚拟地址相同,物理地址也相同,但是会把父子共享的页面标记为“只读”(类似 mmap 的 private 的方式),如果父子进程一直对这个页面是同一个页面,知道其中任何一个进程要对共享的页面“写操作”,这时内核会复制一个物理页面给这个进程使用,同时修改页表。而把原来的只读页面标记为“可写”,留给另外一个进程使用。
写时复制
fork 之后内核会通过将子进程放在队列的前面,以让子进程先执行,以免父进程执行导致写时复制,而后子进程执行 exec 系统调用,因无意义的复制而造成效率的下降。
。正因为 fork 采用了这种写时复制的机制,所以 fork 出来子进程之后,父子进程哪个先调度呢?内核一般会先调度子进程,因为很多情况下子进程是要马上执行 exec,会清空栈、堆。。这些和父进程共享的空间,加载新的代码段。。。,这就避免了“写时复制”拷贝共享页面的机会。如果父进程先调度很可能写共享页面,会产生“写时复制”的无用功。所以,一般是子进程先调度滴。
Fork 复制内部,为什么 Fork 返回 0?
fork 宏函数展开后代码
int fork(void) \
{ \
long __res; \
__asm__ volatile ("int $0x80" \
: "=a" (__res) \
: "0" (__NR_fork)); \
if (__res >= 0) \
return (type) __res; \
errno = -__res; \
return -1; \
}
fork 是一个宏,内部关键指令就是 0x80 中断,调用中断就会执行相应的中断服务程序 kernel/sched.c 中的 sched_init 方法中就对 0x80 号中断进行了配置,就是说发生该中断就会调用 system_call 方法。会进入 system_cal 这个函数,然后有一个 sys_cal_table。这个表就是一个系统调用函数表,存的都是函数指针。
//sys_call_table位于linux/sys.h
fn_ptr sys_call_table[] = { sys_setup, sys_exit, sys_fork, ... };
根据索引找到 sys_fork 这个函数,能找到这个函数就是因为由寄存器 eax 传过来个值 2,__NR_fork = 2,, 索引为 2 的函数指针就是 sys_fork。
sys_fork:
call find_empty_process
testl %eax,%eax # 在eax中返回进程号pid。若返回负数则退出。
js 1f
push %gs
pushl %esi
pushl %edi
pushl %ebp
pushl %eax
call copy_process
addl $20,%esp # 丢弃这里所有压栈内容。
1: ret
首先 find_empty_process 找到一个可用的进程 id,这个可用的进程 id 在 %eax 里面,接者调用 copy_process
int copy_process(int nr,long ebp,long edi,long esi,long gs,long none,
long ebx,long ecx,long edx,
long fs,long es,long ds,
long eip,long cs,long eflags,long esp,long ss)
{
struct task_struct *p;
int i;
struct file *f;
// 首先为新任务数据结构分配内存。如果内存分配出错,则返回出错码并退出。
// 然后将新任务结构指针放入任务数组的nr项中。其中nr为任务号,由前面
// find_empty_process()返回。接着把当前进程任务结构内容复制到刚申请到
// 的内存页面p开始处。
p = (struct task_struct *) get_free_page();
if (!p)
return -EAGAIN;
task[nr] = p;
*p = *current; /* NOTE! this doesn't copy the supervisor stack */
// 随后对复制来的进程结构内容进行一些修改,作为新进程的任务结构。先将
// 进程的状态置为不可中断等待状态,以防止内核调度其执行。然后设置新进程
// 的进程号pid和父进程号father,并初始化进程运行时间片值等于其priority值
// 接着复位新进程的信号位图、报警定时值、会话(session)领导标志leader、进程
// 及其子进程在内核和用户态运行时间统计值,还设置进程开始运行的系统时间start_time.
p->state = TASK_UNINTERRUPTIBLE;
p->pid = last_pid; // 新进程号。也由find_empty_process()得到。
p->father = current->pid; // 设置父进程
p->counter = p->priority; // 运行时间片值
p->signal = 0; // 信号位图置0
p->alarm = 0; // 报警定时值(滴答数)
p->leader = 0; /* process leadership doesn't inherit */
p->utime = p->stime = 0; // 用户态时间和和心态运行时间
p->cutime = p->cstime = 0; // 子进程用户态和和心态运行时间
p->start_time = jiffies; // 进程开始运行时间(当前时间滴答数)
// 再修改任务状态段TSS数据,由于系统给任务结构p分配了1页新内存,所以(PAGE_SIZE+
// (long)p)让esp0正好指向该页顶端。ss0:esp0用作程序在内核态执行时的栈。另外,
// 每个任务在GDT表中都有两个段描述符,一个是任务的TSS段描述符,另一个是任务的LDT
// 表描述符。下面语句就是把GDT中本任务LDT段描述符和选择符保存在本任务的TSS段中。
// 当CPU执行切换任务时,会自动从TSS中把LDT段描述符的选择符加载到ldtr寄存器中。
p->tss.back_link = 0;
p->tss.esp0 = PAGE_SIZE + (long) p; // 任务内核态栈指针。
p->tss.ss0 = 0x10; // 内核态栈的段选择符(与内核数据段相同)
p->tss.eip = eip; // 指令代码指针
p->tss.eflags = eflags; // 标志寄存器
p->tss.eax = 0; // 这是当fork()返回时新进程会返回0的原因所在
p->tss.ecx = ecx;
p->tss.edx = edx;
p->tss.ebx = ebx;
p->tss.esp = esp;
p->tss.ebp = ebp;
p->tss.esi = esi;
p->tss.edi = edi;
p->[tss.es](http://tss.es/) = es & 0xffff; // 段寄存器仅16位有效
p->tss.cs = cs & 0xffff;
p->[tss.ss](http://tss.ss/) = ss & 0xffff;
p->tss.ds = ds & 0xffff;
p->tss.fs = fs & 0xffff;
p->[tss.gs](http://tss.gs/) = gs & 0xffff;
p->tss.ldt = _LDT(nr); // 任务局部表描述符的选择符(LDT描述符在GDT中)
p->tss.trace_bitmap = 0x80000000; // 高16位有效
// 如果当前任务使用了协处理器,就保存其上下文。汇编指令clts用于清除控制寄存器CRO中
// 的任务已交换(TS)标志。每当发生任务切换,CPU都会设置该标志。该标志用于管理数学协
// 处理器:如果该标志置位,那么每个ESC指令都会被捕获(异常7)。如果协处理器存在标志MP
// 也同时置位的话,那么WAIT指令也会捕获。因此,如果任务切换发生在一个ESC指令开始执行
// 之后,则协处理器中的内容就可能需要在执行新的ESC指令之前保存起来。捕获处理句柄会
// 保存协处理器的内容并复位TS标志。指令fnsave用于把协处理器的所有状态保存到目的操作数
// 指定的内存区域中。
if (last_task_used_math == current)
__asm__("clts ; fnsave %0"::"m" (p->tss.i387));
// 接下来复制进程页表。即在线性地址空间中设置新任务代码段和数据段描述符中的基址和限长,
// 并复制页表。如果出错(返回值不是0),则复位任务数组中相应项并释放为该新任务分配的用于
// 任务结构的内存页。
if (copy_mem(nr,p)) {
task[nr] = NULL;
free_page((long) p);
return -EAGAIN;
}
// 如果父进程中有文件是打开的,则将对应文件的打开次数增1,因为这里创建的子进程会与父
// 进程共享这些打开的文件。将当前进程(父进程)的pwd,root和executable引用次数均增1.
// 与上面同样的道理,子进程也引用了这些i节点。
for (i=0; i<NR_OPEN;i++)
if ((f=p->filp[i]))
f->f_count++;
if (current->pwd)
current->pwd->i_count++;
if (current->root)
current->root->i_count++;
if (current->executable)
current->executable->i_count++;
// 随后GDT表中设置新任务TSS段和LDT段描述符项。这两个段的限长均被设置成104字节。
// set_tss_desc()和set_ldt_desc()在system.h中定义。"gdt+(nr<<1)+FIRST_TSS_ENTRY"是
// 任务nr的TSS描述符项在全局表中的地址。因为每个任务占用GDT表中2项,因此上式中
// 要包括'(nr<<1)'.程序然后把新进程设置成就绪态。另外在任务切换时,任务寄存器tr由
// CPU自动加载。最后返回新进程号。
set_tss_desc(gdt+(nr<<1)+FIRST_TSS_ENTRY,&(p->tss));
set_ldt_desc(gdt+(nr<<1)+FIRST_LDT_ENTRY,&(p->ldt));
p->state = TASK_RUNNING; /* do this last, just in case */
return last_pid;
}
copy_process 定义在 kernel/fork.c 里面,就是把父进程资源复制到子进程中,设置子进程的内存页等信息,如果父进程调用 fork 结束,就会返回 __res,因为 __res 与 %eax 寄存器是绑定的,寄存器里面存的就是进程的 pid,因为 copy_process 的返回值就是 last_pid,也就是子进程 pid,注意这里是父进程返回,子进程还没执行呢,但是子进程也会返回 __res,__res 的值就是寄存器 %eax 的值。 睁大眼睛看代码力里有这么一段 p->tss.eax = 0;,父进程已经把子进程 %eax 寄存器中的值设置为 0 了。明白为什么使用 fork 时,返回 0 就代表是子进程了吧。
通俗的解释,可以这样看待:“其实就相当于链表,进程形成了链表,父进程的 fork 函数返回的值指向子进程的进程 id, 因为子进程没有子进程,所以其 fork 函数返回的值为 0.
执行顺序
参考链接 该连接中说在多核是可以同时执行的,单核的话不确定,但是从 cow 来说确实应该子进程先执行。
复制进程的函数是 copy_process,如果 copy_process 调用成功的话,那么系统会有意让新开辟的进程运行,这是因为子进程一般都会马上调用 exec() 函数来执行其他的任务,这样就可以避免写是复制造成的开销,或者从另一个角度说,如果其首先执行父进程,而父进程在执行的过程中,可能会向地址空间中写入数据,那么这个时候,系统就会为子进程拷贝父进程原来的数据,而当子进程调用的时候,其紧接着执行拉 exec() 操作,那么此时,系统又会为子进程拷贝新的数据,这样的话,相比优先执行子程序,就进行了一次“多余”的拷贝。
锁是什么?
为什么子进程先运行?
并发编程中,为了保证数据操作的一致性,操作系统引入了锁机制,为了保证临界区代码的安全。锁其实是一种思想
锁的本质就是计算机中的一块内存。当这块内存空间被赋值为 1 的时候表示加锁了,当被赋值为 0 的时候表示解锁了,当然这块内存空间在哪里并不重要。多个线程抢占一个锁,就是要把这块内存赋为 1。
在单核的情况下,关闭 CPU 中断,使其不能暂停当前请求而处理其他请求,从而达到赋值“锁”对应的内存空间的目的。
在多核的情况下,使用锁总线和缓存一致性技术(详情看这里),可以实现在单一时刻,只有某个 CPU 里面某一个核能够赋值“锁”对应的内存空间,从而达到锁的目的。
锁总线和缓存一致性
随着多核时代的到来,并发操作已经成了很正常的现象,操作系统必须要有一些机制和原语,以保证某些基本操作的原子性,比如处理器需要保证读一个字节或写一个字节是原子的,那么它是如何实现的呢?有两种机制:本质思想。
CPU 和物理内存之间的通信速度远慢于 CPU 的处理速度,所以 CPU 有自己的内部缓存,根据一些规则将内存中的数据读取到内部缓存中来,以加快频繁读取的速度。那么这样就会造成 cpu 寄存器中的值和内存中的值出现不匹配的现象。(比如两核,i=0,第一个核 i+1,第二个核取 i 的时候 i 还是 0)
**总线锁定机制:**在 CPU1 要做 i++ 操作的时候,其在总线上发出一个 LOCK 信号,其他处理器就不能操作缓存了该变量内存地址的缓存,也就是阻塞了其他 CPU,使该处理器可以独享此共享内存。
**缓存一致性:**当某块 CPU 对缓存中的数据进行操作了之后,就通知其他 CPU 放弃储存在它们内部的缓存,或者从主内存中重新读取
锁的开销
总线锁定、缓存一致性
我们针对的是多线程环境下的锁机制,基于 linux 做测试。每种编程语言提供的锁机制都不太一样,不过无论如何,最终都会落实到两种机制上,所有锁的本质:
实际上我们在编程的时候并不会直接调用这两种机制,而是使用编程语言所带函数库里的锁方法,锁方法内部混合使用这两种机制。以 pthread 库(NPTL)的 pthread_mutex 来举例,一把锁本质上只是一个 int 类型的变量,占用 4 个字节内存并且内存边界按 4 字节对齐。加锁的时候先用 trylock 方法(内部使用的是 CAS 指令)来尝试获得锁,如果无法获得锁,则调用系统调用 sys_futex 来试图获得锁,这时候如果还不能获得锁,当前线程就会被阻塞。(一是处理器提供的原子操作指令(现在一般是 CAS—compare and swap),处理器会用轮询的方式试图获得锁,在处理器(包括多核)架构里这是必不可少的机制;二是内核提供的锁系统调用,在被锁住的时候会把当前线程置于睡眠(阻塞)状态。)
futex 的知识
所以很容易得到一个结论,如果锁不存在冲突,每次获得锁和释放锁的处理器开销仅仅是 CAS 指令的开销,在 x86-64 处理器上,这个开销只比一次内存访问(无 cache)高一点(大概是 1.3 倍)。一般的电脑上一次没有缓存的内存访问大概是十几纳秒
无冲突的时候:运行了 10 亿次,平摊到每次加锁/解锁操作大概是 14ns
锁冲突的情况:运行的结果是双核机器上消耗大约 3400ns,所以锁冲突的开销大概是不冲突开销的两百倍了,相差出乎意料的大。
测试:
总结:锁的开销有好几部分,分别是:线程上下文切换的开销,调度器开销(把线程从睡眠改成就绪或者把就运行态改成阻塞),还有后续上下文切换带来的缓存不命中开销,跨处理器调度的开销等等。
锁的开销
从上面可以知道,真正消耗时间的不是上锁的次数,而是锁冲突的次数。减少锁冲突的次数才是提升性能的关键。使用更细粒度的锁,可以减少锁冲突。这里说的粒度包括时间和空间,比如哈希表包含一系列哈希桶,为每个桶设置一把锁,空间粒度就会小很多--哈希值相互不冲突的访问不会导致锁冲突,这比为整个哈希表维护一把锁的冲突机率低很多。减少时间粒度也很容易理解,加锁的范围只包含必要的代码段,尽量缩短获得锁到释放锁之间的时间,最重要的是,绝对不要在锁中进行任何可能会阻塞的操作。使用读写锁也是一个很好的减少冲突的方式,读操作之间不互斥,大大减少了冲突。
假设单向链表中的插入/删除操作很少,主要操作是搜索,那么基于单一锁的方法性能会很差。在这种情况下,应该考虑使用读写锁,即 pthread_rwlock_t,这么做就允许多个线程同时搜索链表。插入和删除操作仍然会锁住整个链表。假设执行的插入和搜索操作数量差不多相同,但是删除操作很少,那么在插入期间锁住整个链表是不合适的,在这种情况下,最好允许在链表中的分离点(disjoint point)上执行并发插入,同样使用基于读写锁的方式。在两个级别上执行锁定,链表有一个读写锁,各个节点包含一个互斥锁,在插入期间,写线程在链表上建立读锁,然后继续处理。在插入数据之前,锁住要在其后添加新数据的节点,插入之后释放此节点,然后释放读写锁。删除操作在链表上建立写锁。不需要获得与节点相关的锁;互斥锁只建立在某一个操作节点之上,大大减少锁冲突的次数。
锁本身的行为也存在进一步优化的可能性,sys_futex 系统调用的作用在于让被锁住的当前线程睡眠,让出处理器供其它线程使用,既然这个过程的消耗很高,也就是说如果被锁定的时间不超过这个数值的话,根本没有必要进内核加锁——释放的处理器时间还不够消耗的。sys_futex 的时间消耗够跑很多次 CAS 的,也就是说,对于一个锁冲突比较频繁而且平均锁定时间比较短的系统,一个值得考虑的优化方式是先循环调用 CAS 来尝试获得锁(这个操作也被称作自旋锁),在若干次失败后再进入内核真正加锁。当然这个优化只能在多处理器的系统里起作用(得有另一个处理器来解锁,否则自旋锁无意义)。在 glibc 的 pthread 实现里,通过对 pthread_mutex 设置 PTHREAD_MUTEX_ADAPTIVE_NP 属性就可以使用这个机制。
读多写一的情况用 double buffer
注:CAS 指令是线程数据同步的原子指令。
什么是 Futex
linux 的 pthreads mutex 采用 futex 实现
Futex 是 Fast Userspace Mutexes 的缩写,现在锁的机制一般使用这个,内核态和用户态的混合机制。其设计思想其实不难理解。
在传统的 Unix 系统中,System V IPC(inter process communication),如 semaphores,msgqueues,sockets 等进程间同步机制都是对一个**锁的优化:**操作来完成的,这个内核对象对要同步的进程都是可见的,其提供了共享的状态信息和原子操作,用来管理互斥锁并且通知阻塞的进程。当进程间要同步的时候必须要通过系统调用(如 semop())在内核中完成。比如进程 A 要进入临界区,先去内核查看这个对象,有没有别的进程在占用这个临界区,出临界区的时候,也去内核查看这个对象,有没有别的进程在等待进入临界区,然后根据一定的策略唤醒等待的进程。同时经研究发现,很多同步是无竞争的,即某个进程进入互斥区,到再从某个互斥区出来这段时间,常常是没有进程也要进这个互斥区或者请求同一同步变量的。但是在这种情况下,这个进程也要陷入内核去看看有没有人和它竞争,退出的时侯还要陷入内核去看看有没有进程等待在同一同步变量上,有的话需要唤醒等待的进程。这些不必要的系统调用 (或者说内核陷入) 造成了大量的性能开销。为了解决这个问题,Futex 就应运而生。
为了解决上述这个问题,Futex 就应运而生,Futex 是一种用户态和内核态混合的同步机制。首先,同步的进程间通过 mmap 共享一段内存,futex 变量就位于这段共享的内存中且操作是原子的,当进程尝试进入互斥区或者退出互斥区的时候,先去查看共享内存中的 futex 变量,如果没有竞争发生,就不用再执行系统调用了。当通过访问 futex 变量后进程发现有竞争发生,则还是得执行系统调用去完成相应的处理(wait 或者 wake up)。简单的说,futex 就是通过在用户态的检查,(motivation)如果了解到没有竞争就不用陷入内核了,大大提高了 low-contention 时候的效率。
mutex 是在 futex 的基础上用的内存共享变量来实现的,如果共享变量建立在进程内,它就是一个线程锁,如果它建立在进程间共享内存上,那么它是一个进程锁。pthread_mutex_t 中的 _lock 字段用于标记占用情况,先使用 CAS 判断 _lock 是否占用,若未占用,直接返回。否则,通过 __lll_lock_wait_private 调用 SYS_futex 系统调用迫使线程进入沉睡。 CAS 是用户态的 CPU 指令,若无竞争,简单修改锁状态即返回,非常高效,只有发现竞争,才通过系统调用陷入内核态。所以,FUTEX 是一种用户态和内核态混合的同步机制,它保证了低竞争情况下的锁获取效率。
中断
中断基本原理
中断定义
中断机制:CPU 在执行指令时,收到某个中断信号转而去执行预先设定好的代码,然后再返回到原指令流中继续执行,这就是中断机制。
中断作用
**外设异步通知 CPU:**外设发生了什么事情或者完成了什么任务或者有什么消息要告诉 CPU,都可以异步给 CPU 发通知。例如,网卡收到了网络包,磁盘完成了 IO 任务,定时器的间隔时间到了,都可以给 CPU 发中断信号。
**CPU 之间发送消息:**在 SMP 系统中,一个 CPU 想要给另一个 CPU 发送消息,可以给其发送 IPI(处理器间中断)。
**处理 CPU 异常:**CPU 在执行指令的过程中遇到了异常会给自己发送中断信号来处理异常。例如,做整数除法运算的时候发现被除数是 0,访问虚拟内存的时候发现虚拟内存没有映射到物理内存上。
**实现系统调用:**早期的系统调用就是靠中断指令来实现的,后期虽然开发了专用的系统调用指令,但是其基本原理还是相似的。
中断产生
中断信号的产生有以下 4 个来源:
**1.外设。**外设产生的中断信号是异步的,一般也叫做硬件中断 (注意硬中断是另外一个概念)。硬件中断按照是否可以屏蔽分为可屏蔽中断和不可屏蔽中断。例如,网卡、磁盘、定时器都可以产生硬件中断。
**2.CPU。**这里指的是一个 CPU 向另一个 CPU 发送中断,这种中断叫做 IPI(处理器间中断)。IPI 也可以看出是一种特殊的硬件中断,因为它和硬件中断的模式差不多,都是异步的
**3.CPU 异常。**CPU 在执行指令的过程中发现异常会向自己发送中断信号,这种中断是同步的,一般也叫做软件中断 (注意软中断是另外一个概念)。CPU 异常按照是否需要修复以及是否能修复分为 3 类:1.陷阱 (trap),不需要修复,中断处理完成后继续执行下一条指令,2.故障 (fault),需要修复也有可能修复,中断处理完成后重新执行之前的指令,3.中止 (abort),需要修复但是无法修复,中断处理完成后,进程或者内核将会崩溃。例如,缺页异常是一种故障,所以也叫缺页故障,缺页异常处理完成后会重新执行刚才的指令。
**4.中断指令。**直接用 CPU 指令来产生中断信号,这种中断和 CPU 异常一样是同步的,也可以叫做软件中断。例如,中断指令 int 0x80 可以用来实现系统调用。
中断信号的 4 个来源正好对应着中断的 4 个作用。前两种中断都可以叫做硬件中断,都是异步的;后两种中断都可以叫做软件中断,都是同步的。很多书上也把硬件中断叫做中断 (异步中断),把软件中断叫做异常 (同步中断)。
内核对象
中断处理
有了中断之后,CPU 就分为两个执行场景了,进程执行场景 (process context) 和中断执行场景 (interrupt context)。进程的执行是进程执行场景,同步中断的处理也是进程执行场景,异步中断的处理是中断执行场景。可能有的人会对同步中断的处理是进程执行场景感到疑惑,但是这也很好理解,因为同步中断处理是和当前指令相关的,可以看做是进程执行的一部分。而异步中断的处理和当前指令没有关系,所以不是进程执行场景。
进程执行场景和中断执行场景有两个区别:一是进程执行场景是可以调度、可以休眠的,而中断执行场景是不可以调度不可用休眠的;二是在进程执行场景中是可以接受中断信号的,而在中断执行场景中是屏蔽中断信号的。所以如果中断执行场景的执行时间太长的话,就会影响我们对新的中断信号的响应性,所以我们需要尽量缩短中断执行场景的时间。
由于同步中断是软件产生的因此可以看做是进程执行的一部分,但是硬件中断在执行中断处理程序的时候会屏蔽其他中断序号,因此需要①立即快速处理。②如果比较耗时可以先预处理然后再完全处理。
中断向量号
不同的中断信号需要有不同的处理方式,那么系统是怎么区分不同的中断信号呢?是靠中断向量号。每一个中断信号都有一个中断向量号,中断向量号是一个整数。CPU 收到一个中断信号会根据这个信号的中断的向量号去查询中断向量表,根据向量表里面的指示去调用相应的处理函数。
中断信号和中断向量号是如何对应的呢?对于 CPU 异常来说,其向量号是由 CPU 架构标准规定的。对于外设来说,其向量号是由设备驱动动态申请的。对于 IPI 中断和指令中断来说,其向量号是由内核规定的。
中断框架结构
中断流程
保存现场
CPU 收到中断信号后会首先把一些数据 push 到内核栈上,保存的数据是和当前执行点相关的,这样中断完成后就可以返回到原执行点。如果 CPU 当前处于用户态,则会先切换到内核态,把用户栈切换为内核栈再去保存数据
查找向量表
保存完被中断程序的信息之后,就要去执行中断处理程序了。CPU 会根据当前中断信号的向量号去查询中断向量表找到中断处理程序。CPU 是如何获得当前中断信号的向量号的呢,如果是 CPU 异常可以在 CPU 内部获取,如果是指令中断,在指令中就有向量号,如果是硬件中断,则可以从中断控制器中获取中断向量号。那 CPU 又是怎么找到中断向量表呢,是通过 IDTR 寄存器。如下
CPU 现在已经把被中断的程序现场保存到内核栈上了,又得到了中断向量号,然后就根据中断向量号从中断向量表中找到对应的门描述符,对描述符做一番安全检查之后,CPU 就开始执行中断处理函数。
中断处理
硬中断 (hardirq)
硬件中断的中断处理和软件中断有一部分是相同的,有一部分却有很大的不同。对于 IPI 中断和 per CPU 中断,其设置是和软件中断相同的,都是一步到位设置到具体的处理函数。但是对于余下的外设中断,只是设置了入口函数,并没有设置具体的处理函数,而且是所有的外设中断的处理函数都统一到了同一个入口函数。然后在这个入口函数处会调用相应的 irq 描述符的 handler 函数,这个 handler 函数是中断控制器设置的。中断控制器设置的这个 handler 函数会处理与这个中断控制器相关的一些事物,然后再调用具体设备注册的 irqaction 的 handler 函数进行具体的中断处理。
对于外设中断为什么要采取这样的处理方式呢?有两个原因,1 是因为外设中断和中断控制器相关联,这样可以统一处理与中断控制器相关的事物,2 是因为外设中断的驱动执行比较晚,有些设备还是可以热插拔的,直接把它们放到中断向量表上比较麻烦。有个 irq_desc 这个中间层,设备驱动后面只需要调用函数 request_irq 来注册 ISR,只处理与设备相关的业务就可以了,而不用考虑和中断控制器硬件相关的处理。
软中断 (softirq)
软中断是把中断处理程序分成了两段:前一段叫做硬中断,执行驱动的 ISR,处理与硬件密切相关的事,在此期间是禁止中断的;后一段叫做软中断,软中断中处理和硬件不太密切的事物,在此期间是开中断的,可以继续接受硬件中断。软中断的设计提高了系统对中断的响应性。下面我们先说软中断的执行时机,然后再说软中断的使用接口。
软中断也是中断处理程序的一部分,是在 ISR 执行完成之后运行的,在 ISR 中可以向软中断中添加任务,然后软中断有事要做就会运行了。有些时候当软中断过多,处理不过来的时候,也会唤醒 ksoftirqd/x 线程来执行软中断。
所有软中断的处理函数都是在系统启动的初始化函数里面用 open_softirq 接口设置的。raise_softirq 一般是在硬中断或者软中断中用来往软中断上 push work 使得软中断可以被触发执行或者继续执行。
Linux 零拷贝技术
splice( ) 函数
tee( ) 函数
硬件中断、软件中断,硬中断、软中断是不同的概念,分别指的是中断的来源和中断的处理方式。
零拷贝(Zero-Copy)是一种 I/O 操作优化技术,可以快速高效地将数据从文件系统移动到网络接口,而不需要将其从内核空间复制到用户空间。其在 FTP 或者 HTTP 等协议中可以显著地提升性能。
概述:
如果服务端要提供文件传输的功能,我们能想到的最简单的方式是:将磁盘上的文件读取出来,然后通过网络协议发送给客户端。传统 I/O 的工作方式是,数据读取和写入是从用户空间到内核空间的复制,而内核空间的数据是通过操作系统层面的 I/O 接口从磁盘读取或写入。其过程如下图所示:
可以想想一下这个过程。服务器读从磁盘读取文件的时候,发生一次系统调用,产生用户态到内核态的转换,将磁盘文件拷贝到内核的内存中。然后将位于内核内存中的文件数据拷贝到用户的缓冲区中。用户应用缓冲区需要将这些数据发送到 socket 缓冲区中,进行一次用户态到内核态的转换,复制这些数据。此时这些数据在内核的 socket 的缓冲区中,在进行一次拷贝放到网卡上发送出去。
所以整个过程一共进行了四次拷贝,四次内核和用户态的切换。这种简单又传统的文件传输方式,存在冗余的上文切换和数据拷贝,在高并发系统里是非常糟糕的,多了很多不必要的开销,会严重影响系统性能。
零拷贝原理
零拷贝主要是用来解决操作系统在处理 I/O 操作时,频繁复制数据的问题。关于零拷贝主要技术有 mmap+write、sendfile 和 splice 等几种方式。
DMA 技术很容易理解,本质上,DMA 技术就是我们在主板上放一块独立的芯片。在进行内存和 I/O 设备的数据传输的时候,我们不再通过 CPU 来控制数据传输,而直接通过 DMA 控制器(DMA Controller,简称 DMAC)。这块芯片,我们可以认为它其实就是一个协处理器(Co-Processor)。DMAC 的价值在如下情况中尤其明显:当我们要传输的数据特别大、速度特别快,或者传输的数据特别小、速度特别慢的时候。
看完下图会发现其实零拷贝就是少了 CPU 拷贝这一步,磁盘拷贝还是要有的
- mmap/write 方式
把数据读取到内核缓冲区后,应用程序进行写入操作时,直接把内核的 Read Buffer 的数据复制到 Socket Buffer 以便写入,这次内核之间的复制也是需要 CPU 的参与的。
- sendfile 方式
可以看到使用 sendfile 后,没有用户空间的参与,一切操作都在内核中进行。但是还是需要 1 次拷贝
- 带有 scatter/gather 的 sendfile 方式
Linux 2.4 内核进行了优化,提供了带有 scatter/gather 的 sendfile 操作,这个操作可以把最后一次 CPU COPY 去除。其原理就是在内核空间 Read BUffer 和 Socket Buffer 不做数据复制,而是将 Read Buffer 的内存地址、偏移量记录到相应的 Socket Buffer 中,这样就不需要复制。其本质和虚拟内存的解决方法思路一致,就是内存地址的记录。
由来:
- splice 方式
其实就是 CPU 在内核空间的读缓冲区(read buffer)和网络缓冲区(socket buffer)之间建立管道(pipeline)直接把数据传过去了,不去要 CPU 复制了
常见的零拷贝实现
Mmap + Write
mmap 将内核中读缓冲区(read buffer)的地址与用户空间的缓冲区(user buffer)进行映射,从而实现内核缓冲区与应用程序内存的共享,省去了将数据从内核读缓冲区(read buffer)拷贝到用户缓冲区(user buffer)的过程,整个拷贝过程会发生 4 次上下文切换,1 次 CPU 拷贝和 2 次 DMA 拷贝。
sendfile()
通过 sendfile 系统调用,数据可以直接在内核空间内部进行 I/O 传输,从而省去了数据在用户空间和内核空间之间的来回拷贝,sendfile 调用中 I/O 数据对用户空间是完全不可见的,整个拷贝过程会发生 2 次上下文切换,1 次 CPU 拷贝和 2 次 DMA 拷贝。
splice()
在内核空间的读缓冲区(read buffer)和网络缓冲区(socket buffer)之间建立管道(pipeline),从而避免了两者之间的 CPU 拷贝操作,2 次上下文切换,0 次 CPU 拷贝以及 2 次 DMA 拷贝。
写时复制
通过尽量延迟产生私有对象中的副本,写时复制最充分地利用了稀有的物理资源。
写时拷贝底层原理
在 Linux 系统中,调用 fork 系统调用创建子进程时,并不会把父进程所有占用的内存页复制一份,而是与父进程共用相同的内存页,而当子进程或者父进程对内存页进行修改时才会进行复制 —— 这就是著名的 写时复制 机制。如果有多个调用者同时请求相同资源(如内存或磁盘上的数据存储),他们会共同获取相同的指针指向相同的资源,直到某个调用者试图修改资源的内容时,系统才会真正复制一份专用副本(private copy)给该调用者,而其他调用者所见到的最初的资源仍然保持不变。
传统的 fork 系统调用直接把所有资源复制给新创建的进程,这种实现过于简单并且效率低下,如果父进程中有很多数据的话依次复制个子进程,那么 fork 函数肯定是非常慢的。因此使用写时拷贝会快很多。
注意: sendfile 适用于文件数据到网卡的传输过程,并且用户程序对数据没有修改的场景;
首先要了解一下内存共享机制。不同进程的 虚拟内存地址 映射到相同的 物理内存地址,那么就实现了共享内存的机制。我们可以用用这种思想来实现写时拷贝。fork() 之后,kernel 把父进程中所有的内存页的权限都设为 read-only,然后子进程的地址空间指向父进程。当父子进程都只读内存时,相安无事。当其中某个进程写内存时,CPU 硬件检测到内存页是 read-only 的,于是触发页异常中断(page-fault),陷入 kernel 的一个中断例程。中断例程中,kernel 就会把触发的异常的页复制一份,于是父子进程各自持有独立的一份。这样父进程和子进程都有了属于自己独立的页。
子进程可以执行 exec() 来做自己想要的功能。
虚拟内存
为什么需要虚拟内存?内存技术发展历程
- 1、无存储器抽象
早期计算机没有存储器抽象,每一个程序都是直接访问物理内存。这种情况下在内存中同时运行两个程序是不可能的,因为如果第一个程序比如在内存 2000 的位置写入一个新的值,那么第二个程序运行的时候就会擦掉第一个程序的值。
但是即使没有抽象概念,运行两个程序也是可能的。。。
操作系统把当前内存中的内容保存在磁盘文件中,然后把下一个程序读入到内存中再运行就行,即某个时间内内存只有一个程序在运行就不会发生冲突。(最早期的交换的概念)
还有就是借助硬件来实现并发运行多个程序,比如 IBM360 中内存被划分为 2KB 的块,每个块有一个 4 位的保护键,保护键存储在 CPU 的特殊寄存器中。一个运行中的程序如果访问的保护键与操作系统中保存的不相符,则硬件会捕获到该异常,由于只有操作系统可以修改保护键,因此这样就可以防止用户进程之间、用户进程和操作系统间的干扰。
- 2、地址空间(存储抽象)
把物理地址暴露给进程会带来几个问题:
- 如果用户进程可以寻址内存中的每一个字节,就可以很容易的破坏操作系统,从而时电脑出故障。
- 想要同时运行多个程序比较困难
在之前所说的解决多个程序在内存中互不影响的办法主要有两个:保护和重定位,也就是说并不是所有内存你都能访问,因此出现了地址空间的概念。
避免死锁的方法是一个进程可用于寻址内存的一套地址的集合。每个进程都有自己的地址空间,并且这个地址空间独立于其他进程的地址空间
最开始的地址空间的解决办法是动态重定位,就是简单地把每个进程的地址空间映射到物理内存的不同部分。但是也有问题,即每个进程都有内存,如果同时运行多个进程,那所需要的内存是很庞大的,这个 ram 数远远超过存储器支持的。比如在 Linux 和 Windows 中,计算机完成引导后会启动 50-100 个进程,那你需要的空间可大了去了。
处理上述内存超载问题有两个办法,就是交换技术和虚拟内存。
交换技术就是把一个进程完整的调入内存,使得该进程完整运行一段时间后在调入磁盘。但是这样的话,你把一个完整的进程调入进去,可能很大程度只会使用一部分内存,这样是不划算的。
所以有了虚拟内存,每个进程的一部分调入内存。
- 3、虚拟内存
这个要重点说
什么是虚拟内存
根据上面所说,虚拟内存就是来解决“地址空间”,即如何不把程序内存全部装进去。
程序大于内存的问题。当程序引用到一部分在物理内存中的地址空间时,由硬件立刻执行映射。当程序引用到一部分不在物理内存中的地址空间时,由操作系统负责将缺失部分装入物理内存并重新执行指令。
虚拟内存的基本思想是每个程序都拥有自己的地址空间,这些空间被分割成多个块儿。每一块儿被称作一页或者页面。每一个页面有连续的地址范围。这些页面被映射到物理内存,但是并不是一个程序的所有的页面都必须在内存中才能运行 使得应用程序认为它拥有连续的可用的内存(一个连续完整的地址空间),而实际上,它通常是被分隔成多个物理内存碎片,还有部分暂时存储在外部磁盘存储器上,在需要时进行数据交换。与没有使用虚拟内存技术的系统相比,使用这种技术的系统使得大型程序的编写变得更容易,对真正的物理内存(例如 RAM)的使用也更有效率。
第一阶段:分段
程序是由若干个逻辑分段组成的,如可由代码分段、数据分段、栈段、堆段组成。不同的段是有不同的属性的,所以就用分段的形式
分段机制下的虚拟地址由两部分组成,虚拟内存和段选择子。
-
段选择子就保存在段寄存器里面。段选择子里面最重要的是段号,用作段表的索引。段表里面保存的是这个段的基地址、段的界限和特权等级等。
-
虚拟地址中的段内偏移量应该位于 0 和段界限之间,如果段内偏移量是合法的,就将段基地址加上段内偏移量得到物理内存地址。
虚拟地址是通过段表与物理地址进行映射的,分段机制会把程序的虚拟地址分成 4 个段,每个段在段表中有一个项,在这一项找到段的基地址,再加上偏移量,于是就能找到物理内存中的地址,如下图:
如果要访问段 3 中偏移量 500 的虚拟地址,我们可以计算出物理地址为,段 3 基地址 7000 + 偏移量 500 = 7500。
分段的办法很好,解决了程序本身不需要关心具体的物理内存地址的问题,但它也有一些不足之处:
- 第一个就是内存碎片的问题。
-
外部内存碎片,也就是产生了多个不连续的小物理内存,导致新的程序无法被装载;
-
内部内存碎片,程序所有的内存都被装载到了物理内存,但是这个程序有部分的内存可能并不是很常使用,这也会导致内存的浪费;
- 第二个就是内存交换的效率低的问题。
解决外部内存碎片的问题就是内存交换。这个内存交换空间,在 Linux 系统里,也就是我们常看到的 Swap 空间,这块空间是从硬盘划分出来的,用于内存与硬盘的空间交换。对于多进程的系统来说,用分段的方式,内存碎片是很容易产生的,产生了内存碎片,那不得不重新 Swap 内存区域,这个过程会产生性能瓶颈。因为硬盘的访问速度要比内存慢太多了,每一次内存交换,我们都需要把一大段连续的内存数据写到硬盘上。所以,**如果内存交换的时候,交换的是一个占内存空间很大的程序,这样整个机器都会显得卡顿。**为了解决内存分段的内存碎片和内存交换效率低的问题,就出现了内存分页。
第二阶段:分页
在没有虚拟内存的计算机上,系统直接将虚拟地址送到内存总线上,读写操作使用具有同样地址的物理内存字。在使用虚拟内存的情况下,虚拟地址不是直接被送到内存总线上的,而是被送往内存管理单元 MMU,然后 MMU 把虚拟地址映射为物理内存地址
在这里 MMU 是作为 CPU 芯片的一部分,通常就是这样的。
段内偏移量
上图可以看到虚拟地址被划分成多个页面组成的单位,而物理内存地址中对应的是页框的概念。页面和页框的大小通常是一样的,在这里是 4KB 作为例子,实际系统中可能是 512KB-1GB。上图虚拟地址空间有 64KB,物理内存有 32KB,因此由 16 个虚拟页面和 8 个页框。请记住,ram 和也磁盘之间的交换总是以整个页面为单位进行的。
比如当程序访问地址 0 的时候,其实是访问虚拟地址,然后将虚拟地址 0 送到 MMU,MMU 根据映射关系发现虚拟地址 0 在页面 0-4095 这个页面上,然后根据映射找到实际物理内存地址是第二个页框,即 8192,然后把地址 8192 送到总线上。内存对 MMU 是一无所知的,他只看到了一个读写 8192 的请求并执行。
当程序访问了一个未被映射的页面,即虚拟地址没有对应的页框索引。此时 MMU 注意到该页面没有映射,使 CPU 陷入到操作系统,即缺页中断或者缺页错误(page fault)。随后操作系统找到了一个很少使用的页框并把该页框内容写入磁盘,然后把需要访问的页面读到刚才被回收的那个页框上,修改映射关系,重新启动引起中断的指令就好。
例如如果操作系统放弃页框 1,即重新映射页框 1,那么重新改变映射关系,将页面 8 装入物理地址 4096(页框 1),并且对 MMU 映射做两处修改:①由于页框 1 之前被页面 1 映射,因此要将页面 1 标记为未映射,使得以后对页面 1 的访问都将导致缺页中断。②把虚拟页面 8 的叉号改为 1,表明虚拟页面 8 映射到页框 1 上去,当指令重新启动时候就会产生新的映射。
具体页表中虚拟地址与物理内存地址之间的映射关系如下:
输入虚拟地址 8196 的二进制在最底下即 0010000000000100,用 MMU 映射机进行映射,这 16 位虚拟地址被分解成 4 位的页号 +12 位的偏移量。4 位页号表示 16 个页面,是页面的索引,12 位的位偏移可以为一页内的全部 4096 个字节编址。
第二阶段:分表
虚拟地址到物理地址的映射可以概括如下:虚拟地址被分成虚拟页号 + 偏移量。虚拟页号是一个页表的索引,可以有该索引即页面号找到对应的页框号,然后将该页框号放到 16 位地址的前 4 位,替换掉虚拟页号,就形成了送往内存的地址。可以参考上面那个图中,两个二进制字符串的替换形式。如下图:
页表的目的是将虚拟页面映射为页框,从数学角度说页表是一个函数,输入参数是虚拟页号,输出结果是物理页框号。
页表的结构如下:
页表项中最重要的字段就是 页框号(Page frame number)。毕竟,页表到页框最重要的一步操作就是要把此值映射过去。下一个比较重要的就是 在/不在 位,如果此位上的值是 1,那么页表项是有效的并且能够被 使用。如果此值是 0 的话,则表示该页表项对应的虚拟页面 不在 内存中,访问该页面会引起一个 缺页异常(page fault)。保护位(Protection) 告诉我们哪一种访问是允许的,啥意思呢?最简单的表示形式是这个域只有一位,MMU 的内部操作:。修改位(Modified) 和 访问位(Referenced) 会跟踪页面的使用情况。当一个页面被写入时,硬件会自动的设置修改位。修改位在页面重新分配页框时很有用。如果一个页面已经被修改过(即它是 脏 的),则必须把它写回磁盘。如果一个页面没有被修改过(即它是 干净 的),那么重新分配时这个页框会被直接丢弃,因为磁盘上的副本仍然是有效的。这个位有时也叫做 脏位(dirty bit),因为它反映了页面的状态。访问位(Referenced) 在页面被访问时被设置,不管是读还是写。这个值能够帮助操作系统在发生缺页中断时选择要淘汰的页。不再使用的页要比正在使用的页更适合被淘汰。这个位在后面要讨论的 页面置换 算法中作用很大。最后一位用于禁止该页面被高速缓存,这个功能对于映射到设备寄存器还是内存中起到了关键作用。通过这一位可以禁用高速缓存。具有独立的 I/O 空间而不是用内存映射 I/O 的机器来说,并不需要这一位。
分页带来的问题
分页系统中要考虑两个问题:
- 虚拟地址到物理地址的映射必须非常快(速度问题)
由于每次访问内存都需要进行虚拟地址到物理地址的映射,所有的指令最终都必须来自内存,并且很多指令也会访问内存中的操作数。因此每条指令进行多次页表访问是必要的。如果执行一条指令需要 1ns,则页表查询必须在 0.2ns 之内完成,以避免映射成为主要的瓶颈。
- 如果虚拟地址空间很大,页表也会很大(空间问题)
现代计算机使用的虚拟地址至少为 32 位,而且越来越多的 64 位。假设一个页面大小为 4KB,则 32 位的地址空间将有 100 万页,那么 64 位地址空间更多了。一个页表有 100 万条表项,你个存储开销就很大。而且每个进程都有自己的页表还
所以我们接下来主要解决的就是这两个问题。
解决速度问题
大多数优化技术都是从内存中的页表开始的,因为这里面会存在这有巨大影响的问题。比如一条 1 字节指令要把一个寄存器中的数据复制到另一个寄存器,在不分页的情况下这条指令只访问一次内存。有了分页机制之后,会因为要访问页表而引起多次的内存访问。同时,CPU 的执行速度会被内存中取指令执行和取数据的速度拉低,所以会使性能下降。解决方案如下:
由于大多数程序总是对少量页面进行多次访问,因此只有很少的页表项会被反复读取,而其他页表项很少会被访问。针对这个问题,为计算机设置一个小型的硬件设备,将虚拟地址直接映射到物理地址,而不必再去访问页表通过 MMU 得到物理地址,这个设备叫做0 表示可读可写,1 表示的是只读又叫做转换检测缓冲区。通常在 MMU 中,包含少量的表项,实际中应该有 256 个,每一个表项都记录了一个页面相关信息,即虚拟页号、修改为、保护码、对应的物理页框号,如下表所示:
| 有效位 | 虚拟页面号 | 修改位 | 保护位 | 页框号 |
| :----: | :--------: | :----: | :----: | :----: |
| 1 | 140 | 1 | RW | 31 |
| 1 | 20 | 0 | R X | 38 |
| 1 | 130 | 1 | RW | 29 |
| 1 | 129 | 1 | RW | 62 |
| 1 | 19 | 0 | R X | 50 |
| 1 | 21 | 0 | R X | 45 |
| 1 | 860 | 1 | RW | 14 |
| 1 | 861 | 1 | RW | 75 |
TLB 其实就是一种内存缓存,用于减少访问内存所需要的时间,它就是 MMU 的一部分,TLB 会将虚拟地址到物理地址的转换存储起来,通常可以称为 地址翻译缓存(address-translation cache)。TLB 通常位于 CPU 和 CPU 缓存之间,它与 CPU 缓存是不同的缓存级别。下面我们来看一下 TLB 是如何工作的。
当一个 MMU 中的虚拟地址需要进行转换时,硬件首先检查虚拟页号与 TLB 中所有表项进行并行匹配,判断虚拟页是否在 TLB 中。如果找到了有效匹配项,并且要进行的访问操作没有违反保护位的话,则将页框号直接从 TLB 中取出而不用再直接访问页表。如果虚拟页在 TLB 中但是违反了保护位的权限的话(比如只允许读但是是一个写指令),则会生成一个 保护错误(protection fault) 返回。上面探讨的是虚拟地址在 TLB 中的情况,那么如果虚拟地址不再 TLB 中该怎么办?如果 MMU 检测到没有有效的匹配项,就会进行正常的页表查找,然后从 TLB 中逐出一个表项然后把从页表中找到的项放在 TLB 中。当一个表项被从 TLB 中清除出,将修改位复制到内存中页表项,除了访问位之外,其他位保持不变。当页表项从页表装入 TLB 中时,所有的值都来自于内存。
下面给出流程图:
快表(TLB)
直到现在,我们假设每台电脑都有可以被硬件识别的页表,外加一个 TLB。在这个设计中,TLB 管理和处理 TLB 错误完全由硬件来完成。仅仅当页面不在内存中时,才会发生操作系统的 陷入(trap)。
但是有些机器几乎所有的页面管理都是在软件中完成的。
在这些计算机上,TLB 条目由操作系统显示加载。当发生 TLB 访问丢失时,软件 TLB 管理。操作系统必须找到该页,把它从 TLB 中移除(移除页表中的一项),然后把新找到的页放在 TLB 中,最后再执行先前出错的指令。然而,所有这些操作都必须通过少量指令完成,因为 TLB 丢失的发生率要比出错率高很多。
无论是用硬件还是用软件来处理 TLB 失效,常见的方式都是找到页表并执行索引操作以定位到将要访问的页面,在软件中进行搜索的问题是保存页表的页可能不在 TLB 中,这将在处理过程中导致其他 TLB 错误。改善方法是可以在内存中的固定位置维护一个大的 TLB 表项的高速缓存来减少 TLB 失效。通过首先检查软件的高速缓存,操作系统 能够有效的减少 TLB 失效问题。
TLB 软件管理会有两种 TLB 失效问题,当一个页访问在内存中而不在 TLB 中时,将产生 软失效(soft miss),那么此时要做的就是把页表更新到 TLB 中(我们上面探讨的过程),而不会产生磁盘 I/O,处理仅仅需要一些机器指令在几纳秒的时间内完成。然而,当页本身不在内存中时,将会产生 硬失效(hard miss),那么此时就需要从磁盘中进行页表提取,硬失效的处理时间通常是软失效的百万倍。在页表结构中查找映射的过程称为 页表遍历(page table walk)。如图:
上面的这两种情况都是理想情况下出现的现象,但是在实际应用过程中情况会更加复杂,未命中的情况可能既不是硬失效又不是软失效。一些未命中可能更 软 或更 硬。比如,如果页表遍历的过程中没有找到所需要的页,那么此时会出现三种情况:
-
所需的页面就在内存中,但是却没有记录在进程的页表中,这种情况可能是由其他进程从磁盘掉入内存,这种情况只需要把页正确映射就可以了,而不需要在从硬盘调入,这是一种软失效,称为
次要缺页错误(minor page fault)。 -
基于上述情况,如果需要从硬盘直接调入页面,这就是
严重缺页错误(major page falut)。 -
还有一种情况是,程序可能访问了一个非法地址,根本无需向 TLB 中增加映射。此时,操作系统会报告一个
段错误(segmentation fault)来终止程序。只有第三种缺页属于程序错误,其他缺页情况都会被硬件或操作系统以降低程序性能为代价来修复
解决内存太大问题
上面引入 TLB 加快虚拟地址到物理地址的转换,另一个要解决的问题就是处理巨大的虚拟空间,有两种解决方法:多级页表和倒排页表。
多级页表
从整体来过一遍虚拟地址的概念,虚拟存储器的基本思想是:程序、数据和堆栈的总大小可能超过可用的物理内存的大小。由操作系统把程序当前使用的那些部分保留在主存中,而把其他部分保存在磁盘上。例如,对于一个 16MB 的程序,通过仔细地选择在每个时刻将哪 4MB 内容保留在内存中,并在需要时在内存和磁盘间交换程序的片段,这样这个程序就可以在一个 4MB 的机器上运行。
由程序产生的地址被称为虚拟地址,它们构成了一个虚拟地址空间。在使用虚拟存储器的情况下,虚拟地址不是被直接送到内存总线上,而且是被送到内存管理单元 (Memory Management Unt,MMU),MMU 把虚拟地址映射为物理内存地址。
虚拟地址空间以页面为单位划分。在物理内存中对应的单位称为页帧。页面和页帧的大小总是一样的。
- 不再是由 MMU 到页表中查找并取出需要的页表项,而是生成一个 TLB 失效并将问题交给操作系统解决
任何进程的切换都会更换活动页表集,Linux 中为每一个进程维护了一个 tast_struct 结构体(进程描述符 PCB),其中 tast_struct→mm_struct 结构体成员用来保存该进程页表。在进程切换的过程中,内核把新的页表的地址写入 CR3 控制寄存器。CR3 中含有页目录表的物理内存基地址,如下图:
- 页表在哪儿
假如每个进程都有 4GB 的虚拟地址空间,而显然对于大多数程序来说,其使用到的空间远未达到 4GB,何必去映射不可能用到的空间呢?一级页表覆盖了整个 4GB 虚拟地址空间,但如果某个一级页表的页表项没有被用到,也就不需要创建这个页表项对应的二级页表了,即可以在需要时才创建二级页表。
如果每个页表项都存在对应的映射地址那也就算了,但是,绝大部分程序仅仅使用了几个页,也就是说,只需要几个页的映射就可以了,如下图(左),进程 1 的页表,只用到了 0,1,1024 三个页,剩下的 1048573 页表项是空的,这就造成了巨大的浪费,为了避免内存浪费,计算机系统开发人员想出了一个方案,多级页表。
)
)
下面计算一下上图(右)的内存占用情况,对于一级页表来说,假设一个表项是 4B,则
一级页表占用:1024 * 4 B= 4K
2 级页表占用 = (1024 * 4 B) * 2 = 8K。
总共的占用情况是 12K,相比只有一个页表 4M,节省了 99.7% 的内存占用。
为什么省空间?
- 因此引入多级页表的原因就是避免把全部页表一直保存在内存中。
假设 32 位的虚拟地址被划分为 10 位的 PT1 域、10 位的 PT2 域和 12 位的偏移量域,工作过程如下:当一个虚拟地址被送到 MMU 时,MMU 首先提取 PT1 域并把该值作为访问顶级页表的索引。由于虚拟空间为 32G,顶级页表有 2^10(PT1)^=1024 个表项,则二级页表有 2^10(PT2)^=1024 个表项,每一个二级页表都含有虚拟地址对应物理地址的页框号,该页框号与偏移量结合便形成物理地址,放到地址总线送入内存中。
如果没有多级页表,则 32 位虚拟内存应该有 100 万个页面,这个存储开销是很大的,而有了多级页表,则实际只需要 4 个页表,顶级页表、0、1、1024 这四个,顶级页表中的其他表项的“在/不在”为被设置为 0,访问他们是强制产生一个缺页中断。
倒排页表
针对不断分级的页表的替代方案是**《深入理解计算机操作系统》 中的解释**,实际内存中的每个页框对应一个表项,而不是每个虚拟页面对应一个表项。
对于 64 位虚拟地址,4KB 的页,4GB 的 RAM,一个倒排页表仅需要 1048576 个表项。
由于 4KB 的页面,因此需要 2^64^/2^12^=2^52^个页面,但是 1GB 物理内存只能有 2^18^个 4KB 页框
虽然倒排页表节省了大量的空间,但是它也有自己的缺陷:那就是从虚拟地址到物理地址的转换会变得很困难。当进程 n 访问虚拟页面 p 时,硬件不能再通过把 p 当作指向页表的一个索引来查找物理页。而是必须搜索整个倒排表来查找某个表项。另外,搜索必须对每一个内存访问操作都执行一次,而不是在发生缺页中断时执行。解决这一问题的方式时使用 TLB。当发生 TLB 失效时,需要用软件搜索整个倒排页表。一个可行的方式是建立一个散列表,用虚拟地址来散列。当前所有内存中的具有相同散列值的虚拟页面被链接在一起。如下图所示
如果散列表中的槽数与机器中物理页面数一样多,那么散列表的冲突链的长度将会是 1 个表项的长度,这将会大大提高映射速度。一旦页框被找到,新的(虚拟页号,物理页框号)就会被装在到 TLB 中。
逻辑地址到物理地址的转换
页式存储管理的逻辑地址分为两部分:页号和页内地址。物理地址分为两部分:块号+页内地址;
逻辑地址= 页号+页内地址
物理地址= 块号+页内地址;
倒排页表
某虚拟存储器的用户编程空间共 32 个页面,每页为 1KB,内存为 16KB。假定某时刻一用户页表中已调入内存的页面的页号和物理块号的对照表如下,则逻辑地址 0A5C(H) 所对应的物理地址是什么?要求:写出主要计算过程。
| 页号 | 物理块号 |
| ---- | -------- |
| 0 | 3 |
| 1 | 7 |
| 2 | 11 |
| 3 | 8 |
举例子
用户编程空间共 32 个页面, 得知页号部分占5位,由“每页为 1KB”,,可知内页地址占 10 位。
由“内存为 16KB”, 得知块号占 4 位。
然后找页号部分,转换成二进制查表,得到物理块号,然后转为二进制拼接页内地址就 ok 了。
逻辑地址 0A5C(H)所对应的二进制表示形式是:0000101001011100,后十位1001011100是页内地址,
00010为为页号,页号化为十进制是2,在对照表中找到2对应的物理块号是11,11转换二进制是1011,即可求出物理地址为10111001011100,化成十六进制为2E5C;
即则逻辑地址 0A5C(H) 所对应的物理地址是2E5C;
页面置换算法
**缺页中断:**一个进程所有地址空间里的页面不必全部常驻内存,在执行一条指令时,如果发现他要访问的页没有在内存中(即存在位为 0),那么停止该指令的执行,并产生一个页不存在的异常,对应的故障处理程序可通过从物理内存加载该页的方法来排除故障,之后,原先引起的异常的指令就可以继续执行,而不再产生异常。
- 最佳页面置换算法(OPT)(理想算法)
最佳页面置换算法基本思路是,置换在「未来」最长时间不访问的页面。所以,该算法实现需要计算内存中每个逻辑页面的「下一次」访问时间,然后比较,选择未来最长时间不访问的页面。这很理想,但是实际系统中无法实现,因为程序访问页面时是动态的,我们是无法预知每个页面在「下一次」访问前的等待时间。所以,最佳页面置换算法作用是为了衡量你的算法的效率,你的算法效率越接近该算法的效率,那么说明你的算法是高效的。
- 先进先出置换算法(FIFO)
选择在内存驻留时间很长的页面进行中置换
- 最近最久未使用的置换算法(LRU)
发生缺页时,选择最长时间没有被访问的页面进行置换,也就是说,该算法假设已经很久没有使用的页面很有可能在未来较长的一段时间内仍然不会被使用。
虽然 LRU 在理论上是可以实现的,但代价很高。为了完全实现 LRU,需要在内存中维护一个所有页面的链表,最近最多使用的页面在表头,最近最少使用的页面在表尾。困难的是,在每次访问内存时都必须要更新「整个链表」。在链表中找到一个页面,删除它,然后把它移动到表头是一个非常费时的操作。所以,LRU 虽然看上去不错,但是由于开销比较大,实际应用中比较少使用。
- 时钟页面置换算法(Lock)
把所有的页面都保存在一个类似钟面的「环形链表」中,一个表针指向最老的页面。
当发生缺页中断时,算法首先检查表针指向的页面:
-
如果它的访问位位是 0 就淘汰该页面,并把新的页面插入这个位置,然后把表针前移一个位置;
-
如果访问位是 1 就清除访问位,并把表针前移一个位置,重复这个过程直到找到了一个访问位为 0 的页面为止;
-
最不常用置换算法(LFU)
当发生缺页中断时,选择「访问次数」最少的那个页面,并将其淘汰。
要增加一个计数器来实现,这个硬件成本是比较高的,另外如果要对这个计数器查找哪个页面访问次数最小,查找链表本身,如果链表长度很大,是非常耗时的,效率不高。
对缺页中断的处理
-
硬件陷入内核,在堆栈中保存程序计数器。大多数机器将当前的指令,各种状态信息保存在特殊的 CPU 寄存器中。
-
启动一个汇编代码保存通用寄存器和其他易失信息,防止被操作系统破坏
-
当操作系统收到缺页中断信号后,定位到需要的虚拟页面。
-
找到发生缺页中断的虚拟地址,操作系统检查这个地址是否有效,并检查存取与保护是否一致。
如果不一致则杀掉该进程
如果地址有效且没有保护错误发生,系统会检查是否有空闲页框。如果没有空闲页框就执行页面置换算法淘汰一个页面。
-
如果选择的页框对应的页面发生了修改,即为“脏页面”,需要写回磁盘,并发生一次上下文切换,挂起产生缺页中断的进程,让其他进程运行直至全部把内容写到磁盘。
-
一旦页框是干净的,则 OS 会查找要发生置换的页面对应磁盘上的地址,通过磁盘操作将其装入。在装入该页面的时候,产生缺页中断的进程仍然被挂起,运行其他可运行的进程
-
当发生磁盘中断时表明该页面已经被装入,页表已经更新可以反映其位置,页框也被标记为正常状态。
-
恢复发生缺页中断指令以前的状态,程序计数器重新指向引起缺页中断的指令
-
调度引发缺页中断的进程
-
该进程恢复寄存器和其他状态信息,返回用户空间继续执行。
Linux 可执行文件如何装载进虚拟内存
源代码通过预处理,编译,汇编,链接后形成可执行文件,因为计算机的操作系统的启动程序是写死在硬件上的,每次计算机上电时,都将自动加载启动程序,之后的每一个程序,每一个应用,都是不断的 fork 出来的新进程。那么我们的可执行文件,以 linux 系统为例,也是由 shell 进程 fork 出一个新进程,在新进程中调用 exec 函数装载我们的可执行文件并执行。
-
execve()。当 shell 中敲入执行程序的指令之后,shell 进程获取到敲入的指令,并执行 execve() 函数,该函数的参数是敲入的可执行文件名和形参,还有就是环境变量信息。execve() 函数对进程栈进行初始化,即压栈环境变量值,并压栈传入的参数值,最后压栈可执行文件名。初始化完成后调用 sys_execve()
-
sys_execve()。该函数进行一些参数的检查与复制,而后调用 do_execve()
-
do_execve()。该函数在当前路径与环境变量的路径中寻找给定的可执行文件名,找到文件后读取该文件的前 128 字节。读取这 128 个字节的目的是为了判断文件的格式,每个文件的开头几个字节都是魔数,可以用来判断文件类型。读取了前 128 字节的文件头部后,将调用 search_binary_handle()
-
search_binary_handle()。该函数将去搜索和匹配合适的可执行文件装载处理程序。Linux 中所有被支持的可执行文件格式都有相应的装在处理程序。以 Linux 中的 ELF 文件为例,接下来将会调用 elf 文件的处理程序:load_elf_binary()
-
load_elf_binary()。
该函数执行以下三个步骤:
a)创建虚拟地址空间:实际上指的是建立从虚拟地址空间到物理内存的映射函数所需要的相应的数据结构。(即创建一个空的页表)
b)读取可执行文件的文件头,建立可执行文件到虚拟地址空间之间的映射关系
c)将 CPU 指令寄存器设置为可执行文件入口(虚拟空间中的一个地址)
load_elf_binary() 函数执行完毕,事实上装载函数执行完毕后,可执行文件真正的指令和数据都没有被装入内存中,只是建立了可执行文件与虚拟内存之间的映射关系,以及分配了一个空的页表,用来存储虚拟内存与物理内存之间的映射关系。
- 程序返回到 execve() 中。此时从内核态返回到用户态,且寄存器的地址被设置为了 ELF 的入口地址,于是新的程序开始启动,发现程序入口对应的页面并没有加载(因为初始时是空页面),则此时引发一个缺页错误,操作系统根据可执行文件和虚拟内存之间的映射关系,在磁盘上找到缺的页,并申请物理内存,将其加载到物理内存中,并在页表中填入该虚拟内存页与物理内存页之间的映射关系。之后程序正常运行,直至结束后回到 shell 父进程中,结束回到 shell。
说一说内存对齐
- 由一个例子引出内存对齐
//32位系统
#include<stdio.h>
struct{
int x;
char y;
}s;
int main()
{
printf("%d\n",sizeof(s); // 输出8
return 0;
}
上述代码实际得到的值是 8byte,这就是内存对齐所导致的。
从理论上来讲,任何类型的变量可以从任意地址开始存放。但是实际上计算机对基本数据类型在内存中的存放位置有限制,会要求数据首地址的值是某个数(通常是 4 或 8)的整数倍,这就是内存对齐。
- 为什么要内存对齐呢?
计算机的内存是以字节为单位的,但是大部分处理器并不是按照字节块来存取内存的。计算机有个内存存取粒度的概念,就是说一般以 2 字节,4 字节,8 字节这样的单位来存取内存。
性能原因:加入没有内存对齐机制,数据可以任意存放。地址排列为 01234567 现在读取一个 int 型变量,这个变量存放地址从 1 开始,那我们从 0 字节开始找,找到 1 后读取第一个 4 字节,把 0 扔掉,然后从地址 4 开始读下 4 个字节,扔掉 567 地址,int 变量就存储在 1234 这里,可以看到这样很浪费开销,因为访问了两次内存。
- 内存对齐的规则
对其规则如下:
-
基本类型的对齐值就是 sizeof 值。如果该成员是 c++ 自带类型如 int、char、double 等,那么其对齐字节数=该类型在内存中所占的字节数;如果该成员是自定义类型如某个 class 或者 struct,那个它的对齐字节数 = 该类型内最大的成员对齐字节数
-
结构体。结构体本身也要对齐,按照最大成员长度来参照的。
-
编译器可以设置最大对齐值,gcc 中默认是#pragma pack(4)。但是类型的实际对齐值与默认对齐值取最小值来
-
如果设置了对齐字节数,就另说。①如果定义的字节数为 1,就是所有默认字节数直接相加。②定义的字节数大于任何一个成员大小时候,不产生任何效果。如果定义的对齐字节数大于结构体内最小的,小于结构体内最大的话,就按照定义的字节数来计算
写时拷贝底层原理
在 Linux 系统中,调用 fork 系统调用创建子进程时,并不会把父进程所有占用的内存页复制一份,而是与父进程共用相同的内存页,而当子进程或者父进程对内存页进行修改时才会进行复制 —— 这就是著名的 写时复制 机制。如果有多个调用者同时请求相同资源(如内存或磁盘上的数据存储),他们会共同获取相同的指针指向相同的资源,直到某个调用者试图修改资源的内容时,系统才会真正复制一份专用副本(private copy)给该调用者,而其他调用者所见到的最初的资源仍然保持不变。
传统的 fork 系统调用直接把所有资源复制给新创建的进程,这种实现过于简单并且效率低下,如果父进程中有很多数据的话依次复制个子进程,那么 fork 函数肯定是非常慢的。因此使用写时拷贝会快很多。
do one thing and do it best
首先要了解一下内存共享机制。不同进程的 虚拟内存地址 映射到相同的 物理内存地址,那么就实现了共享内存的机制。我们可以用用这种思想来实现写时拷贝。fork() 之后,kernel 把父进程中所有的内存页的权限都设为 read-only,然后子进程的地址空间指向父进程。当父子进程都只读内存时,相安无事。当其中某个进程写内存时,CPU 硬件检测到内存页是 read-only 的,于是触发页异常中断(page-fault),陷入 kernel 的一个中断例程。中断例程中,kernel 就会把触发的异常的页复制一份,于是父子进程各自持有独立的一份。这样父进程和子进程都有了属于自己独立的页。
子进程可以执行 exec() 来做自己想要的功能。
申请内存时底层发生了什么?
就是 malloc 如何实现内存管理的
Cache
什么是 Cache
Cache 存储器,是位于 CPU 和主存储器 DRAM 之间的一块高速缓冲存储器,规模较小,但是速度很快,通常由 SRAM(静态存储器)组成。Cache 的功能是提高 CPU 数据输入输出的速率。Cache 容量小但速度快,内存速度较低但容量大,通过优化调度算法,可以让系统的性能大大改善,感觉就像是有了主存储器的内存,又有了 Cache 的访问速度。
工作方式
CPU 在访问存储器的时候,会同时把虚拟地发送给 MMU 中的 TLB 以及 Cache,CPU 会在 TLB 中查找最终的 RPN(Real Page Number),也就是真实的物理页面,如果找到了,就会返回相应的物理地址。同时,CPU 通过 cache 编码地址中的 Index,也可以很快找到相应的 Cache line 组,但是这个 cache line 中存储的数据不一定是 CPU 所需要的,需要进一步检查,前面我们说了,如果 TLB 命中后,会返回一个真实的物理地址,将 cache line 中存放的地址和这个转换出来的物理地址进行比较,如果相同并且状态位匹配,那么就会发生 cache 命中。如果 cache miss,那么 CPU 就需要重新从存储器中获取数据,然后再将其存放在 cache line 中。
多级 Cache 存储结构
cache 的速度在一定程度上同样影响着系统的性能。一般情况下 cache 的速度可以达到 1ns,几乎可以和 CPU 寄存器速度媲美。但是,这就满足了人们对性能的追求了吗?并没有。当 cache 中没有缓存我们想要的数据的时候,依然需要漫长的等待从主存中 load 数据。为了进一步提升性能,引入多级 cache。前面提到的 cache,称为 L1 cache(第一级 cache)。我们在 L1 cache 后面连接 L2 cache,在 L2 cache 和主存之间连接 L3 cache。等级越高,速度越慢,容量越大。 但是速度和主存相比而言,依然很快。
内存屏障
现在大多数现代计算机为了提高性能而采取乱序执行,这可能会导致程序运行不符合我们预期,内存屏障就是一类同步屏障指令,是 CPU 或者编译器在对内存随机访问的操作中的一个同步点,只有在此点之前的所有读写操作都执行后才可以执行此点之后的操作。