操作系统总结

操作系统结构

用户态和核心态

⽤户态和内核态的区别

⽤户态(User Mode)和内核态(Kernel Mode)是操作系统为了保护系统资源和实现权限控制⽽设计的两种不同的CPU运⾏级别,可以控制进程或程序对计算机硬件资源的访问权限和操作范围

⽤户态:在⽤户态下,进程或程序只能访问受限的资源和执⾏受限的指令集,不能直接访问操作系统的核⼼部分,也不能直接访问硬件资源。

核⼼态:核⼼态是操作系统的特权级别,允许进程或程序执⾏特权指令和访问操作系统的核⼼部分。在核⼼态下,进程可以直接访问硬件资源,执⾏系统调⽤,管理内存、⽂件系统等操作。



在什么场景下,会发⽣内核态和⽤户态的切换

系统调⽤:当⽤户程序需要请求操作系统提供的服务时,会通过系统调⽤进⼊内核态。

异常:当程序执⾏过程中出现错误或异常情况时,CPU会⾃动切换到内核态,以便操作系统能够处理这些异常。

中断:外部设备(如键盘、⿏标、磁盘等)产⽣的中断信号会使CPU从⽤户态切换到内核态。操作系统会处理
这些中断,执⾏相应的中断处理程序,然后再将CPU切换回⽤户态。





中断和异常

中断和异常都会导致处理器暂停当前正在执⾏的任务,并转向执⾏⼀个特定的处理程序(中断处理程序或异常处理程序)。然后在处理完这些特殊情况后,处理器会返回到被打断的任务继续执⾏。

  1. 中断是由计算机系统外部事件触发的,通常与硬件设备相关。中断的⽬的是为了及时响应重要事件⽽暂时中断
    正常的程序执⾏。典型的中断包括时钟中断、I/O设备中断(如键盘输⼊、⿏标事件)和硬件错误中断等。操
    作系统通常会为每种类型的中断分配⼀个中断处理程序,⽤于处理相应的事件。

  2. 异常是由计算机系统内部事件触发的,通常与正在执⾏的程序或指令有关,⽐如程序的⾮法操作码、地址越
    界、运算溢出等错误引起的事件,异常不能被屏蔽,当出现异常时,计算机系统会暂停正常的执⾏流程,并转
    到异常处理程序来处理该异常。





并行和并发

并⾏是指在同⼀时刻执⾏多个任务,这些任务可以同时进⾏,每个任务都在不同的处理单元(如多个CPU核⼼)上执⾏。在并⾏系统中,多个处理单元可以同时处理独⽴的⼦任务,从⽽加速整体任务的完成。

并发是指在相同的时间段内执⾏多个任务,这些任务可能不是同时发⽣的,⽽是交替执⾏,通过时间⽚轮转或者事件驱动的⽅式。并发通常与任务之间的交替执⾏和任务调度有关。

进程与线程

进程调度算法

定义

进程调度算法是操作系统中⽤来管理和调度进程(也称为任务或作业)执⾏的⽅法。

分类

先来先服务算法

最短作业优先调度算法

高响应比优先调度算法

时间片轮转调度算法

最高优先级调度算法

多级反馈队列调度算法

最短剩余时间优先

最大吞吐量调度





进程通信方式

在这里插入图片描述

管道

是⽤于连接⼀个读进程和⼀个写进程以实现它们之间的通信的⼀个共享⽂件,⼜名pipe⽂件。

是⼀种半双⼯的通信⽅式,数据只能单向流动。



匿名管道

只能用于父子进程通信


命名管道

可以用于不相关的进程通信



信号

信号是进程间通信机制中唯⼀的异步通信机制,它可以在⼀个进程中通知另⼀个进程发⽣了某种事件从⽽实现进程通信。



消息队列

消息队列是消息的链表,存放在内核中并由消息队列标识符标识。消息队列克服了信号传递信息少、管道只能承载⽆格式字节流以及缓冲区⼤⼩受限等缺点。



信号量

是计数器。

可以⽤来控制多个进程对共享资源的访问,常作为⼀种锁机制,防⽌某进程正在访问共享资源时,其他进程也访问该资源。因此主要作为进程间以及同⼀进程内不同线程之间的同步⼿段。



共享内存

就是映射⼀段能被其他进程所访问的内存。这段共享内存由⼀个进程创建,但多个进程都可以访问。共享内存是最快的进程通信⽅式,它是针对其他进程间通信⽅式运⾏效率低⽽专⻔设计的。它往往与其他通信机制,⽐如信号量配合使⽤,来实现进程间的同步和通信。



socket通信

是⽀持TCP/IP 的⽹络通信的基本操作单元,主要⽤于在客户端和服务器之间通过⽹络进⾏通信。





面试问题

如何杀死一个进程

使⽤ kill 命令可以向⼀个进程发送信号,终⽌进程,kill [options] PID
killall 命令⽤于根据进程名杀死所有匹配的进程。

杀死⽗进程并不会同时杀死⼦进程,每个进程都有⼀个⽗进程。可以使⽤ pstree 或 ps ⼯具来观察这⼀点。
杀死⽗进程后,⼦进程将会成为孤⼉进程,⽽ init 进程将重新成为它的⽗进程。



说一说僵尸进程和孤儿进程

孤儿进程和僵尸进程两者是Unix和类Unix系统中的两个进程状态。

孤儿进程是指父进程已经退出或者异常终止,而子进程仍然在运行的情况。这时候子进程会被称为孤儿进程,它的父进程ID变成1号进程(init),这个进程会接管孤儿进程的后续处理,防止孤儿进程一直运行占用资源。

僵尸进程是指子进程已经退出,但其父进程还没有来得及处理它的退出状态信息。在这种情况下,子进程被称为僵尸进程,它虽然不再运行,但仍然占用系统的进程表项和一些系统资源,如果大量的僵尸进程积累,就会导致系统资源耗尽,导致系统崩溃。



信号和信号量有什么区别

信号:是由用户、系统或者进程发送给目标进程的信息,以通知目标进程某个状态的改变或系统异常。
信号量:信号量是一个特殊的变量,它的本质是计数器,信号量里面记录了临界资源的数目,有多少数目,信号量的值就为多少,进程对其访问都是原子操作(pv操作,p:占用资源,v:释放资源)。它的作用就是,调协进程对共享资源的访问,让一个临界区同一时间只有一个进程在访问它。

所以它们两的区别也就显而易见了,信号是通知进程产生了某个事件,信号量是用来同步进程的(用来协调进程对共享资源的访问的)



进程的状态有哪些

进程的3种基本状态:运⾏、就绪和阻塞。
(1)就绪:当⼀个进程获得了除处理机以外的⼀切所需资源,⼀旦得到处理机即可运⾏,则称此进程处于就绪状态。就绪进程可以按多个优先级来划分队列。例如,当⼀个进程由于时间⽚⽤完⽽进⼊就绪状态时,排⼊低优先级队列;当进程由I/O操作完成⽽进⼊就绪状态时,排⼊⾼优先级队列。

