操作系统复习

发布于:2022-12-20 ⋅ 阅读:(523) ⋅ 点赞:(0)

操作系统概述

操作系统概念

操作系统简化图
大部分计算机有两种运行模式:内核态用户态,软件中最基础的部分是操作系统,它运行在内核态中(管态核心态)。操作系统具有硬件的访问权,可以执行机器能够运行的任何指令。软件的其他部分运行在用户态下。

操作系统结构

内核

内核作为应用连接硬件设备的桥梁,应用程序只需要关心与内核交互,无需关心硬件的细节。
内核简化图

内核作用

  • 管理进程、线程,分配CPU,进行进程调度
  • 管理内存,决定内存的分配与回收
  • 管理硬件设备,为进程与硬件设备之间提供通信能力
  • 提供系统调用,如果应用程序需要更高权限运行的服务,则需要有系统调用,它是用户程序与操作系统之间的接口

内核工作原理

内核具备很高的权限,可以控制CPU、内存、硬盘等硬件,而应用程序具有的权限很小,因此大多数操作系统,将内存划分为两个区域:

  • 内核空间:只有内核程序可以访问
  • 用户空间:专门给普通的应用程序访问
    用户空间的代码只能访问一个局部的内存空间,而内核空间的代码可以访问所有的空间
    因此,当程序使用用户空间时,称之程序在用户态中执行,而当程序使用内核空间时,称之程序在内核态中执行。

应用程序如果需要访问内核空间,就需要通过系统调用
系统调用
当应用程序使用系统调用时,会产生一个中断,CPU会中断当前正在执行的用户程序,转而跳转到中断处理程序,也就是开始执行内核程序,当内核处理完成后,主动触发中断,将CPU执行权限交回给用户程序,回到用户态继续工作。

Linux内核: 内存管理,进程管理,设备驱动程序,文件系统,网络管理
Linux内核
Windows内核
Windows内核

内存管理

虚拟内存

将进程所使用的地址隔离开来,让操作系统为每个进程分配独立的一套虚拟地址,互相隔离。

操作系统会提供一种机制,将不同的进程的虚拟地址和不同内存的物理地址映射起来。

当程序需要访问虚拟地址时,由操作系统转换成不同的物理地址,不同进程运行时写入的是不同的物理地址,便不会产生冲突。

  1. 虚拟内存地址:普通应用程序使用的内存地址
  2. 物理内存地址:实际存储在硬件的空间地址

操作系统引入了虚拟内存,进程持有的虚拟地址会通过 CPU 芯片中的 内存管理单元(MMU) 的映射关系,来转换变成物理地址,然后再通过物理地址访问内存,如下图所示:
MMU

内存分段

程序由若干逻辑分段组成,可以由 代码分段、数据分段、栈段、堆段组成。 不同的段具有不同属性,因此使用分段的形式将这些段分离开来。

分段机制下,虚拟地址和物理地址如何映射?

分段机制下的虚拟地址由两部分组成,段选择因子段内偏移量
内存分段

  • 段选择因子就保存在段寄存器里面。段选择子里面最重要的是段号,用作段表的索引。段表里面保存的是这个段的基地址段的界限特权等级等。

  • 虚拟地址中的段内偏移量应该位于 0 和段界限之间,如果段内偏移量是合法的,就将段基地址加上段内偏移量得到物理内存地址。
    程序内存分段

缺陷

  1. 内存碎片
  2. 内存交换效率低下

举例:
关闭Chrome浏览器,释放的空间如果小于需要分配的进程内存则会成为内存碎片无法使用。
在这里插入图片描述
该问题可以通过内存交换解决。

先将某个进程的内存存入硬盘,然后再从硬盘读入到空缺部位,此时内存碎片便被消化掉,可以理解为整理内存移动到同一端。

内存分页

分页是把整个虚拟和物理内存空间切成一段固定尺寸的大小,这样一个连续并且尺寸固定的内存空间,称为。Linux下,每页4KB

虚拟地址与物理地址之间通过页表来映射。
页表
页表实际上存储在 CPU 的内存管理单元 (MMU) 中,于是 CPU 就可以直接通过 MMU,找出要实际要访问的物理内存地址。

当进程访问的虚拟地址在页表中查不到时,系统会产生一个缺页异常,进入系统内核空间分配物理内存、更新进程页表,最后再返回用户空间,恢复进程的运行。

分页如何解决分段的内存碎片、内存交换效率低下的问题?

分页,内存的单位以页为单位(非常小),因此可以避免分段之间空间的浪费(内存碎片)。

当内存空间不足时,操作系统可以通过LRU方式替换页(换出)。一旦需要再加载到内存中(换入)。因此,一次性写入磁盘的只有少数的几个页,消耗的时间较少,内存交换效率就相对较高

分页
分页机制可以在加载程序时,不用一次性全部把程序加载到物理内存中。可以在进行虚拟内存和物理内存的页之间映射后,并不把页真的加载到物理内存中,而是只有在程序运行中,需要用到对应虚拟内存页里面的指令和数据时,再加载到物理内存中

分页机制下,虚拟地址与物理地址如何映射?

分页机制下,虚拟地址氛围两个部分,页号页内偏移。页号作为页表的索引,页表包含物理页每页所在物理内存的基地址,这个基地址与页内偏移的组合形成了物理内存地址。

分页机制 虚拟内存映射
内存转换步骤:

  1. 把虚拟内存地址,切分为页号和偏移量
  2. 根据页号,从页表中查询对应物理页号
  3. 通过物理页号,加上偏移量,得到物理内存地址

示例 分页内存转换

简单分页缺陷

页的大小很小,Linux一般是4KB,而正常虚拟空间有4GB(32位),那么大约需要上百万个页,则每个页表项需要4个字节存储,整个空间的映射就需要4MB的内存来存储页表。

每个进程都有自己的虚拟空间,因此需要消耗很大内存来存储,64位系统需要更多空间。

多级页表

多级页表
由于局部性原理,每个进程只需要很小部分的虚拟内存空间,因此只需要映射一小部分的二级表,节省了空间。

使用二级分页机制,一级页表便可以覆盖整个4GB虚拟空间,若 如果某个一级页表的页表项没有使用到,则无需创建对应的二级页表,只在需要时才创建对应的二级页表

对于 64 位的系统,两级分页肯定不够了,就变成了四级目录,分别是:
● 全局页目录项 PGD(Page Global Directory);
● 上层页目录项 PUD(Page Upper Directory);
● 中间页目录项 PMD(Page Middle Directory);
● 页表项 PTE(Page Table Entry);
64位系统 多级页表

页表缓存TLB(Translation Lookaside Buffer)

多级页表解决空间上的问题,但是虚拟地址到物理地址的转换多了几道工序,降低了地址转换速度。

局部性原理,一段时间内,整个程序的执行仅限于程序中的一部分。相应的,执行锁访问的存储空间也局限于某个内存区域。

内存访问

利用该特性,将最常用的几个页表项存储到访问速度更快的硬件中。在CPU芯片中有一个专门存放程序最常访问的页表项的Cache,该Cache结束TLB,通常称为页表缓存、转址路旁缓存、快表。

在 CPU 芯片里面,封装了内存管理单元(Memory Management Unit)芯片,它用来完成地址转换和 TLB 的访问与交互。

有了 TLB 后,那么 CPU 在寻址时,会先查 TLB,如果没找到,才会继续查常规的页表。

Linux内存管理

Linux内存管理

逻辑地址是段式内存管理转换前的地址,线性地址则是 页式内存管理 转换前的地址。

Linux虚拟空间分布

32 位系统的内核空间占用 1G,位于最高处,剩下的 3G 是用户空间;
64 位系统的内核空间和用户空间都是 128T,分别占据整个内存空间的最高和最低处,剩下的中间部分是未定义的。

虽然每个进程都各自有独立的虚拟内存,但是每个虚拟内存中的内核地址,其实关联的都是相同的物理内存。这样,进程切换到内核态后,就可以很方便地访问内核空间内存。

进程的虚拟空间
用户空间分布:
用户空间分布
● 程序文件段,包括二进制可执行代码;
● 已初始化数据段,包括静态常量;
● 未初始化数据段,包括未初始化的静态变量;
● 堆段,包括动态分配的内存,从低地址开始向上增长;
● 文件映射段,包括动态库、共享内存等,从低地址开始向上增长(跟硬件和内核版本有关)
● 栈段,包括局部变量和函数调用的上下文等。栈的大小是固定的,一般是 8 MB。当然系统也提供了参数,以便我们自定义大小;

进程与线程

进程

进程概念

