GraphQL之OS

224 阅读1小时+

1. 操作系统概述

用户态和内核态

根据进程访问资源的特点,我们可以把进程在系统上的运行分为两个级别:

  • 用户态(user mode) : 用户态运行的进程可以直接读取用户程序的数据。
  • 系统态(kernel mode):可以简单的理解系统态运行的进程或程序几乎可以访问计算机的任何资源,不受限制。

什么是系统调用

  • 我们运行的程序基本都是运行在用户态,如果我们调用操作系统提供的系统态级别的子功能咋办呢?那就需要系统调用了!
  • 也就是说在我们运行的用户程序中,凡是与系统态级别的资源有关的操作(如文件管理、进程控制、内存管理等),都必须通过系统调用方式向操作系统提出服务请求,并由操作系统代为完成。

这些系统调用按功能大致可分为如下几类:

  • 设备管理。完成设备的请求或释放,以及设备启动等功能。
  • 文件管理。完成文件的读、写、创建及删除等功能。
  • 进程控制。完成进程的创建、撤销、阻塞及唤醒等功能。
  • 进程通信。完成进程之间的消息传递或信号传递等功能。
  • 内存管理。完成内存的分配、回收以及获取作业占用内存区大小及地址等功能。

如何从用户态切换到内核态

  • 系统调用

这是用户态进程主动要求切换到内核态的一种方式,用户态进程通过系统调用申请使用操作系统提供的服务程序完成工作,比如 read 操作,比如前例中 fork() 实际上就是执行了一个创建新进程的系统调用。而系统调用的机制其核心还是使用了操作系统为用户特别开放的一个中断来实现,例如Linux的int 80h中断。

  • 异常

当CPU在执行运行在用户态下的程序时,发生了某些事先不可知的异常,这时会触发由当前运行进程切换到处理此异常的内核相关程序中,也就转到了内核态,比如缺页异常。

  • 外围设备的中断

当外围设备完成用户请求的操作后,会向CPU发出相应的中断信号,这时CPU会暂停执行下一条即将要执行的指令转而去执行与中断信号对应的处理程序,如果先前执行的指令是用户态下的程序,那么这个转换的过程自然也就发生了由用户态到内核态的切换。比如硬盘读写操作完成,系统会切换到硬盘读写的中断处理程序中执行后续操作等。

这3种方式是系统在运行时由用户态转到内核态的最主要方式,其中系统调用可以认为是用户进程主动发起的,异常和外围设备中断则是被动的。

其他必会知识

  • 并行与并发

并发:在操作系统中,某一时间段,几个程序在同一个CPU上运行,但在任意一个时间点上,只有一个程序在CPU上运行。

并行:两个程序在某一时刻同时运行,强调同时发生。

  • 阻塞与非阻塞

阻塞是指调用线程或者进程被操作系统挂起。

非阻塞是指调用线程或者进程不会被操作系统挂起。

  • 同步与异步

同步是阻塞模式,异步是非阻塞模式。

同步就是指一个进程在执行某个请求的时候,若该请求需要一段时间才能返回信息,那么这个进程将会一直等待下去,直到收到返回信息才继续执行下去;

异步是指进程不需要一直等下去,而是继续执行下面的操作,不管其他进程的状态。当有消息返回式系统会通知进程进行处理,这样可以提高执行的效率。

进程管理

线程、进程、协程的区别

  • 进程是资源分配的最基本的单位,运行一个程序会创建一个或多个进程,进程就是运行起来的可执行程序。
  • 线程是程序执行的最基本的单位,是轻量级的进程,每个进程里都有一个主线程,且只能有一个,和进程是相互依存的关系,生命周期和进程一样。
  • 协程是用户态的轻量级线程,是线程内部的基本单位。协程不是被操作系统内核所管理的,而是完全由程序所控制,也就是在用户态执行。这样带来的好处是性能大幅度的提升,因为不会像线程切换那样消耗资源。一个线程内协程却绝对是串行的。当一个协程运行时,其它协程必须挂起。

进程和线程的区别

  • 首先从资源来说,进程是资源分配的基本单位,但是线程不拥有资源,线程可以访问隶属进程的资源。
  • 然后从调度来说,线程是独立调度的基本单位,在同一进程中线程切换的话不会引起进程的切换,从一个进程中的线程切换到另一个进程中的线程时,会引起进程的切换。
  • 从系统开销来讲,由于创建或撤销进程,系统都要分配回收资源,所付出的开销远大于创建或撤销线程时的开销。类似的,在进行进程切换的时候,涉及当前执行进程 CPU 环境的保存及新调度进程 CPU 环境设置,而线程切换只需保存和设置少量寄存器的内容,开销很小。
  • 通信方面来说,线程间可以通过直接读写同一进程的数据进行通信,但是进程通信需要借助一些复杂的方法。

什么是PCB

PCB是进程控制块,它主要包含下面几部分:

  1. 进程描述信息:
  • 进程标识符:标识各个进程,每个进程都有⼀个并且唯⼀的标识符;
  • 用户标识符:进程归属的⽤户,⽤户标识符主要为共享和保护服务;
  1. 进程控制和管理信息:
  • 进程当前状态,如 new、ready、running、waiting 或 blocked 等;
  • 进程优先级:进程抢占 CPU 时的优先级;
  1. 资源分配清单:
  • 有关内存地址空间或虚拟地址空间的信息,所打开⽂件的列表和所使⽤的I/O设备信息。
  1. CPU相关信息:
  • CPU中各个寄存器的值,当进程被切换时,CPU的状态信息都会被保存在相应的 PCB中,以便进程重新执行时,能从断点处继续执行。

PCB 的作用

  • PCB是进程实体的一部分,是操作系统中最重要的数据结构
  • 由于它的存在,使得多道程序环境下,不能独立运行的程序成为一个能独立运行的基本单位,使得程序可以并发执行
  • 系统通过PCB来感知进程的存在。(换句话说,PCB 是进程存在的唯一标识)
  • 进程的组成可以用下图来表示,PCB 就是他唯一标识符。

进程和线程创建和撤销的过程

进程允许创建和控制另一个进程,前者称为父进程,后者称为子进程,子进程又可以创建孙进程,如此下去进而形成一个进程的家族树,这样子进程就可以从父进程那里继承所有的资源,当子进程撤销时,便将从父进程处获得的所有资源归还,此外,撤销父进程,则必须撤销所有的子进程。(撤销的过程实际上就是对这棵家族树进行后序遍历的过程)

在应用中创建一个子进程的过程如下:

  • 申请空白的PCB
  • 初始化进程描述信息
  • 为进程分配资源以及地址空间
  • 将其插入就绪队列中

当进程完成后,系统会回收占用的资源,撤销进程,而引发进程撤销的情况有:进程正常结束或者异常结束,外界的干预(比如我们在任务管理器中强制停止某个进程的运行)。

  • 查找需要撤销的进程的PCB
  • 如果进程处于运行状态,终止进程并进行调度
  • 终止子孙进程 - 归还资源
  • 将它从所在的队列中移除 

进程的五种状态

面试在答的时候这么答:有创建状态、就绪状态、运行状态、阻塞状态、结束状态。

  • 其中只有就绪状态和运行状态能互相转化,当进程为就绪态时,等待 CPU 分配时间片,得到时间片后就进入 运行状态
  • 运行状态在使用完 CPU 时间片后,又重回就绪态。
  • 阻塞状态是进程在运行状态时,需要等待某个资源比如打印机资源,而进入一个挂起的状态,等资源拿到后会回到就绪状态,等待 CPU 时间片。

2.5 进程调度算法(面试高频知识点)

不同环境的调度算法目标不同,因此需要针对不同环境来讨论调度算法。

批处理系统

批处理系统没有太多的用户操作,调度算法目标是保证吞吐量和周转时间。

先来先服务 first-come first-serverd(FCFS)

非抢占式的调度算法,按照请求的顺序进行调度。

有利于长作业,适用于CPU繁忙的系统,不适合IO繁忙的系统。

短作业优先 shortest job first(SJF)

非抢占式的调度算法,按估计运行时间最短的顺序进行调度。

长作业有可能会饿死,处于一直等待短作业执行完毕的状态。因为如果一直有短作业到来,那么长作业永远得不到调度。

(3) 最短剩余时间优先 shortest remaining time next(SRTN)

最短作业优先的抢占式版本,按剩余运行时间的顺序进行调度。 当一个新的作业到达时,其整个运行时间与当前进程的剩余时间作比较。如果新的进程需要的时间更少,则挂起当前进程,运行新的进程。否则新的进程等待。

(4)高响应比的调度

  • 如果两个等待时间相同,要求服务的时间越短,响应比越高,这样短作业容易被选中
  • 如果两个进程的服务时间相同,等待时间越长,响应比越高,这样就兼顾到了长作业,因为只要其等待的时间足够长,其响应比就越高,越容易运行
  1. 交互式系统

交互式系统有大量的用户交互操作,在该系统中调度算法的目标是快速地进行响应。

(1)时间片轮转