(2)运⾏:当⼀个进程在处理机上运⾏时,则称该进程处于运⾏状态。处于此状态的进程的数⽬⼩于等于处理器的数⽬,对于单处理机系统,处于运⾏状态的进程只有⼀个。在没有其他进程可以执⾏时(如所有进程都在阻塞状态),通常会⾃动执⾏系统的空闲进程。

(3)阻塞:也称为等待或睡眠状态,⼀个进程正在等待某⼀事件发⽣(例如请求I/O⽽等待I/O完成等)⽽暂时停⽌运⾏,这时即使把处理机分配给进程也⽆法运⾏,故称该进程处于阻塞状态。

进程的总共5种状态
创建状态:进程在创建时需要申请⼀个空⽩PCB,向其中填写控制和管理进程的信息,完成资源分配。如果创建⼯作⽆法完成,⽐如资源⽆法满⾜,就⽆法被调度运⾏,把此时进程所处状态称为创建状态
就绪状态:进程已经准备好,已分配到所需资源,只要分配到CPU就能够⽴即运⾏
执⾏状态:进程处于就绪状态被调度后,进程进⼊执⾏状态
阻塞状态:正在执⾏的进程由于某些事件(I/O请求,申请缓存区失败)⽽暂时⽆法运⾏,进程受到阻塞。在满⾜请求时进⼊就绪状态等待系统调⽤
终⽌状态:进程结束,或出现错误,或被系统终⽌,进⼊终⽌状态。⽆法再执⾏



进程和线程的区别

  1. 概念
    进程是系统进⾏资源分配和调度的基本单位。
    线程是操作系统能够进⾏运算调度的最⼩单位,线程是进程的⼦任务,是进程内的执⾏单元。 ⼀个进程⾄少有⼀个
    线程,⼀个进程可以运⾏多个线程,这些线程共享同⼀块内存。

  2. 资源开销
    进程:由于每个进程都有独⽴的内存空间,创建和销毁进程的开销较⼤。进程间切换需要保存和恢复整个进程的状态,因此上下⽂切换的开销较⾼。
    线程:线程共享相同的内存空间,创建和销毁线程的开销较⼩。线程间切换只需要保存和恢复少量的线程上下⽂,因此上下⽂切换的开销较⼩。

  3. 通信
    进程:由于进程间相互隔离,进程之间的通信需要使⽤⼀些特殊机制,如管道、消息队列、共享内存等。
    线程:由于线程共享相同的内存空间,它们之间可以直接访问共享数据,线程间通信更加⽅便。

  4. 安全性
    进程:由于进程间相互隔离,⼀个进程的崩溃不会直接影响其他进程的稳定性。
    线程:由于线程共享相同的内存空间,⼀个线程的错误可能会影响整个进程的稳定性。



进程切换为何比线程慢

进程切换涉及虚拟地址空间的切换⽽线程不会。

因为每个进程都有⾃⼰的虚拟地址空间,⽽线程是共享所在进程的虚拟地址空间的,所以同⼀个进程中的线程进⾏线程切换时不涉及虚拟地址空间的转换。

把虚拟地址转换为物理地址需要查找⻚表,⻚表查找是⼀个很慢的过程(⾄少访问2次内存),因此通常使⽤Cache来缓存常⽤的地址映射,这样可以加速⻚表查找,这个cache就是TLB(快表)。

由于每个进程都有⾃⼰的虚拟地址空间,那么显然每个进程都有⾃⼰的⻚表,那么当进程切换后⻚表也要进⾏切换,⻚表切换后TLB就失效了,cache失效导致命中率降低,那么虚拟地址转换为物理地址就会变慢,表现出来的就是程序运⾏会变慢,⽽线程切换则不会导致TLB失效,因为线程线程⽆需切换地址空间,这也就是进程切换要⽐同进程下线程切换慢的原因。









互斥与同步

什么是互斥、同步

进程同步是指多个并发执⾏的进程之间协调和管理它们的执⾏顺序,以确保它们按照⼀定的顺序或时间间隔执⾏。
⽐如说,你想要和你的队友⼀起完成⼀个副本,你们需要相互配合,有时候等待对⽅的信号或者消息,有时候按照对⽅的要求执⾏某些动作,这就是进程同步。

互斥指的是在某⼀时刻只允许⼀个进程访问某个共享资源。当⼀个进程正在使⽤共享资源时,其他进程不能同时访问该资源。⽐如说,你想要使⽤⼀个祭坛来祈愿,但是这个祭坛⼀次只能被⼀个⼈使⽤,如果有其他⼈也想要使⽤,他们就必须等待你使⽤完毕后再去使⽤,这就是进程互斥。

解决互斥同步的方法

常⻅的⽅法是使⽤信号量和 PV 操作。信号量是⼀种特殊的变量,它表示系统中某种资源的数量或者状态。PV 操作是⼀种对信号量进⾏增加或者减少的操作,它们可以⽤来控制进程之间的同步或者互斥。

下⾯的⽅法也可以解决进程同步和互斥问题:
临界区(Critical Section): 将可能引发互斥问题的代码段称为临界区。为了实现互斥,每个进程在进⼊临界区前必须获取⼀个锁,退出临界区后释放该锁。这确保同⼀时间只有⼀个进程可以进⼊临界区。

互斥锁(Mutex): 互斥锁是⼀种同步机制,⽤于实现互斥。每个共享资源都关联⼀个互斥锁,进程在访问该资源前需要先获取互斥锁,使⽤完后释放锁。只有获得锁的进程才能访问共享资源。

条件变量(Condition Variable): 条件变量⽤于在进程之间传递信息,以便它们在特定条件下等待或唤醒。通常与互斥锁⼀起使⽤,以确保等待和唤醒的操作在正确的时机执⾏。

死锁

定义

死锁是指两个或多个进程在争夺系统资源时,由于互相等待对方释放资源⽽⽆法继续执⾏的状态。



资源

可抢占资源

不可抢占资源



发生死锁的必要条件

同时满足以下四个条件才会发生

  1. 互斥条件:⼀个进程占⽤了某个资源时,其他进程⽆法同时占⽤该资源

  2. 占有保持条件:⼀个进程因为请求资源⽽阻塞的时候,不会释放⾃⼰的资源

  3. 不可剥夺条件:已经分配给⼀个进程的资源不能强制性地被剥夺,它只能被占有它的进程显式地释放

  4. 环路等待条件:多个进程之间形成⼀个循环等待资源的链,每个进程都在等待下⼀个进程所占有资源



死锁处理

鸵鸟算法

把头埋在沙⼦⾥,假装根本没发⽣问题。