编写的代码是一个存储在硬盘的静态文件,编译后生成二进制可执行文件,运行后装载到内存中,然后由CPU执行每一条指令,这个 在运行中的程序,称之为进程

并行与并发区别

并行与并发

进程状态

生成PCB的原因:

  1. 系统初始化
  2. 用户通过系统提供的API创建新进程
  3. 批处理作业初始化
  4. 由现有进程派生子进程

一个进程,因为某种原因被创建了,那么它可以按照以下步骤进行一系列的初始化

  1. 给新进程分配一个进程ID
  2. 分配内存空间
  3. 初始化PCB
  4. 进入就绪队列

五状态模型

五状态模型
就绪 -> 运行:当操作系统内存在着调度程序,当需要运行一个新进程时,调度程序选择一个就绪态的进程,让其进入运行态。
运行 -> 就绪:运行态的进程,会占有CPU(参照一开始的饼状图)。每个进程会被分配一定的执行时间,当时间结束后,重新回到就绪态。
运行 -> 阻塞:进程请求调用系统的某些服务,但是操作系统没法立即给它(比如这种服务可能要耗时初始化,比如I/O资源需要等待),那么它就会进入阻塞态。
阻塞 -> 就绪:当等待结束了,就由阻塞态进入就绪态。
运行 -> 终止:当进程表示自己已经完成了,它会被操作系统终止。

七状态模型

一旦排队的进程多了,对于有限的内存空间将会是极大的考验。为了解决内存占用问题,可以将一部分内存中的进程交换到磁盘中,这些被交换到磁盘的进程,会进入挂起状态

挂起状态可以分为两种:
● 阻塞挂起状态:进程在外存(硬盘)并等待某个事件的出现;
● 就绪挂起状态:进程在外存(硬盘),但只要进入内存,即刻立刻运行;

七状态模型

进程控制结构

对于一个被执行的程序,操作系统会为该程序创建一个进程。进程作为一种抽象概念,可将其视为一个容器,该容器聚集了相关资源,包括地址空间、线程、打开的文件、保护许可等。而操作系统本身就是一个进程,程序 = 算法 + 数据结构,因此对于单个进程,可以使用一种数据结构进行表示(进程控制块 PCB)。

进程控制块 PCB (process control block) 作为描述进程的数据结构。

PCB 是进程存在的唯一标识, 这意味着一个进程的存在,必然有一个PCB,如果进程消失,那么PCB也会随之消失。

PCB 包含的信息

PCB 包含的信息

PCB 组织方式

通常是通过链表的方式进行组织,把具有相同状态的进程链在一起,组成各种队列。比如:
● 将所有处于就绪状态的进程链在一起,称为就绪队列;
● 把所有因等待某事件而处于等待状态的进程链在一起就组成各种阻塞队列;
● 另外,对于运行队列在单核 CPU 系统中则只有一个运行指针了,因为单核 CPU 在某个时间,只能运行一个程序。
那么,就绪队列和阻塞队列链表的组织形式如下图:
PCB组织方式 链表
除了链接的组织方式,还有索引方式,它的工作原理:将同一状态的进程组织在一个索引表中,索引表项指向相应的 PCB,不同状态对应不同的索引表。
PCB组织方式 索引
一般会选择链表,因为可能面临进程创建,销毁等调度导致进程状态发生变化,所以链表能够更加灵活的插入和删除。

进程切换

当一个正在运行中的进程被中断,操作系统指定另一个就绪态的进程进入运行态,这个过程就是进程切换,也可以叫上下文切换

切换过程:
1.保存处理器上下文环境:将CPU程序计数器和寄存器的值保存到当前进程的私有堆栈里
2.更新当前进程的PCB(包括状态更变)
3.将当前进程移到就绪队列或者阻塞队列
4.根据调度算法,选择就绪队列中一个合适的新进程,将其更改为运行态
5.更新内存管理的数据结构
6.新进程内对堆栈所保存的上下文信息载入到CPU的寄存器和程序计数器,占有CPU

进程上下文切换场景

  • 进程调度算法,时间片轮转,每个进程轮流执行一段时间,时间片耗尽则由系统挂起,切换到其他等待执行的进程运行。
  • 进程在系统资源不足时,由系统挂起等到资源满足后才可以运行。
  • 当进程通过睡眠函数sleep这样的方法主动挂起时,也会重新调度。
  • 当有优先级更高的进程运行时,未来保证高优先级进程的运行,当前进程会被挂起,由高优先级进程来运行。
  • 当发生硬件中断时,CPU上的进程会被中断挂起,转而去执行内核中的中断服务程序。

线程

线程是比进程更小的能独立运行的基本单位。

线程概念

线程是进程中的一条执行流程。
同一个进程内多个线程之间可以共享代码段、数据段、打开的文件等资源,但每个线程各自都有一套独立的寄存器和栈,这样可以确保线程的控制流是相对独立的。

多线程
线程是CPU调度的基本单位
进程线程任务

线程的上下文切换

线程与进程最大区别: 线程是调度的基本单位,进程是资源拥有的基本单位
任务调度其实是线程调度,而进程只是给线程提供了虚拟内存、全局变量等资源。

  • 当进程只有一个线程时,可以认为进程就等于线程。
  • 当进程有多个线程时,这些线程会共享虚拟内存和全局变量等资源,在上下文切换时无需修改。
    线程有自己的私有数据,例如栈和寄存器,上下文切换时需要进行保存切换。

线程上下文切换

  • 若两个线程不属于同一个进程,切换过程与进程上下文切换一样。
  • 若两个线程属于同一个进程,由于虚拟内存共享,因此在切换时,虚拟内存等共享资源保持不动只需要切换线程私有数据、寄存器等不同享的数据

因此同一个进程的线程上下文切换开销要小很多。

进程调度

一旦操作系统把进程切花到运行状态,也就意味着该进程占用CPU正在执行,但是当操作系统把进程切换到其他状态时,就不能在CPU中执行,于是操作系统需要选择下一个要运行的进程。

选择进程的功能由操作系统实现,成为 调度程序

调度进程时机

在进程的生命周期中,当进程从一个运行状态到另外一状态变化的时候,其实会触发一次调度。

● 从就绪态 -> 运行态:当进程被创建时,会进入到就绪队列,操作系统会从就绪队列选择一个进程运行;
● 从运行态 -> 阻塞态:当进程发生 I/O 事件而阻塞时,操作系统必须另外一个进程运行;
● 从运行态 -> 结束态:当进程退出结束后,操作系统得从就绪队列选择另外一个进程运行;

调度算法分类:
非抢占式调度算法挑选一个进程,然后让该进程运行直到被阻塞,或者直到该进程退出,才会调用另外一个进程,也就是说不会理时钟中断这个事情。
抢占式调度算法挑选一个进程,然后让该进程只运行某段时间,如果在该时段结束时,该进程仍然在运行时,则会把它挂起,接着调度程序从就绪队列挑选另外一个进程。这种抢占式调度处理,需要在时间间隔的末端发生时钟中断,以便把 CPU 控制返回给调度程序进行调度,也就是常说的时间片机制

调度进程原则

进程调度原则

CPU 利用率:调度程序应确保 CPU 是始终匆忙的状态,这可提高 CPU 的利用率;
系统吞吐量:吞吐量表示的是单位时间内 CPU 完成进程的数量,长作业的进程会占用较长的 CPU 资源,因此会降低吞吐量,相反,短作业的进程会提升系统吞吐量;
周转时间:周转时间是进程运行和阻塞时间总和,一个进程的周转时间越小越好;
等待时间:这个等待时间不是阻塞状态的时间,而是进程处于就绪队列的时间,等待的时间越长,用户越不满意;
响应时间:用户提交请求到系统第一次产生响应所花费的时间,在交互式系统中,响应时间是衡量调度算法好坏的主要标准。

进程调度算法

● 先来先服服务
● 时间片轮转
● 最短作业优先
● 最短剩余时间优先
● 优先级调度
● 多级反馈队列调度

先来先服务

先来先服务(First Come First Serverd, FCFS)。先进就绪队列,则先被调度,先来先服务是最简单的调度算法。

先来先服务

时间片轮转调度

每一个进程会被分配一个时间片,表示允许该进程在这个时间段运行,如果时间结束了,进程还没运行完毕,那么会通过抢占式调度,将CPU分配给其他进程,该进程回到就绪队列。这是一种最简单最公平的调度算法,但是有可能会存在问题。由于进程的切换,需要耗费时间,如果时间片太短,频繁进行切换,会影响效率。如果进程时间片太长,有可能导致排后面的进程等待太长时间。因此时间片的长度,需要有大致合理的数值。(《现代操作系统》的观点是建议时间片长度在20ms~50ms)。
在这里插入图片描述