将所有就绪进程按FCFS的原则排成一个队列,每次调度时,把CPU时间分配给队首进程,该进程可以执行一个时间片。当时间片用完时,由计时器发出时钟中断,调度程序便停止该进程的执行,并将它送往就绪队列的末尾,同时继续把CPU时间分配给队首的进程。

时间片轮转算法的效率和时间片的大小有很大关系:

  • 因为进程切换都要保存进程的信息并且载入新进程的信息,如果时间片太小,会导致进程切换得太频繁,在进程切换上就会花过多时间。
  • 而如果时间片过长,那么实时性就不能得到保证。

(2)优先级调度

为每个进程分配一个优先级,按优先级进行调度。

为了防止低优先级的进程永远等不到调度,可以随着时间的推移增加等待进程的优先级。

(3)多级反馈队列

一个进程需要执行100个时间片,如果采用时间片轮转调度算法,那么需要交换100次。

多级队列是为这种需要连续执行多个时间片的进程考虑,它设置了多个队列,每个队列时间片大小都不同,例如 1,2,4,8,..。进程在第一个队列没执行完,就会被移到下一个队列。这种方式下,之前的进程只需要交换 7 次。

每个队列优先权也不同,最上面的优先权最高。因此只有上一个队列没有进程在排队,才能调度当前队列上的进程。

可以将这种调度算法看成是时间片轮转调度算法和优先级调度算法的结合。

3. 实时系统

实时系统要求一个请求在一个确定时间内得到响应。

分为硬实时和软实时,前者必须满足绝对的截止时间,后者可以容忍一定的超时。

2.6 进程同步的方式

  1. 临界区

首先对临界资源的访问那段代码被称为临界区,为了互斥的访问临界区,每个进程在进入临界区时,都需要先进行检查,也就是查看锁。

  1. 同步与互斥

同步:多个进程因为合作产生的直接制约关系,使得进程有一定的先后顺序。

互斥:多个进程在同一时刻只有一个进程能进入临界区。

  1. 信号量

信号量是一个整型变量,可以对其执行P和V操作。

P:如果信号量大于零,就对其进行减 1 操作;如果信号量等于 0,进程进入 waiting 状态,等待信号量大于零。

V:对信号量执行加 1 操作,并唤醒正在 waiting 的进程

如果信号量只能取 0 或者 1,那么就变成了互斥量,其实也可以理解成加锁解锁操作,0 表示已经加锁,1 表示解锁。

  1. 管程

使用信号量机制实现的生产者消费者问题需要客户端代码做很多控制,而管程把控制的代码独立出来,不仅不容易出错,也使得客户端代码调用更容易。管程有一个重要特性:在一个时刻只能有一个进程使用管程。进程在无法继续执行的时候不能一直占用管程,否则其它进程永远不能使用管程。管程引入了 条件变量 以及相关的操作:wait() 和 signal() 来实现同步操作。对 条件变量 执行 wait() 操作会导致调用进程阻塞,把管程让出来给另一个进程持有。signal() 操作用于唤醒被阻塞的进程。

2.7 进程间通信的方式(重要!)

建议把BiliBili王道考研的这一节课给看了

进程通信和进程同步很容易混淆,其实可以把进程通信当成一种手段,进程同步是一种目的,为了实现进程同步,可传输一些进程同步所需要的信息。

(1)管道

  • 管道传输数据是单向的,是内核里面的一串缓存,只能采用半双工通信。

  • 管道写满前只能写,不能读;管道变空前,只能读,不能写。

  • 管道通信的效率很低,不适合进程间的频繁交换数据。

  • 管道通信分为:匿名管道和命名管道

  • 匿名管道:它的通信范围是存在父子关系的进程。因为管道没有实体,只能通过fork来复制父进程的fd文件描述符,来达到通信目的。

  • 命名管道:它可以在不相关的进程间也能相互通信。因为命名管道提前创建了一个类型是管道的设备文件,在进程中只要使用这个文件就能进行通信。

(2)消息队列

  • 消息队列的方式需要把消息封装成【消息头+消息体】的格式。
  • 消息传递分为直接通信方式和间接通信方式。
  • 消息队列不适合较大数据的传输,因为消息队列通信过程中,存在用户态和内核态的数据拷贝开销,进程写入数据到内核的消息队列,会发生用户态拷贝数据到内核态的过程,同理另一个进程读取内核态的消息,会发生内核态拷贝数据到用户态的过程。

(3)共享内存

  • 共享内存的方式就可以解决拷贝耗时很长的问题了。 因为每个进程都有自己的独立的虚拟内存空间,不同进程的虚拟内存空间映射到不同的物理内存。共享内存机制就是两个进程拿出一块虚拟地址出来,映射到相同的物理内存。这样这个线程写的东西,另一个线程马上就能看到,不需要拷贝。

  • 共享内存是最快的一种进程通信的方式,因为进程是直接对内存进行存取的。因为可以多个进程对共享内存同时操作,所以对共享空间的访问必须要求进程对共享内存的访问是互斥的。所以我们经常把信号量和共享内存一起使用来实现进程通信。

(4)信号量

  • 共享内存最大的问题就是多进程竞争内存的问题,就像平时所说的线程安全的问题,那么就需要靠信号量来保证进程间的操作的同步与互斥。

  • 信号量其实就是个计数器,用来表示资源的数量,它有两个原子操作:

  • P操作:例如信号量的初始值是 1,然后 a 进程访问临界资源的时候,把信号量减1,当信号量为0时,其他进程执行P操作后会被阻塞就会被阻塞。

  • V操作:信号量加1,然后唤醒阻塞线程。

(5)信号

上面说的进程之间的通信都是常规模式下,对于异常模式的工作模式,需要使用信号方式来通知进程

信号通知的来源主要有硬件来源(cltr+c)和软件来源(kil)

信号是进程通信机制中唯一的异步通信机制,因为可以在任何时候发生信号给某一进程,一旦有了信号产生,我们就有下面几种用户进程对信号的处理方式:

  • 执行默认操作。Linux系统对每个信号规定了默认操作。如SIGTERM信号是终止进程
  • 捕捉信号。可以为信号定义一个信号处理函数。当信号发生时,就执行相应函数
  • 忽视信号。我们不希望处理某些信号时,就忽视它。

(6)Socket

管道,消息队列,共享内存,信号量和信号都在同一台主机上面通信,想要跨网络与不用主机进程通信,就需要Socket通信了。

Socket通信既可以用在不同主机,也可以用在相同的主机之间,可以根据创建Socket的类型不同分为:基于TCP协议的通信方式,基于UDP协议的通信方式和本地进程间的通信方式

2.8 生产者消费者模型(要会写伪代码)

生产者和消费者模型,期间就需要线程之间进行通信来实现互斥和同步。

需求是这样的:

  • 生产者只有当缓冲区有空位时才生成产品;
  • 消费者当缓冲区有产品时才能消费。

做法:

  • 需要三个信号量:

  • 互斥信号量mutex: 用于访问缓冲区,初始为1;

  • 资源信号量full: 用于消费者询问缓冲区是否有产品,有才消费,默认为0;

  • 资源信号量empty:用于生产者询问缓冲区是否有空位,有才生产,默认为n;

  • 注意!不可对缓存区先加锁,设想这样一个情况:当 empty = 0 时,生产者此时先对临界区加锁,然后发现缓冲区的数量为0,则开始进入阻塞等待消费者消费的状态,而此时一个消费者开始进入消耗一个产品,但发现临界区被加锁,所以生产者在等待消费者消费产品,而消费者在等待生产者释放临界区锁,进入了一个死锁状态。

伪代码如下

mutex = 1; // 互斥信号量
empty = 100; // 表示缓冲区的空槽个数
full = 0; // 表示缓冲区的满槽个数