当发⽣死锁时不会对⽤户造成多⼤影响,或发⽣死锁的概率很低,可以采⽤鸵⻦算法。

死锁恢复
  1. 利用抢占恢复:将进程挂起,强⾏取⾛资源给另⼀个进程使⽤,⽤完再放回

  2. 利用回滚恢复:复位到更早的状态,那时它还没有取得所需的资源

  3. 通过杀死进程恢复:杀掉环中的⼀个进程或多个,牺牲掉⼀个环外进程

死锁预防
  1. 破坏互斥条件

  2. 破坏占有保持条件

规定所有进程在开始执⾏前请求所需要的全部资源。

或者要求当⼀个进程请求资源时,先暂时释放其当前占⽤的所有资源,然后在尝试⼀次获得所需的全部资源。

  1. 破环不可剥夺条件

保证每⼀个进程在任何时刻只能占⽤⼀个资源,如果请求另⼀个资源必须先释放第⼀个资源

或者将所有的资源统⼀编号,进程可以在任何时刻提出资源请求,但是所有请求必须按照资源编号的顺序(升序)提出

  1. 破环环路等待条件

死锁避免

银行家算法

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

tip:安全状态:如果没有死锁发⽣,并且即使所有进程突然请求对资源的最⼤需求,也仍然存在某种调度次序能够使得每⼀个进程 运⾏完毕,则称该状态是安全的。





互斥锁

概念

互斥锁也叫互斥量,互斥锁是⼀种简单的加锁的⽅法来控制对共享资源的访问,互斥锁只有两种状态,即加锁( lock )和解锁( unlock )

  1. 在访问共享资源后临界区域前,对互斥锁进⾏加锁。
  2. 在访问完成后释放互斥锁导上的锁。
  3. 对互斥锁进⾏加锁后,任何其他试图再次对互斥锁加锁的线程将会被阻塞,直到锁被释放。

物理层面原理

**所谓的锁,在计算机里本质上就是一块内存空间。**当这个空间被赋值为 1 的时候表示加锁了,被赋值为 0 的时候表示解锁了,仅此而已。

多个线程抢一个锁,就是抢着要把这块内存赋值为 1 。在一个多核环境里,内存空间是共享的,每个核上各跑一个线程,那如何保证一次只有一个线程成功抢到锁呢?你或许已经猜到了,这必须要硬件的某种保证。

在单核的情况下,关闭 CPU 中断,使其不能暂停当前请求而处理其他请求,从而达到赋值“锁”对应的内存空间的目的。

在多核的情况下,使用锁总线和缓存一致性技术(CPU相关章节有详细说明),可以实现在单一时刻,只有某个CPU里面某一个核能够赋值“锁”对应的内存空间,从而达到锁的目的。





读写锁

出现的原因

在对数据的读写操作中,更多的是读操作,写操作较少,例如对数据库数据的读写应⽤。
为了满⾜当前能够允许多个读出,但只允许⼀个写⼊的需求,线程提供了读写锁来实现。



读写锁的特点

1、如果有其它线程读数据,则允许其它线程执⾏读操作,但不允许写操作
2、如果有其它线程写数据,则其它线程都不允许读、写操作





条件变量

概念

与互斥锁不同,条件变量是⽤来等待⽽不是⽤来上锁的,条件变量本身不是锁!

条件变量⽤来⾃动阻塞⼀个线程,直到某特殊情况发⽣为⽌。通常条件变量和互斥锁同时使⽤。

条件变量的两个动作

  1. 条件不满, 阻塞线程
  2. 当条件满⾜, 通知阻塞的线程开始⼯作

条件变量的优点

相较于mutex⽽⾔,条件变量可以减少竞争

如直接使⽤mutex,除了⽣产者、消费者之间要竞争互斥量以外,消费者之间也需要竞争互斥量;
但如果汇聚(链表)中没有数据,消费者之间竞争互斥锁是⽆意义的。
有了条件变量机制以后,只有⽣产者完成⽣产,才会引起消费者之间的竞争。提⾼了程序效率。





信号量

概念

信号量⼴泛⽤于进程或线程间的同步和互斥,信号量本质上是⼀个⾮负的整数计数器,它被⽤来控制对共享资源的访问。

PV 原语是对信号量的操作,⼀次 P 操作使信号量减1,⼀次 V 操作使信号量加1。

面试问题

线程之间的同步方式

线程同步机制是指在多线程编程中,为了保证线程之间的互不⼲扰,⽽采⽤的⼀种机制。常⻅的线程同步机制有以下⼏种:

  1. 互斥锁:互斥锁是最常⻅的线程同步机制。它允许只有⼀个线程同时访问被保护的临界区(共享资源)。
  2. 读写锁:读写锁允许多个线程同时读取共享资源,但只允许⼀个线程写⼊资源。
  3. 条件变量:条件变量⽤于线程间通信,允许⼀个线程等待某个条件满⾜,⽽其他线程可以发出信号通知等待线程。通常与互斥锁⼀起使⽤。
  4. 信号量:⽤于控制多个线程对共享资源进⾏访问的⼯具。



介绍一下你知道的锁

两个基础的锁:
互斥锁:互斥锁是⼀种最常⻅的锁类型,⽤于实现互斥访问共享资源。在任何时刻,只有⼀个线程可以持有互斥锁,其他线程必须等待直到锁被释放。这确保了同⼀时间只有⼀个线程能够访问被保护的资源。
⾃旋锁:⾃旋锁是⼀种基于忙等待的锁,即线程在尝试获取锁时会不断轮询,直到锁被释放。

其他的锁都是基于这两个锁的 :
读写锁:允许多个线程同时读共享资源,只允许⼀个线程进⾏写操作。分为读(共享)和写(排他)两种状态。
悲观锁:认为多线程同时修改共享资源的概率⽐较⾼,所以访问共享资源时候要上锁
乐观锁:先不管,修改了共享资源再说,如果出现同时修改的情况,再放弃本次操作



如何解除死锁

只需要破坏死锁四个必要条件中任意一个就可以破坏死锁。但一般破坏以下三个条件中一个:

破坏请求与保持条件:⼀次性申请所有的资源。
破坏不可剥夺条件:占⽤部分资源的线程进⼀步申请其他资源时,如果申请不到,可以主动释放它占有的资源。
破坏循环等待条件:靠按序申请资源来预防。让所有进程按照相同的顺序请求资源,释放资源则反序释放。

编写多线程下必定死锁的场景代码

当一个程序的多个线程获取多个互斥锁资源的时候,就有可能发生死锁问题,比如线程A先获取了锁1,线程B获取了锁2,进而线程A还需要获取锁2才能继续执行,但是由于锁2被线程B持有还没有释放,线程A为了等待锁2资源就阻塞了;线程B这时候需要获取锁1才能往下执行,但是由于锁1被线程A持有,导致A也进入阻塞。