最短作业优先

最短作业优先(Shortest Job First, SJF),顾名思义即进程按照作业时间长短排队,作业时间段的排前面先执行,如 下图。

在这里插入图片描述

这显然对长作业不利,很容易造成一种极端现象。

比如,一个长作业在就绪队列等待运行,而这个就绪队列有非常多的短作业,那么就会使得长作业不断的往后推,周转时间变长,致使长作业长期不会被运行。

最短剩余时间优先

最短剩余时间优先(Shortest Remaining Time Next),从就绪队列中选择剩余时间最短的进程进行调度。该算法可以理解最短作业优先和时间片轮转的结合。如果没有时间片,那么最短剩余时间其实就是最短作业时间,因为每个进程都是从头执行到尾。

优先级调度

假设就绪队列中有如下进程

进程 执行时间 优先级
p1 5 1
p2 2 3
p3 3 2

按照优先级调度,执行顺序为p1->p3->p2。如果多个进程优先级相同,则按照先来先服务的方式依次执行。
优先级调度可以进一步细分为抢占式和非抢占式。
非抢占式:和上面提及的非抢占式类似,一旦该进程占有CPU就将一直执行到结束或者阻塞。
抢占式:进程执行期间,一旦有更高优先级的进程进入就绪队列,那么该进程就会被暂停,重回就绪队列,让更高优先级的进程执行。但是为了防止最高优先级进程一直执行,每个进程依然有自己的时间片,每次时间片结束后,会根据一定规则降低该进程优先级,避免某些最高优先级长作业进程一直占用CPU。

多级反馈队列调度

多级反馈队列调度基于时间片轮转和优先级调度,设置多个就绪队列,赋予每个就绪队列优先级,优先级越高的队列进程的时间片越短。如下图,第1级就绪队列优先级最高,进程的时间片长度最短,第2级就绪队列次之,以此类推。

在这里插入图片描述
当有新的进程创建时,先进入第1级就绪队列,时间片结束之前就运行完毕,则终止,否则进入第2级队列等待下一次调度。在n级队列之前,进程按照先到先服务规则依次调度,到了第n级队列(最后一级)采用时间片轮转调度。仅当第1级队列为空时,才调度第2级队列的进程,如果第i级队列的进程正在运行,此时有一个更高优先级的进程进入,则会停下第i级的进程,让它回到第i级队列尾部,转而执行更高优先级的进程,即满足优先级调度算法的原则。

可以发现,对于短作业可能可以在第一级队列很快被处理完。对于长作业,如果在第一级队列处理不完,可以移入下次队列等待被执行,虽然等待的时间变长了,但是运行时间也会更长了,所以该算法很好的兼顾了长短作业,同时有较好的响应时间

进程间通信

每个进程的用户地址空间都是独立的,一般而言是不能相互访问的,但内核空间是每个进程都共享的,所以进程之间要通信必须通过内核。

在这里插入图片描述
进程间通信目的一般有 数据共享,消息通知,进程控制等。

管道

在Linux命令中,|这个竖线便是一个管道命令。

$ ps auxf | grep mysql

上述管道功能是将前一个命令的输出结果作为后一个命令的输入。
是一个单向的匿名管道。使用完便会销毁。

还有一种命名管道,也被叫做FIFO,因为数据是先进先出的传输方式。在使用命名管道时,需要通过mkfifo命令创建,并指定管道名字。

$ mkfifo myPipe

myPipe 就是这个管道的名称,基于 Linux 一切皆文件的理念,所以管道也是以文件的方式存在,我们可以用 ls 看一下,这个文件的类型是 p,也就是 pipe(管道) 的意思

$ ls -l
prw-r--r--. 1 root    root         0 Jul 17 02:45 myPipe

接下来,我们往 myPipe 这个管道写入数据:

$ echo "hello" > myPipe  // 将数据写进管道
                         // 停住了 ...

操作后,会发现命令执行后就停在这了,这是因为管道里的内容没有被读取,只有当管道里的数据被读完后,命令才可以正常退出。

于是,我们执行另外一个命令来读取这个管道里的数据:

$ cat < myPipe  // 读取管道里的数据
hello

可以看到,管道里的内容被读取出来了,并打印在了终端上,另外一方面,echo 那个命令也正常退出了。

我们可以看出,管道这种通信方式效率低,不适合进程间频繁地交换数据。当然,它的好处,自然就是简单,同时也我们很容易得知管道里的数据已经被另一个进程读取了。

我们可以得知,对于匿名管道,它的通信范围是存在父子关系的进程。因为管道没有实体,也就是没有管道文件,只能通过 fork 来复制父进程 fd 文件描述符,来达到通信的目的。

在这里插入图片描述
shell 里面执行 A | B命令的时候,A 进程和 B 进程都是 shell 创建出来的子进程,A 和 B 之间不存在父子关系,它俩的父进程都是 shell
在这里插入图片描述
另外,对于命名管道,它可以在不相关的进程间也能相互通信。因为命令管道,提前创建了一个类型为管道的设备文件,在进程里只要使用这个设备文件,就可以相互通信。

消息队列

消息队列是保存在内核中的消息链表,在发送数据时,会分成一个一个独立的数据单元,也就是消息体(数据块),消息体是用户自定义的数据类型,消息的发送方和接收方要约定好消息体的数据类型,所以每个消息体都是固定大小的存储块,不像管道是无格式的字节流数据。如果进程从消息队列中读取了消息体,内核就会把这个消息体删除。
在这里插入图片描述
消息队列生命周期随内核,如果没有释放消息队列或者没有关闭操作系统,消息队列会一直存在,而前面提到的匿名管道的生命周期,是随进程的创建而建立,随进程的结束而销毁。

消息队列通信过程中,存在用户态与内核态之间的数据拷贝开销,因为进程写入数据到内核中的消息队列时,会发生从用户态拷贝数据到内核态的过程,同理另一进程读取内核中的消息数据时,会发生从内核态拷贝数据到用户态的过程。

共享内存

共享内存的机制,就是拿出一块虚拟地址空间来,映射到相同的物理内存中。这样这个进程写入的东西,另外一个进程马上就能看到了,都不需要拷贝来拷贝去,传来传去,大大提高了进程间通信的速度。
在这里插入图片描述

信号量

为了防止多进程竞争共享资源,而造成的数据错乱,所以需要保护机制,使得共享的资源,在任意时刻只能被一个进程访问。正好,信号量就实现了这一保护机制。

信号量其实是一个整型的计数器,主要用于实现进程间的互斥与同步,而不是用于缓存进程间通信的数据。

信号量表示资源的数量,控制信号量的方式有两种原子操作:
● 一个是 P 操作,这个操作会把信号量减去 -1,相减后如果信号量 < 0,则表明资源已被占用,进程需阻塞等待;相减后如果信号量 >= 0,则表明还有资源可使用,进程可正常继续执行。
● 另一个是 V 操作,这个操作会把信号量加上 1,相加后如果信号量 <= 0,则表明当前有阻塞中的进程,于是会将该进程唤醒运行;相加后如果信号量 > 0,则表明当前没有阻塞中的进程;

P 操作是用在进入共享资源之前,V 操作是用在离开共享资源之后,这两个操作是必须成对出现的。

在这里插入图片描述
可以发现,信号初始化为 1,就代表着是互斥信号量,它可以保证共享内存在任何时刻只有一个进程在访问,这就很好的保护了共享内存。

生产者 消费者

另外,在多进程里,每个进程并不一定是顺序执行的,它们基本是以各自独立的、不可预知的速度向前推进,但有时候我们又希望多个进程能密切合作,以实现一个共同的任务。

例如,进程 A 是负责生产数据,而进程 B 是负责读取数据,这两个进程是相互合作、相互依赖的,进程 A 必须先生产了数据,进程 B 才能读取到数据,所以执行是有前后顺序的。

那么这时候,就可以用信号量来实现多进程同步的方式,我们可以初始化信号量为 0。

在这里插入图片描述
具体过程:
● 如果进程 B 比进程 A 先执行了,那么执行到 P 操作时,由于信号量初始值为 0,故信号量会变为 -1,表示进程 A 还没生产数据,于是进程 B 就阻塞等待;
● 接着,当进程 A 生产完数据后,执行了 V 操作,就会使得信号量变为 0,于是就会唤醒阻塞在 P 操作的进程 B;
● 最后,进程 B 被唤醒后,意味着进程 A 已经生产了数据,于是进程 B 就可以正常读取数据了。
可以发现,信号初始化为 0,就代表着是同步信号量,它可以保证进程 A 应在进程 B 之前执行。