//生产者线程
void producer(){
  while(true){
    P(empty); // 生产线程查看有没有空槽,将空槽数-1
    P(mutex); // 进入临界区
    将生产的数据放入缓存区
    V(mutex); // 离开临界区
    V(full); // 将满槽数+1, 表示生产了一个产品
  }


// 消费者线程
void consumer(){
  while(true){
    P(full);  // 消费线程查看有没有产品,将满槽数-1
    P(mutex); // 进入临界区 
    从缓冲区取数据
    V(mutex); // 离开临界区
    V(empty); // 消费了一个产品,多出一个空槽
  }
  
void P(S){
    S--;
    if(S < 0) block();  // 如果小于0,代表资源没了
}
 
void V(S){
    S++;
    if(S <= 0) wakeUp(); // 如果小于等于0,代表有进程仍然在等待,通知他们ok了
}

2. 死锁

2.1 讲讲死锁发生的条件是什么?

  1. 互斥条件:是资源分配是互斥的,资源要么处于被分配给一个进程的状态,要么就是可用状态。
  2. 等待和占有条件:进程在请求资源得不到满足的时候,进入阻塞等待状态,且不释放已占有的资源。
  3. 不剥夺条件:已经分配给一个进程的资源不能强制性地被抢占,只能等待占有他的进程释放。
  4. 环路等待:有两个或者两个以上的进程组成一条环路,该环路中的每个进程都在等待下一个进程释放所占有的资源。

2.2 死锁检测

  1. 每种类型一个资源的死锁检测

可以通过检测有向图中是否存在环来检测是否有死锁,从一个节点出发进行 dfs,对访问过的节点进行标记,如果访问到了已经标记的节点,就表示有向图存在环,也就是检测到死锁的发生。

  1. 每种类型多个资源的死锁检测

上图中,有三个进程四个资源,每个数据代表的含义如下:

  • E 向量:资源总量
  • A 向量:资源剩余量
  • C 矩阵:每个进程所拥有的资源数量,每一行都代表一个进程拥有资源的数量
  • R 矩阵:每个进程请求的资源数量

进程 P1 和 P2 所请求的资源都得不到满足,只有进程 P3 可以,让 P3 执行,之后释放 P3 拥有的资源,此时 A = (2 2 2 0)。P2 可以执行,执行后释放 P2 拥有的资源,A = (4 2 2 1) 。P1 也可以执行。所有进程都可以顺利执行,没有死锁。

算法总结如下:

每个进程最开始时都不被标记,执行过程有可能被标记。当算法结束时,任何没有被标记的进程都是死锁进程。

  1. 寻找一个没有标记的进程 Pi,它所请求的资源小于等于 A。
  2. 如果找到了这样一个进程,那么将 C 矩阵的第 i 行向量加到 A 中,标记该进程,并转回 1。
  3. 如果没有这样一个进程,算法终止

2.3 死锁恢复

  • 死锁恢复:从下到上逐渐变态。。。

  • 撤销进程法:

    1. 撤消陷于死锁的全部进程;
    1. 逐个撤消陷于死锁的进程,直到死锁不存在;
  • 资源剥夺法:

    1. 从陷于死锁的进程中逐个强迫放弃所占用的资源,直至死锁消失;
    1. 从另外的进程那里强行剥夺足够数量的资源分配给死锁进程,以解除死锁状态。
  • 鸵鸟算法,直接不管!

2.4 死锁的预防

预防策略:从形成死锁的条件入手,只要打破形成死锁的四个条件中的一个或多个,就可以保证系统不会进入死锁状态。

  • 破坏互斥条件:比如只读文件、磁盘等软硬件资源可采用这种办法处理。
  • 破坏占有和等待条件:在进程开始执行之前,就把其要申请的所有资源全部分配给他,直到所有资源都满足,才开始执行。
  • 破坏不剥夺条件:允许进程强行从资源占有者那里夺取某些资源
  • 破坏环路等待条件:给系统的所有资源编号,规定进程请求所需资源的顺序必须按照资源的编号依次执行。

2.5 死锁的避免

在程序运行时避免发生死锁。

  • 在系统运行过程中,对进程提出的每一个(系统能够满足的)资源申请进行动态检查(安全性检查);
  • 根据检查结果决定是否分配资源,若分配后系统可能发生死锁,则不予分配,否则予以分配。

1. 安全状态

系统能按某种进程推进顺序( P1, P2, …, Pn),为每个进程Pi分配其所需资源,直至满足每个进程对资源的最大需求,使每个进程都可顺序地完成。此时称 P1, P2, …, Pn 为安全序列。如果存在这样一个安全序列,则系统是安全的。

2. 单个资源的银行家算法

一个小城镇的银行家,他向一群客户分别承诺了一定的贷款额度,算法要做的是判断对请求的满足是否会进入不安全状态,如果是,就拒绝请求;否则予以分配。

上图 c 为不安全状态,因此算法会拒绝之前的请求,从而避免进入图 c 中的状态

3. 多个资源的银行家算法

上图中有五个进程,四个资源。左边的图表示已经分配的资源,右边的图表示还需要分配的资源。最右边的 E、P 以及 A 分别表示:总资源、已分配资源以及可用资源,注意这三个为向量,而不是具体数值,例如 A=(1020),表示 4 个资源分别还剩下 1/0/2/0。

检查一个状态是否安全的算法如下:

  • 查找右边的矩阵是否存在一行小于等于向量 A。如果不存在这样的行,那么系统将会发生死锁,状态是不安全的。
  • 假若找到这样一行,将该进程标记为终止,并将其已分配资源加到 A 中。
  • 重复以上两步,直到所有进程都标记为终止,则状态时安全的。

如果一个状态不是安全的,需要拒绝进入这个状态。

2.6 死锁避免和死锁预防的区别

  • 死锁预防是设法至少破坏产生死锁的四个必要条件之一,严格的防止死锁的出现
  • 而死锁避免则不那么严格的限制产生死锁的必要条件的存在,因为即使死锁的必要条件存在,也不一定发生死锁。
  • 死锁避免是在系统运行过程中注意避免死锁的最终发生。

2.7 你能举出一个死锁的例子吗?

要举就直接举刚才的生产者消费者模型,可以知识复用。

对临界区的上锁如果放在了检测缓冲区是否已经满之前,就有可能发生死锁。比如生产者此时要产生一个产品,如果先对临界区上锁,然后检测缓冲区已满,这时就进入等待消费者消耗产品的状态,而消费者想消费产品时,必须先检测临界区是否上锁,此时临界区已经被生产者占有,这样就形成了死锁。

3. 内存管理(重中之重)

3.1 内存管理到底是干什么的?

  • 内存分配
  • 内存回收
  • 地址转换
  • 内存保护功能

3.2 内存管理分页的基础知识(有很多概念非常容易混淆,面试并不会问)

**页:**就是一个4KB的连续地址空间

**页面大小:**指的是一个页占多大的存储空间,就是上面的4KB!

**页表:**一个存放页表项的特殊的页,其实就是页!!!里面放的页表项用来映射逻辑地址的高位页号=>物理地址的内存块号!!!

**页表项:**指的是页表中的一条记录,也就是块号,还可以认为是起始地址!一个页表项映射一个4KB的页!当然我也可以多级页表,那么一个一级页表项就可以映射4MB空间,一个二级页表项再映射4KB空间。

**页表项长度:**指的是一个页表项占用的存储空间的大小,一般为4Byte

**页表长度:**一个页表的长度,指的是我这个页表有几个页表项的记录,即几个块号(块号就是页面的起始对应的物理地址)

如果是直接物理地址编地址。

假设是4GB的内存,那么有2^32次方个位置需要编映射表。

那么需要把内存表从0~2^32-1编码,每个编码需要32位来表示,也就是每个编码需要32/8=4B

那么一共需要编2^32个位置,也就是光映射表需要占2^32*4B=16GB的内存,肯定不能这么干。

所以采用分页处理,把4GB内存划分为一个个的小块,每块大小为4KB,那么共划分为2^20次方个页。每个页大小4KB,为每个页编上号,当然一个号编码我们最少需要20位,为了方便就用32位来表示了,一个页的编码就是32位的,页表的大小需要32/8 Byte * 2^20 = 4B的内存,光页表就需要占用1024个页

页表一句话总结:

页号和内存块号是一一映射的!映射方法是通过页表!

页号和内存块号一一映射,映射方法是通过页表,而页表中没有页号,因为每个页表的项的长度都是固定且已知的,把他们叫做页表项长度,页表项长度已知的前提下,只需要用:

页号 * 页表顶长度

就可以映射到该页号对应的页表项在页表内的偏移地址了!这个操作其实也是取代了直接用页号寻址的地位,然后最后再加上页表的首地址即可。

基本地址变换机构基本地址变换机构可以借助进程的页表将逻辑地址转换为物理地址。

通常会在系统中设置一个页表寄存器(PTR),存放页表在内存中的起始地址F和页表长度M。

进程未执行时,页表的始址和页表长度放在进程控制块(PCB)中,当进程被调度时,操作系统内核会把它们放到页表寄存器中。

注意:页面大小是2的整数幂设页面大小为L,逻辑地址A到物理地址E的变换过程如下:

①计算页号P和页内偏移量W(如果用十进制数手算,则P=AL,W=A%L:但是在计算机实际运行时,逻辑地址结构是固定不变的,因此计算机硬件可以更快地得到二进制表示的页号、页内偏移量)

②比较页号P和页表长度M,若P≥M,则产生越界中断,否则继续执行。(注意:页号是从0开始的,而页表长度至少是1,因此P=M时也会越界)

③页表中页号P对应的页表项地址=页表起始地址F+页号P·页表项长度,取出该页表项内容6,即为内存块号,(注意区分页表项长度、页表长度、页面大小的区别。页表长度指的是这个页表中总共有几个页表项,即总共有几个页:页表项长度指的是每个页表项占多大的存储空间:

页面大小指的是一个页面占多大的存储空间)

3.3 讲讲虚拟内存?

按照 why and how 来回答!

  • why? 传统的内存管理必须把作业一次性的导入到内存中,并且一直驻留到其作业运行结束,当作业很大时,是没有办法一次性装入内存的。
  • how? 而在一段时间内,只需要访问小部分数据就可以保证程序的正常运行。所以基于局部性原理,在程序加载的时候,把很快就会用到的部分放入内存中,暂时用不到的部分留在磁盘上。在程序执行的过程中,当信息不在内存时,再从外存把信息加载到内存里。当内存不够的时候,根据一些策略把用不到的内存换出到外存中,从而腾出空间给要调入内存的信息。而在 os 的管理下,让应用程序认为自己拥有一连续可用的内存,产生独享主存的错觉,这就是虚拟内存。

其实虚拟内存的基础是局部性原理,也正是因为有局部性原理,程序运行时才可以做到只装入部分到内存就可以运行。操作系统将内存抽象成地址空间。每个程序拥有自己的地址空间,这个地址空间被分割成多个页。这些页被映射到物理内存,不需要映射到连续的物理内存,也不需要所有页都必须在物理内存中。

例如有一台计算机可以产生16位地址,那么一个程序的地址空间范围是 0~64K。该计算机只有32KB的物理内存,虚拟内存技术允许该计算机运行一个 64K 大小的程序

3.4 内存分段

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

  • 段选择子保存在段寄存器里,段选择子最重要的是段号,作为段表的索引,段表里面保存的是段的基地址,段的界限和特权等级
  • 虚拟地址的段内偏移量位于0和段界限之间,如果段内偏移合法,就直接加上段内偏移得到物理内存地址

分段的不足:

  • 内存碎片问题
  • 内存交换的效率低

内存碎片

我们来看看这样一个例子。假设有1G的物理内存,用户执行了多个程序,其中:

  • 游戏占用了512MB内存
  • 浏览器占用了128MB内存
  • 音乐占用了256MB内存。

这个时候,如果我们关闭了浏览器,则空闲内存还有1024-512-256=256MB。

如果这个256MB不是连续的,被分成了两段128MB内存,这就会导致没有空间再打开一个200MB的程序。

解决办法:

解决外部内存碎片办法就是内存交换

  • 可以把音乐程序占用的那256MB内存写到硬盘上,然后再从硬盘上读回来到内存里。不过再读回的时候,我们不能装载回原来的位置,而是紧紧跟着那已经被占用了的512MB内存后面。这样就能空缺出连续的256MB空间,于是新的200MB程序就可以装载进来。

这个内存交换空间,在Linux系统里,就是我们常看到的Swap空间,这块空间是从硬盘划分出来的,用于内存与硬盘的空间交换。

因为硬盘的访问速度要比内存慢太多了,每一次内存交换,我们都需要把一大段连续的内存数据写到硬盘上。所以,如果内存交换的时候,交换的是一个占内存空间很大的程序,这样整个机器都会显得卡顿。

3.5 内存分页

分页是把虚拟和物理地址空间切成了一段段固定尺寸的大小,这样一个连续且固定的内存空间,我们叫页,在linux下,每一页的大小为4KB

虚拟地址和物理地址之间通过页表来映射:

页表是存储在内存,内存管理单元(MMU)就将虚拟内存地址转化为物理地址

当进程访问虚拟地址在页表中查不到的时候,会产生缺页异常

分页是如何解决内存碎片和内存交换效率低的问题

由于内存空间都是预先划分好的,也就不会像分段会产生间隙非常小的内存,这正是分段会产生内存碎片的原因。而采用了分页,那么释放的内存都是以页为单位释放的,也就不会产生无法给进程使用的小内存。

如果内存空间不够,操作系统会把其他正在运行的进程中的「最近没被使用」的内存页面给释放掉,也就是暂时写在硬盘上,称为换出(Swap Out)。一旦需要的时候,再加载进来,称为换入(Swap In)。所以,一次性写入磁盘的也只有少数的一个页或者几个页,不会花太多时间,内存交换的效率就相对比较高。

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

页表的缺陷:

页表的缺陷在32位的环境下,虚拟地址空间共有4GB,假设一个页的大小是4KB(2^12),那么就需要大约100万(2^20)个页,每个「页表项」需要4个字节大小来存储,那么整个4GB空间的映射就需要有4MB的内存来存储页表。

这4MB大小的页表,看起来也不是很大。但是要知道每个进程都是有自己的虚拟地址空间的,也就说都有自己的页表。

那么,100个进程的话,就需要400MB的内存来存储页表,这是非常大的内存了,更别说64位的环境了。

3.6 多级页表

为了解决上面的问题,就需要一种叫做多级页表的解决方案。

在前面我们知道了,对于单页表的实现方式,在32位和页大小4KB的环境下,一个进程的页表需要装下100多万个「页表项」,并且每个页表项是占用4字节大小的,于是相当于每个页表需占用4MB大小的空间。

我们把这个100多万个「页表项」的单级页表再分页,将页表(一级页表)分为1024个页表(二级页表),每个表(二级页表)中包含1024个「页表项则,形成二级分页。如下图所示:

这样看起来需要4KB+4KB的内存,占用空间更加大了,但是我们不需要为一个进程分配那么多的内存

如果使用了二级分页,一级页表就可以覆盖整个4GB虚拟地址空间,但如果某个一级页表的页表项没有被用到,也就不需要创建这个页表项对应的二级页表了,即可以在需要时才创建二级页表。做个简单的计算,假设只有20%的一级页表项被用到了,那么页表占用的内存空间就只有4KB(一级页表)+20%*

4MB(二级页表)=0.804MB,这对比单级页表的4MB是不是一个巨大的节约?

那么为什么不分级的页表就做不到这样节约内存呢?我们从页表的性质来看,保存在内存中的页表承担的职责是将虚拟地址翻译成物理地址。假如虚拟地址在页表中找不到对应的页表项,计算机系统就不能工作了。所以页表一定要覆盖全部虚拟地址空间,不分级的页表就需要有100多万个页表项来映射,而二级分页则只需要1024个页表项(此时一级页表覆盖到了全部虚拟地址空间,二级页表在需要时创建)。

3.7 TLB

多级页表虽然解决了空间上的问题,但是地址的转换就多了好几道的转换的工序,带来了时间上的开销。程序是有局部性的,我们可以利用这一点把最常访问的几个页表存储到访问速度更快的硬件,于是加了一个页表项的Cache,这个Cache就是TLB,通常称为页表缓存,转址旁路缓存,块表等。

3.8 段页式内存管理

段页式内存管理实现的方式:

  • 先将程序划分为多个有逻辑意义的段,也就是前面提到的分段机制:
  • 接着再把每个段划分为多个页,也就是对分段划分出来的连续空间,再划分固定大小的页;

这样,地址结构就由段号、段内页号和页内位移三部分组成。

用于段页式地址变换的数据结构是每一个程序一张段表,每个段又建立一张页表,段表中的地址是页表的起始地址,而页表中的地址则为某页的物理页号,如图所示:

段页式地址变化中要得到物理地址必须经过三次内存访问:

  • 第一次访问段表,得到页表起始地址;
  • 第二次访问页表,得到物理页号;
  • 第三次将物理页号与页内位移组合,得到物理地址。

3.9 分页和分段有什么区别呢?

共同点的话:

  • 首先都是离散分配的,单每个页和每个段的内存是连续的。
  • 都是为了提高内存利用率,减少内存碎片。

不同点:

  • 分页式管理的页面大小是固定的,由操作系统决定;分段式管理的页面是由用户程序所决定的。
  • 分页是为了满足操作系统内存管理的需求,每一页是没有实际的意义的;而段是有逻辑意义的,在程序中可认为是代码段、数据段。
  • 分页的内存利用率高,不会产生外部碎片;而分段如果单段长度过大,为其分配很大的连续空间不方便,会产生外部碎片。

3.10 虚拟内存的三种实现技术

  1. 请求分页式存储管理建立在分页管理的基础之上,为了支持虚拟内存实现了请求调页和页面置换功能。其具体流程是这样的:首先作业运行时,仅装入当前要执行的部分页面即可。假如在运行的过程中,发现要请求的页面不在内存中,那么处理器通知操作系统按照对应的页面置换算法把相应的页面调入到内存中。如果发现在把页面调入内存时,内存已满,同时也可以把不用的页面置换出去,以便腾出空间装入新的页面。
  2. 请求分段式存储管理和分页是一样的,把页换成段即可。
  3. 请求段页式存储管理

总结需要一定量的内存和外存,在刚开始运行的时候,只把部分要执行的页面加载到内存,就可以运行了。缺页中断,如果指令或数据不在内存,则处理器通知操作系统把页面段调入到内存。虚拟地址空间,都需要把虚拟地址转换为物理地址。

这和内存管理的机制有什么不同呢?请求分页式存储管理建立在分页管理之上,他们的根本区别是用不用把程序所需的全部地址空间加载到内存里。请求分页式不需要全部 load 到内存中,而分页式管理需要,前者能够提供虚拟内存,后者不可以!

3.11 讲讲内存管理的几种机制

(1)早期的内存分配机制

在早期的计算机中,要运行一个程序,会把这些程序全都装入内存,程序都是直接运行在内存上的,也就是说程序中访问的内存地址都是实际的物理内存地址。当计算机同时运行多个程序时,必须保证这些程序用到的内存总量要小于计算机实际物理内存的大小。那当程序同时运行多个程序时,操作系统是如何为这些程序分配内存的呢?

某台计算机总的内存大小是128M,现在同时运行两个程序A和B,A需占用内存10M,B需占用内存110M。计算机在给程序分配内存时会采取这样的方法:先将内存中的前10M分配给程序A,接着再从内存中剩余的118M中划分出110M分配给程序B。这种分配方法可以保证程序A和程序B都能运行,但是这种简单的内存分配策略问题很多。

  • 问题1:**进程地址空间不隔离。**由于程序都是直接访问物理内存,所以恶意程序或者无心的操作可以随意修改别的进程的内存数据,以达到破坏的目的。
  • 问题2:**内存使用效率低。**在A和B都运行的情况下,如果用户又运行了程序C,而程序C需要20M大小的内存才能运行,而此时系统只剩下8M的空间可供使用,所以此时系统必须在已运行的程序中选择一个将该程序的数据暂时拷贝到硬盘上,释放出部分空间来供程序C使用,然后再将程序C的数据全部装入内存中运行。可以想象得到,在这个过程中,有大量的数据在装入装出,导致效率十分低下。
  • 问题3:程序运行的地址不确定。当内存中的剩余空间可以满足程序C的要求后,操作系统会在剩余空间中随机分配一段连续的20M大小的空间给程序C使用,因为是随机分配的,所以程序运行的地址是不确定的。

(2)内存分段

为了解决上述问题,人们想到了一种变通的方法,就是增加一个中间层,利用虚拟地址访问方法访问物理内存。按照这种方法,程序中访问的内存地址不再是实际的物理内存地址,而是一个虚拟地址,然后由操作系统将这个虚拟地址映射到适当的物理内存地址上。

人们之所以要创建一个虚拟地址空间,目的是为了解决进程地址空间隔离的问题。但程序要想执行,必须运行在真实的内存上,所以,必须在虚拟地址与物理地址间建立一种映射关系。这样,通过映射机制,当程序访问虚拟地址空间上的某个地址值时,就相当于访问了物理地址空间中的另一个值。人们想到了一种分段(Sagmentation)的方法,它的思想是在虚拟地址空间和物理地址空间之间做一一映射。比如说虚拟地址空间中某个10M大小的空间映射到物理地址空间中某个10M大小的空间。这种思想理解起来并不难,操作系统保证不同进程的地址空间被映射到物理地址空间中不同的区域上,这样每个进程最终访问到的物理地址空间都是彼此分开的。通过这种方式,就实现了进程间的地址隔离。

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

  • 段选择子保存在段寄存器里,段选择子最重要的是段号,作为段表的索引,段表里面保存的是段的基地址,段的界限和特权等级
  • 虚拟地址的段内偏移量位于0和段界限之间,如果段内偏移合法,就直接加上段内偏移得到物理内存地址

这种分段的映射方法虽然解决了上述中的问题一和问题三,但并没能解决问题二,即内存的使用效率问题。

内存分段的问题是:

  • 内存碎片问题
  • 内存交换的效率低

在分段的映射方法中,每次换入换出内存的都是整个程序,这样会造成大量的磁盘访问操作,导致效率低下。所以这种映射方法还是稍显粗糙,粒度比较大。实际上,程序的运行有局部性特点,在某个时间段内,程序只是访问程序的一小部分数据,也就是说,程序的大部分数据在一个时间段内都不会被用到。基于这种情况,人们想到了粒度更小的内存分割和映射方法,这种方法就是分页(Paging)。

(3)内存分页

分页是把虚拟和物理地址空间切成了一段段固定尺寸的大小,这样一个连续且固定的内存空间,我们叫页,在linux下,每一页的大小为4KB。

页表是存储在内存,内存管理单元(MMU)就将虚拟内存地址转化为物理地址

当进程访问虚拟地址在页表中查不到的时候,会产生缺页异常

分页是如何解决内存碎片和内存交换效率低的问题

由于内存空间都是预先划分好的,也就不会像分段会产生间隙非常小的内存,这正是分段会产生内存碎片的原因。而采用了分页,那么释放的内存都是以页为单位释放的,也就不会产生无法给进程使用的小内存。

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

查询物理地址具体分为三步:

  • 虚拟地址切分为页号和偏移量
  • 根据页号,从页表里查询对应的物理页号
  • 直接拿物理页号,加上前面的偏移量,得到物理内存地址

(4)段页式内存管理

段页式内存管理实现的方式:

  • 先将程序划分为多个有逻辑意义的段,也就是前面提到的分段机制:
  • 接着再把每个段划分为多个页,也就是对分段划分出来的连续空间,再划分固定大小的页;这样,地址结构就由段号段内页号页内位移三部分组成。

用于段页式地址变换的数据结构是每一个程序一张段表,每个段又建立一张页表,段表中的地址是页表的起始地址,而页表中的地址则为某页的物理页号,如图所示:

(5)总结

  1. 分块管理

分块管理是连续管理的一种,把内存分为几个大小相等且固定的块,每个进程占用其中一个;如果进程很小的话,会浪费大量的空间。已经淘汰。

  1. 分段管理

分段管理把内存分为几个大小不定的有实际意义的段,比如main函数段,局部变量段,通过管理段表来把逻辑地址转为物理地址。

  1. 分页管理

分页管理把内存分为若干个很小的页面,相对比分块的划分力度更大一些。提高内存利用率。减少碎片,页式管理通过页表对应逻辑地址和物理地址。

  1. 段页式管理

段页式管理结合了段式管理和页面管理的优点,把主存先分为若干个段,每个段又分为若干个页,也就是说段页式管理的段与段以及段的内部都是离散的。

3.12 内存分页页表的缺陷

在32位的环境下,虚拟地址空间共有4GB,假设一个页的大小是4KB(2^12),那么就需要大约100万(2^{20})个页,每个「页表项」需要4个字节大小来存储,那么整个4GB空间的映射就需要有4MB的内存来存储页表。

这4MB大小的页表,看起来也不是很大。但是要知道每个进程都是有自己的虚拟地址空间的,也就说都有自己的页表。

那么,100个进程的话,就需要400MB的内存来存储页表,这是非常大的内存了,更别说64位的环境了。

(1)多级页表

为了解决上面的问题,就需要一种叫做多级页表的解决方案

在前面我们知道了,对于单页表的实现方式,在32位和页大小4KB的环境下,一个进程的页表需要装下100多万个「页表项」,并且每个页表项是占用4字节大小的,于是相当于每个页表需占用4MB大小的空间。

我们把这个100多万个「页表项」的单级页表再分页,将页表(一级页表)分为1024个页表(二级页表),每个表(二级页表)中包含1024个「页表项」,形成二级分页。如下图所示:

这样看起来需要4KB+4KB的内存,占用空间更加大了,但是我们不需要为一个进程分配那么多的内存

如果使用了二级分页,一级页表就可以覆盖整个4GB虚拟地址空间,但如果某个一级页表的页表项没有被用到,也就不需要创建这个页表项对应的二级页表了,即**可以在需要时才创建二级页表。**做个简单的计算,假设只有20%的一级页表项被用到了,那么页表占用的内存空间就只有4KB(一级页表)+20% * 4MB(二级页表)=0.804MB,这对比单级页表的4MB是不是一个巨大的节约?

那么为什么不分级的页表就做不到这样节约内存呢?我们从页表的性质来看,保存在内存中的页表承担的职责是将虚拟地址翻译成物理地址。假如虚拟地址在页表中找不到对应的页表项,计算机系统就不能工作了。所以页表一定要覆盖全部虚拟地址空间,不分级的页表就需要有100多万个页表项来映射,而二级分页则只需要1024个页表项(此时一级页表覆盖到了全部虚拟地址空间,二级页表在需要时创建)。

(2)TLB页表缓存

多级页表虽然解决了空间上的问题,但是地址的转换就多了好几道的转换的工序,带来了时间上的开销。程序是有局部性的,我们可以利用这一点把最常访问的几个页表存储到访问速度更快的硬件,于是加了一个页表项的Cache,这个Cache就是TLB,通常称为页表缓存,转址旁路缓存,块表等。

3.12 讲讲分页管理的快表和多级页表(按照why how的方式来回答,即为什么出现快表,是如何解决痛点的)

(1)快表(TLB)

  • why? 首先快表的引入是为了加快逻辑地址到物理地址的访问速度的,在引入快表之前,由逻辑地址访问到内存的过程是这样的:

1)首先根据逻辑地址的高位拿到页号

2)根据页号访问内存中页表,根据页表的映射拿到实际的内存块号。(一次访问)