线程A和线程B都在等待对方释放锁资源,但是它们又不肯释放原来的锁资源,导致线程A和B一直互相等待,进程死锁了。下面代码示例演示这个问题:

#include <iostream>           // std::cout
#include <thread>             // std::thread
#include <mutex>              // std::mutex, std::unique_lock
#include <condition_variable> // std::condition_variable
#include <vector>

// 锁资源1
std::mutex mtx1;
// 锁资源2
std::mutex mtx2;

// 线程A的函数
void taskA()
{
	// 保证线程A先获取锁1
	std::lock_guard<std::mutex> lockA(mtx1);
	std::cout << "线程A获取锁1" << std::endl;

	// 线程A睡眠2s再获取锁2,保证锁2先被线程B获取,模拟死锁问题的发生
	std::this_thread::sleep_for(std::chrono::seconds(2));

	// 线程A先获取锁2
	std::lock_guard<std::mutex> lockB(mtx2);
	std::cout << "线程A获取锁2" << std::endl;

	std::cout << "线程A释放所有锁资源,结束运行!" << std::endl;
}

// 线程B的函数
void taskB()
{
	// 线程B先睡眠1s保证线程A先获取锁1
	std::this_thread::sleep_for(std::chrono::seconds(1));
	std::lock_guard<std::mutex> lockB(mtx2);
	std::cout << "线程B获取锁2" << std::endl;

	// 线程B尝试获取锁1
	std::lock_guard<std::mutex> lockA(mtx1);
	std::cout << "线程B获取锁1" << std::endl;

	std::cout << "线程B释放所有锁资源,结束运行!" << std::endl;
}
int main()
{
	// 创建生产者和消费者线程
	std::thread t1(taskA);
	std::thread t2(taskB);

	// main主线程等待所有子线程执行完
	t1.join();
	t2.join();

	return 0;
}









内存管理

虚拟内存

概念

虚拟内存地址:程序所使⽤的内存地址
物理内存地址:实际存在硬件⾥⾯的空间地址



地址映射表(页表)

操作系统会提供⼀种机制,在物理内存和虚拟内存之间建⽴⼀个地址映射表,进程持有的虚拟地址会通过 CPU 芯⽚中的内存管理单元(MMU)的映射关系,来转换变成物理地址,然后再通过物理地址访问内存。



TLB加速分⻚

概念:将虚拟地址直接映射到物理地址,⽽不必再访问⻚表,这种设备被称为转换检测缓冲区(TLB)、相联存储
器或快表
⼯作过程:将⼀个虚拟地址放⼊MMU中进⾏转换时,硬件⾸先通过将该虚拟⻚号与TLB中所有表项同时进⾏匹配,
判断虚拟⻚⾯是否在其中:

  1. 虚拟⻚号在TLB中。如果MMU检测⼀个有效的匹配并且访问操作并不违反保护位,则将⻚框号直接从TLB中取
    出⽽不必访问⻚表。
  2. 虚拟⻚号不在TLB中。如果MMU检测到没有有效的匹配项就会进⾏正常的⻚表查询。接着从TLB中淘汰⼀个表
    项,然后⽤新的⻚表项替换它。



如何管理虚拟内存

操作系统主要通过内存分段内存分⻚来管理虚拟内存和物理内存之间的关系。

为什么需要虚拟内存

内存扩展: 虚拟内存使得每个程序都可以使⽤⽐实际可⽤内存更多的内存,从⽽允许运⾏更⼤的程序或处理更多的数据。

内存隔离:虚拟内存还提供了进程之间的内存隔离。每个进程都有⾃⼰的虚拟地址空间,因此⼀个进程⽆法直接访问另⼀个进程的内存。

物理内存管理:虚拟内存允许操作系统动态地将数据和程序的部分加载到物理内存中,以满⾜当前正在运⾏的进程的需求。当物理内存不⾜时,操作系统可以将不常⽤的数据或程序暂时移到硬盘上,从⽽释放内存,以便其他进程使⽤。

⻚⾯交换:当物理内存不⾜时,操作系统可以将⼀部分数据从物理内存写⼊到硬盘的虚拟内存中,这个过程被称为⻚⾯交换。当需要时,数据可以再次从虚拟内存中加载到物理内存中。这样可以保证系统可以继续运⾏,尽管物理内存有限。

内存映射⽂件:虚拟内存还可以⽤于将⽂件映射到内存中,这使得⽂件的读取和写⼊可以像访问内存⼀样⾼效。





内存分段

内存分段将物理内存划分成不同的逻辑段或区域,每个段⽤于存储特定类型的数据或执⾏特定类型的任务。每个段具有不同的⼤⼩和属性。常⻅的段包括代码段(存储程序执⾏代码)、数据段(存储程序数据)、堆栈段(存储函数调⽤和局部变量),以及其他⾃定义段,如共享库段等。

分段机制下的虚拟地址由两部分组成,段选择因⼦段内偏移量,⽽虚拟地址是通过段表与物理地址进⾏映射的。
内存分段
段选择因⼦中的段号与段表对应,作为段表的索引。段表⾥⾯保存的是这个段的基地址、段的界限和特权等级等。虚拟地址中的段内偏移量应该位于 0 和段界限之间,如果段内偏移量是合法的,就将段基地址加上段内偏移量得到物理内存地址。





内存分页

页面置换算法

  1. 最佳页面置换算法(OPT)
    置换在「未来」最⻓时间不访问的⻚⾯,但是实际系统中⽆法实现,因为程序访问⻚⾯时是动态的
    我们是⽆法预知每个⻚⾯在「下⼀次」访问前的等待时间,因此作为实际算法效率衡量标准。

  2. 先进先出置换算法(FIFO)
    顾名思义,将⻚⾯以队列形式保存,先进⼊队列的⻚⾯先被置换进⼊磁盘。

  3. 最近最久未使用置换算法(LRU)
    据⻚⾯未被访问时⻓⽤升序列表将⻚⾯排列,每次将最久未被使⽤⻚⾯置换出去。

  4. 时钟页面置换算法
    把所有的⻚⾯都保存在⼀个类似钟⾯的「环形链表」中,⻚⾯包含⼀个访问位。
    当发⽣缺⻚中断时,顺时针遍历⻚⾯,如果访问位为1,将其改为0,继续遍历,直到访问到访问位为0⻚⾯,进⾏
    置换。

  5. 最不常⽤算法
    记录每个⻚⾯访问次数,当发⽣缺⻚中断时候,将访问次数最少的⻚⾯置换出去,此⽅法需要对每个⻚⾯访问次数
    统计,额外开销。



与分段的区别

  1. 对程序员的透明性
    分页透明,分段需要程序员显式划分每个段

  2. 地址空间的维度
    分页是一维地址空间,分段是二维

  3. 大小是否可以改变
    页的大小不可变,段的大小可以动态改变

  4. 出现的原因
    分页主要用于实现虚拟内存,从而获得更大的地址空间;分段主要是为了使程序和数据可以被划分为逻辑上独立的地址空间并且有助于共享和保护