信号

信号一般用于一些异常情况下的进程间通信,是一种异步通信,它的数据结构一般就是一个数字。
在 Linux 操作系统中, 为了响应各种各样的事件,提供了几十种信号,分别代表不同的意义。我们可以通过 kill -l 命令,查看所有的信号:

$ kill -l
 1) SIGHUP       2) SIGINT       3) SIGQUIT      4) SIGILL       5) SIGTRAP
 6) SIGABRT      7) SIGBUS       8) SIGFPE       9) SIGKILL     10) SIGUSR1
11) SIGSEGV     12) SIGUSR2     13) SIGPIPE     14) SIGALRM     15) SIGTERM
16) SIGSTKFLT   17) SIGCHLD     18) SIGCONT     19) SIGSTOP     20) SIGTSTP
21) SIGTTIN     22) SIGTTOU     23) SIGURG      24) SIGXCPU     25) SIGXFSZ
26) SIGVTALRM   27) SIGPROF     28) SIGWINCH    29) SIGIO       30) SIGPWR
31) SIGSYS      34) SIGRTMIN    35) SIGRTMIN+1  36) SIGRTMIN+2  37) SIGRTMIN+3
38) SIGRTMIN+4  39) SIGRTMIN+5  40) SIGRTMIN+6  41) SIGRTMIN+7  42) SIGRTMIN+8
43) SIGRTMIN+9  44) SIGRTMIN+10 45) SIGRTMIN+11 46) SIGRTMIN+12 47) SIGRTMIN+13
48) SIGRTMIN+14 49) SIGRTMIN+15 50) SIGRTMAX-14 51) SIGRTMAX-13 52) SIGRTMAX-12
53) SIGRTMAX-11 54) SIGRTMAX-10 55) SIGRTMAX-9  56) SIGRTMAX-8  57) SIGRTMAX-7
58) SIGRTMAX-6  59) SIGRTMAX-5  60) SIGRTMAX-4  61) SIGRTMAX-3  62) SIGRTMAX-2
63) SIGRTMAX-1  64) SIGRTMAX

运行在 shell 终端的进程,我们可以通过键盘输入某些组合键的时候,给进程发送信号。例如

Ctrl+C 产生 SIGINT 信号,表示终止该进程;
Ctrl+Z 产生 SIGTSTP 信号,表示停止该进程,但还未结束;

如果进程在后台运行,可以通过 kill 命令的方式给进程发送信号,但前提需要知道运行中的进程 PID 号,例如:

kill -9 1050 ,表示给 PID1050 的进程发送 SIGKILL 信号,用来立即结束该进程;

所以,信号事件的来源主要有硬件来源(如键盘 Cltr+C )和软件来源(如 kill 命令)。

信号是进程间通信机制中唯一的异步通信机制

进程需要为信号设置相应的监听处理,当收到特定信号时,执行相应的操作,类似很多编程语言里的通知机制。

Socket

跨网络与不同主机上的进程之间通信,需要用 Socket 通信
也可以在同主机上进程间通信。

进程间通信小结

在这里插入图片描述

文件系统

文件系统功能规划

  1. 文件系统要有严格的组织形式,使得文件能够以块为单位进行存储。
  2. 文件系统中也要有索引区,用来方便查找一个文件分成的多个块都存放在了什么位置。
    在这里插入图片描述
  3. 提供缓存层存放热点文件
  4. 以文件夹形式组织文件,便于管理与查询

在这里插入图片描述
5. 在内存中维护一套数据结构,来记录哪些文件被哪些进程打开和使用

文件系统的基本组成

一切皆文件

在Linux中,一切皆文件,除了普通的文件和目录,就连块设备、管道、socket等都统一由文件系统管理。

Linux文件系统会为每个文件分配两个数据结构:

  1. 索引节点
    inode,用于记录文件的元信息,比如inode编号、文件大小、访问权限、创建时间、修改时间、数据在磁盘的位置等。索引节点是文件的唯一标识。同样存储在磁盘中,占用磁盘空间。
  2. 目录项
    dentry,用来记录文件的名字、索引节点指针以及与其他目录项的层级关联关系。目录项是由内核维护的一个数据结构,不存放于磁盘,而是缓存在内存。

用于记录文件的元信息与目录层次结构。

由于索引节点唯一标识一个文件,而目录项记录着文件的名,所以目录项和索引节点的关系是多对一,也就是说,一个文件可以有多个别名。比如,硬链接的实现就是多个目录项中的索引节点指向同一个文件。

目录也是文件,也是用索引节点唯一标识,和普通文件不同的是,普通文件在磁盘里面保存的是文件数据,而目录文件在磁盘里面保存子目录或文件。

目录项与目录区别

目录是个文件,持久化在硬盘。而目录项是内核一个数据结构,缓存在内存。

如果查询目录频繁从磁盘读,效率会很低,所以内核会把已经读过的目录用目录项这个数据结构缓存在内存,下次再次读到相同的目录时,只需从内存读就可以,大大提高了文件系统的效率。

目录项这个数据结构不只是表示目录,也是可以表示文件的。

文件数据存储在磁盘的方式

磁盘读写的最小单位是扇区,扇区的大小只有 512B 大小,很明显,如果每次读写都以这么小为单位,那这读写的效率会非常低。

文件系统把多个扇区组成了一个逻辑块,每次读写的最小单位就是逻辑块(数据块),Linux 中的逻辑块大小为 4KB,也就是一次性读写 8 个扇区,这将大大提高了磁盘的读写的效率。
在这里插入图片描述
磁盘进行格式化的时候,会被分成三个存储区域,分别是超级块、索引节点区和数据块区。
● 超级块,用来存储文件系统的详细信息,比如块个数、块大小、空闲块等等。
● 索引节点区,用来存储索引节点;
● 数据块区,用来存储文件或目录数据;

加载进内存的时机:
● 超级块:当文件系统挂载时进入内存;
● 索引节点区:当文件被访问时进入内存;

虚拟文件系统

虚拟文件系统(Virtual File System,VFS)
在这里插入图片描述
Linux 支持的文件系统也不少,根据存储位置的不同,可以把文件系统分为三类:

● 磁盘的文件系统,它是直接把数据存储在磁盘中,比如 Ext 2/3/4、XFS 等都是这类文件系统。
● 内存的文件系统,这类文件系统的数据不是存储在硬盘的,而是占用内存空间,我们经常用到的 /proc 和 /sys 文件系统都属于这一类,读写这类文件,实际上是读写内核中相关的数据数据。
● 网络的文件系统,用来访问其他计算机主机数据的文件系统,比如 NFS、SMB 等等。

文件系统首先要先挂载到某个目录才可以正常使用,比如 Linux 系统在启动时,会把文件系统挂载到根目录。

文件的物理结构

在操作系统的辅助之下,磁盘中的数据在计算机中都会呈现为易读的形式,并且我们不需要关心数据到底是如何存放在磁盘中,存放在磁盘的哪个地方等等问题,这些全部都是由操作系统完成的。

文件块

磁盘中的存储单元会被划分为一个个的“块”,也被称为扇区,扇区的大小一般都为 512byte。这说明即使一块数据不足 512byte,那么它也要占用 512byte 的磁盘空间。

几乎所有的文件系统都会把文件分割成固定大小的块来存储,通常一个块的大小为 4K。如果磁盘中的扇区为 512byte,而文件系统的块大小为 4K,那么文件系统的存储单元就为 8 个扇区。所以我们写入 1byte 的数据到文本中,但是它占用的空间也会是 4K。

在 Windows 下的 NTFS 文件系统中,如果一开始文件数据小于 1K,那么则不会分配磁盘块来存储,而是存在一个文件表中。但是一旦文件数据大于 1K,那么不管以后文件的大小,都会分配以 4K 为单位的磁盘空间来存储。

在这里插入图片描述
与内存管理一样,为了方便对磁盘的管理,文件的逻辑地址也被分为一个个的文件块。于是文件的逻辑地址就是(逻辑块号,块内地址)。用户通过逻辑地址来操作文件,操作系统负责完成逻辑地址与物理地址的映射。

文件分配方式

不同的文件系统为文件分配磁盘空间会有不同的方式,这些方式各自都有优点和缺点。

连续分配

在这里插入图片描述
在这里插入图片描述
(逻辑块号,块内地址)-> (物理块号,块内地址),只需要知道逻辑块号对应的物理块号即可,块内地址不变。