3)把内存块号和逻辑地址的低位拼接,得到物理地址

4)访问对应的内存物理地址。(二次访问)

这样是需要有两次直接访问内存的过程的,所以为了加快这个速度,引入了快表,快表可以认为是一个 Cache,内容是页表的一部分或者全部内容。和页表的功能是一样的,只不过比在内存中的页表的访问速度要快很多。

  • how? 根据局部性原理,被访问后的内存块儿很可能在短时间内再次被访问,可能程序在一段时间内会多次访问同一个页表项。所以在每次访问页表项时,先在快表里查询是否有该页表项,如果没有再去页表中查询,并把查到的页表项放入快表。如果快表满了,就根据一些策略把里面的页表项淘汰掉,再把新查询的页表加入进去。

(2)多级页表

  • why? 多级页表主要是为了解决页表在内存中占用空间太大的问题的,典型的时间换空间。
  • how?讲个例子即可:在引入多级页表之前,我们使用单级页表来进行存储页表项,假如虚拟内存为 4GB,每个页大小为 4KB,那么需要的页表项就为 4GB / 4KB = 1M 个!每个页表项一般为 4B,那么就需要 4MB 的空间,大概需要占用 1000 个页来存页表项。所以如果引入两级页表,让一级页表的每个页表项不再映射 4KB,而是映射 4MB,那么需要的一级页表项的个数为 4GB / 4MB = 1K 个,再让每个一级的页表项映射 1K 个二级页表项。当一级页表的某个页表项被用到时,再把该一级页表项对应的所有 1K 个二级页表项加载到内存中,这样可以节省大量的空间!

