操作系统
硬件结构
冯诺依曼模型、寄存器、总线、指令
冯诺依曼模型:运算器、控制器、存储器、输入设备、输出设备。
寄存器种类:
- 通用寄存器,用来存放需要进行运算的数据,比如需要进行加和运算的两个数据。
- 程序计数器,用来存储 CPU 要执行下一条指令「所在的内存地址」。
- 指令寄存器,用来存放当前正在执行的指令,也就是指令本身,指令被执行完成之前,指令都存储在这里。
总线是用于 CPU 和内存以及其他设备之间的通信,总线可分为 3 种:
- 地址总线,用于指定 CPU 将要操作的内存地址;
- 数据总线,用于读写内存的数据;
- 控制总线,用于发送和接收信号,比如中断、设备复位等信号,CPU 收到信号后自然进行响应,这时也需要控制总线;
CPU 要读写内存数据的过程:首先要通过「地址总线」来指定内存的地址,然后通过「控制总线」控制是读或写命令,最后通过「数据总线」来传输数据。
程序执行的基本过程:程序编译成二进制文件以后,会变成一堆指令和数据,一般会将指令放在正文段,数据放到数据段,然后执行段通过地址访问数据段中的数据。程序的运行过程就是把每一条指令一步一步的执行起来,负责执行指令的就是 CPU 了。CPU 执行程序的过程如下:
- 读取指令:CPU 读取「程序计数器」的值,这个值是指令的内存地址,然后 CPU 的「控制单元」操作「地址总线」指定需要访问的内存地址,接着通知内存设备准备数据,数据准备好后通过「数据总线」将指令数据传给 CPU,CPU 收到内存传来的数据后,将这个指令数据存入到「指令寄存器」。
- 「程序计数器」的值自增:表示指向下一条指令。这个自增的大小,由 CPU 的位宽决定,比如 32 位的 CPU,指令是 4 个字节,需要 4 个内存地址存放,因此「程序计数器」的值会自增 4;
- 执行指令:CPU 分析「指令寄存器」中的指令,确定指令的类型和参数,如果是计算类型的指令,就把指令交给「逻辑运算单元」运算;如果是存储类型的指令,则交由「控制单元」执行;
CPU 从程序计数器读取指令、到执行、再到下一条指令,这个过程会不断循环,直到程序执行结束,这个不断循环的过程被称为 CPU 的指令周期。
最简单的 MIPS 指集,MIPS指令的集中类型:
- R 指令,用在算术和逻辑操作,里面有读取和写入数据的寄存器地址。
- I 指令,加载数据到寄存器,保存寄存器数据到内存、对立即数进行运算等。
【立即数】操作码后面直接保存一个数,而不是地址;直接操作这个数,而不是从某个地址加载数。 - J 指令,用在跳转,里面存储着跳转到的目标地址。
立即数执行加法的过程:CPU从指令中提取立即数的值,然后可能从一个寄存器中取出另一个数,然后将这两个数相加。将加法的结果存储回目标寄存器中,以便后续的操作使用。
流水线处理指令:一条指令通常分为 4 个阶段,从而使用流水线执行指令
- 取指令的阶段,我们的指令是存放在存储器里的,实际上,通过程序计数器和指令寄存器取出指令的过程,是由控制器操作的;
- 指令的译码过程,也是由控制器进行的;
- 指令执行的过程,无论是进行算术操作、逻辑操作,还是进行数据传输、条件分支操作,都是由算术逻辑单元操作的,也就是由运算器处理的。但是如果是一个简单的无条件地址跳转,则是直接在控制器里面完成的,不需要用到运算器。
- 数据回写:CPU 将计算结果存回寄存器或者将寄存器的值存入内存。
指令从功能角度划分,可以分为 5 大类:
- 数据传输类型的指令,比如 store/load 是寄存器与内存间数据传输的指令,mov 是将一个内存地址的数据移动到另一个内存地址的指令;
- 运算类型的指令,比如加减乘除、位运算、比较大小等等,它们最多只能处理两个寄存器中的数据;
- 跳转类型的指令,通过修改程序计数器的值来达到跳转执行指令的过程,比如编程中常见的 if-else、switch-case、函数调用等。
- 信号类型的指令,比如发生中断的指令 trap;
- 闲置类型的指令,比如指令 nop,执行后 CPU 会空转一个周期;
程序的CPU执行时间=指令数x CPI x时钟周期时间
- CPU的频率为2.4 GHz,则时钟周期时间就是 \(1 / 2.4 * 10^9\) 秒 。
- CPI:每条指令的平均时钟周期数
- 时钟周期时间:超频就是时钟周期时间变短了。
64 位和 32 位软件
- 如果 32 位指令在 64 位机器上执行,需要一套兼容机制,就可以做到兼容运行了。但是如果 64 位指令在 32 位机器上执行,就比较困难了,因为 32 位的寄存器存不下 64 位的指令;
64 位 CPU:代表一次处理64位的数据。
存储器
存储器
- 寄存器:可以被CPU直接访问。
- CPU Cache:为SRAM(Static Random-Access Memory,静态随机存储器),一旦断电,数据就会丢失了。
- L1-Cache:每个 CPU 核心都有一块属于自己的 L1 高速缓存,指令和数据在 L1 是分开存放的,所以 L1 高速缓存通常分成指令缓存和数据缓存。
- L2-Cache:L2 高速缓存同样每个 CPU 核心都有
- L3-Cahce:L3 高速缓存通常是多个 CPU 核心共用的
- 内存:为 DRAM (Dynamic Random Access Memory,动态随机存取存储器),数据会被存储在电容里,电容会不断漏电,所以需要「定时刷新」电容,才能保证数据不会被丢失,这就是 DRAM 之所以被称为「动态」存储器的原因,只有不断刷新,数据才能被存储起来。
- SSD/HDD 硬盘:断电后数据还在。
当 CPU 需要访问内存中某个数据的时候,如果寄存器有这个数据,CPU 就直接从寄存器取数据即可,如果寄存器没有这个数据,CPU 就会查询 L1 高速缓存,如果 L1 没有,则查询 L2 高速缓存,L2 还是没有的话就查询 L3 高速缓存,L3 依然没有的话,才去内存中取数据。
【问题】CPU可以直接访问CPU Cache吗,可以不加载到寄存器吗?
【下面小林的说法真的正确吗】程序执行时,会先将内存中的数据加载到共享的 L3 Cache 中,再加载到每个核心独有的 L2 Cache,最后进入到最快的 L1 Cache,之后才会被 CPU 读取。
CPU Cache:
- CPU Cache是一块一块从内存中读取数据,一个块称为Cache Line(缓存块)。
- 内存和CPU Cache直接映射:
- 内存:将内存分成一个一个组,每个组的大小为CPU Cache可以存储的数据的大小。通过索引号可以访问到组中的每个块。通过偏移量可以访问到每个块中的每个字。上面其实就是将一个内存地址分成了[组号+索引号+偏移量]。
【字】CPU一次处理的数据大小。 - CPU Cache:可以通过索引号访问到CPU Cache中的每个块,每个块由[有效位+组号+数据]组成。使用内存地址中的组号+索引号就可以在CPU Cache中找到对应块,然后通过偏移量找到数据中对应的字。
- 内存:将内存分成一个一个组,每个组的大小为CPU Cache可以存储的数据的大小。通过索引号可以访问到组中的每个块。通过偏移量可以访问到每个块中的每个字。上面其实就是将一个内存地址分成了[组号+索引号+偏移量]。
- 其他映射方法:全相连 Cache (Fully Associative Cache)、组相连 Cache (Set Associative Cache)等
如何写出让 CPU 跑得更快的代码——提升数据缓存的命中率,比如:
//形式一:
for(i=0;i<N;i+=1){
for(j=0;j<N;j+=1){
array[i][j] = 0;
}
}
//形式二:
for(i=0;i<N;i+=1){
for(j=0;j<N;j+=1){
array[j][i] = 0;
}
}
因为array在内存中是按行存储的,所以每次从内存中加载一个块到CPU Cache时,会将多个元素加载到CPU Cache中。所以形式一会提升缓存的命中率,代码执行更快。
多核 CPU提升缓存命中率:多核 CPU 可以通过将线程绑定在某一个 CPU 核心上,从而提升缓存的命中率。
Cache 中的数据写回到内存的时机:
- 写直达(Write Through):如果数据已经在 Cache 里面,先将数据更新到 Cache 里面,再写入到内存里面;如果数据没有在 Cache 里面,就直接把数据更新到内存里面。【效率低】
- 写回(Write Back):只有当修改过的 Cache Block「被替换」时才需要写到内存中,减少了数据写回内存的频率,这样便可以提高系统的性能。如下:
- 命中cache时,只将对应cache的位置置为脏。
- 其他数据检测将要写入的cache的位置,如果发现为脏,则写入到内存。
不同cpu核心中的cache中的数据如何保持一致:因为写回策略,只有在Cache Block 要被替换的时候,数据才会写入到内存里。所以其他核心中的cache如果从内存中读取到的数据可能为旧数据。
- 所以要保证一致性,需要实现以下两点:
- 写传播:如果cache中的数据更新了,要告诉其他核心的cache
- 事务的串行化:对一个数据的多次操作,其他核心的cache看到的操作顺序应该是相同的。
- 实现方法:
- 总线嗅探:如果cache中的数据更新了,总线把这个事件广播通知给其他所有的核心。只实现了写传播,没有实现事务的串行化。
- MESI 协议:Cache Line有四种状态,已修改、独占、共享、已失效
- 「独占」和「共享」状态都代表 Cache Block 里的数据是干净的
- 修改「共享」状态的cache,需要先广播来将其他核心中的cache line标记为无效。
- 加载数据到cache时,发现其他核心的cache存在此数据,且数据已被修改。此时会先将新数据同步到内存,然后再加载数据到cache。
【注】L1/L2 cache应该不是直接从内存中加载数据,而是从L3 cache中加载数据的,因为L3 cache被所有核心共享。
伪共享的问题:多个线程同时写同一个 Cache Line中的变量时,每一次写都会将其他核心中的Cache Line变为失效。其他核心也要修改此Cache Line中的变量时,由于Cache Line失效了,就需要从内存中重新载入数据(然后新数据需要先同步到内存中)。一个Cache Line有多个变量,所以有以下两种情况:
- 如果多个线程写的是同一个变量,那没有办法。
- 如果多个线程写的是同一个Cache Line中的不同变量,那么可以使用方法如下:
- 使用
__cacheline_aligned_in_smp
将不同变量放在不同的Cache Line中。 - 每一个Cache Line只保存一个变量,其他空间使用没有意义的空间填充。如下:
- 使用
struct SharedData {
int value;
char padding[CACHE_LINE_SIZE - sizeof(int)]; // 使用填充数据,使每个结构体占用整个缓存行
};
linux内核中使用task_struct 结构体表示进程和线程。Linux 内核里的调度器,调度的对象就是 task_struct,接下来我们就把这个数据结构统称为任务。数值越小优先级越高:
- 实时任务,对系统的响应时间要求很高,也就是要尽可能快的执行实时任务,优先级在 0~99 范围内的就算实时任务;Deadline 和 Realtime 这两个调度类,都是应用于实时任务的。
- Deadline:是按照 deadline 进行调度的,距离当前时间点最近的 deadline 的任务会被优先调度;
- Realtime:
- FIFO:优先级相同遵循先来先服务,优先级高的抢占优先级低的。
- RR:优先级相同遵循时间片轮转,优先级高的抢占优先级低的。
- 普通任务,响应时间没有很高的要求,优先级在 100~139 范围内都是普通任务级别; Fair 调度类是应用于普通任务。
- NORMAL:普通任务使用的调度策略;
- BATCH:后台任务的调度策略,不和终端进行交互,因此在不影响其他需要交互的任务,可以适当降低它的优先级。
普通任务的调度算法—— CFS 算法调度:在 CFS 算法调度的时候,会优先选择 vruntime 少的任务,以保证每个任务的公平性。
虚拟运行时间vruntime +=实际运行时间delta_exec * NICE_0_LOAD/权重
- NICE_0_LOAD是个常量。
- 权重:nice 级别越低的权重值就越大,nice 的值能设置的范围是 -20~19。如下:
前面的 0~99 范围是提供给实时任务使用的,而 nice 值是映射到 100~139,这个范围是提供给普通任务用的,因此 nice 值调整的是普通任务的优先级。
CPU中有三个运行队列:Deadline 运行队列 dl_rq、实时任务运行队列 rt_rq 和 CFS 运行队列 cfs_rq,其中 cfs_rq 是用红黑树来描述的,按 vruntime 大小来排序的,最左侧的叶子节点,就是下次会被调度的任务。
优先级如下:Deadline > Realtime > Fair,因此,实时任务总是会比普通任务优先被执行。
软中断
在计算机中,中断是系统用来响应硬件设备请求的一种机制,操作系统收到硬件的中断请求,会打断正在执行的进程,然后调用内核中的中断处理程序来响应请求。当前中断处理程序没有执行完之前,系统中其他的中断请求都无法被响应,也就说中断有可能会丢失
为了避免由于中断处理程序执行时间过长,而影响正常进程的调度,Linux 将中断处理程序分为上半部和下半部:
- 上半部,对应硬中断,由硬件触发中断,用来快速处理中断;
- 下半部,对应软中断,由内核触发中断,用来异步处理上半部未完成的工作;
网卡接收网络包的例子:网卡收到网络包后,通过 DMA 方式将接收到的数据写入内存,接着会通过硬件中断通知内核有新的数据到了,于是内核就会调用对应的中断处理程序来处理该事件,这个事件的处理也是会分成上半部和下半部。
- 中断是由硬件设备产生的,通过中断控制器发送给CPU,由CPU发送给内核。
- 异常是由CPU产生的,同时,它会发送给内核,要求内核处理这些异常
二进制
int:int的第一位为符号位,1代表负数,0代表正数。
负数使用补码的原因:使用负数补码时,负数和正数之间的相加减和期望的效果一样。如果不使用,就会导致相加减和期望的效果不一样。
补码:补码就是把正数的二进制全部取反再加 1
十进制与二进制:
- 十进制数转二进制采用的是除 2 取余法:除一下,取一下余数,直到结果为0为止。从下往上得到二进制结果。
- 十进制小数与二进制的转换: 将十进制中的小数部分乘以 2得到的结果的整数部分作为二进制的一位,然后继续取小数部分乘以 2 得到的结果的整数部分作为下一位,直到不存在小数为止。所以不是所有的小数都可以使用二进制表示,比如0.1,只能在有限的精度情况下,最大化接近 0.1 的二进制数,于是就会造成精度缺失的情况。
二进制小数转十进制:
浮点数保存:首先将一个数的整数部分和小数部分都转换为二进制,然后移动小数点,比如 1000.101 这个二进制数,可以表示成 1.000101 x 2^3,单精度和多精度浮点数如下所示:
我们就以 10.625 作为例子,看看这个数字在 float 里是如何存储的。
说明:
- 由于移动小数点以后的二进制数的开头都为1,所以尾数中只存储了
010101
,开头的1并没有存储。 - 1010.101 右移 3 位成 1.010101。指数位为3+127,这是因为指数位可以为负数也可以为正数,IEEE 标准规定单精度浮点的指数取值范围是 -126 ~ +127,所以直接加上127,从而让所有数都变成正数。
从 float 的二进制浮点数转换成十进制时,要考虑到这个隐含的 1,转换公式如下:
内存管理
虚拟地址:为了让每个进程都使用独立的物理内存,非必要时,不同进程之间不共享内存。虚拟地址就是实现这一功能的方案。
- 进程运行时,操作系统为进程分配独立的一套「虚拟地址」,虚拟地址和物理地址的映射由操作系统完成。操作系统保证了不同的进程使用的物理内存都是不一样的。
- 虚拟内存可以使得进程对运行内存超过物理内存大小。因为可以将不经常使用的内存放到硬盘上的 swap 区域。
段表:
- 程序是由若干个逻辑分段组成的,如可由代码分段、数据分段、栈段、堆段组成。不同的段是有不同的属性的,所以就用分段(Segmentation)的形式把这些段分离出来。
- 将虚拟地址拆分成段号和偏移量。
段表和页表:
- 段表:存在外部碎片,不存在内部碎片。解决「外部内存碎片」的问题就是内存交换:如linux中将程序占用的内存拷贝到硬盘上,然后将外部碎片使用上。频繁进行内存与硬盘的空间交换,会导致运行卡顿
【注】Linux的Swap空间是从硬盘划分出来的,用于内存与硬盘的空间交换。 - 页表:存在内部碎片,不存在外部碎片。内存空间不够,操作系统会把其他正在运行的进程中的「最近没被使用」的内存页面给释放掉,也就是暂时写在硬盘上,称为换出(Swap Out)。一旦需要的时候,再加载进来,称为换入(Swap In)。由于页占用的内存比较小,所以内存交换的效率就相对比较高。
页表
- 32位系统中,每个虚拟地址都是一个32位的指针。假设页表项有2^20个,则代表虚拟地址的前20位代表页号,后12代表偏移量。
- 页表中并不存储虚拟页号,虚拟页号相当于数组中的索引,物理页号相当于数组中的元素,所以数组的元素中是不存储索引的。
以二级页表为例介绍多级页表:
二级页表:原本一个页表中存储所有物理页号,虚拟页号用索引表示。现在将一个页表拆分成多个页表,虚拟页号前缀相同的在同一个页表中,比如虚拟页号前10位相同的放在同一个页表中,如下图。由于每个页表中的前缀都是相同的,所以使用另一个页表保存这些前缀,所以就有了两个页表,即二级页表。
- 二级页表增加了一个页表,所以页表对内存的占用量升高了而不是降低了。二级页表通过如下方式减少内存消耗:如果存在最近一定时间未访问的页表,在物理内存紧张的情况下,操作系统会将页面换出到硬盘,也就是说不会占用物理内存。
- 不使用多级页表就不能将页表项移出内存吗?答:可以的,只是多级页表是较为高效的内存管理方式。在没有多级页表的情况下,仍然可以采取一些内存管理策略来优化页表项的存储:
- 页表划分: 将页表分为多个部分,每个部分负责一部分虚拟地址空间。这样可以将未使用的部分从内存中移出。
- 懒加载页表: 不一次性将所有的页表项加载到内存中,而是在需要访问某个虚拟地址时再加载对应的页表项。
- 多级页表需要访问多个页表,这就增加了访问内存的次数。
快表:把最常访问的几个页表项存储到cache中,这个cache就是TLB(快表)。有了 TLB 后,那么 CPU 在寻址时,会先查 TLB,如果没找到,才会继续查常规的页表。
段页式内存管理:先将程序进行分段(如代码段、数据段、栈段等),然后为每段构建一个页表。
页面置换策略
FIFO:按时间排序,选择最旧的页进行置换。
LRU页置换:用过的放前面,没用过的后面。
LFU:每个都有一个数,这个数随着时间减少,每次访问时增加,所以替换这个数最小的就行。
Linux 内存布局
在 Linux 操作系统中,虚拟地址空间的内部又被分为内核空间和用户空间两部分。虽然每个进程都各自有独立的虚拟内存,但是每个虚拟内存中的内核地址,其实关联的都是相同的物理内存。
用户空间内存,从低到高分别是 6 种不同的内存段:
- 代码区(Text Segment):也称为只读区,存储程序的机器代码。这是程序的执行部分,包含了程序的指令和逻辑。
- 数据区(Data Segment):存储已经初始化的全局变量和静态变量,它们在程序的整个运行周期内都存在。数据段的大小在编译时就确定了。
- BSS区,存储未初始化的全局变量和静态变量,通常会初始化为零值。BSS段只用于存储未初始化的变量。已初始化的全局变量和静态变量通常存储在数据段(data segment)中。
- 堆(Heap):用于动态分配内存,通常在运行时由程序员手动管理,例如使用malloc()和free()函数。堆区的大小一般由操作系统和程序运行时的需求决定。
- 栈(Stack):存储函数调用、局部变量以及函数参数等信息。栈区是自动分配和管理的,随着函数的调用和返回而动态变化。它遵循"先进后出"的原则,即最后进栈的先出栈。
- 常量区(Constant Segment):存储常量数据,例如字符串常量。这些数据通常被视为只读数据,不能被修改。
- 文件映射区(File Mapping Segment)是操作系统中的一个概念,通常用于在进程之间共享内存或将磁盘上文件内容映射到进程的虚拟地址空间中。
内核区(Kernel Space):这部分区域由操作系统内核使用,用于存储操作系统的数据结构、内核代码以及中断向量等。
malloc 申请内存的时候,会有两种方式向操作系统申请堆内存。
- 如果用户分配的内存小于 128 KB,则使用 brk() 从堆分配内存。malloc()会分配比申请的字节数大的空间。
free 释放内存的时候,并不会把内存归还给操作系统,而是缓存在 malloc 的内存池中,待下次使用。等下次在申请内存的时候,就直接从内存池取出对应的内存块就行了,而且可能这个内存块的虚拟地址与物理地址的映射关系还存在,这样不仅减少了系统调用的次数,也减少了缺页中断的次数,这将大大降低 CPU 的消耗。 - 如果用户分配的内存大于 128 KB,则使用mmap()在文件映射区域分配内存。
free 释放内存的时候,会把内存归还给操作系统,内存得到真正的释放。
为什么malloc要使用mmap()从文件映射区获取大块的内存,使用brk()从堆获取小块的内存?
- 使用mmap()的原因:因为堆区由于每次分配小块的内存,所以存在大量的碎片,限制了大块连续内存的分配能力。故将大块的内存从文件映射区获取
- 使用brk()的原因:释放内存的时候,并不会把内存归还给操作系统,而是缓存在 malloc 的内存池中,待下次使用,从而减少了系统调用。
如果使用malloc分配后的虚拟内存没有被访问的话,虚拟内存是不会映射到物理内存的,这样就不会占用物理内存了。只有在访问已分配的虚拟地址空间的时候,操作系统通过查找页表,发现虚拟内存对应的页没有在物理内存中,就会触发缺页中断,然后操作系统会建立虚拟内存和物理内存之间的映射关系。
free() 函数只传入一个内存地址,为什么能知道要释放多大的内存:内存地址的前16个字节里描述了后面内存块的信息,包括该内存块的大小。malloc()返回的地址其实时16个字节的头部以后的地址。
进程管理
- 互斥锁:向内核申请锁,申请失败以后,线程会释放 CPU ,并保存上下文信息。等待锁释放时,内核会唤醒该线程,加载上下文信息。由于保存和加载上下文信息需要时间,所以如果你能确定被锁住的代码执行时间很短,就不应该用互斥锁,而应该选用自旋锁。
- 自旋锁加锁失败后,线程会忙等待,直到它拿到锁。
- 自旋锁是通过 CPU 提供的 CAS 函数(Compare And Swap),在「用户态」完成加锁和解锁操作,不会主动产生线程上下文切换,所以相比互斥锁来说,会快一些,开销也小一些。
- 设锁为变量 lock,整数 0 表示锁是空闲状态,整数 pid 表示线程 ID,那么 CAS(lock, 0, pid) 就表示自旋锁的加锁操作,CAS(lock, pid, 0) 则表示解锁操作。通过
while(CAS(lock, 0, pid))
查看锁是否释放。
【注】上下文切换:在计算机的多线程编程中,上下文切换(Context Switching)是指在CPU中切换线程执行的过程。当一个线程正在执行时,CPU需要暂停当前线程的执行,并将其上下文(如程序计数器、寄存器内容、堆栈指针等)保存到内存中,然后加载另一个线程的上下文,使其继续执行。
- 非抢占式调度算法:先来先服务、最短作业优先、高响应比优先调度算法
- 高响应比优先调度算法:等待时间长或需要时间短,更容易被运行。故利于短作业,但也兼顾长作业。
等待处理的时间 + 预计的服务时间 = 周转时间
比率 = 周转时间 / 预计的服务时间
- 高响应比优先调度算法:等待时间长或需要时间短,更容易被运行。故利于短作业,但也兼顾长作业。
- 抢占式调度算法:时间片轮转、最高优先级调度算法
- 时间片轮转:时间片用完就挂起,并从就绪队列里选择另外一个进程
- 多级反馈队列调度算法:新进程放入最高级队列,运行一个时间片,如果没有运行完,就放到下一个队列的队尾。
多级:表示有多个队列,每个队列优先级从高到低,同时优先级越高时间片越短。
反馈:表示如果有新的进程加入优先级高的队列时,立刻停止当前正在运行的进程,转而去运行优先级高的队列。
MDA
CPU复制磁盘文件到内核的PageCache:先读到cpu寄存器中,然后再读到内存中。
- 磁盘控制器收到cpu发送的指令后,,会把数据放入到磁盘控制器的内部缓冲区中,然后产生一个中断;
- CPU 收到中断信号后,停下手头的工作,接着把磁盘控制器的缓冲区的数据一次一个字节地读进自己的寄存器,然后再把寄存器里的数据写入到内核的PageCache,而在数据传输的期间 CPU 是无法执行其他任务的。
DMA复制文件到内核的PageCache:使用DMA读到内存中,不需要cpu参与。
- 用户进程向cpu发出IO请求,然后进入阻塞状态,然后cpu向DMA发送IO请求,然后让 CPU 执行其他任务;
- DMA向磁盘发送IO请求,磁盘将数据放到磁盘控制器的缓冲区,DMA将磁盘控制器缓冲区中的数据拷贝到内核缓冲区PageCache中
零拷贝:mmap、sendfile和SG-DMA减少拷贝:
read+write,四次拷贝:
mmap + write:mmap() 来代替 read()。mmap() 系统调用函数会直接把内核缓冲区里的数据「映射」到用户空间,这样,操作系统内核与用户空间就不需要再进行任何的数据拷贝操作。
sendfile:减少了系统调用次数,减少了用户态和内核态之间的切换次数
如果网卡支持 SG-DMA:直接将内核缓存中的数据拷贝到网卡的缓冲区里
零拷贝含义:没有在内存层面去拷贝数据,也就是说全程没有通过 CPU 来搬运数据,所有的数据都是通过 DMA 来进行传输的。所以 SG-DMA算是真正的零拷贝,只需要两次DMA拷贝操作。 mmap + write
和sendfile
并没有完全实现零拷贝。
PageCache
PageCache:write写文件时,会将内容先写入名为PageCache的内核缓冲区。所以即使进程崩溃了,文件数据还是保留在内核的 page cache,操作系统会自动在合适的时机将page cache中的数据持久化到磁盘中。但是如果 page cache 里的文件数据,在持久化到磁盘化到磁盘之前,操作系统发生了崩溃,那这部分数据就会丢失了。
page 是内存管理分配的基本单位,以下是两种page:
File-backed pages:读写时使用的内核缓冲区就是File-backed pages。
Anonymous pages:匿名页不对应磁盘上的任何磁盘数据块,它们是进程的运行是内存空间(例如方法栈、局部变量表等属性)。
- 页面替换:在新进程启动发现内存不够时,会将一部分内存放到swap交换区(特殊的磁盘空间),从而腾出内存给新进程。
- 缺页中断:当进程发现需要访问的数据不在内存时,将swap区中的数据重新读取到内存中。swap分区中的组织形式应该是一个一个文件,所以缺页中断时,会先将文件读取到Page Cache中,然后再读到应用层缓冲区。
Page Cache的预读:由于程序的空间局部性,所以每次会将磁盘的相邻空间的数据读取进Page Cache。
Page Cache落盘:
- Write Through(写穿):应用层调用fsync,fdatasync 以及 sync 等进行落盘。
- Write back(写回):内核线程周期性执行落盘。
PageCache优点——加快访问速度:数据会预读到Page Cache,所以如果直接命中内存缓存,就不需要访问磁盘。
Page Cache缺点:
- 1.占用内存,内存紧张时,会执行swap操作。
- 2.Direct I/O 即直接 I/O中,用户空间直接通过 DMA 的方式与磁盘以及网卡进行数据拷贝。Page Cache中需要先将数据拷贝到内核缓冲区Page Cache中。
PageCache 传输大文件:
- 在传输大文件(GB 级别的文件)的时候,上一次访问的数据下一次不会再访问,且PageCache 被占满,也就没了预读功能,即PageCache 会不起作用,那就白白浪费 DMA 多做的一次数据拷贝,造成性能的降低,即使使用了 PageCache 的零拷贝也会损失性能。
- 由于 PageCache 被大文件占据,而导致「热点」小文件无法利用到 PageCache,这样在高并发的环境下,会带来严重的性能问题。
异步 I/O + 直接 I/O进行大文件传输:
- 前半部分,内核向磁盘发起读请求,但是可以不等待数据就位就可以返回,于是进程此时可以处理其他任务;
- 后半部分,当内核将磁盘中的数据拷贝到进程缓冲区后,进程将接收到内核的通知,再去处理数据;
直接 I/O 应用场景:
- 应用层实现了缓存功能,那么可以不需要 PageCache 再次缓存,如在 MySQL 数据库中,可以通过参数设置开启直接 I/O,默认是不开启;
- 传输大文件时。在 Nginx 里,可以通过配置,设定一个文件大小阈值,针对大文件使用异步 IO 和直接 IO,而对小文件使用零拷贝。