内存碎⽚

内存碎片即“碎片的内存”描述一个系统中所有不可用的空闲内存,这些碎片之所以不能被使用,是因为负责动态分配内存的分配算法使得这些空闲的内存无法使用,这一问题的发生,原因在于这些空闲内存以小且不连续方式出现在不同的位置。因此这个问题的或大或小取决于内存管理算法的实现上。

内存碎片只存在于虚拟内存上。

内部碎片
内部碎片就是已经被分配出去(能明确指出属于哪个进程)却不能被利用的内存空间;
内部碎片是处于区域内部或页面内部的存储块。占有这些区域或页面的进程并不使用这个存储块。而在进程占有这块存储块时,系统无法利用它。直到进程释放它,或进程结束时,系统才有可能利用这个存储块。
单道连续分配只有内部碎片。多道固定连续分配既有内部碎片,又有外部碎片。

外部碎片
外部碎片指的是还没有被分配出去(不属于任何进程),但由于太小了无法分配给申请内存空间的新进程的内存空闲区域。
外部碎片是出于任何已分配区域或页面外部的空闲存储块。这些存储块的总和可以满足当前申请的长度要求,但是由于它们的地址不连续或其他原因,使得系统无法满足当前申请。
多道可变连续分配只有外部碎片 [2]。

面试问题

CPU相关

CPU缓存一致性

CPU Cache的写入

概念

CPU Cache 通常分为三级缓存:L1 Cache、L2 Cache、L3 Cache,级别越低的离 CPU 核心越近,访问速度也快,但是存储容量相对就会越小。其中,在多核心的 CPU 里,每个核心都有各自的 L1/L2 Cache,而 L3 Cache 是所有核心共享使用的。
在这里插入图片描述

我们先简单了解下 CPU Cache 的结构,CPU Cache 是由很多个 Cache Line 组成的,CPU Line 是 CPU 从内存读取数据的基本单位,而 CPU Line 是由各种标志(Tag)+ 数据块(Data Block)组成,你可以在下图清晰的看到:
在这里插入图片描述

我们当然期望 CPU 读取数据的时候,都是尽可能地从 CPU Cache 中读取,而不是每一次都要从内存中获取数据。
事实上,数据不光是只有读操作,还有写操作,那么如果数据写入 Cache 之后,内存与 Cache 相对应的数据将会不同,这种情况下 Cache 和内存数据都不一致了,于是我们肯定是要把 Cache 中的数据同步到内存里的。

问题来了,那在什么时机才把 Cache 中的数据写回到内存呢?为了应对这个问题,下面介绍两种针对写入数据的方法:

写直达(Write Through)
写回(Write Back)

写直达

保持内存与 Cache 一致性最简单的方式是,把数据同时写入内存和 Cache 中,这种方法称为写直达(Write Through)。
在这里插入图片描述
在这个方法里,写入前会先判断数据是否已经在 CPU Cache 里面了:
如果数据已经在 Cache 里面,先将数据更新到 Cache 里面,再写入到内存里面;
如果数据没有在 Cache 里面,就直接把数据更新到内存里面。

写直达法很直观,也很简单,但是问题明显,无论数据在不在 Cache 里面,每次写操作都会写回到内存,这样写操作将会花费大量的时间,无疑性能会受到很大的影响。

写回

既然写直达由于每次写操作都会把数据写回到内存,而导致影响性能,于是为了要减少数据写回内存的频率,就出现了写回(Write Back)的方法。
在写回机制中,当发生写操作时,新的数据仅仅被写入 Cache Block 里,只有当修改过的 Cache Block「被替换」时才需要写到内存中,减少了数据写回内存的频率,这样便可以提高系统的性能。
在这里插入图片描述
那具体如何做到的呢?下面来详细说一下:
如果当发生写操作时,数据已经在 CPU Cache 里的话,则把数据更新到 CPU Cache 里,同时标记 CPU Cache 里的这个 Cache Block 为脏(Dirty)的,这个脏的标记代表这个时候,我们 CPU Cache 里面的这个 Cache Block 的数据和内存是不一致的,这种情况是不用把数据写到内存里的;

如果当发生写操作时,数据所对应的 Cache Block 里存放的是「别的内存地址的数据」的话,就要检查这个 Cache Block 里的数据有没有被标记为脏的:
如果是脏的话,我们就要把这个 Cache Block 里的数据写回到内存,然后再把当前要写入的数据,先从内存读入到 Cache Block 里(注意,这一步不是没用的,具体为什么要这一步,可以看这个「回答」),然后再把当前要写入的数据写入到 Cache Block,最后也把它标记为脏的;
如果不是脏的话,把当前要写入的数据先从内存读入到 Cache Block 里,接着将数据写入到这个 Cache Block 里,然后再把这个 Cache Block 标记为脏的就好了。

可以发现写回这个方法,在把数据写入到 Cache 的时候,只有在缓存不命中,同时数据对应的 Cache 中的 Cache Block 为脏标记的情况下,才会将数据写到内存中,而在缓存命中的情况下,则在写入后 Cache 后,只需把该数据对应的 Cache Block 标记为脏即可,而不用写到内存里。
这样的好处是,如果我们大量的操作都能够命中缓存,那么大部分时间里 CPU 都不需要读写内存,自然性能相比写直达会高很多。
为什么缓存没命中时,还要定位 Cache Block?这是因为此时是要判断数据即将写入到 cache block 里的位置,是否被「其他数据」占用了此位置,如果这个「其他数据」是脏数据,那么就要帮忙把它写回到内存。

CPU 缓存与内存使用「写回」机制的流程图如下,左半部分就是读操作的流程,右半部分就是写操作的流程,也就是我们上面讲的内容。
在这里插入图片描述

缓存一致性问题

现在 CPU 都是多核的,由于 L1/L2 Cache 是多个核心各自独有的,那么会带来多核心的缓存一致性(Cache Coherence) 的问题,如果不能保证缓存一致性的问题,就可能造成结果错误。

那缓存一致性的问题具体是怎么发生的呢?我们以一个含有两个核心的 CPU 作为例子看一看。