3.13 讲讲虚拟地址和物理地址?为什么要有虚拟地址空间?

我们在写程序的时候打交道的都是虚拟地址,比如 C 语言的指针,这个虚拟地址由操作系统决定,而物理地址指的是真实内存地址寄存器的地址。现代处理器通常使用虚拟寻址,用 MMU 把虚拟地址翻译成物理地址才能访问到真正的物理地址。

那么为什么要有虚拟地址呢?

  • 如果没有虚拟地址空间的话,我们操作的都是直接的物理地址,这样用户程序可以直接访问到底层的物理地址,很容易破坏操作系统,造成操作系统崩溃。
  • 想要同时运行多个程序特别困难,多个程序可能对同一个寄存器进行操作,会发生崩溃。

通过虚拟地址就会得到如下优势(记不住也无所谓这一段)

  • 程序可以使用一系列相邻的虚拟地址来访问物理内存中不相邻的内存缓冲区。
  • 程序可以使用一系列虚拟地址访问大于可用物理内存的内存缓冲区。当物理内存的供应量变小的时候,内存管理器会将物理页面保存到磁盘里。数据或代码页会根据需要在物理内存和磁盘之间移动。
  • 不同进程使用的虚拟地址彼此隔离。一个进程中的代码无法更改正在由另一个进程使用的物理内存。

