本文主要是找工作时针对操作系统可能会碰到的一些问题
0.操作系统基础
1.什么是操作系统?
操作系统(Operating System, OS)是计算机系统中管理硬件和软件资源的中间层系统,屏蔽了硬件的复杂性,并且为用户提供了便捷的交互方式,比如说 Windows、Linux、MacOS 等。
2.操作系统有哪些功能?
- 进程和线程的管理:进程的创建、撤销、阻塞、唤醒,进程间的通信等。
- 存储管理:内存的分配和管理、外存(磁盘等)的分配和管理等。
- 文件管理:文件的读、写、创建及删除等。
- 设备管理:完成设备(输入输出设备和外部存储设备等)的请求或释放,以及设备启动等功能。
- 网络管理:操作系统负责管理计算机网络的使用。网络是计算机系统中连接不同计算机的方式,操作系统需要管理计算机网络的配置、连接、通信和安全等,以提供高效可靠的网络服务。
3.用户态和内核态
什么是用户态和内核态?
用户态(User Mode) : 用户态运行的进程可以直接读取用户程序的数据,拥有较低的权限。当应用程序需要执行某些需要特殊权限的操作,例如读写磁盘、网络通信等,就需要向操作系统发起系统调用请求,进入内核态。
**内核态(Kernel Mode)**:内核态运行的进程几乎可以访问计算机的任何资源包括系统的内存空间、设备、驱动程序等,不受限制,拥有非常高的权限。
简单说:二者的权限级别不同,这样做是为了安全性、稳定性、性能考虑。
用户态和内核态是如何切换的?
系统调用(Trap):用户态进程 主动 要求切换到内核态的一种方式。主要是为了使用内核态才能做的事情比如读取磁盘资源。系统调用的机制其核心还是使用了操作系统为用户特别开放的一个中断来实现。
中断(Interrupt):当外围设备完成用户请求的操作后,会向 CPU 发出相应的中断信号,这时 CPU 会暂停执行下一条即将要执行的指令转而去执行与中断信号对应的处理程序,如果先前执行的指令是用户态下的程序,那么这个转换的过程自然也就发生了由用户态到内核态的切换。比如硬盘读写操作完成,系统会切换到硬盘读写的中断处理程序中执行后续操作等。
异常(Exception):当 CPU 在执行运行在用户态下的程序时,发生了某些事先不可知的异常,这时会触发由当前运行进程切换到处理此异常的内核相关程序中,也就转到了内核态,比如缺页异常。
在系统的处理上,中断和异常类似,都是通过中断向量表来找到相应的处理程序进行处理。区别在于,中断来自处理器外部,不是由任何一条专门的指令造成,而异常是执行当前指令的结果。
4.系统调用
系统调用是应用程序与操作系统之间进行交互的一种方式,通过系统调用,应用程序可以访问操作系统底层资源例如文件、设备、网络等。
系统调用的过程
- 用户态的程序发起系统调用,因为系统调用中涉及一些特权指令(只能由操作系统内核态执行的指令),用户态程序权限不足,因此会中断执行,也就是 Trap(Trap 是一种中断)。
- 发生中断后,当前 CPU 执行的程序会中断,跳转到中断处理程序。内核程序开始执行,也就是开始处理系统调用。
- 内核处理完成后,主动触发 Trap,这样会再次发生中断,切换回用户态工作。
1.进程管理
这里附一个小插曲:
竞态条件:是指当多个线程同时访问和操作同一块数据时,最终结果依赖于线程的执行顺序, 这可能导致数据的不一致性。
1.并发和并行
- 并发(Concurrency)
并发就是在一段时间内,多个任务都会被处理;但在某一时刻,只有一个任务在执行。单核处理器做到的并发,其实是利用时间片的轮转
- 并行(Parallelism)
并行就是在同一时刻,有多个任务在执行。这个需要多核处理器才能完成 ,因为每个任务在不同的核心或处理器上独立执行。
并发更多的是处理“任务管理”,比如多任务的切换与调度。并行更多的是需要硬件的支持
2.进程上下文切换
上下文切换是操作系统在多任务处理环境中,将 CPU 从一个进程切换到另一个进程的过程。通过让多个进程共享 CPU 资源,使系统能够并发执行多个任务。
进程是由内核管理和调度的,所以进程的切换只能发生在内核态。
所以,进程的上下文切换不仅包含了虚拟内存、栈、全局变量等用户空间的资源, 还包括了内核堆栈、寄存器等内核空间的资源。
通常,会把交换的信息保存在进程的 PCB,当要运行另外一个进程的时候, 我们需要从这个进程的 PCB 取出上下文,然后恢复到 CPU 中
3.进程的状态
- **创建状态(new)**:进程正在被创建,尚未到就绪状态。
- **就绪状态(ready)**:进程已处于准备运行状态,即进程获得了除了处理器之外的一切所需资源,一旦得到处理器资源(处理器分配的时间片)即可运行。
- **运行状态(running)**:进程正在处理器上运行(单核 CPU 下任意时刻只有一个进程处于运行状态)。
- **阻塞状态(waiting)**:又称为等待状态,进程正在等待某一事件而暂停运行如等待某资源为可用或等待 IO 操作完成。即使处理器空闲,该进程也不能运行。
- **结束状态(terminated)**:进程正在从系统中消失。可能是进程正常结束或其他原因中断退出运行
4.什么是PCB?
PCB(Process Control Block) 即进程控制块,是操作系统中用来管理和跟踪进程的数据结构,每个进程都对应着一个独立的 PCB。
PCB 是进程存在的唯一标识 ,包含以下信息:
- 进程描述信息:
- 进程标识符:标识各个进程,每个进程都有一个并且唯一的标识符;
- 进程控制和管理信息:
- 进程当前状态,如 new、ready、running、waiting 或 blocked 等;
- 进程优先级:进程抢占 CPU 时的优先级;
- 资源分配清单:
- 有关内存地址空间或虚拟地址空间的信息,所打开文件的列表和所使用的 I/O 设备信息。
- CPU 相关信息:
- CPU 中各个寄存器的值,当进程被切换时,CPU 的状态信息都会被保存在相应的 PCB 中, 以便进程重新执行时,能从断点处继续执行。
5.僵尸进程/孤儿进程
僵尸进程
子进程已经终止,但是其父进程仍在运行,且父进程没有调用 wait()或 waitpid()等系统调用来获取子进程的状态信息,释放子进程占用的资源,导致子进程的 PCB 依然存在于系统中, 但无法被进一步使用。
这种情况下,子进程被称为“僵尸进程”。避免僵尸进程的产生,父进程需要及时调用 wait()
或 waitpid()
系统调用来回收子进程。
孤儿进程
一个进程的父进程已经终止或者不存在,但是该进程仍在运行。这种情况下,该进程就是孤儿进程。
孤儿进程通常是由于父进程意外终止或未及时调用 wait()或 waitpid()等系统调用来回收子进程导致的。
为了避免孤儿进程占用系统资源,操作系统会将孤儿进程的父进程设置为 init 进程(进程号为 1),由 init 进程来回收孤儿进程的资源。
6.进程调度算法
1.先来先服务
从就绪队列中选择一个最先进入该队列的进程为之分配资源 然后一直运行,直到进程退出或被阻塞,才会继续从队列中选择第一个进程接着运行。
当一个长作业先运行了,那么后面的短作业等待的时间就会很长,不利于短作业。 可能会导致较短的进程等待较长进程执行完成,从而产生“饥饿”现象
2.短作业优先
优先选择运行时间最短的进程来运行,这有助于提高系统的吞吐量。
对长作业不利,很容易造成一种极端现象。
3.时间片轮转
每个进程被分配一个时间段,称为时间片(Quantum),即允许该进程在该时间段中运行。 如果进程在时间片结束时还没有完成,它将被放回队列的末尾。
公平的
4.优先级调度
这种调度方式中,每个进程都被分配一个优先级。CPU 首先分配给优先级最高的进程。
该算法也有两种处理优先级高的方法,非抢占式和抢占式:
- 非抢占式:当就绪队列中出现优先级高的进程,运行完当前进程,再选择优先级高的进程。
- 抢占式:当就绪队列中出现优先级高的进程,当前进程挂起,调度优先级高的进程运行。
可能会导致低优先级的进程永远不会运行。
5.多级反馈队列
既能使高优先级的作业得到响应又能使短作业(进程)迅速完成
- 「多级」表示有多个队列,每个队列优先级从高到低,同时优先级越高时间片越短。
- 「反馈」表示如果有新的进程加入优先级高的队列时,立刻停止当前正在运行的进程, 转而去运行优先级高的队列;
工作流程:
- 设置了多个队列,赋予每个队列不同的优先级,每个队列优先级从高到低,同时优先级越高时间片越短(意思是允许它执行的时间越短);
- 新的进程会被放入到第一级队列的末尾,按先来先服务的原则排队等待被调度,如果在第一级队列规定的时间片没运行完成,则将其转入到第二级队列的末尾,以此类推,直至完成;
- 当较高优先级的队列为空,才调度较低优先级的队列中的进程运行。如果进程运行时,有新进程进入较高优先级的队列,则停止当前运行的进程并将其移入到原队列末尾,接着让较高优先级的进程运行;
可以发现,对于短作业可能可以在第一级队列很快被处理完。对于长作业,如果在第一级队列处理不完, 可以移入下次队列等待被执行,虽然等待的时间变长了,但是运行时间也会更长了, 所以该算法很好的兼顾了长短作业,同时有较好的响应时间。
7.进程间的通信方式
管道(匿名管道):用于具有亲缘关系的父子进程间或者兄弟进程之间的通信。
命名管道:允许无亲缘关系的进程通信,需要在文件系统创建一个类型为 p 的设备文件,那么毫无关系的进程就可以通过这个设备文件进行通信。
缺点:管道的效率低,而且只能单向通信,不适合进程间频繁地交换数据。
信号:用于通知接收进程某个事件已经发生;
消息队列:消息队列是保存在内核中的消息链表,按照消息的类型进行消息传递,具有较高的可靠性和稳定性。 缺点:消息体有一个最大长度的限制,不适合比较大的数据传输;存在用户态与内核态之间的数据拷贝开销。
共享内存:允许两个或多个进程共享一个给定的内存区,一个进程写⼊的东西,其他进程⻢上就能看到。
共享内存是最快的进程间通信方式,它是针对其他进程间通信方式运行效率低而专门设计的。缺点:当多进程竞争同一个共享资源时,会造成数据错乱的问题。
信号量:它本质上是一个计数器,表示的是资源个数,其值可以通过两个原子操作来控制,分别是 P 操作和 V 操作。 用来控制对共享资源的访问数量。它常作为一种锁机制,防止某进程正在访问共享资源时,其他进程也访问该资源。 信号量是一个计数器,用于多进程对共享数据的访问,信号量的意图在于进程间同步。这种通信方式主要用于解决与同步相关的问题并避免竞争条件。
套接字socket:不仅用于不同的主机进程间通信,还可以用于本地主机进程间通信, 可根据创建 Socket 的类型不同,分为三种常见的通信方式,一个是基于 TCP 协议的通信方式, 一个是基于 UDP 协议的通信方式,一个是本地进程间通信方式。
8.进程和线程的区别
进程:进程是程序在操作系统中的执行实例,是系统资源分配的基本单位。每个进程拥有自己的地址空间、数据段、堆栈、程序计数器等,进程间相互独立,通常不能直接共享内存。
特点:
- 进程之间相互独立,有各自的资源和内存空间。
- 进程创建、销毁的开销比较大。
- 进程切换时,操作系统需要保存和恢复上下文,因此开销较大。
线程:线程是进程中的一个执行单元,它是比进程更小的执行单位。一个进程可以包含多个线程, 它们共享进程的内存空间和资源(如堆,全局变量,静态变量,文件等公共资源),但拥有独立的栈和程序计数器。
特点:
- 线程之间共享进程的内存空间和资源,因此创建和销毁的开销较小。
- 线程切换的开销比进程小
- 由于多个线程共享内存空间,因此存在数据竞争和线程安全的问题,需要通过同步和互斥机制来解决。
协程:协程是一种用户态的轻量级线程,由程序员在代码中显式控制调度,而非操作系统。
特点:
- 协程切换的开销极小,比线程更轻量,因为不需要操作系统的干预。
- 协程通常在同一个线程中执行,因此它们共享线程的所有资源。
- 协程是非抢占式的调度(即协程必须显式让出控制权),避免了线程的切换和同步的复杂性。
适用场景
高隔离性任务——进程
高并发共享数据任务——线程
高并发 I/O 密集型任务——协程
9.线程间的通信方式(TODO)
也即线程间的同步方式,因为线程共享同一进程的内存空间,通信相对容易,但也需要同步机制来避免竞争条件。
互斥锁:只有拥有互斥对象的线程才有访问公共资源的权限。因为互斥对象只有一个,所以可以保证公共资源不会被多个线程同时访问。比如 Java 中的
synchronized
关键词和各种Lock
都是这种机制。读写锁:允许多个线程同时读取共享资源,但只有一个线程可以对共享资源进行写操作。
读写锁的工作原理是:当「写锁」没有被线程持有时,多个线程 能够并发地持有读锁,这大大提高了共享资源的访问效率,因为「读锁」是用于读取共享资源的场景, 所以多个线程同时持有读锁也不会破坏共享资源的数据。但是,一旦「写锁」被线程持有后,读线程的 获取读锁的操作会被阻塞,而且其他写线程的获取写锁的操作也会被阻塞。所以说,写锁是独占锁,因 为任何时刻只能有一个线程持有写锁,类似互斥锁和自旋锁,而读锁是共享锁,因为读锁可以被多个线 程同时持有。知道了读写锁的工作原理后,我们可以发现,读写锁在读多写少的场景,能发挥出优势。
信号量:它允许同一时刻多个线程访问同一资源,但是需要控制同一时刻访问此资源的最大线程数量。 通常信号量表示资源的数量 对应的变量是一个整型(sem)变量。 还有两个原子操作的系统调用函数来控制信号量的。分别是:P 操作:将 sem 减 1,相减 后,如果 sem < 0,则进程/线程进入阻塞等待,否则继续,表明 P 操作可能会阻塞; V 操作:将 sem 加 1,相加后,如果 sem <= 0,唤醒一个等待中的进程/线程,表明 V 操作不会阻塞;
10.线程上下文切换
得看线程是不是属于同⼀个进程:
- 当两个线程不是属于同⼀个进程,则切换的过程就跟进程上下⽂切换⼀样;
- 当两个线程是属于同⼀个进程,因为虚拟内存是共享的,所以在切换时,虚拟内存这些资源就保持不动,只需要切换线程的私有数据、寄存器等不共享的数据;
所以,线程的上下⽂切换相⽐进程,开销要⼩很多。
11.死锁
什么是死锁?
死锁(Deadlock) 是指两个或多个线程在执行过程中,由于竞争资源而导致的相互等待的状态,最终使得它们都无法继续执行,造成系统无法恢复的情况。简单来说,死锁发生时,线程永远处于等待状态,不会释放它们持有的资源,因此其他线程也无法继续执行
死锁产生有哪些条件?
产生死锁需要同时满足四个必要条件:
- 互斥:资源不能被多个进程共享,即资源一次只能被一个进程使用。如果一个资源已经被分配给了一个进程,其他进程必须等待,直到该资源被释放。
- 持有并等待:一个进程已经持有了至少一个资源,同时还在等待获取其他被占用的资源。在此期间,该进程不会释放已经持有的资源。
- 不可剥夺:已分配给进程的资源不能被强制剥夺,只有持有该资源的进程可以主动释放资源。
- 循环等待:有两个及以上的进程获取资源的顺序形成了环
如何避免死锁?
破坏产生死锁的条件之一就可以了
- 消除持有并等待:在线程开始时,确保它一次性请求并持有所有资源。如果不能一次性获取所有资源,线程就不会获得任何资源,而是等待其他线程释放资源。
- 消除不可剥夺:占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源
- 消除循环等待:在系统中对所有的资源进行排序,线程在获取资源时,必须按顺序请求资源。这样即使多个线程竞争资源,也不会形成循环等待。
活锁/饥饿锁
饥饿锁,这个饥饿指的是资源饥饿,某个线程一直等不到它所需要的资源,从而无法向前推进,就像一个人因为饥饿无法成长。
活锁:是指线程或进程虽然处于“活动”状态,但它们在不断地互相干扰,导致无法有效完成任务,系统始终处于忙碌状态,但没有任何实质性的进展。活锁通常是因为线程在执行过程中不停地尝试重新进入某个操作,但由于不断调整或重试,导致无法有效执行工作。
2.内存管理
操作系统的内存管理非常重要,主要负责下面这些事情:
- 内存的分配与回收:对进程所需的内存进行分配和释放,malloc 函数:申请内存,free 函数:释放内存。
- 地址转换:将程序中的虚拟地址转换成内存中的物理地址。
- 内存扩充:当系统没有足够的内存时,利用虚拟内存技术或自动覆盖技术,从逻辑上扩充内存。
- 内存映射:将一个文件直接映射到进程的进程空间中,这样可以通过内存指针用读写内存的办法直接存取文件内容,速度更快。
- 内存优化:通过调整内存分配策略和回收算法来优化内存使用效率。
- 内存安全:保证进程之间使用内存互不干扰,避免一些恶意程序通过修改内存来破坏系统的安全性。
1.什么是内存碎片?
通常分为两种
- 内部内存碎片:简单说就是已经分配的内存但没有用完,未用完的部分就是内存碎片
- 外部内存碎片: 简单说就是未分配的连续内存太小了,不能再用了,所以就是外部内存碎片
2.局部性原理
程序在执行时,倾向于频繁访问特定的数据和指令。局部性原理的核心思想是,程序通常不会随机地访问内存中的任何位置,而是有规律地集中访问某些区域。
时间局部性:如果程序访问了某个内存位置,那么它在不久的将来可能会再次访问该位置。换句话说,程序倾向于重复访问最近使用过的数据或指令。
- 优化方法:为了利用时间局部性,缓存系统会将最近使用的数据存储在高速缓存(如CPU缓存)中,这样当数据再次需要时,可以快速从缓存中获取,而不需要从较慢的内存或磁盘中读取。
空间局部性:如果程序访问了内存中的某个位置,它很可能会在不久的将来访问该位置附近的内存地址。即,程序倾向于集中访问内存中的相邻位置。
- 优化方法:为了利用空间局部性,操作系统和硬件会尽量将数据按照地址的顺序加载到缓存中,或者将连续的内存块存储在一起。
3.虚拟内存
操作系统设计了虚拟内存,每个进程都有自己的独立的虚拟内存,我们所写的程序不会直接与物理内打交道。
本质上来说它只是逻辑存在的,是一个假想出来的内存空间,主要作用是作为进程访问主存(物理内存)的桥梁并简化内存管理。
虚拟地址和物理地址
- 虚拟地址:是由程序使用的地址,程序通过虚拟地址访问内存。
- 物理地址:是计算机硬件(如RAM)中的实际内存地址。
优点:
- 扩展内存容量:虚拟内存允许程序使用的内存空间超过物理内存的大小。操作系统通过将不常用的页面存储到硬盘中,使得程序可以“认为”它有更大的内存。
- 内存隔离和保护:每个进程都有独立的虚拟地址空间,进程间的内存是隔离的,这提供了更好的内存保护和安全性。进程之间彼此隔离,一个进程中的代码无法更改正在由另一进程或操作系统使用的物理内存。 【补充】:还可以避免用户可以直接访问到物理地址
- 简化程序设计:程序员不需要关心物理内存的具体分配,虚拟内存使得每个程序可以拥有独立的内存空间,极大地简化了内存管理。
虚拟地址和物理地址的映射方式主要有
分页,分段,段页
4.分页机制
分页是把整个虚拟和物理内存空间切成一段段固定尺寸的大小。 虚拟地址与物理地址之间通过页表来映射,如下图:
在分页机制下,每个进程都会有一个对应的页表。
分页机制下的虚拟地址由两部分组成:
- 页号:通过虚拟页号可以从页表中取出对应的物理页号;
- 页内偏移量:物理页起始地址+页内偏移量=物理内存地址。
因为操作系统是可以同时运行非常多的进程的,那这不就意味着页表会非常的庞大。 为了减少页表的开销,操作系统引入了 多级页表 ,多级页表对应多个页表,每个页表与前一个页表相关联。
为了能够快速得到经常访问的数据,又引入了快表(TLB),利用了局部性原理。这样就是先访问快表,再访问页表了。
5.分段机制
程序是由若⼲个逻辑分段组成的,如可由代码分段、数据分段、栈段、堆段组成。不同的段是有不同的属性的,所以就⽤分段(Segmentation)的形式把这些段分离出来。
分段机制下的虚拟地址由两部分组成,段号和段内偏移量。
虚拟地址和物理地址通过段表映射,段表主要包括段号、段的界限
。
6.分页和分段的区别
- 段是信息的逻辑单位,它是根据用户的需要划分的,因此段对用户是可见的 ;页是信息的物理单位,是为了管理主存的方便而划分的,对用户是透明的。
- 段的大小不固定,有它所完成的功能决定;页的大小固定,由系统决定
7.页面置换算法
当 CPU 访问的页面不在物理内存时,便会产生一个缺页中断, 请求操作系统将所缺页从磁盘调入到物理内存。
如果物理内存中没有空闲的物理页面可用的话。操作系统就必须将物理内存中的一个物理页淘汰出去,这样就可以腾出空间来加载新的页面了。
用来选择淘汰哪一个物理页的规则叫做 页面置换算法 ,我们可以把页面置换算法看成是淘汰物物理页的规则。
当物理内存不足时,操作系统会将一部分不常用的页面从内存中换出(换出),并将需要的页面加载到内存中(换入)。这一过程称为页面置换。 页面置换算法的任务就是选择合适的页面进行置换,避免频繁的页面交换(也称为“页面抖动”),从而提高系统的效率。
最佳页面置换算法:选择在未来最长时间内不会被访问的页面进行置换
缺点:需要提前知道未来的页面访问模式,这在实际应用中是不可能实现的,因此它通常作为性能对比的基准,或者用来评估其他算法的效果。
先进先出算法:选择内存中最早进入的页面进行置换
缺点:FIFO并不考虑页面的实际使用频率和最近使用情况,因此可能会导致一些“反直觉”的效果(即置换掉一些未来会频繁使用的页面)。这也就是所谓的Belady异常(即增加物理内存时,页面错误反而增多)。
最近最久未使用的置换算法(LRU):选择最久未使用的页面进行置换。 该算法假设已经很久没有使用的页面很有可能在未来较长的一段时间内仍然不会被使用。
最不常用算法(LFU):选择使用频率最少的页面进行置换。 LFU需要维护每个页面的访问计数。当一个页面被访问时,它的计数器会增加;当内存需要置换时,选择访问次数最少的页面进行换出。
时钟页面置换算法:通过使用一个圆形链表和一个指针来模拟时钟的走动,决定哪个页面应当被置换。
实现:每个页面都有一个引用位(reference bit),在每次访问页面时,引用位被设置为1。当发生页面置换时,算法从指针指向的页面开始,如果该页面的引用位为0,则置换该页面;如果引用位为1,则将引用位清零,并将指针移向下一个页面。这样,Clock算法可以在近似LRU的同时减少开销。
3.文件系统
文件系统主要负责管理和组织计算机存储设备上的文件和目录,其功能包括以下几个方面:
- 存储管理:将文件数据存储到物理存储介质中,并且管理空间分配,以确保每个文件都有足够的空间存储,并避免文件之间发生冲突。
- 文件管理:文件的创建、删除、移动、重命名、压缩、加密、共享等等。
- 目录管理:目录的创建、删除、移动、重命名等等。
- 文件访问控制:管理不同用户或进程对文件的访问权限,以确保用户只能访问其被授权访问的文件,以保证文件的安全性和保密性。
1.硬链接和软链接的区别
在 Linux/类 Unix 系统上,文件链接(File Link)是一种特殊的文件类型,可以在文件系统中指向另一个文件。常见的文件链接类型有两种:
硬链接是多个目录项中的「索引节点」指向一个文件,也就是指向同一个 inode,但是 inode 是不可能跨越文件系统的,每个文件系统都有各自的 inode 数据结构和列表, 所以硬链接是不可用于跨文件系统的。由于多个目录项都是指向一个 inode, 那么只有删除文件的所有硬链接以及源文件时,系统才会彻底删除该文件。
软链接相当于重新创建一个文件,这个文件有独立的 inode, 但是这个文件的内容是另外一个文件的路径,所以访问软链接的时候, 实际上相当于访问到了另外一个文件,所以软链接是可以跨文件系统的, 甚至目标文件被删除了,链接文件还是在的,只不过指向的文件找不到了而已。
2.常见的磁盘调度算法
磁盘调度算法的目的很简单,就是为了提高磁盘的访问性能, 一般是通过优化磁盘的访问请求顺序来做到的。
- 先来先服务:按照请求到达磁盘调度器的顺序进行处理,先到达的请求的先被服务。
4.I/O
1.I/O模型
五种I/O模式
Java的IO模型是一个综合性的概念,既包括本地数据IO(文件读写等),也涵盖网络IO(Socket通信等)。它们的核心区别在于数据来源和底层实现,但共享相同的设计思想。
阻塞I/O——BIO
应用程序发出一个 read 调用,内核空间需要经历准备数据的几个阶段,准备好之后返回数据给应用程序。期间如果另一个应用程序也需要 read 调用,那么它必须等待;这就是阻塞。
非阻塞I/O——NIO
应用程序发起I/O操作后立即返回,不会被阻塞,但需要不断轮询或者使用。
特点:用户进程需要不断的主动询问kernel数据好了没有
第一个阶段:等待数据的时候是非阻塞的,第二个阶段:从内核拷贝数据到用户空间仍然是阻塞的
当用户进程发出 read
操作时,如果 kernel
中的数据还没有准备好,那么它并不会 block
用户进程,而是立刻返回一个 error
。从用户进程角度讲 ,它发起一个 read
操作后,并不需要等待,而是马上就得到了一个结果。用户进程判断结果是一个 error
时,它就知道数据还没有准备好,于是它可以再次发送 read
操作。一旦 kernel
中的数据准备好了,并且又再次收到了用户进程的 system call
,那么它马上就将数据拷贝到了用户内存,然后返回。
I/O多路复用
特点:一个线程能同时等待多个文件描述符,而这些文件描述符(套接字)其中的任意一个进入读就绪状态,select()函数就可以返回。
它的基本原理就是select,poll,epoll这个function会不断的轮询所负责的所有socket,当某个socket有数据到达了,就通知用户进程。
信号驱动I/O
信号驱动I0是与内核建立SIGI0的信号关联并设置回调,当内核有FD就绪时,会发出SIGIO信号通知用户,期间用户应用可以执行其它业务,而无需等待
缺点:
- 当有大量I0操作时,信号较多,SIGIO处理函数不能及时处理可能导致信号队列溢出
- 而且内核空间与用户空间的频繁信号交互性能也较低。
异步I/O——AIO
异步I0的整个过程都是非阻塞的,用户进程调用完异步API后就可以去做其它事情,内核等待数据就绪并拷贝到用户空间后才会递交信号,通知用户进程。
缺点:忙的忙死,闲的闲死。用户进程由于可以一直做其它业务,然而内核却要一直准备数据,可能导致内核承受不住。所以要想使用这种方式,一个是增加用户进程处理数据的复杂度,让用户进程没那么“闲”,另一个是对用户进程限流。
最后,再举几个不是很恰当的例子来说明这四个IO Model:
有A,B,C,D四个人在钓鱼:
A用的是最老式的鱼竿,所以呢,得一直守着,等到鱼上钩了再拉杆;
B的鱼竿有个功能,能够显示是否有鱼上钩,所以呢,B就和旁边的MM聊天,隔会再看看有没有鱼上钩,有的话就迅速拉杆;
C用的鱼竿和B差不多,但他想了一个好办法,就是同时放好几根鱼竿,然后守在旁边,一旦有显示说鱼上钩了,它就将对应的鱼竿拉起来;
D是个有钱人,干脆雇了一个人帮他钓鱼,一旦那个人把鱼钓上来了,就给D发个短信。
2.I/O多路复用
传统的 I/O 模型中,如果服务端需要支持多个客户端,我们可能要为每个客户端分配一个进程/线程。 引入多路复用之后,一个进程/线程维护多个 Socket,这个多路复用就是多个连接复用一个进程/线程。 它们允许单个线程同时监控多个 I/O 操作,是高并发服务器(如 Nginx、Redis)的核心技术。
1.select
select 实现多路复用的方式是,将已连接的 Socket(一个Socket连接对应一个文件描述符fd) 都放到一个文件描述符集合(fd_set),然后调用 select 函数将文件描述符集合拷贝到内核里,让内核来检查是否有网络事件产生,检查的方式很粗暴,就是通过遍历文件描述符集合的方式,当检查到有事件产生后,将此 Socket 标记为可读或可写,返回给用户空间的是就绪的事件个数,接着再把整个文件描述符集合拷贝回用户态里(实际上是覆盖原来的fd_set),然后用户态还需要再通过遍历的方法找到可读或可写的 Socket,然后再对其处理。
对于 select 这种方式,需要进行 2 次「遍历」文件描述符集合,一次是在内核态里, 一个次是在用户态里 ,而且还会发生 2 次「拷贝」文件描述符集合,先从用户空间传入内核空间,由内核修改后,再传出到用户空间中。
select 使用固定长度的 BitsMap,表示文件描述符集合, 所支持的文件描述符的个数是有限制的,默认最大值为 1024
缺点:
- 两次拷贝:先从用户空间传入内核空间,由内核修改后,再传出到用户空间中。
- 需要遍历才能得知就绪的fd
- 可传入的fd数量有限制
2.poll
流程基本和select类似
唯一不同的是,在传入fd的数量上有了突破,用户空间传入的是可自定义大小的pollfd
数组,然后传入内核空间后,用链表形式组织。所以说fd的数量理论上是没有限制的(但实际受限于查询效率的问题,不可能没有限制的)。但仍然没有解决select的问题,拷贝,遍历。
3.epoll
执行流程
epoll_create()
创建epoll实例,返回对应的句柄epfd。具体而言,这个实例包括一个红黑树和一个链表。红黑树用于记录要监听的fd,每个节点就是一个fd。而链表用于记录就绪的fdepoll_ctl()
将要监听的fd添加到红黑树中,当这个fd就绪的时候,内核通过回调函数将该fd添加到链表中epoll_wait()
等待fd就绪,调用该方法会在用户空间创建一个数组,用于接受就绪的fd
改进点
- 红黑树结构增加效率:基于红黑树保存要监听的fd,增删改查效率高
- 减少重复拷贝:每个fd只需要一次
epoll_ctl()
添加到红黑树,无需重复拷贝fd到内核空间(相对于select和poll) - 无需遍历就能得到就绪fd:内核将就绪的fd拷贝到用户空间的数组中,用户空间无需再次遍历,可以直接得到就绪的fd
3.epoll的边缘触发和水平触发
epoll事件的通知机制有两种模式,分别是:
水平触发(levelTrrigered, LT):当有fd就绪时,会重复通知多次,直至数据被处理完。是epoll的默认模式
边缘触发(edgeTrrigered, ET):当有fd就绪时,仅通知一次,如果数据没有被处理完就会丢失
LT模式的缺点:
- 频繁调用
epoll_wait()
会产生很多开销 - 可能会产生“惊群”现象,比如当前剩余没有被处理的数据仅仅一两个线程就能解决,但在这种模式下会唤醒所有线程处理数据,也就是会产生不必要的唤醒
ET模式如何保证数据可以被处理完毕?
采用非阻塞IO一次性把就绪fd处理完。不能采用阻塞IO,如果采用阻塞IO在处理完就绪的fd后会阻塞等待未就绪的fd到就绪的状态,而非阻塞IO在处理完就绪的fd后就会返回错误,代表就绪的fd已经处理完了,剩下的都是未就绪的fd了。
手动把未处理完的fd添加到epoll实例中。(类似手动挡的LT)
前置了解:在拷贝就绪fd到用户空间时,会在就绪链表中移除拷贝的fd,同时会检查当前通知模式时ET还是LT,如果是ET,会永久的移除拷贝的fd,如果是LT,则会检查就绪fd数据有没有被处理完毕,如果没有,会重新把移除的fd添加到链表中。
我们可以调用
epoll_ctl()
手动将未处理完的fd添加到epoll实例上,完了之后红黑树检测到fd就绪,就会重新给这些就绪的fd添加到链表上。
4.零拷贝
零拷贝(Zero-copy)是一种优化数据传输的技术,旨在减少或消除数据在用户态和内核态之间的拷贝操作,从而提高I/O性能。在传统的I/O操作中,数据通常需要从内核缓冲区拷贝到用户缓冲区,或者从用户缓冲区拷贝回内核缓冲区,这会带来额外的CPU和内存开销。零拷贝技术通过避免这些不必要的拷贝,显著提升数据传输的效率,尤其在高并发、大数据量的场景下效果尤为明显。
1.DMA技术
在DMA技术出现之前,IO过程如下:
可以看到,整个数据的传输过程,都要需要 CPU 亲自参与搬运数据的过程,而且这个过程,CPU 是不能做其他事情的。
简单的搬运几个字符数据那没问题,但是如果我们用千兆网卡或者硬盘传输大量数据的时候,都用 CPU来搬运的话,肯定忙不过来。
所以引出了DMA技术
什么是 DMA 技术?简单理解就是,在进行 I/O 设备和内存的数据传输的时候, 数据搬运的工作全部交给 DMA 控制器,而 CPU 不再参与任何与数据搬运相关的事情, 这样 CPU 就可以去处理别的事务。
使用DMA技术的IO流程如下:
2.传统的文件传输
1 | 1. 应用程序调用read(),触发DMA将磁盘数据拷贝到内核缓冲区 |
期间共发生了 4 次用户态与内核态的上下文切换, 因为发生了两次系统调用,一次是 read() ,一次是 write() ,每次系统调用都得先从用户态切换到内核态,等内核完成任务后, 每次系统调用都得先从用户态切换到内核态,等内核完成任务后, 再从内核态切换回用户态。
4次数据拷贝:2次CPU拷贝 + 2次DMA拷贝
所以,要想提高文件传输的性能,就需要减少「用户态与内核态的上下文切换」和「内存拷贝」的次数。
在前面我们知道了,传统的文件传输方式会历经4次数据拷贝,而且这里面,「从内核的读缓冲区拷贝到用户的缓冲区里,再从用户的缓冲区里拷贝到 socket 的缓冲区里」,这个过程是没有必要的。
因为文件传输的应用场景中,在用户空间我们并不会对数据「再加工」,所以数据实际上可以不用搬运到用户空间,因此用户的缓冲区是没有必要存在的,
3.零拷贝的实现
1.mmap + write(内存映射)
mmap替换read系统调用函数
1 | 1. DMA拷贝到内核缓冲区 |
特点:
- 减少1次CPU拷贝
- 仍然需要4次上下文切换
2.sendfile
在 Linux 内核版本 2.1 中,提供了一个专门发送文件的系统调用函数 sendfile()
,可以替代前面的 read()
和 write()
这两个系统调用 ,这样就可以减少一次系统调用,也就减少了 2 次上下文切换的开销。
该系统调用,可以直接把内核缓冲区里的数据拷贝到 socket 缓冲区里,不再拷贝到用户态, 这样就只有 2 次上下文切换,和 3 次数据拷贝。如下图:
- DMA拷贝到内核缓冲区
- CPU拷贝到Socket缓冲区
- DMA拷贝到网卡
如果网卡支持 SG-DMA 技术(和普通的 DMA 有所不同),可以进一步减少通过 CPU 把内核缓冲区里的数据拷贝到 socket 缓冲区的过程。
- DMA拷贝到内核缓冲区
- SG-DMA直接从内核缓冲区到网卡
这个零拷贝技术可以实现只需要 2 次上下文切换和数据拷贝次数,就可以完成文件的传输 ,而且 2 次的数据拷贝过程,都不需要通过 CPU,2 次都是由 DMA 来搬运。 相比传统文件传输的方式,减少了 2 次上下文切换和数据拷贝次数