用户访问一个文件的内容,操作系统通过文件的标识符找到目录项 FCB,物理块号 = 起始块号 + 逻辑块号。当然,还需要检查逻辑块号是否合法,是否超过长度等。因为可以根据逻辑块号直接算出物理块号,所以连续分配支持顺序访问随机访问

因为读/写文件是需要移动磁头的,如果访问两个相隔很远的磁盘块,移动磁头的时间就会变长。使用连续分配来作为文件的分配方式,会使文件的磁盘块相邻,所以文件的读/写速度最快。

连续空间存放的方式虽然读写效率高,但是有「磁盘空间碎片」和「文件长度不易扩展」的缺陷。
在这里插入图片描述

非连续空间存放方式

非连续空间存放方式分为「链表方式」和「索引方式」。

链式分配

链式分配采取离散分配的方式,可以为文件分配离散的磁盘块。它有两种分配方式:显示链接和隐式链接。

隐式分配

只记录文件所占磁盘块第一块地址和最后一块的地址,通过每块地址中存放的下一块的指针寻找到下一块地址。
在这里插入图片描述
隐式链接分配只能顺序访问,不支持随机访问,查找效率低。

隐式链接分配很方便文件拓展。所有空闲磁盘块都可以被利用到,无碎片问题,存储利用率高。

显示分配

把用于链接各个物理块的指针显式地存放在一张表中,该表称为文件分配表(FAT,File Allocation Table)
在这里插入图片描述
查找记录的过程是在内存中进行的,因而不仅显著地提高了检索速度,而且大大减少了访问磁盘的次数。但也正是整个表都存放在内存中的关系,它的主要的缺点是不适用于大磁盘

索引分配

链表的方式解决了连续分配的磁盘碎片和文件动态扩展的问题,但是不能有效支持直接访问(FAT除外),索引的方式可以解决这个问题。

为每个文件创建一个 「索引数据块」 ,里面存放的是 指向文件数据块的指针列表文件头需要包含指向「索引数据块」的指针,这样就可以通过文件头知道索引数据块的位置,再通过索引数据块里的索引信息找到对应的数据块。

在这里插入图片描述
索引的方式优点在于:
● 文件的创建、增大、缩小很方便;
● 不会有碎片的问题;
● 支持顺序读写和随机读写;

由于索引数据也是存放在磁盘块的,如果文件很小,明明只需一块就可以存放的下,但还是需要额外分配一块来存放索引数据,所以缺陷之一就是存储索引带来的开销。

组合方式处理大文件

链表 + 索引
称为 「链式索引块」,它的实现方式是 在索引数据块留出一个存放下一个索引数据块的指针(一旦某个指针数据丢失)
在这里插入图片描述
索引 + 索引
称为 「多级索引块」 ,实现方式是通过一个索引块来存放多个索引数据块,一层套一层索引
在这里插入图片描述

空闲空间管理

空闲表法

为所有空闲空间建立一张表,表内容包括空闲区的第一个块号和该空闲区的块个数
在这里插入图片描述
请求分配磁盘空间时,系统依次扫描空闲表里的内容,直到找到一个合适的空闲区域为止。当用户撤销一个文件时,系统回收文件空间。这时,也需顺序扫描空闲表,寻找一个空闲表条目并将释放空间的第一个物理块号及它占用的块数填到这个条目中。

仅当有少量的空闲区时才有较好的效果。因为,如果存储空间中有着大量的小的空闲区,则空闲表变得很大,这样查询效率会很低。另外,这种分配技术适用于建立连续文件。

空闲链表法

使用「链表」的方式来管理空闲空间,每一个空闲块里有一个指针指向下一个空闲块
在这里插入图片描述
创建文件需要一块或几块时,就从链头上依次取下一块或几块。反之,当回收空间时,把这些空闲块依次接到链头上。

只要在主存中保存一个指针,令它指向第一个空闲块。其特点是简单,但不能随机访问,工作效率低,因为每当在链上增加或移动空闲块时需要做很多 I/O 操作,同时数据块的指针消耗了一定的存储空间。

位图法

利用二进制的一位来表示磁盘中一个盘块的使用情况,磁盘上所有的盘块都有一个二进制位与之对应。
当值为 0 时,表示对应的盘块空闲,值为 1 时,表示对应的盘块已分配。

1111110011111110001110110111111100111 ...

在 Linux 文件系统就采用了位图的方式来管理空闲空间,不仅用于数据空闲块的管理,还用于 inode 空闲块的管理,因为 inode 也是存储在磁盘的,自然也要有对其管理。

文件系统结构

文件存储

用户在创建一个新文件时,Linux 内核会通过 inode 的位图找到空闲可用的 inode,并进行分配。要存储数据时,会通过块的位图找到空闲的块,并分配。

数据块的位图是放在磁盘块里的,假设是放在一个块里,一个块 4K,每位表示一个数据块,共可以表示4 * 1024 * 8 = 2^15个空闲块,由于 1 个数据块是 4K 大小,那么最大可以表示的空间为 2^15 * 4 * 1024 = 2^27 个 byte,也就是 128M

这个结构称为一个块组,那么有 N 多的块组,就能够表示 N 大的文件。

在这里插入图片描述

目录存储

基于 Linux 一切皆文件的设计思想,目录其实也是个文件,你甚至可以通过 vim 打开它,它也有 inode,inode 里面也是指向一些块。

和普通文件不同的是,普通文件的块里面保存的是文件数据,而目录文件的块里面保存的是目录里面一项一项的文件信息。

在目录文件的块中,最简单的保存格式就是列表,就是一项一项地将目录下的文件信息(如文件名、文件 inode、文件类型等)列在表里。

列表中每一项就代表该目录下的文件的文件名和对应的 inode,通过这个 inode,就可以找到真正的文件。
在这里插入图片描述
通常,第一项是「.」,表示当前目录,第二项是「…」,表示上一级目录,接下来就是一项一项的文件名和 inode。

如果一个目录有超级多的文件,可以转换为哈希表,对文件名进行哈希计算,把哈希值保存起来。

Linux 系统的 ext 文件系统就是采用了哈希表,来保存目录的内容,这种方法的优点是查找非常迅速,插入和删除也较简单,不过需要一些预备措施来避免哈希冲突。

目录查询是通过在磁盘上反复搜索完成,需要不断地进行 I/O 操作,开销较大。所以,为了减少 I/O 操作,把当前使用的文件目录缓存在内存,以后要使用该文件时只要在内存中操作,从而降低了磁盘操作次数,提高了文件系统的访问速度。(目录项)

设备管理

设备控制器

为了屏蔽设备之间的差异,每个设备都有一个叫设备控制器(Device Control) 的组件,比如硬盘有硬盘控制器、显示器有视频控制器等。
在这里插入图片描述
因为这些控制器都很清楚的知道对应设备的用法和功能,所以 CPU 是通过设备控制器来和设备打交道的。

设备控制器里有芯片,它可执行自己的逻辑,也有自己的寄存器,用来与 CPU 进行通信,比如:

  • 通过写入这些寄存器,操作系统可以命令设备发送数据、接收数据、开启或关闭,或者执行某些其他操作。
  • 通过读取这些寄存器,操作系统可以了解设备的状态,是否准备好接收一个新的命令等

实际上,控制器是有三类寄存器,它们分别是状态寄存器(Status Register)、 命令寄存器(Command Register)以及数据寄存器(Data Register)
在这里插入图片描述
输入输出设备可分为两大类 :块设备(Block Device)字符设备(Character Device)
● 块设备,把数据存储在固定大小的块中,每个块有自己的地址,硬盘、USB 是常见的块设备。
● 字符设备,以字符为单位发送或接收一个字符流,字符设备是不可寻址的,也没有任何寻道操作,鼠标是常见的字符设备。

块设备通常传输的数据量会非常大,于是控制器设立了一个可读写的数据缓冲区
● CPU 写入数据到控制器的缓冲区时,当缓冲区的数据囤够了一部分,才会发给设备。
● CPU 从控制器的缓冲区读取数据时,也需要缓冲区囤够了一部分,才拷贝到内存。

这样做是为了,减少对设备的操作次数。

那 CPU 是如何与设备的控制寄存器和数据缓冲区进行通信的?存在两个方法:
● 端口 I/O,每个控制寄存器被分配一个 I/O 端口,可以通过特殊的汇编指令操作这些寄存器,比如 in/out 类似的指令。
● 内存映射 I/O,将所有控制寄存器映射到内存空间中,这样就可以像读写内存一样读写数据缓冲区。

I/O控制方式