3.14 讲讲页面置换算法

当CPU访问的页面不在物理内存时,便会产生一个缺页中断,请求操作系统将所缺页调入到物理内存。那它与一般中断的主要区别在于:

  • 缺页中断在指令执行「期间」产生和处理中断信号,而一般中断在一条指令执行「完成」后检查和处理中断信号。
  • 缺页中断返回到该指令的开始重新执行「该指令」,而一般中断返回回到该指令的「下一个指令」执行。

  1. 在CPU里访问一条Load M指令,然后CPU会去找M所对应的页表项。
  2. 如果该页表项的状态位是「有效的」,那CPU就可以直接去访问物理内存了,如果状态位是「无效的」,则CPU则会发送缺页中断请求。
  3. 操作系统收到了缺页中断,则会执行缺页中断处理函数,先会查找该页面在磁盘中的页面的位置。
  4. 找到磁盘中对应的页面后,需要把该页面换入到物理内存中,但是在换入前,需要在物理内存中找空闲页,如果找到空闲页,就把页面换入到物理内存中。
  5. 页面从磁盘换入到物理内存完成后,则把页表项中的状态位修改为「有效的」。
  6. 最后,CPU重新执行导致缺页异常的指令。

上面第四步是能在物理内存中找到空闲页,找不到空闲页说明内存已经满了,这时候就需要页面置换算法,

(1)最近页面置换算法(OPT)

最佳页面置换算法,置换在未来最长时间不访问的页面

所以该算法实现需要计算内存中每个逻辑页面的下一次访问时间,然后比较,选择未来最长时间不访问的页面

这很理想,但在实际系统中无法实现,因为程序访问页面是动态的,无法预知下一次访问前的等待时间。

(2)先进先出置换算法(FIFO)

选择在内存驻留时间很长的页面进行置换

(3)第二次机会算法

FIFO 算法可能会把经常使用的页面置换出去,为了避免这一问题,对该算法做一个简单的修改:

当页面被访问 (读或写) 时设置该页面的R位为 1。需要替换的时候,检查最老页面的R位。如果R位是 0,那么这个页面既老又没有被使用,可以立刻置换掉;如果是1,就将 R位清0,并把该页面放到链表的尾端,修改它的装入时间使它就像刚装入的一样,然后继续从链表的头部开始搜索。

(4)最近最近未使用置换算法(LRU)

选择最长时间没有被访问的页面置换

为了实现 LRU,需要在内存中维护一个所有页面的链表。当一个页面被访问时,将这个页面移到链表表头。这样就能保证链表表尾的页面是最近最久未访问的。

因为每次访问都需要更新链表,因此这种方式实现的 LRU 代价很高

(5)最不常用置换算法(LFU)

当发生缺页中断的时候,选择访问次数最少的那个页,并且将其淘汰

对每个页面增加一个访问计数器,每当访问这个页面,计数器就加1,发生缺页中断的时候,淘汰计数器最小的那个页面

要增加⼀个计数器来实现,这个硬件成本是比较高的,另外如果要对这个计数器查找哪个页面访问次数最小,查找链表本身,如果链表长度很大,是非常耗时的,效率不高。

还有个问题就是LFU只考虑得了频率的问题,没有考虑时间的问题,比如有些页面过去访问的频率很高但是现在已经没有访问了,而当前访问频繁的页面没有这么页面访问的次数高。

解决办法:定期减少访问次数,比如发生时间中断的时候,把过去时间访问的页面次数除以2,随着时间的流失,以前高访问次数的页面会慢慢减少。

(6)时钟页面置换算法

有没有一种既能优化置换次数,又能方便实现的算法

时钟页面置换算法就可以两者兼得,与LRU近似,又是对FIFO的一种改进

算法思路是:将所有的页面保存在一个环形链表里面,表针指向最老的页面。当发生缺页中断时,算法⾸先检查表针指向的页面:

  • 如果它的访问位位是0就淘汰该页面,并把新的页面插⼊这个位置,然后把表针前移⼀个位置
  • 如果访问位是1就清除访问位,并把表针前移⼀个位置,重复这个过程直到找到了⼀个访问位为 0 的页面为⽌

4.设备管理

4.1 磁盘结构

  • 盘面(Platter):一个磁盘有多个盘面;
  • 磁道(Track):盘面上的圆形带状区域,一个盘面可以有多个磁道;
  • 扇区(Track Sector):磁道上的一个弧段,一个磁道可以有多个扇区,它是最小的物理储存单位,目前主要有 512 bytes 与 4 K 两种大小;
  • 磁头(Head):与盘面非常接近,能够将盘面上的磁场转换为电信号(读),或者将电信号转换为盘面的磁场(写);
  • 制动手臂(Actuator arm):用于在磁道之间移动磁头;
  • 主轴(Spindle):使整个盘面转动。

4.2 磁盘调度算法

寻道是磁盘访问最耗时的部分

(1)先来先服务(FCFS)

简单粗暴,但是如果大量进程竞争使用,请求访问的磁道可能会很分散,那么先来先服务算法性能上就会显得很差

(2)最短寻道时间(SSF)

优先选择距离磁头寻道时间最短的请求

算法可能存在某些请求的饥饿,产生饥饿的原因是磁头在一小块区域来回移动

(3)扫描算法/电梯算法(SCAN)

最短寻道时间优先算法会产生饥饿的原因在于:磁头有可能再⼀个小区域内来回得移动。

为了防止这个问题,可以规定:磁头在⼀个方向上移动,访问所有未完成的请求,直到磁头到达该方向上的最后的磁道,才调换方向,这就是扫描(Scan)算法。

磁头先响应左边的请求,知道达到最左端(0磁道)才开始反向请求。

扫描算法的性能好,不会产生饥饿的现象,但是存在问题就是中间的磁道会占便宜,比其他部分响应的频率会比较多,也就是说磁道的响应频率存在差异。

(4)循环扫描算法(CSCAN)

循环扫描规定:只有磁头朝某个特定方向才会处理磁道的访问请求,而返回时直接快速移动到最靠边缘的磁道也就是复位磁头,这个过程是很快的并且返回途中不处理任何请求,该算法特点,磁道只响应一个方向的请求

(5)LOOK和C-LOOK

前面说的扫描和循环扫描算法都是移动到磁盘的最始端或者最末端才开始调换方向。其实是可以优化的,优化的思路就是磁头移动到最远请求的位置,立即返回

(5.1)LOOK

针对SCAN的叫LOOK,它的工作方式,磁头在每个⽅向上仅仅移动到最远的请求位置,然后立即反向移动,而不需要移动到磁盘的最始端或最末端,反向移动的途中会响应请求

(5.2)C-LOOK