假设 A 号核心和 B 号核心同时运行两个线程,都操作共同的变量 i(初始值为 0 )。
这时如果 A 号核心执行了 i++ 语句的时候,为了考虑性能,使用了我们前面所说的写回策略,先把值为 1 的执行结果写入到 L1/L2 Cache 中,然后把 L1/L2 Cache 中对应的 Block 标记为脏的,这个时候数据其实没有被同步到内存中的,因为写回策略,只有在 A 号核心中的这个 Cache Block 要被替换的时候,数据才会写入到内存里。
如果这时旁边的 B 号核心尝试从内存读取 i 变量的值,则读到的将会是错误的值,因为刚才 A 号核心更新 i 值还没写入到内存中,内存中的值还依然是 0。这个就是所谓的缓存一致性问题,A 号核心和 B 号核心的缓存,在这个时候是不一致,从而会导致执行结果的错误。
在这里插入图片描述
那么,要解决这一问题,就需要一种机制,来同步两个不同核心里面的缓存数据。要实现的这个机制的话,要保证做到下面这 2 点:
第一点,某个 CPU 核心里的 Cache 数据更新时,必须要传播到其他核心的 Cache,这个称为写传播(Write Propagation)
第二点,某个 CPU 核心里对数据的操作顺序,必须在其他核心看起来顺序是一样的,这个称为事务的串行化(Transaction Serialization)

第一点写传播很容易就理解,当某个核心在 Cache 更新了数据,就需要同步到其他核心的 Cache 里。而对于第二点事务的串行化,我们举个例子来理解它。
假设我们有一个含有 4 个核心的 CPU,这 4 个核心都操作共同的变量 i(初始值为 0 )。A 号核心先把 i 值变为 100,而此时同一时间,B 号核心先把 i 值变为 200,这里两个修改,都会「传播」到 C 和 D 号核心。
在这里插入图片描述
那么问题就来了,C 号核心先收到了 A 号核心更新数据的事件,再收到 B 号核心更新数据的事件,因此 C 号核心看到的变量 i 是先变成 100,后变成 200。

而如果 D 号核心收到的事件是反过来的,则 D 号核心看到的是变量 i 先变成 200,再变成 100,虽然是做到了写传播,但是各个 Cache 里面的数据还是不一致的。

所以,我们要保证 C 号核心和 D 号核心都能看到相同顺序的数据变化,比如变量 i 都是先变成 100,再变成 200,这样的过程就是事务的串行化。

要实现事务串行化,要做到 2 点:
CPU 核心对于 Cache 中数据的操作,需要同步给其他 CPU 核心;
要引入「锁」的概念,如果两个 CPU 核心里有相同数据的 Cache,那么对于这个 Cache 数据的更新,只有拿到了「锁」,才能进行对应的数据更新。

那接下来我们看看,写传播和事务串行化具体是用什么技术实现的。

总线嗅探

写传播的原则就是当某个 CPU 核心更新了 Cache 中的数据,要把该事件广播通知到其他核心。最常见实现的方式是总线嗅探(Bus Snooping)。

我还是以前面的 i 变量例子来说明总线嗅探的工作机制,当 A 号 CPU 核心修改了 L1 Cache 中 i 变量的值,通过总线把这个事件广播通知给其他所有的核心,然后每个 CPU 核心都会监听总线上的广播事件,并检查是否有相同的数据在自己的 L1 Cache 里面,如果 B 号 CPU 核心的 L1 Cache 中有该数据,那么也需要把该数据更新到自己的 L1 Cache。

可以发现,总线嗅探方法很简单, CPU 需要每时每刻监听总线上的一切活动,但是不管别的核心的 Cache 是否缓存相同的数据,都需要发出一个广播事件,这无疑会加重总线的负载。

另外,总线嗅探只是保证了某个 CPU 核心的 Cache 更新数据这个事件能被其他 CPU 核心知道,但是并不能保证事务串行化。

于是,有一个协议基于总线嗅探机制实现了事务串行化,也用状态机机制降低了总线带宽压力,这个协议就是 MESI 协议,这个协议就做到了 CPU 缓存一致性。

MESI协议

MESI 协议其实是 4 个状态单词的开头字母缩写,分别是:

  • Modified,已修改
  • Exclusive,独占
  • Shared,共享
  • Invalidated,已失效

这四个状态来标记 Cache Line 四个不同的状态。

「已修改」状态就是我们前面提到的脏标记,代表该 Cache Block 上的数据已经被更新过,但是还没有写到内存里。而「已失效」状态,表示的是这个 Cache Block 里的数据已经失效了,不可以读取该状态的数据。

「独占」和「共享」状态都代表 Cache Block 里的数据是干净的,也就是说,这个时候 Cache Block 里的数据和内存里面的数据是一致性的。

「独占」和「共享」的差别在于,独占状态的时候,数据只存储在一个 CPU 核心的 Cache 里,而其他 CPU 核心的 Cache 没有该数据。这个时候,如果要向独占的 Cache 写数据,就可以直接自由地写入,而不需要通知其他 CPU 核心,因为只有你这有这个数据,就不存在缓存一致性的问题了,于是就可以随便操作该数据。

另外,在「独占」状态下的数据,如果有其他核心从内存读取了相同的数据到各自的 Cache ,那么这个时候,独占状态下的数据就会变成共享状态。

那么,「共享」状态代表着相同的数据在多个 CPU 核心的 Cache 里都有,所以当我们要更新 Cache 里面的数据的时候,不能直接修改,而是要先向所有的其他 CPU 核心广播一个请求,要求先把其他核心的 Cache 中对应的 Cache Line 标记为「无效」状态,然后再更新当前 Cache 里面的数据。

我们举个具体的例子来看看这四个状态的转换:

  1. 当 A 号 CPU 核心从内存读取变量 i 的值,数据被缓存在 A 号 CPU 核心自己的 Cache 里面,此时其他 CPU 核心的 Cache 没有缓存该数据,于是标记 Cache Line 状态为「独占」,此时其 Cache 中的数据与内存是一致的;
  2. 然后 B 号 CPU 核心也从内存读取了变量 i 的值,此时会发送消息给其他 CPU 核心,由于 A 号 CPU 核心已经缓存了该数据,所以会把数据返回给 B 号 CPU 核心。在这个时候, A 和 B 核心缓存了相同的数据,Cache Line 的状态就会变成「共享」,并且其 Cache 中的数据与内存也是一致的;
  3. 当 A 号 CPU 核心要修改 Cache 中 i 变量的值,发现数据对应的 Cache Line 的状态是共享状态,则要向所有的其他 CPU 核心广播一个请求,要求先把其他核心的 Cache 中对应的 Cache Line 标记为「无效」状态,然后 A 号 CPU 核心才更新 Cache 里面的数据,同时标记 Cache Line 为「已修改」状态,此时 Cache 中的数据就与内存不一致了。
  4. 如果 A 号 CPU 核心「继续」修改 Cache 中 i 变量的值,由于此时的 Cache Line 是「已修改」状态,因此不需要给其他 CPU 核心发送消息,直接更新数据即可。
  5. 如果 A 号 CPU 核心的 Cache 里的 i 变量对应的 Cache Line 要被「替换」,发现 Cache Line 状态是「已修改」状态,就会在替换前先把数据同步到内存。