当 CPU 给设备发送了一个指令,让设备控制器去读设备的数据,它读完的时候,要怎么通知 CPU 呢?—> 中断

软中断:代码调用指令触发,硬件中断:硬件通过中断控制器触发

但中断的方式对于频繁读写数据的磁盘,并不友好,这样 CPU 容易经常被打断,会占用 CPU 大量的时间。对于这一类设备的问题的解决方法是使用 DMA(Direct Memory Access) 功能,它可以使得设备在 CPU 不参与的情况下,能够自行完成把设备 I/O 数据放入到内存。那要实现 DMA 功能要有 「DMA 控制器」硬件的支持。

在这里插入图片描述
DMA 的工作方式如下:
● CPU 需对 DMA 控制器下发指令,告诉它想读取多少数据,读完的数据放在内存的某个地方就可以了;
● 接下来,DMA 控制器会向磁盘控制器发出指令,通知它从磁盘读数据到其内部的缓冲区中,接着磁盘控制器将缓冲区的数据传输到内存;
● 当磁盘控制器把数据传输到内存的操作完成后,磁盘控制器在总线上发出一个确认成功的信号到 DMA 控制器;
● DMA 控制器收到信号后,DMA 控制器发中断通知 CPU 指令完成,CPU 就可以直接用内存里面现成的数据了;
可以看到, CPU 当要读取磁盘数据的时候,只需给 DMA 控制器发送指令,然后返回去做其他事情,当磁盘数据拷贝到内存后,DMA 控制机器通过中断的方式,告诉 CPU 数据已经准备好了,可以从内存读数据了。仅仅在传送开始和结束时需要 CPU 干预。

设备驱动程序

虽然设备控制器屏蔽了设备的众多细节,但每种设备的控制器的寄存器、缓冲区等使用模式都是不同的,所以为了屏蔽「设备控制器」的差异,引入了设备驱动程序
在这里插入图片描述
设备控制器不属于操作系统范畴,它是属于硬件,而设备驱动程序属于操作系统的一部分,操作系统的内核代码可以像本地调用代码一样使用设备驱动程序的接口,而设备驱动程序是面向设备控制器的代码,它发出操控设备控制器的指令后,才可以操作设备控制器。

不同的设备控制器虽然功能不同,但是设备驱动程序会提供统一的接口给操作系统,这样不同的设备驱动程序,就可以以相同的方式接入操作系统。

在这里插入图片描述
设备完成了事情,则会发送中断来通知操作系统。那操作系统就需要有一个地方来处理这个中断,这个地方也就是在设备驱动程序里,它会及时响应控制器发来的中断请求,并根据这个中断的类型调用响应的中断处理程序进行处理。

通常,设备驱动程序初始化的时候,要先注册一个该设备的中断处理函数。
在这里插入图片描述
中断处理程序的处理流程:

  1. 在 I/O 时,设备控制器如果已经准备好数据,则会通过中断控制器向 CPU 发送中断请求;
  2. 保护被中断进程的 CPU 上下文;
  3. 转入相应的设备中断处理函数;
  4. 进行中断处理;
  5. 恢复被中断进程的上下文;

通用块层

对于块设备,为了减少不同块设备的差异带来的影响,Linux 通过一个统一的通用块层,来管理不同的块设备。

通用块层是处于文件系统和磁盘驱动中间的一个块设备抽象层,它主要有两个功能:

  • 第一个功能,向上为文件系统和应用程序,提供访问块设备的标准接口,向下把各种不同的磁盘设备抽象为统一的块设备,并在内核层面,提供一个框架来管理这些设备的驱动程序;
  • 第二功能,通用层还会给文件系统和应用程序发来的 I/O 请求排队,接着会对队列重新排序、请求合并等方式,也就是 I/O 调度,主要目的是为了提高磁盘读写的效率。

Linux 内存支持 5 种 I/O 调度算法,分别是:

  • 没有调度算法
    不对文件系统和应用程序的I/O做任何处理,常用于虚拟机I/O中,此时磁盘I/O调度算法交给物理机系统负责。
  • 先入先出调度算法
  • 完全公平调度算法
    大部分系统都把这个算法作为默认的 I/O 调度器,它为每个进程维护了一个 I/O 调度队列,并按照时间片来均匀分布每个进程的 I/O 请求。
  • 优先级调度
    优先级高的 I/O 请求先发生, 它适用于运行大量进程的系统,像是桌面环境、多媒体应用等。
  • 最终期限调度算法
    分别为读、写请求创建了不同的 I/O 队列,这样可以提高机械磁盘的吞吐量,并确保达到最终期限的请求被优先处理,适用于在 I/O 压力比较大的场景,比如数据库等。

存储系统I/O软件分层

可以把 Linux 存储系统的 I/O 由上到下可以分为三个层次,分别是文件系统层通用块层设备层
在这里插入图片描述
这三个层次的作用是:

  • 文件系统层,包括虚拟文件系统其他文件系统的具体实现,它向上为应用程序统一提供了标准的文件访问接口,向下会通过通用块层来存储和管理磁盘数据。
  • 通用块层,包括块设备的 I/O 队列I/O 调度器,它会对文件系统的 I/O 请求进行排队,再通过 I/O 调度器,选择一个 I/O 发给下一层的设备层。
  • 设备层,包括硬件设备设备控制器驱动程序,负责最终物理设备的 I/O 操作。

存储系统的 I/O 是整个系统最慢的一个环节,所以 Linux 提供了不少缓存机制来提高 I/O 的效率。

  • 为了提高文件访问的效率,会使用页缓存索引节点缓存目录项缓存等多种缓存机制,目的是为了减少对块设备的直接调用
  • 为了提高块设备的访问效率, 会使用缓冲区,来缓存块设备的数据。

键盘敲入字母时,整体流程

在这里插入图片描述
CPU 里面的内存接口,直接和系统总线通信,然后系统总线再接入一个 I/O 桥接器,这个 I/O 桥接器,另一边接入了内存总线,使得 CPU 和内存通信。再另一边,又接入了一个 I/O 总线,用来连接 I/O 设备,比如键盘、显示器等。

当用户输入了键盘字符,键盘控制器就会产生扫描码数据,并将其缓冲在键盘控制器的寄存器中,紧接着键盘控制器通过总线给 CPU 发送中断请求

CPU 收到中断请求后,操作系统会保存被中断进程的 CPU 上下文,然后调用键盘的中断处理程序

键盘的中断处理程序是在键盘驱动程序初始化时注册的,那键盘中断处理函数的功能就是从键盘控制器的寄存器的缓冲区读取扫描码,再根据扫描码找到用户在键盘输入的字符,如果输入的字符是显示字符,那就会把扫描码翻译成对应显示字符的 ASCII 码,比如用户在键盘输入的是字母 A,是显示字符,于是就会把扫描码翻译成 A 字符的 ASCII 码。

得到了显示字符的 ASCII 码后,就会把 ASCII 码放到 「读缓冲区队列」 ,接下来就是要把显示字符显示屏幕了,显示设备的驱动程序会定时从 「读缓冲区队列」 读取数据放到 「写缓冲区队列」,最后把 「写缓冲区队列」 的数据一个一个写入到显示设备的控制器的寄存器中的数据缓冲区,最后将这些数据显示在屏幕里。

显示出结果后,恢复被中断进程的上下文

网络系统

一台机器将自己想要表达的内容,按照某种约定好的格式发送出去,当另外一台机器收到这些信息后,也能够按照约定好的格式解析出来,从而准确、可靠地获得发送方想要表达的内容。这种约定好的格式就是网络协议(Networking Protocol)

网络分层原因

场景模拟:
Linux 服务器 A 和 Linux 服务器 B 处于不同的网段,通过中间的 Linux 服务器作为路由器进行转发。
在这里插入图片描述

OSI 的标准七层模型业界标准的 TCP/IP 模型
在这里插入图片描述
由于网络环境过于复杂,不是一个能够集中控制的体系。全球各个服务器与设备可以通过同一套网络协议栈通过切分为多个层次和组合,来满足不同服务器和设备的通信需求。

发送数据包

对于数据包的发送,就是层层封装的过程。

在 Linux 服务器 A 上的客户端,打开一个 Firefox 连接 Ngnix。也是通过 Socket,客户端会被分配一个随机端口 12345。同理,打开一个 Chrome 连接 Tomcat,同样通过 Socket 分配随机端口 12346。
在这里插入图片描述
在客户端浏览器,我们将请求封装为 HTTP 协议,通过 Socket 发送到内核。内核的网络协议栈里面,在 TCP 层创建用于维护连接、序列号、重传、拥塞控制的数据结构,将 HTTP 包加上 TCP 头,发送给 IP 层,IP 层加上 IP 头,发送给 MAC 层,MAC 层加上 MAC 头,从硬件网卡发出去。