针对S-SCAN的算法优化叫C-LOOK,他的工作方式是磁头在每个方向仅仅移到最远请求的位置,然后立即返回,不需要移动到最始端或者最末端,反向途中不会响应请求

5.键盘敲入字母A,操作系统期间发生了什么?

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

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

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

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

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

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

6.文件系统

6.1 零拷贝

DMA技术,也就是直接内存访问,在进行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控制器。

6.2 传统的数据传输

数据读取和写入是从用户空间到内核空间来回复制,而内核空间的数据是通过操作系统层面的I/O接口从磁盘读取或写入。

代码通常需要两个系统调用:

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

期间,一共发生了四次用户态和内核态的上下文切换,因为发生了两次系统调用。

其次,发生了四次数据拷贝,其中两次是DMA的拷贝,另外两次是CPU拷贝。

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

用户缓冲区是没有必要的,因为我们不会对数据的再加工

零拷贝的实现方式:

  • mmap+write
  • sendfile

(1)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次。

(2)sendfile()

在Linux内核版本2.1中,提供了一个专门发送调用函数的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技术,还可以进一步减少通过CPU将内核缓冲区的数据拷贝到Socket缓冲区的过程。

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

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

这就是所谓的零拷贝技术,因为没有在内存层面去拷贝数据,全程没有CPU来搬运数据,所有数据都是通过DMA来传输的。总体看来,零拷贝技术可以吧文件传输的性能提高至少一倍以上

7.网络模式

7.1 I/O的多路复用:select/poll/epoll

一个进程在任一时刻只能处理一个请求,但是处理请求事件控制在1ms以内,那么1秒就可以处理上千个请求,多个请求复用了一个进程,这就是多路复用,也可以叫做时分多路复用。

(1)select/poll

select实现多路复用的方式是:

  1. 将已连接的Socket放到一个文件描述集合,
  2. 然后调用select函数将文件描述符拷贝到内核里面,让内核检查是否有网络事件的产生,检查的方式很暴力,就是通过遍历文件描述符的方式,
  3. 当检查到有事件产生之后,将Socket标记为可读或者可写,
  4. 接着将整个文件描述符拷贝到用户态里,
  5. 然后用户态还需要遍历的方法找到可读可写的Socket,然后在对其处理。

所以对于select方式,需要进行2次遍历文件描述符集合,一次在内核态,一次在用户态,然后还会发生2次拷贝文件描述符集合,先从用户态传入内核空间,然后内核修改之后,在传出到用户空间。

select使用固定长度的BitsMap,表示文件描述符集合,而且所支持的文件描述符的个数是有限制的,在Linux系统中,由内核中的FD_SETSIZE限制,默认最大值1024,只能监听0-1023的文件描述符。

poll():

poll不再使用BitsMap存储所关注的文件描述符,取而代之只用动态数组,以链表形式来组织,突破了select的文件描述符个数限制,当然还会受到系统文件描述符限制。

但是两者本质没有太大的区别,都是使用线性结构存储进程关注的Socket集合,一次都需要遍历文件描述符来找可读或者可写的Socket,时间复杂度为O(n),而且也需要在用户态和内核态之间拷贝文件描述符集合,随着并发上来,性能的损耗会呈现指数级增长。

(2)epoll

epoll相对于上面的有两个方面的改进:

  • epoll在内核使用红黑树来跟踪所有待检测的文件描述字,把需要监控的socket通过epoll_ctl()函数加入内核中的红黑树,红黑树是个高效的数据结构,增删改查的复杂度是O(logn),通过对红黑函数的操作,不需要像select/poll每次操作都传入整个socket集合,只需要传入一个待检测的socket,减少了内核和用户空间大量的数据拷贝和内存分配。
  • epoll使用事件驱动的机制,内核里维护了一个链表来记录就绪事件,当某个socket有事件发生时,通过回调函数内核会将其加入到就绪队列事件中,当用户调用epoll_wait()函数时,只会返回有事件发生的文件描述符的个数,不需要像select/poll那样轮询扫描整个socket集合,大大提高了检测的效率。

epoll的方式即使监听的Socket数量越多,效率不会大幅度降低,能够同时监听的socket的数目也非常的多了,上限是系统定义的进程的打开的最大文件描述符的个数,因而epoll被称为解决C10K问题的利器。

epoll支持两种触发模式,分别是边缘触发(ET)和水平触发(LT)。

  • 使用边缘触发模式时,当被监控的 Socket 描述符上有可读事件发生时,服务器端只会从 epoll_wait中苏醒⼀次,即使进程没有调用read 函数从内核读取数据,也依然只苏醒⼀次,因此我们程序要保证⼀次性将内核缓冲区的数据读取完
  • 使用水平触发模式时,当被监控的socket上有可读的事件发生时,服务器不断的从epoll_wait中苏醒,直到内核缓冲区数据被read读完才结束,目的是告诉我们有数据需要读取。

这就是两者的区别,水平触发的意思是只要满足事件的条件,比如内核中有数据需要读,就一直不断地把这个事件传递给用户;而边缘触发的意思是只有第一次满足条件的时候才触发,之后就不会再传递同样的事件了。

如果使用水平触发模式,当内核通知文件描述符可读写时,接下来还可以继续去检测它的状态,看它是否依然可读或可写。所以在收到通知后,没必要一次执行尽可能多的读写操作。

如果使用边缘触发模式,I/O事件发生时只会通知一次,而且我们不知道到底能读写多少数据,所以在收到通知后应尽可能地读写数据,以免错失读写的机会。因此,我们会循环从文件描述符读写数据,那么如果文件描述符是阻塞的,没有数据可读写时,进程会阻塞在读写函数那里,程序就没办法继续往下执行。所以,**边缘触发模式一般和非阻塞I/O搭配使用,**程序会一直执行I/O操作,直到系统调用(如rad和write)返回错误,错误类型为EAGAIN或VOULDBLOCK。

一般来说,边缘触发的效率比水平触发的效率要高,因为边缘触发可以减少epoll_wait的系统调用次数,系统调用也是有一定的开销的,毕竟也存在上下文的切换。

select/poll只有水平触发模式,epoll默认的触发模式是水平触发,但是可以根据应用场景设置为边缘触发模式。

8.高性能网络模式:Reactor和Proactor

Reactor即I/O多路复用监听事件,收到事件后,根据事件类型分配给某个进程/线程

Reactor模式是灵活多变的,面对不同的业务场景,灵活在于:

  • Reactor的数量可以只有一个,也可以有多个

  • 处理资源池也可以是单个进程/线程,也可以是多个进程/线程

  • 单Reactor单进程/线程;

  • 单Reactor多进程/线程;

  • 多Reactor单进程/线程;

  • 多Reactor多进程/线程;

  • 多Reactor单进程/线程实现方案不仅复杂而且没有性能优势,因此实际中没有应用。

8.1 Reactor

(1)单Reactor单进程/线程

可以看到进程里面有Reactor,Acceptor,Handler这三个对象:

  • Reactor对象的作用是监听和分发事件
  • Acceptor对象的作用是获取连接
  • Handler对象的作用是处理业务

对象里的 select、accept、read、send 是系统调⽤函数,dispatch和业务处理是需要完成的操作

单Reactor单进程的方案:

  • Reactor对象通过select(I/O多路复用接口)监听事件,收到事件之后dispatch进行分发,具体分发给Acceptor还是Handler对象,还要看收到的事件类型
  • 如果是连接建立的事件,则交给Acceptor对象进行处理,Accept对象会通过accept方法获取连接,并创建一个handler对象来处理后续的响应事件
  • 如果不是连接事件,则交给当前对应的Handler对象来进行响应
  • Handler对象通过read->业务处理->send的流程来完成完整的业务流程

方案存在的缺点:

  • 因为只有一个进程,无法充分利用多核CPU的性能
  • Handler对象在进行业务处理的时候,整个进程无法处理其他的连接的事件,如果业务处理耗时较长,会造成响应的时延。

Reactor单进程的方案不适合计算密集型的场景,只适用于业务员处理非常快速的场景

Redis是由C语言实现的,采用就是单Reactor单进程的方案,因为Redis处理业务在内存中,操作的速度非常快,性能瓶颈不在CPU上,所以Redis对于命令的处理是单进程的方案。

(2)单Reactor多进程/线程

如果要克服「单 Reactor 单线程 / 进程」方案的缺点,那么就需要引⼊多线程 / 多进程,这样就产生了单Reactor 多线程 / 多进程的方案

具体方案:

  • Reactor对象通过Select监听事件,收到事件后通过dispatch进行转发,具体分发给Acceptor对象还是Handler对象,看具体事件的类型。
  • 如果是连接建立的事件,则交给Acceptor对象进行处理,Accept对象会通过accept方法获取连接,并创建一个handler对象来处理后续的响应事件
  • 如果不是连接事件,则交给当前对应的Handler对象来进行响应

下面就开始不一样了

  • Handler对象不再负责业务处理,只负责数据的接受和发送,Handler对象通过read读取到数据后,会将数据发送给子线程里面的Processor对象进行业务处理
  • 子线程里的Processor对象就进行业务处理,处理完后,将结果发给主线程中的Handler对象,接着由Handler通过send方法将响应结果发送给client。