所以,可以发现当 Cache Line 状态是「已修改」或者「独占」状态时,修改更新其数据不需要发送广播给其他 CPU 核心,这在一定程度上减少了总线带宽压力。

事实上,整个 MESI 的状态可以用一个有限状态机来表示它的状态流转。还有一点,对于不同状态触发的事件操作,可能是来自本地 CPU 核心发出的广播事件,也可以是来自其他 CPU 核心通过总线发出的广播事件。下图即是 MESI 协议的状态图:
在这里插入图片描述
MESI 协议的四种状态之间的流转过程,我汇总成了下面的表格,你可以更详细的看到每个状态转换的原因:
在这里插入图片描述

Linux网络编程相关

I/O多路复用

LT和ET

是什么

LT:水平触发模式。只要内核缓冲区有数据就⼀直通知,只要socket处于可读状态或可写状态,就会⼀直返
回sockfd;是默认的⼯作模式,⽀持阻塞IO和⾮阻塞IO

ET:边沿触发模式。只有状态发⽣变化才通知并且这个状态只会通知⼀次,只有当socket由不可写到可写或
由不可读到可读,才会返回其sockfd;只⽀持⾮阻塞IO



epoll

是什么

epoll 是 Linux 内核的可扩展 I/O 事件通知机制,是一种I/O多路复用方式。

epoll数据结构

struct epoll_event

typedef union epoll_data {
    void *ptr;
     int fd;
     __uint32_t u32;
     __uint64_t u64;
 } epoll_data_t;//保存触发事件的某个文件描述符相关的数据

 struct epoll_event {
     __uint32_t events;      /* epoll event */
     epoll_data_t data;      /* User data variable */
 };

其中events表示感兴趣的事件和被触发的事件,可能的取值为:
EPOLLIN:表示对应的文件描述符可以读;
EPOLLOUT:表示对应的文件描述符可以写;
EPOLLPRI:表示对应的文件描述符有紧急的数可读;
EPOLLERR:表示对应的文件描述符发生错误;
EPOLLHUP:表示对应的文件描述符被挂断;
EPOLLET: ET的epoll工作模式。

epoll函数

参考链接

其他的多路复用方式有哪些,它们的区别,为什么选择用epoll

其他的多路复用方式

  • select
  • poll

区别

  • 文件描述符集合的存储位置
    对于 select 和 poll 来说,所有⽂件描述符都是在⽤户态被加⼊其⽂件描述符集合的,每次调⽤都需要将整个集合
    拷⻉到内核态;epoll 则将整个⽂件描述符集合维护在内核态,每次添加⽂件描述符的时候都需要执⾏⼀个系统调
    ⽤。系统调⽤的开销是很⼤的,⽽且在有很多短期活跃连接的情况下,由于这些⼤量的系统调⽤开销,epoll 可能
    会慢于 select 和 poll。

  • 文件描述符集合的表示方法
    select 使⽤线性表描述⽂件描述符集合,⽂件描述符有上限;poll使⽤链表来描述;epoll底层通过红⿊树来描述,
    并且维护⼀个就绪列表,将事件表中已经就绪的事件添加到这⾥,在使⽤epoll_wait调⽤时,仅观察这个list中有没
    有数据即可。

  • 判断是否有⽂件描述符就绪的方式
    select 和 poll 的最⼤开销来⾃内核判断是否有⽂件描述符就绪这⼀过程:每次执⾏ select 或 poll 调⽤时,它们会
    采⽤遍历的⽅式
    ,遍历整个⽂件描述符集合去判断各个⽂件描述符是否有活动;epoll 则不需要去以这种⽅式检
    查,当有活动产⽣时,会⾃动触发 epoll 回调函数通知epoll⽂件描述符,然后内核将这些就绪的⽂件描述符放到就
    绪列表中等待epoll_wait调⽤后被处理。

epoll为何高效

  • 红黑树
  • 就绪队列
  • 回调函数

各自的适用场景
当监测的fd数量较⼩,且各个fd都很活跃的情况下,建议使⽤select和poll;当监听的fd数量较多,且单位时间仅部
分fd活跃的情况下,使⽤epoll会明显提升性能。





I/O模型

阻塞I/O模型

最传统的一种IO模型,即在读写数据过程中会发生阻塞现象。

当用户线程发出IO请求之后,内核会去查看数据是否就绪,如果没有就绪就会等待数据就绪,而用户线程就会处于阻塞状态,用户线程交出CPU。当数据就绪之后,内核会将数据拷贝到用户线程,并返回结果给用户线程,用户线程才解除block状态。

例如:贾同学去机场柜台买机票,买到从西安到上海的机票之后才离开。这一过程就可以看成是使用了阻塞IO模型,因为如果没有从西安到上海的机票,他也要等到有有票了才能离开去做别的事情。很显然,这种IO模型是同步的

在这里插入图片描述



非阻塞I/O

概念

我们使用recvfrom让recvfrom不管有没有获取到数据都返回,如果没有数据那么一段时间后再调用recvfrom看看,如此轮询。

例如:张同学也要买从西安到上海的机票,发现现在没有票,于是乎她离开了,过了一段时间她又来机场看看有没有去上海的机票…在中间离开的这些时间里,张同学离开了机场(回到用户进程空间),可以做她自己的事情。

这就是非阻塞IO模型。但是它只有是检查无数据的时候是非阻塞的,在数据到达的时候依然要等待复制数据到用户空间(等着排队买机票),因此它还是同步IO。

在这里插入图片描述

相关代码

socket()的参数设置为SOCKNONBLOCK

// Acceptor.cc
static int createNonblocking()
{
    int sockfd = ::socket(AF_INET, SOCK_STREAM | SOCK_NONBLOCK | SOCK_CLOEXEC, IPPROTO_TCP);
    ...
    return sockfd;
}

Acceptor::Acceptor(EventLoop *loop, const InetAddress &ListenAddr, bool reuseport) 
    ...
    acceptSocket_(createNonblocking()),
    ...  
}

accept4()的参数设置为SOCKNONBLOCK

// Socket.cc
int Socket::accept(InetAddress *peeraddr) {
	...
	int connfd = ::accept4(sockfd_, (sockaddr *)&addr, &len, SOCK_NONBLOCK | SOCK_CLOEXEC);
	...
}



I/O复用模型

概念

这里在调用recv前先调用select或者poll,这两个系统调用都可以在内核准备好数据(网络数据到达内核)时告知用户进程,这个时候再调用recv一定是有数据的。因此这一过程中它是阻塞于select或poll,而没有阻塞于recv

有人将非阻塞IO定义成在读写操作时没有阻塞于系统调用的IO操作(不包括数据从内核复制到用户空间时的阻塞,因为这相对于网络IO来说确实很短暂),如果按这样理解,这种IO模型也能称之为非阻塞IO模型,但是按POSIX来看,它也是同步IO,那么也和楼上一样称之为同步非阻塞IO吧。