网络包会先到达网络 1 的交换机。我们常称交换机为二层设备,这是因为,交换机只会处理到第二层,然后它会将网络包的 MAC 头拿下来,发现目标 MAC 是在自己右面的网口,于是就从这个网口发出去。

网络包会到达中间的 Linux 路由器,它左面的网卡会收到网络包,发现 MAC 地址匹配,就交给 IP 层,在 IP 层根据 IP 头中的信息,在路由表中查找。下一跳在哪里,应该从哪个网口发出去?在这个例子中,最终会从右面的网口发出去。我们常把路由器称为三层设备,因为它只会处理到第三层。

从路由器右面的网口发出去的包会到网络 2 的交换机,还是会经历一次二层的处理,转发到交换机右面的网口。

最终网络包会被转发到 Linux 服务器 B,它发现 MAC 地址匹配,就将 MAC 头取下来,交给上一层。IP 层发现 IP 地址匹配,将 IP 头取下来,交给上一层。TCP 层会根据 TCP 头中的序列号等信息,发现它是一个正确的网络包,就会将网络包缓存起来,等待应用层的读取。

应用层通过 Socket 监听某个端口,因而读取的时候,内核会根据 TCP 头中的端口号,将网络包发给相应的应用。

HTTP 层的头和正文,是应用层来解析的。通过解析,应用层知道了客户端的请求,例如购买一个商品,还是请求一个网页。当应用层处理完 HTTP 的请求,会将结果仍然封装为 HTTP 的网络包,通过 Socket 接口,发送给内核。

内核会经过层层封装,从物理网口发送出去,经过网络 2 的交换机,Linux 路由器到达网络 1,经过网络 1 的交换机,到达 Linux 服务器 A。在 Linux 服务器 A 上,经过层层解封装,通过 socket 接口,根据客户端的随机端口号,发送给客户端的应用程序,浏览器。于是浏览器就能够显示出一个绚丽多彩的页面了。

即便在如此简单的一个环境中,网络包的发送过程,竟然如此的复杂。

零拷贝

DMA技术

在没有 DMA 技术前,I/O 的过程是这样的:

  • CPU 发出对应的指令给磁盘控制器,然后返回;
  • 磁盘控制器收到指令后,于是就开始准备数据,会把数据放入到磁盘控制器的内部缓冲区中,然后产生一个中断;
  • CPU 收到中断信号后,停下手头的工作,接着把磁盘控制器的缓冲区的数据一次一个字节地读进自己的寄存器,然后再把寄存器里的数据写入到内存,而在数据传输的期间 CPU 是无法执行其他任务的。

在这里插入图片描述
整个数据的传输过程,都要需要 CPU 亲自参与搬运数据的过程,而且这个过程,CPU 是不能做其他事情的。

DMA 技术,也就是直接内存访问(Direct Memory Access) 技术

在进行 I/O 设备和内存的数据传输的时候,数据搬运的工作全部交给 DMA 控制器,而 CPU 不再参与任何与数据搬运相关的事情,这样 CPU 就可以去处理别的事务。
在这里插入图片描述
具体过程:

  • 用户进程调用 read 方法,向操作系统发出 I/O 请求,请求读取数据到自己的内存缓冲区中,进程进入阻塞状态;
  • 操作系统收到请求后,进一步将 I/O 请求发送 DMA,然后让 CPU 执行其他任务;
  • DMA 进一步将 I/O 请求发送给磁盘;
  • 磁盘收到 DMA 的 I/O 请求,把数据从磁盘读取到磁盘控制器的缓冲区中,当磁盘控制器的缓冲区被读满后,向 DMA 发起中断信号,告知自己缓冲区已满;
  • DMA 收到磁盘的信号,将磁盘控制器缓冲区中的数据拷贝到内核缓冲区中,此时不占用 CPU,CPU 可以执行其他任务;
  • 当 DMA 读取了足够多的数据,就会发送中断信号给 CPU;
  • CPU 收到 DMA 的信号,知道数据已经准备好,于是将数据从内核拷贝到用户空间,系统调用返回;

可以看到, 整个数据传输的过程,CPU 不再参与数据搬运的工作,而是全程由 DMA 完成,但是 CPU 在这个过程中也是必不可少的,因为传输什么数据,从哪里传输到哪里,都需要 CPU 来告诉 DMA 控制器。

早期 DMA 只存在在主板上,如今由于 I/O 设备越来越多,数据传输的需求也不尽相同,所以每个 I/O 设备里面都有自己的 DMA 控制器。

传统文件传输

如果服务端要提供文件传输的功能,我们能想到的最简单的方式是:将磁盘上的文件读取出来,然后通过网络协议发送给客户端。
传统 I/O 的工作方式是,数据读取和写入是从用户空间到内核空间来回复制,而内核空间的数据是通过操作系统层面的 I/O 接口从磁盘读取或写入。
代码通常如下,一般会需要两个系统调用:

read(file, tmp_buf, len);
write(socket, tmp_buf, len);

在这里插入图片描述
首先,期间共发生了 4 次用户态与内核态的上下文切换,因为发生了两次系统调用,一次是 read() ,一次是 write(),每次系统调用都得先从用户态切换到内核态,等内核完成任务后,再从内核态切换回用户态。

上下文切换到成本并不小,一次切换需要耗时几十纳秒到几微秒,虽然时间看上去很短,但是在高并发的场景下,这类时间容易被累积和放大,从而影响系统的性能。

其次,还发生了 4 次数据拷贝,其中两次是 DMA 的拷贝,另外两次则是通过 CPU 拷贝的,下面说一下这个过程:

  • 第一次拷贝,把磁盘上的数据拷贝到操作系统内核的缓冲区里,这个拷贝的过程是通过 DMA 搬运的。
  • 第二次拷贝,把内核缓冲区的数据拷贝到用户的缓冲区里,于是我们应用程序就可以使用这部分数据了,这个拷贝到过程是由 CPU 完成的。
  • 第三次拷贝,把刚才拷贝到用户的缓冲区里的数据,再拷贝到内核的 socket 的缓冲区里,这个过程依然还是由 CPU 搬运的。
  • 第四次拷贝,把内核的 socket 缓冲区里的数据,拷贝到网卡的缓冲区里,这个过程又是由 DMA 搬运的。

我们回过头看这个文件传输的过程,我们只是搬运一份数据,结果却搬运了 4 次,过多的数据拷贝无疑会消耗 CPU 资源,大大降低了系统性能。

这种简单又传统的文件传输方式,存在冗余的上文切换和数据拷贝,在高并发系统里是非常糟糕的,多了很多不必要的开销,会严重影响系统性能。

所以,要想提高文件传输的性能,就需要减少「用户态与内核态的上下文切换」和「内存拷贝」的次数

文件传输优化 -> 零拷贝

要想减少上下文切换到次数,就要减少系统调用的次数。

传统的文件传输方式会历经 4 次数据拷贝,而且这里面,「从内核的读缓冲区拷贝到用户的缓冲区里,再从用户的缓冲区里拷贝到 socket 的缓冲区里」,这个过程是没有必要的。因为文件传输的应用场景中,在用户空间我们并不会对数据「再加工」,所以数据实际上可以不用搬运到用户空间,因此用户的缓冲区是没有必要存在的

零拷贝实现

  • mmap + write
  • sendfile

mmap + write

read() 系统调用的过程中会把内核缓冲区的数据拷贝到用户的缓冲区里,于是为了减少这一步开销,我们可以用 mmap() 替换 read() 系统调用函数。

buf = mmap(file, len);
write(sockfd, buf, len);

mmap() 系统调用函数会直接把内核缓冲区里的数据「映射」到用户空间,这样,操作系统内核与用户空间就不需要再进行任何的数据拷贝操作。