单 Reator 多线程的⽅案优势在于能够充分利用多核 CPU 的能,那既然引⼊多线程,那么自然就带来了多线程竞争资源的问题。

要避免多线程由于竞争共享资源而导致数据错乱的问题,就需要在操作共享资源前加上互斥锁,以保证任意时间里只有⼀个线程在操作共享资源,待该线程操作完释放互斥锁后,其他线程才有机会操作共享数据。

对于单Reactor多进程的方案,实现起来很麻烦,因为要考虑子进程和父进程的双向通信,而且父进程还得知道子进程要将数据发送给哪个客户端

另外,单 Reactor的模式还有个问题,因为⼀个 Reactor 对象承担所有事件的监听和响应,而且只在主线程中运行,在面对瞬间高并发的场景时,容易成为性能的瓶颈的地方。

(3)多Reactor多进程/线程

具体方案说明:

  • 主线程中的MainReactor对象通过select监控连接建立事件,收到事件后通过Acceptor对象中的accept获取连接,将新的连接分配给某个子线程
  • 子线程中的SubReactor对象将MainReactor对象分配的连接加入select继续进行监听,并创建一个Handler用于处理连接的响应事件。
  • 如果有新的事件发生,SubReactor对象会调用当前连接对应的Handler对象进行响应
  • Handler对象通过read->业务处理->send的流程来完成完整的业务流程。

多 Reactor 多线程的方案虽然看起来复杂的,但是实际实现时比单 Reactor 多线程的方案要简单的多,原因如下:

  • 主线程和子线程分工明确,主线程只负责接受新的连接,子线程只负责完成后续的业务处理。
  • 主线程和子线程的交互很简单,主线程只需要把新的连接传给子线程,子线程无须返回数据,直接就可以在子线程将处理结果发送给客户端

大名鼎鼎的两个开源软件 Netty 和 Memcache 都采用了「多 Reactor 多线程」的方案。

8.2 Proactor

前面提到的Reactor是非阻塞同步网络模式**,而Proactor是异步**网络模式

阻塞、非阻塞、同步、异步 I/O 的概念

  • 阻塞I/O:当用户程序执行 read ,线程会被阻塞,⼀直等到内核数据准备好,并把数据从内核缓冲区拷贝到应用程序的缓冲区中,当拷用过程完成, read才会返回

阻塞等待的是内核数据准备好和数据从内核态拷贝到用户态两个过程

  • 非阻塞I/O:非阻塞的 read 请求在数据未准备好的情况下立即返回,可以继续往下执行,此时应用程序不断轮询内核,直到数据准备好,内核将数据拷贝到应用程序缓冲区, read 调用才可以获取到结果。过程如下图

这里最后⼀次 read 调用,获取数据的过程,是⼀个同步的过程,是需要等待的过程。这⾥的同步指的是内核态的数据拷贝到用户程序的缓存区这个过程

举个例子,如果socket设置了O_NONBLOCK标志,那么就表示使用的是非阻塞I/O的方式访问,而不做任何设置的话,默认是阻塞I/O。

因此,无论read和send是阻塞I/O,还是非阻塞I/O都是同步调用。因为在read调用时,内核将数据从内核空间拷贝到用户空间的过程都是需要等待的,也就是说这个过程是同步的,如果内核实现的拷贝效率不高,read调用就会在这个同步过程中等待比较长的时间。

而真正的异步 I/O 是内核数据准备好和数据从内核态拷贝到用户态这两个过程都不用等待

当我们发起 aio_read (异步 I/O)之后,就⽴即返回,内核⾃动将数据从内核空间拷贝到用户空间,这个拷贝过程同样是异步的,内核自动完成的,和前⾯的同步操作不⼀样,应用程序并不需要主动发起拷贝。

举个你去饭堂吃饭的例子,你好比应用程序,饭堂好比操作系统。

阻塞I/O好比,你去饭堂吃饭,但是饭堂的菜还没做好,然后你就一直在那里等啊等,等了好长一段时间终于等到饭堂阿姨把菜端了出来(数据准备的过程),但是你还得继续等阿姨把菜(内核空间)打到你的饭盒里(用户空间),经历完这两个过程,你才可以离开。

非阻塞I/O好比,你去了饭堂,问阿姨菜做好了没有,阿姨告诉你没,你就离开了,过几十分钟,你又来饭堂问阿姨,阿姨说做好了,于是阿姨帮你把菜打到你的饭盒里,这个过程你是得等待的。

异步I/O好比,你让饭堂阿姨将菜做好并把菜打到饭盒里后,把饭盒送到你面前,整个过程你都不需要任何等待。

很明显,异步I/O比同步I/O性能更好,因为异步I/O在「内核数据准备好」和「数据从内核空间拷贝到用户空间」这两个过程都不用等待。

Proactor正是采用了异步/O技术,所以被称为异步网络模型。

8.3 Reactor和Proactor区别

  • Reactor是非阻塞同步网络模式,感知的是就绪可读写事件。在每次感知到有事件发生(比如可读就绪事件)后,就需要应用进程主动调用 read 方法来完成数据的读取,也就是要应用进程主动将socket 接收缓存中的数据读到应用进程内存中,这个过程是同步的,读取完数据后应⽤进程才能处理数据
  • Proctor是异步网络模式,感知的是已完成的读写事件。在发起异步读写请求时,需要传入数据缓冲区的地址,这样系统内核才可以自动帮我们把数据读写工作完成,这里的读写工作全程由操作系统来完成,并不需要像Reactor那样需要应用进程主动发起read/write来写数据。操作系统完成读写工作之后,就会通知应用程序直接处理数据。

因此,Reactor可以理解为「来了事件操作系统通知应用进程,让应用进程来处理」,而Proactor可以理解为「来了事件操作系统来处理,处理完再通知应用进程。这里的「事件」就是有新连接、有数据可读、有数据可写的这些VO事件这里的「处理」包含从驱动读取到内核以及从内核读取到用户空间。

举个实际生活中的例子,Reactor模式就是快递员在楼下,给你打电话告诉你快递到你家小区了,你需要自己下楼来拿快递。而在Proactor模式下,快递员直接将快递送到你家门口,然后通知你。

无论是Reactor还是Proactor,都是一种基于「事件分发」的网络编程模式,区别在于Reactor模式是基于「待完成」的VO事件,而Proactor模式则是基于「已完成」的VO事件。

Proactor模式示意图:

Proactor模式的工作流程:

  • Proactor Initiator 负责创建 Proactor 和 Handler 对象,并将 Proactor 和 Handler 都通过Asynchronous Operation Processor 注册到内核;
  • Asynchronous Operation Processor 负责处理注册请求,并处理 I/O 操作;
  • Asynchronous Operation Processor 完成 I/O 操作后通知 Proactor;
  • Proactor 根据不同的事件类型回调不同的 Handler 进⾏业务处理;
  • Handler 完成业务处理;

可惜的是,在Liux下的异步/O是不完善的, IO系列函数是由POSX定义的异步操作接口,不是真正的操作系统级别支持的,而是在用户空间模拟出来的异步,并且仅仅支持基于本地文件的aio异步操作,网络编程中的socket是不支持的,这也使得基于Linux的高性能网络程序都是使用Reactor方案。

而Windows里实现了一套完整的支持socket的异步编程接口,这套接口就是IOCP,是由操作系统级别实现的异步I/O,真正意义上异步I/O,因此在Windows里实现高性能网络程序可以使用效率更高的Proactor方案。

总结:

第一种方案单Reactor单进程/线程,不用考虑进程间通信以及数据同步的问题,因此实现起来比较简单,这种方案的缺陷在于无法充分利用多核CPU,而且处理业务逻辑的时间不能太长,否则会延迟响应,所以不适用于计算机密集型的场景,适用于业务处理快速的场景,比如Redis采用的是单Reactor单进程的方案。

第二种方案单Reactor多线程,通过多线程的方式解决了方案一的缺陷,但它离高并发还差一点距离,差在只有一个Reactor对象来承担所有事件的监听和响应,而且只在主线程中运行,在面对瞬间高并发的场景时,容易成为性能的瓶颈的地方。

第三种方案多Reactor多进程/线程,通过多个Reactor来解决了方案二的缺陷,主Reactor只负责监听事件,响应事件的工作交给了从Reactor。Netty和Memcache都采用了「多Reactor多线程」的方案, Nginx则采用了类似于「多Reactor多进程」的方案。

Reactor可以理解为「来了事件操作系统通知应用进程,让应用进程来处理」,而Proactor可以理解为「来了事件操作系统来处理,处理完再通知应用进程」。

因此,真正的大杀器还是Proactor,它是采用异步I/O实现的异步网络模型,感知的是已完成的读写事件,而不需要像Reactor感知到事件后,还需要调用read来从内核中获取数据。

不过,无论是Reactor还是Proactor,都是一种基于「事件分发」的网络编程模式,区别在于Reactor模式是基于「待完成』的I/O事件,而Proactor模式则是基于「已完成」的I/O事件。