这种IO模型比较特别,因为它能同时监听多个文件描述符(fd)。

例如:这个时候又有一位王同学来机场买西安到上海的机票,发现有好多柜台,工作人员告诉他这些柜台都没有卖从西安到上海的机票,让王同学留个电话号码,等有票了之后再打电话告诉他,于是他回去等啊等(select/poll调用中),过了好久工作人员打电话告诉他有票了让他来买票,但是不知道是那个柜台卖的是先到上海的机票,需要王同学自己一个一个去找(select/poll的轮询查找就需事件)这时候就需要提出来epoll的高性能了,他会直接告诉王同学具体是哪个柜台卖的是西安到上海的机票,而不需要一个一个查看。

在这里插入图片描述



信号驱动I/O模型

我们也可以用信号,让内核在文件描述符准备好之后用信号SIGIO通知我们,我们把这种方法称之为信号驱动IO。

首先我们允许套接字进行信号驱动IO,并通过系统调用sigaction安装一个信号处理程序,这个系统调用立即返回,进程继续工作,因为他是非阻塞的,当数据报准备好时,就为该进程生成一个SIGIO信号,然后我们得到通知后可用通过recvfrom来接收数据了。

在这里插入图片描述



异步I/O模型

概念

异步IO是我们让内核启动操作,在所有动作都做完的时候才通知我们,包括接收数据把数据从内核拷贝到我们的用户空间缓冲区。异步IO和前边我们讲的信号驱动IO主要区别在于:信号驱动IO是由内核告诉我们什么时候可以启动一个IO操作,而异步IO是由内核告诉我们什么IO操作时候完成。

我们调用函数aio_read(Posix 异步I/O函数以aio_或lio _开头),给内核传递描述字、缓冲区指针、缓冲区大小(与read相同的三个参数)、文件偏移(与lseek类似),并告诉内核当整个操作完成时如何通知我们。此系统调用立即返回,我们的进程不阻塞于等待I/O操作的完成。
在此例子中,我们假设要求内核在操作完成时生成一个信号,此信号直到数据已拷贝到应用缓冲区才产生,这一点是与信号驱动I/O模型不同的。

例如:这个时候又有一位赵同学来机场买西安到上海的机票,工作人员告诉他现在没有卖从西安到上海的机票,让王同学留个电话号码和住址,等有票了之后会帮他买然后给他寄到他们家里,于是他回去等啊等,过了好久工作人员打电话告诉他票已经买好送到他家门口了。
这种模式不需要赵同学跑去机场自己买票,而只需要在家等着票送到自己家里,在这个过程中赵同学没有做任何事,在票买好之后送到他家里之后才会通知他。

在这里插入图片描述

不同I/O模型比较

下图给出了上面五种不同I/O模型的比较。它表明:前四种模型的主要区别都在第一阶段,因为前四种模型的第二阶段基本相同:在数据从内核拷贝到调用者的缓冲区时,进程阻塞于recvfrom调用。然而,异步1/O模型处理的两个阶段都不同于前四个模型。
在这里插入图片描述
同步I/O与异步I/O定义:

  • 同步I/O操作引起请求进程阻塞,直到I/O操作完成。
  • 异步I/O操作不引起请求进程阻塞。

根据上述定义,我们的前四个模型——阻塞I/O模型、非阻塞I/O模型、1/O复用模型和信号驱动I/O模型都是同步I/O模型,因为真正的I/O操作(recvfrom)阻塞进程,只有异步I/O模型与此异步I/O的定义相匹配。





高性能IO设计模式

reactor模型

reactor、proactor模型的区别
  1. Reactor 是⾮阻塞同步⽹络模式,感知的是就绪可读写事件。在每次感知到有事件发⽣(⽐如可读就绪事
    件)后,就需要应⽤进程主动调⽤ read ⽅法来完成数据的读取,也就是要应⽤进程主动将 socket 接收缓存
    中的数据读到应⽤进程内存中,这个过程是同步的,读取完数据后应⽤进程才能处理数据。
  2. Proactor 是异步⽹络模式, 感知的是已完成的读写事件。在发起异步读写请求时,需要传⼊数据缓冲区的地
    址(⽤来存放结果数据)等信息,这样系统内核才可以⾃动帮我们把数据的读写⼯作完成,这⾥的读写⼯作全
    程由操作系统来做,并不需要像 Reactor 那样还需要应⽤进程主动发起 read/write 来读写数据,操作系统完
    成读写⼯作后,就会通知应⽤进程直接处理数据。

为什么不用proactor

在 Linux 下的异步 I/O 是不完善的,aio系列函数是由 POSIX 定义的异步操作接⼝,不是真正的操作系统级别⽀
持的,⽽是在⽤户空间模拟出来的异步,并且仅仅⽀持基于本地⽂件的 aio 异步操作,⽹络编程中的 socket 是不
⽀持的,也有考虑过使⽤模拟的proactor模式来开发,但是这样需要浪费⼀个线程专⻔负责 IO 的处理。
⽽ Windows ⾥实现了⼀套完整的⽀持 socket 的异步编程接⼝,这套接⼝就是
IOCP ,是由操作系统级别实现的异
步 I/O,真正意义上异步 I/O,因此在 Windows ⾥实现⾼性能⽹络程序可以使⽤效率更⾼的 Proactor ⽅案。


reactor模式中,各个模式的区别

Reactor模型是⼀个针对同步I/O的⽹络模型,主要是使⽤⼀个reactor负责监听和分配事件,将I/O事件分派给对应
的Handler。新的事件包含连接建⽴就绪、读就绪、写就绪等。reactor模型中⼜可以细分为单reactor单线程、单
reactor多线程、以及主从reactor模式。

  1. 单reactor单线程模型就是使⽤ I/O 多路复⽤技术,当其获取到活动的事件列表时,就在reactor中进⾏读取请
    求、业务处理、返回响应,这样的好处是整个模型都使⽤⼀个线程,不存在资源的争夺问题。但是如果⼀个事
    件的业务处理太过耗时,会导致后续所有的事件都得不到处理。
  2. 单reactor多线程就是⽤于解决这个问题,这个模型中reactor中只负责数据的接收和发送,reactor将业务处
    理分给线程池中的线程进⾏处理,完成后将数据返回给reactor进⾏发送,避免了在reactor进⾏业务处理,但
    是 IO 操作都在reactor中进⾏,容易存在性能问题。⽽且因为是多线程,线程池中每个线程完成业务后都需要
    将结果传递给reactor进⾏发送,还会涉及到共享数据的互斥和保护机制。
  3. 主从reactor就是将reactor分为主reactor和从reactor,主reactor中只负责连接的建⽴和分配,读取请求、
    业务处理、返回响应等耗时的操作均在从reactor中处理,能够有效地应对⾼并发的场合。











  • 19
    点赞
  • 22
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值