在这里插入图片描述
具体过程如下:

  • 应用进程调用了 mmap()` 后,DMA 会把磁盘的数据拷贝到内核的缓冲区里。接着,应用进程跟操作系统内核「共享」这个缓冲区;
  • 应用进程再调用 write(),操作系统直接将内核缓冲区的数据拷贝到 socket 缓冲区中,这一切都发生在内核态由 CPU 来搬运数据
  • 最后,把内核的 socket 缓冲区里的数据,拷贝到网卡的缓冲区里,这个过程是由 DMA 搬运的。

我们可以得知,通过使用 mmap() 来代替 read(), 可以减少一次数据拷贝的过程。
但这还不是最理想的零拷贝,因为仍然需要通过 CPU 把内核缓冲区的数据拷贝到 socket 缓冲区里,而且仍然需要 4 次上下文切换,因为系统调用还是 2 次。

sendfile

#include <sys/socket.h>
ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);	

它的前两个参数分别是目的端和源端的文件描述符,后面两个参数是源端的偏移量和复制数据的长度,返回值是实际复制数据的长度。

首先,它可以替代前面的 read()write() 这两个系统调用,这样就可以减少一次系统调用,也就减少了 2 次上下文切换的开销。

其次,该系统调用,可以直接把内核缓冲区里的数据拷贝到 socket 缓冲区里,不再拷贝到用户态,这样就只有 2 次上下文切换,和 3 次数据拷贝。
在这里插入图片描述
但是这还不是真正的零拷贝技术,如果网卡支持 SG-DMA(The Scatter-Gather Direct Memory Access)技术(和普通的 DMA 有所不同),我们可以进一步减少通过 CPU 把内核缓冲区里的数据拷贝到 socket 缓冲区的过程。

可以在 Linux 系统通过下面这个命令,查看网卡是否支持 scatter-gather 特性:

$ ethtool -k eth0 | grep scatter-gather
scatter-gather: on

于是,从 Linux 内核 2.4 版本开始起,对于支持网卡支持 SG-DMA 技术的情况下, sendfile() 系统调用的过程发生了点变化,具体过程如下:

  • 第一步,通过 DMA 将磁盘上的数据拷贝到内核缓冲区里;
  • 第二步,缓冲区描述符和数据长度传到 socket 缓冲区,这样网卡的 SG-DMA 控制器就可以直接将内核缓存中的数据拷贝到网卡的缓冲区里,此过程不需要将数据从操作系统内核缓冲区拷贝到 socket 缓冲区中,这样就减少了一次数据拷贝;

所以,这个过程之中,只进行了 2 次数据拷贝
在这里插入图片描述
这就是所谓的零拷贝(Zero-copy)技术,因为我们没有在内存层面去拷贝数据,也就是说全程没有通过 CPU 来搬运数据,所有的数据都是通过 DMA 来进行传输的。

零拷贝技术的文件传输方式相比传统文件传输的方式,减少了 2 次上下文切换和数据拷贝次数,只需要 2 次上下文切换和数据拷贝次数,就可以完成文件的传输,而且 2 次的数据拷贝过程,都不需要通过 CPU,2 次都是由 DMA 来搬运。

所以,总体来看,零拷贝技术可以把文件传输的性能提高至少一倍以上。

PageCache作用

「内核缓冲区」实际上是磁盘高速缓存(PageCache)

由于零拷贝使用了 PageCache 技术,可以使得零拷贝进一步提升了性能。

读写磁盘相比读写内存的速度慢太多了,所以我们应该想办法把「读写磁盘」替换成「读写内存」。于是,我们会通过 DMA 把磁盘里的数据搬运到内存里,这样就可以用读内存替换读磁盘。
但是,内存空间远比磁盘要小,内存注定只能拷贝磁盘里的一小部分数据。

程序运行的时候,具有「局部性」,所以通常,刚被访问的数据在短时间内再次被访问的概率很高,于是我们可以用 PageCache 来缓存最近被访问的数据,当空间不足时淘汰最久未被访问的缓存。

所以,读磁盘数据的时候,优先在 PageCache 找,如果数据存在则可以直接返回;如果没有,则从磁盘中读取,然后缓存 PageCache 中。

还有一点,读取磁盘数据的时候,需要找到数据所在的位置,但是对于机械磁盘来说,就是通过磁头旋转到数据所在的扇区,再开始「顺序」读取数据,但是旋转磁头这个物理动作是非常耗时的,为了降低它的影响,PageCache 使用了「预读功能」

比如,假设 read 方法每次只会读 32 KB 的字节,虽然 read 刚开始只会读 0 ~ 32 KB 的字节,但内核会把其后面的 32~64 KB 也读取到 PageCache,这样后面读取 32~64 KB 的成本就很低,如果在 32~64 KB 淘汰出 PageCache 前,进程读取到它了,收益就非常大。

所以,PageCache 的优点主要是两个:

  • 缓存最近被访问的数据
  • 预读功能
    这两个做法,将大大提高读写磁盘的性能。

但是,在传输大文件(GB 级别的文件)的时候,PageCache 会不起作用,那就白白浪费 DMA 多做的一次数据拷贝,造成性能的降低,即使使用了 PageCache 的零拷贝也会损失性能

这是因为如果你有很多 GB 级别文件需要传输,每当用户访问这些大文件的时候,内核就会把它们载入 PageCache 中,于是 PageCache 空间很快被这些大文件占满。

另外,由于文件太大,可能某些部分的文件数据被再次访问的概率比较低,这样就会带来 2 个问题:

  • PageCache 由于长时间被大文件占据,其他「热点」的小文件可能就无法充分使用到 PageCache,于是这样磁盘读写的性能就会下降了;
  • PageCache 中的大文件数据,由于没有享受到缓存带来的好处,但却耗费 DMA 多拷贝到 PageCache 一次;

所以,针对大文件的传输,不应该使用 PageCache,也就是说不应该使用零拷贝技术,因为可能由于 PageCache 被大文件占据,而导致「热点」小文件无法利用到 PageCache,这样在高并发的环境下,会带来严重的性能问题。

大文件传输方式

当调用 read 方法读取文件时,进程实际上会阻塞在 read 方法调用,因为要等待磁盘数据的返回
在这里插入图片描述
具体过程:

  • 当调用 read 方法时,会阻塞着,此时内核会向磁盘发起 I/O 请求,磁盘收到请求后,便会寻址,当磁盘数据准备好后,就会向内核发起 I/O 中断,告知内核磁盘数据已经准备好;
  • 内核收到 I/O 中断后,就将数据从磁盘控制器缓冲区拷贝到 PageCache 里;
  • 最后,内核再把 PageCache 中的数据拷贝到用户缓冲区,于是 read 调用就正常返回了。

对于阻塞的问题,可以用异步 I/O 来解决
在这里插入图片描述
它把读操作分为两部分:

  • 前半部分,内核向磁盘发起读请求,但是可以不等待数据就位就可以返回,于是进程此时可以处理其他任务;
  • 后半部分,当内核将磁盘中的数据拷贝到进程缓冲区后,进程将接收到内核的通知,再去处理数据;

而且,我们可以发现,异步 I/O 并没有涉及到 PageCache,所以使用异步 I/O 就意味着要绕开 PageCache。

绕开 PageCache 的 I/O 叫直接 I/O,使用 PageCache 的 I/O 则叫缓存 I/O。通常,对于磁盘,异步 I/O 只支持直接 I/O。

于是,在高并发的场景下,针对大文件的传输的方式,应该使用「异步 I/O + 直接 I/O」来替代零拷贝技术。

直接 I/O 应用场景常见的两种:

  • 应用程序已经实现了磁盘数据的缓存,那么可以不需要 PageCache 再次缓存,减少额外的性能损耗。在 MySQL 数据库中,可以通过参数设置开启直接 I/O,默认是不开启;
  • 传输大文件的时候,由于大文件难以命中 PageCache 缓存,而且会占满 PageCache 导致「热点」文件无法充分利用缓存,从而增大了性能开销,因此,这时应该使用直接 I/O。

另外,由于直接 I/O 绕过了 PageCache,就无法享受内核的这两点的优化:

  • 内核的 I/O 调度算法会缓存尽可能多的 I/O 请求在 PageCache 中,最后「合并」成一个更大的 I/O 请求再发给磁盘,这样做是为了减少磁盘的寻址操作;
  • 内核也会「预读」后续的 I/O 请求放在 PageCache 中,一样是为了减少对磁盘的操作;

于是,传输大文件的时候,使用「异步 I/O + 直接 I/O」了,就可以无阻塞地读取文件了。

所以,传输文件的时候,我们要根据文件的大小来使用不同的方式:

  • 传输大文件的时候,使用「异步 I/O + 直接 I/O」
  • 传输小文件的时候,则使用「零拷贝技术」

在 Nginx 中,我们可以用如下配置,来根据文件的大小来使用不同的方式:

location /video/ { 
    sendfile on; 
    aio on; 
    directio 1024m; 
}

当文件大小大于 directio 值后,使用「异步 I/O + 直接 I/O」,否则使用「零拷贝技术」。

本文含有隐藏内容,请 开通VIP 后查看

网站公告

今日签到

点亮在社区的每一天
去签到