计算机操作系统
概述
并发和并行
- 并发:**指的是在同一时间段内,宏观上,多个任务都在执行,但不一定是同时刻。**操作系统会通过在任务之间快速切换来实现并发,每个任务都会被分配一些处理器时间,然后被迅速地切换,以使得用户感觉它们在同时执行。这种方式通常用于提高系统的吞吐量和资源利用率,尤其是在多任务环境下。
- 并行:**指的是在同一时间段内,微观上,多个任务真正同时执行,每个任务都在不同的处理器核心上运行。**这种情况下,系统中会有多个处理器核心或者多个计算资源同时执行不同的任务,从而实现真正的并行计算。并行通常可以显著提高计算性能,特别是对于需要大量计算的任务。
共享
共享是指系统中的资源可以被多个并发进程共同使用。
有两种共享方式:互斥共享和同时共享。
互斥共享的资源称为临界资源,例如打印机等,在同一时刻只允许一个进程访问,需要用同步机制来实现互斥访问
虚拟
虚拟技术把一个物理实体转换为多个逻辑实体。
主要有两种虚拟技术:时(时间)分复用技术和空(空间)分复用技术。
多个进程能在同一个处理器上并发执行使用了时分复用技术,让每个进程轮流占用处理器,每次只执行一小个时间片并快速切换。
虚拟内存使用了空分复用技术,它将物理内存抽象为地址空间,每个进程都有各自的地址空间。地址空间的页被映射到物理内存,地址空间的页并不需要全部在物理内存中,当使用到一个没有在物理内存的页时,执行页面置换算法,将该页置换到内存中。(空分复用技术利用存储器的空闲空间分区域存放和运行其他的多道程序,以此来提高内存的利用率。)
异步
异步指进程不是一次性执行完毕,而是走走停停,以不可知的速度向前推进
- **异步案例:**发送电子邮件。当一个用户发送电子邮件时,邮件客户端会将邮件发送到邮件服务器,然后立即返回,让用户继续执行其他任务。邮件服务器在收到邮件后,会自行处理发送邮件的操作,而不需要用户等待。这是一个典型的异步过程。
- 实现异步的方法有很多,这里列举几种常见的方法:
- 回调函数(Callback):在调用异步操作时,传入一个回调函数。当异步操作完成时,系统会自动调用回调函数来处理结果。这种方法将异步操作的结果处理与主线程逻辑分离,使主线程可以继续执行其他任务。
- 协程/轻量级线程:协程(Coroutine)是一种轻量级的线程,可以在用户态实现多任务调度。协程之间可以通过 yield 关键字或 async/await 语法实现异步调度。协程相较于多线程/多进程,具有更低的资源消耗和更高的调度效率。
操作系统基本功能
1. 进程管理
- 进程控制
- 进程同步
- 进程通信
- 死锁处理
- 处理机调度
2. 内存管理
- 内存分配
- 地址映射
- 内存保护与共享
- 虚拟内存
3. 文件管理
- 文件存储空间的管理
- 目录管理
- 文件读写管理和保护
4. 设备管理
- 缓冲管理
- 设备分配
- 设备处理
- 虚拟设备
系统调用
如果一个进程在用户态需要使用内核态的功能,就进行系统调用从而陷入内核,由操作系统代为完成。
系统调用通过中断完成。
以数据读写为例:
- 读数据:操作系统将设备描述符读取到内核空间,读取完数据后写入用户空间
- 写数据:将数据从用户空间读取到内核空间,然后写入设备描述符
宏内核与微内核
1. 宏内核
宏内核将操作系统功能作为一个紧密结合的整体放到内核中。各模块共享信息,因此具有很高的性能。
2. 微内核
微内核是为了降低内核的复杂性而将一部分操作系统功能移出内核的结构。这些功能根据分层原则划分成若干服务,相互独立。
在微内核结构下,操作系统被划分成小的、定义良好的模块,只有微内核这一个模块运行在内核态,其余模块运行在用户态。由于需要频繁地在用户态和核心态之间进行切换,会有一定的性能损失。
3. 混合内核
混合内核,是宏内核和微内核的结合体,内核中抽象出了微内核的概念,也就是内核中会有一个小型的内核,其他模块就在这个基础上搭建,整个内核是个完整的程序;
- linux是宏内核
- Window 的内核设计是混合型内核
中断分类
1. 外中断
由 CPU 执行指令以外的事件引起,如:
- I/O 完成中断:表示设备输入/输出处理已经完成,处理器能够发送下一个输入/输出请求。
- 时钟中断
- 控制台中断等。
2. 异常
由 CPU 执行指令的内部事件引起,如:
- 非法操作码
- 地址越界
- 算术溢出等。
3. 陷入
在用户程序中使用系统调用。
陷入和中断,陷入是用户主动发起的,而中断是被动的,此外,中断是异步事件,而陷入是同步事件。
进程管理
进程与线程
进程
进程是资源分配的基本单位。
进程控制块 (Process Control Block, PCB) 描述进程的基本信息和运行状态,所谓的创建进程和撤销进程,都是指对 PCB 的操作。
线程
线程是独立调度的基本单位。
一个进程中可以有多个线程,它们共享进程资源。
区别
Ⅰ 拥有资源
进程是资源分配的基本单位,但是线程不拥有资源,线程可以访问隶属进程的资源。
Ⅱ 调度
线程是独立调度的基本单位,在同一进程中,线程的切换不会引起进程切换,从一个进程中的线程切换到另一个进程中的线程时,会引起进程切换。
Ⅲ 系统开销
由于创建或撤销进程时,系统都要为之分配或回收资源,如内存空间、I/O 设备等,所付出的开销远大于创建或撤销线程时的开销。类似地,在进行进程切换时,涉及当前执行进程 CPU 环境的保存及新调度进程 CPU 环境的设置,而线程切换时只需保存和设置少量寄存器内容,开销很小。
进程、线程、协程的切换开销主要体现在以下几个方面:
- 进程切换:进程切换的开销是最大的。因为每个进程都有自己独立的地址空间,所以在进行进程切换时,操作系统需要保存和恢复大量的上下文信息,包括CPU寄存器、内存映射、全局变量等。此外,进程切换还需要进行TLB(Translation Lookaside Buffer)的刷新,这会增加额外的开销。
- 线程切换:线程切换的开销小于进程切换。因为同一进程下的线程共享地址空间和全局变量,所以在进行线程切换时,操作系统只需要保存和恢复少量的上下文信息,如CPU寄存器、栈指针等。但是,线程切换仍然需要操作系统的介入,所以开销仍然较大。
- 协程切换:协程切换的开销最小。因为协程是在用户空间进行调度的,所以在进行协程切换时,不需要操作系统的介入,不需要用户态和内核态的切换,只需要保存和恢复少量的上下文信息,如栈指针、协程状态等。因此,协程切换的开销非常小
Ⅳ 通信方面
线程间可以通过直接读写同一进程中的数据进行通信,但是进程通信需要借助 IPC。(Inter-Process Communication,进程间通信)是一种机制,
进程状态的切换
- 就绪状态(ready):等待被调度,资源有了,等着分配CPU运行时间
- 运行状态(running)
- 阻塞状态(waiting):等待资源
应该注意以下内容:
- 只有就绪态和运行态可以相互转换,其它的都是单向转换。就绪状态的进程通过调度算法从而获得 CPU 时间,转为运行状态;而运行状态的进程,在分配给它的 CPU 时间片用完之后就会转为就绪状态,等待下一次调度。
- 阻塞状态是缺少需要的资源从而由运行状态转换而来,但是该资源不包括 CPU 时间,缺少 CPU 时间会从运行态转换为就绪态。
进程调度与切换
进程调度算法根据不同环境有不同的目标和方法。
批处理系统
这种系统不涉及用户交互,主要目标是保证吞吐量和周转时间。
-
先来先服务(FCFS) 按照请求的顺序调度,适合长作业,但可能会导致短作业等待时间过长。
-
短作业优先(SJF) 按估计运行时间最短的顺序调度,但可能导致长作业饿死。
交互式系统
这种系统需要快速响应用户交互。
-
时间片轮转 按FCFS原则排队,每次分配一个时间片给队首进程,但时间片大小影响效率。
-
优先级调度 为每个进程分配优先级,按照优先级调度,可动态调整优先级以避免低优先级进程长时间等待。
-
多级反馈队列 设置多个队列,每个队列时间片大小不同(先小时间片队列,再大时间片队列),进程在队列间移动,可减少进程切换次数。
实时系统
实时系统要求一个请求在一个确定时间内得到响应。
分为硬实时和软实时,前者必须满足绝对的截止时间,后者可以容忍一定的超时
进程同步
临界区
对临界资源进行访问的那段代码称为临界区,(即资源占用的那部分)。为了互斥访问临界资源,每个进程在进入临界区之前,需要先进行检查。
|
|
同步与互斥
同步:多个进程因为合作产生的直接制约关系,使得进程有一定的先后执行关系。 互斥:多个进程在同一时刻只有一个进程能进入临界区。
信号量
信号量(Semaphore)是一个整型变量,可以对其执行 down 和 up 操作,也就是常见的 P 和 V 操作。
|
|
down 和 up 操作需要被设计成原语,不可分割,通常的做法是在执行这些操作的时候屏蔽中断。
如果信号量的取值只能为 0 或者 1,那么就成为了 互斥量(Mutex) ,0 表示临界区已经加锁,1 表示临界区解锁。
|
|
管程
使用信号量机制实现的生产者消费者问题需要客户端代码做很多控制,而管程把控制的代码独立出来,不仅不容易出错,也使得客户端代码调用更容易。管程(Monitor):解决信号量在临界区的 PV 操作上的配对的麻烦,把配对的 PV 操作集中在一起,生成的一种并发编程方法。其中使用了条件变量这种同步机制。
管程有一个重要特性:在一个时刻只能有一个进程使用管程。进程在无法继续执行的时候不能一直占用管程,否则其它进程永远不能使用管程。
管程引入了 条件变量 以及相关的操作:wait() 和 signal() 来实现同步操作。对条件变量执行 wait() 操作会导致调用进程阻塞,把管程让出来给另一个进程持有。signal() 操作用于唤醒被阻塞的进程。
PV操作是用于操作信号量的原语,用于底层的资源管理和同步。而管程中的wait和signal操作是高级别的同步机制,封装了对共享数据的访问和条件变量的等待/唤醒操作,更适用于实现复杂的同步和通信。
进程通信(IPC)
进程同步与进程通信很容易混淆,它们的区别在于:
- 进程同步:控制多个进程按一定顺序执行;
- 进程通信:进程间传输信息。
进程通信是一种手段,而进程同步是一种目的。也可以说,为了能够达到进程同步的目的,需要让进程进行通信,传输一些进程同步所需要的信息。
进程通信(IPC) 是多个进程之间传输信息的手段,而进程同步是控制多个进程按一定顺序执行的目的。
1. 管道
- 半双工通信
- 只能在父子进程或兄弟进程中使用
2. FIFO(命名管道)
- 去除了管道的父子进程限制
- 常用于客户-服务器应用程序
- 命名管道:存在于实际的磁盘介质或者文件系统
3. 消息队列
- 独立于读写进程存在
- 避免了同步阻塞问题
- 可以选择性地接收消息
- 消息队列存放在内核中,只有在内核重启(即,操作系统重启)或者显式地删除一个消息队列时,该消息队列才会被真正的删除
4. 信号量
- 用于为多个进程提供对共享数据对象的访问控制
5. 共享存储
- 多个进程共享一个存储区
- 速度快,但需要信号量来同步访问
6. 套接字
- 可用于不同机器间的进程通信
总而言之,大部分都是通过内核缓冲区来进行通信,还有存储空间,网络socket
线程通信
线程同步的方式:
- 互斥锁(Mutex):采用互斥对象机制,只有拥有互斥对象的线程才有访问公共资源的权限。因为互斥对象只有一个,所以可以保证公共资源不会被多个线程同时访问。比如 Java 中的
synchronized
关键词和各种Lock
都是这种机制。 - 读写锁(Read-Write Lock):允许多个线程同时读取共享资源,但只有一个线程可以对共享资源进行写操作。
- 信号量(Semaphore):它允许同一时刻多个线程访问同一资源,但是需要控制同一时刻访问此资源的最大线程数量。
- 屏障(Barrier):屏障是一种同步原语,用于等待多个线程到达某个点再一起继续执行。当一个线程到达屏障时,它会停止执行并等待其他线程到达屏障,直到所有线程都到达屏障后,它们才会一起继续执行。比如 Java 中的
CyclicBarrier
是这种机制。 - 事件(Event) :Wait/Notify:通过通知操作的方式来保持多线程同步,还可以方便的实现多线程优先级的比较
- java里面还可以通过对象共享
死锁
必要条件
- 互斥:每个资源要么已经分配给了一个进程,要么就是可用的。
- 占有和等待:已经得到了某个资源的进程可以再请求新的资源。
- 不可抢占:已经分配给一个进程的资源不能强制性地被抢占,它只能被占有它的进程显式地释放。
- 环路等待:有两个或者两个以上的进程组成一条环路,该环路中的每个进程都在等待下一个进程所占有的资源。
死锁代码
|
|
处理办法
1. 鸵鸟策略:
- 忽略死锁,即不采取任何措施来解决或避免死锁,而是等待死锁自行解除。
2. 死锁检测与死锁恢复:
- 周期性地检测系统中是否存在死锁,一旦检测到死锁,系统会采取措施来恢复正常运行,通常是终止一个或多个死锁进程,释放资源。
3. 死锁预防:
在程序运行之前预防发生死锁。
-
破坏互斥条件:例如假脱机打印机技术允许若干个进程同时输出,唯一真正请求物理打印机的进程是打印机守护进程。
-
破坏占有和等待条件:一种实现方式是规定所有进程在开始执行前请求所需要的全部资源。
-
破坏不可抢占条件
-
破坏环路等待:给资源统一编号,进程只能按编号顺序来请求资源。
4. 死锁避免:
- 在资源分配过程中采取预防措施,通过动态地分配资源来避免系统进入死锁状态,通常需要使用一些算法和策略来实现。
银行家算法
安全序列(决定了是否是安全状态):
流程
样例
计算:Need = Max - Allocation
available > need(并存在安全序列)
互斥锁与自旋锁
开发过程中,最常见的就是互斥锁的了,互斥锁加锁失败时,会用「线程切换」来应对,当加锁失败的线程再次加锁成功后的这一过程,会有两次线程上下文切换的成本,性能损耗比较大。
如果我们明确知道被锁住的代码的执行时间很短,那我们应该选择开销比较小的自旋锁,因为自旋锁加锁失败时,并不会主动产生线程切换,而是一直忙等待,直到获取到锁,那么如果被锁住的代码执行时间很短,那这个忙等待的时间相对应也很短。
线程崩溃导致进程崩溃
正常情况下,操作系统为了保证系统安全,所以针对非法内存访问会发送一个 SIGSEGV 信号,而操作系统一般会调用默认的信号处理函数(一般会让相关的进程崩溃)。
但如果进程觉得"罪不致死",那么它也可以选择自定义一个信号处理函数(让进程不崩溃),这样的话它就可以做一些自定义的逻辑,比如记录 crash 信息等有意义的事。
内存管理
进程的内存结构
- 入栈地址往低走
- 堆新建对象地址往高走
堆与栈
- 栈(Stack):(从高地址向低地址延伸)
- 栈是一种后进先出(LIFO)的数据结构,用于存储局部变量和函数调用时的临时数据。
- 栈内存分配和回收由操作系统自动管理,速度较快。
- 栈空间有限,当程序需要大量内存时,栈可能会溢出。
- 栈中的数据在函数调用结束后会被自动清除,因此不适用于需要在函数调用之间持久保存的数据。
- 堆(Heap):
- 堆是一种动态分配内存的区域,用于存储程序运行过程中动态创建的对象。
- 堆内存分配和回收由程序员手动管理(或通过垃圾回收机制自动管理,如在 Python 中),速度相对较慢。
- 堆空间较大,适用于存储大量数据或需要在函数调用之间持久保存的数据。
- 由于堆内存分配和回收需要手动管理,可能会导致内存泄漏等问题。
虚拟内存
虚拟内存的目的是为了让物理内存扩充成更大的逻辑内存,从而让程序获得更多的可用内存。
(说白了就是把硬盘利用换页虚拟成内存,使得内存在不太损失性能的情况下,得到逻辑上的扩展,用时间换空间)
程序使用内存逻辑:为了更好的管理内存,操作系统将内存抽象成地址空间。每个程序拥有自己的地址空间,这个地址空间被分割成多个块,每一块称为一页。这些页被映射到物理内存,但不需要映射到连续的物理内存,也不需要所有页都必须在物理内存中。当程序引用到不在物理内存中的页时,由硬件执行必要的映射,将缺失的部分装入物理内存并重新执行失败的指令。
好处
- 更大的逻辑内存
- 更安全的操作
虚拟内存与进程
每个进程都有虚拟地址空间,那么每个进程就都有个页表用来映射地址。
分页系统地址映射
内存管理单元(MMU)管理着地址空间和物理内存的转换,其中的页表(Page table)存储着页(程序地址空间)和页框(物理内存空间)的映射表。
一个虚拟地址分成两个部分,一部分存储页面号,一部分存储偏移量。(相当于第多少页,多少行)
分页:
具体的地址翻译过程如下:
- MMU 首先解析得到虚拟地址中的虚拟页号;
- 通过虚拟页号去该应用程序的页表中取出对应的物理页号(找到对应的页表项);
- 用该物理页号对应的物理页起始地址(物理地址)加上虚拟地址中的页内偏移量得到最终的物理地址。
多级页表
为什么需要多级页表:
简单分页产生的页表过大的问题,就有了多级页表,
快表(相当于给页表加了个缓存)
了提高虚拟地址到物理地址的转换速度,操作系统在 页表方案 基础之上引入了 转址旁路缓存(Translation Lookaside Buffer,TLB,也被称为快表) 。
页面置换算法(三最,一时钟FIFO)
在程序运行过程中,如果要访问的页面不在内存中,就发生缺页中断从而将该页调入内存中。此时如果内存已无空闲空间,系统必须从内存中调出一个页面到磁盘对换区中来腾出空间。
页面置换算法的主要目标是使页面置换频率最低(也可以说缺页率最低)。
这段文本总结了几种常见的页面置换算法,其目标是减少页面置换频率,从而降低缺页率。以下是对每种算法的简要概述:(三最,一时钟FIFO)
-
最佳 (OPT):
- 选择被换出的页面是在未来最长时间内不会被访问的页面。
- 算法理论上最优,但无法实现,因为无法预测未来页面访问模式。
-
最近最久未使用 (LRU):
- 根据最近页面访问情况,选择最久未被使用的页面进行置换。
- 实现上需要维护一个访问链表,代价较高。
-
最近少使用 (NRU):
- 每个页面有两个状态位:R(最近被访问)和M(最近被修改)。
- 根据页面的R和M位将页面分成不同类别,并随机选择一个非空类别中的页面进行置换。
-
先进先出 (FIFO):
- 选择最早被加入内存的页面进行置换。
- 可能会置换出经常被访问的页面,导致较高的缺页率。
-
第二次机会算法:
- 在FIFO算法的基础上,通过设置R位来给予某些页面第二次机会。
- 当页面需要被替换时,如果该页面的R位为1,则将其移到链表尾端,并将R位清零。
-
时钟算法:
- 使用一个环形链表来存储页面,并使用一个指针指向最老的页面。
- 当页面需要被替换时,时钟指针向前移动,并检查指向的页面的R位。
磁盘调度算法
重点:FCFS,最短寻道(找最近),两扫描(一单向,一电梯),
LOOK和C-LOOK
LOOK和C-LOOK 是电梯算法的改进版本,它们不会在到达磁盘末端时立即返回,而是根据需要调整方向。这可以减少一些请求的等待时间,提高了效率。
LOOK
分段
虚拟内存采用的是分页技术,也就是将地址空间划分成固定大小的页,每一页再与内存进行映射。
- 单元大小:
- 分页:内存被划分为固定大小的页,通常大小为 4KB 或 4MB。
- 分段:内存被划分为不同大小的段,每个段可以有不同的大小,适应不同大小的逻辑单位。
- 逻辑结构映射:
- 分页:逻辑地址被划分成固定大小的页,页内的逻辑结构可能被打破。
- 分段:逻辑地址空间被划分成不同大小的段,每个段可以包含一个逻辑单位,比如代码段、数据段等,更好地反映程序的逻辑结构。
- 碎片问题:
- 分页:可能存在内部碎片,即一页中可能会有未被完全利用的空间。
- 分段:可能存在外部碎片,即分段之间的空闲空间无法被利用。
段页式(先分段,再分页)
程序的地址空间划分成多个拥有独立地址空间的段,每个段上的地址空间划分成大小相同的页。这样既拥有分段系统的共享和保护,又拥有分页系统的虚拟内存功能。
- 分段机制容易出现外部内存碎片,即在段与段之间留下碎片空间(不足以映射给虚拟地址空间中的段)。分页机制解决了外部内存碎片的问题,但仍然可能会出现内部内存碎片(分页)。
局部性原理
局部性原理的作用体现在两个方面:
-
时间局部性:由于程序中存在一定的循环或者重复操作,因此会反复访问同一个页或一些特定的页,这就体现了时间局部性的特点。为了利用时间局部性,分页机制中通常采用缓存机制来提高页面的命中率,即将最近访问过的一些页放入缓存中,如果下一次访问的页已经在缓存中,就不需要再次访问内存,而是直接从缓存中读取。
-
空间局部性:由于程序中数据和指令的访问通常是具有一定的空间连续性的,因此当访问某个页时,往往会顺带访问其相邻的一些页。为了利用空间局部性,分页机制中通常采用预取技术来预先将相邻的一些页读入内存缓存中,以便在未来访问时能够直接使用,从而提高访问速度。总之,局部性原理是计算机体系结构设计的重要原则之一,也是许多优化算法的基础。在分页机制中,利用时间局部性和空间局部性,采用缓存和预取技术,可以提高页面的命中率,从而提高内存访问效率
什么是交换空间?
操作系统把物理内存(Physical RAM)分成一块一块的小内存,每一块内存被称为页(page)。当内存资源不足时,Linux 把某些页的内容转移至磁盘上的一块空间上,以释放内存空间。磁盘上的那块空间叫做交换空间(swap space),而这一过程被称为交换(swapping)。物理内存和交换空间的总容量就是虚拟内存的可用容量。
用途:
- 物理内存不足时一些不常用的页可以被交换出去,腾给系统。
- 程序启动时很多内存页被用来初始化,之后便不再需要,可以交换出去。
其他题目
孤儿进程与僵尸进程[总结]
**孤儿进程(没有爸妈就是孤儿):一个父进程退出,而它的一个或多个子进程还在运行,那么那些子进程将成为孤儿进程。孤儿进程将被init进程(进程号为1)所收养,并由init进程对它们完成状态收集工作。**因此孤儿进程并不会有什么危害。
**僵尸进程(g了,但是没g透。子进程g了,但是父进程没回收):一个进程使用fork创建子进程,如果子进程退出,而父进程并没有调用wait或waitpid获取子进程的状态信息,那么子进程的进程描述符仍然保存在系统中。这种进程称之为僵死进程。**当子进程变为僵尸进程时,其占用的大部分资源会被释放,包括内存、CPU 时间等。然而,一些系统资源,例如进程号等,仍将由内核保留,直到父进程通过调用wait()或waitpid()等函数来回收该僵尸进程。一旦父进程成功回收了僵尸进程,系统会立即释放所占用的所有资源。
线程与协程
线程和协程都可以用于实现并发编程,但它们之间有几点关键区别:
- 调度方式不同:
- 线程由操作系统进行调度,涉及上下文切换需要内核参与,开销相对较大。
- 协程是由程序员在代码中显式控制的,不涉及内核态和用户态的切换,因此通常更加轻量级。
- 并行性:
- 线程在多核处理器上可以并行执行,每个线程都独立运行。
- 协程是在单线程内部执行,通过在适当的时机手动切换,实现看似同时执行多个任务的效果。
- 共享状态:
- 线程之间共享内存,因此需要考虑同步和互斥来避免竞态条件。
- 协程一般基于消息传递或者协作式的方式,更容易避免共享状态带来的问题。
- 扩展性:
- 随着线程数量增多,管理和调度会变得复杂,容易出现死锁、竞态等问题。
- 协程可以轻松创建数千甚至数万个而不会导致问题,因为它们只是在单线程内切换,不存在并发问题。
总体而言,线程更适合多CPU密集型任务,而协程更适合IO密集型任务,因为它们能更好地管理并发、提高效率
单个协程本身无法利用多核,因为它们仍然在单个线程内执行。但是可以通过以下方式结合协程和多核处理器来充分利用多核资源:
- 多进程 + 多协程:在多个进程中各自运行独立的事件循环,每个事件循环内部使用协程进行任务调度,以此实现多核利用。
- 使用异步IO库:像
asyncio
、gevent
等库提供了事件循环机制,允许在单线程内使用协程执行IO密集型任务。即使是单线程,这些库可以利用操作系统底层的多线程或者多进程模型实现并发。 - 分布式计算:将协程部署到不同的物理机器上,利用网络通信完成协程之间的协作,从而实现分布式并发计算。
I/O 多路复用:select/poll/epoll(底层针对socket)
每个请求分配一个进程/线程的方式不合适,那有没有可能只使用一个进程来维护多个 Socket 呢?答案是有的,那就是 I/O 多路复用技术。
select 、poll
select 实现多路复用的方式是,将已连接的 Socket 都放到一个文件描述符集合,然后调用 select 函数将文件描述符集合拷贝到内核里,让内核来检查是否有网络事件产生,检查的方式很粗暴,就是通过遍历文件描述符集合的方式,当检查到有事件产生后,将此 Socket 标记为可读或可写, 接着再把整个文件描述符集合****拷贝回用户态里,然后用户态还需要再通过遍历的方法找到可读或可写的 Socket,然后再对其处理。
但是 poll 和 select 并没有太大的本质区别,都是使用「线性结构」存储进程关注的 Socket 集合,因此都需要遍历文件描述符集合来找到可读或可写的 Socket,时间复杂度为 O(n),而且也需要在用户态与内核态之间拷贝文件描述符集合,这种方式随着并发数上来,性能的损耗会呈指数级增长。
epoll(红黑树+就绪链表,回调函数)
第一点,epoll 在内核里使用红黑树来跟踪进程所有待检测的文件描述字,把需要监控的 socket 通过 epoll_ctl()
函数加入内核中的红黑树里
第二点, epoll 使用事件驱动的机制,内核里维护了一个链表来记录就绪事件,当某个 socket 有事件发生时,通过回调函数内核会将其加入到这个就绪事件列表中,当用户调用 epoll_wait()
函数时,只会返回有事件发生的文件描述符的个数,不需要像 select/poll 那样轮询扫描整个 socket 集合,大大提高了检测的效率。
epoll 的方式即使监听的 Socket 数量越多的时候,效率不会大幅度降低,能够同时监听的 Socket 的数目也非常的多了,上限就为系统定义的进程打开的最大文件描述符个数。
epoll 支持两种事件触发模式,分别是边缘触发(*edge-triggered,ET*)**和**水平触发(*level-triggered,LT*)。
select/poll只支持水平触发,一般而言,边缘触发的方式会比水平触发的效率高。
Linux 一切皆文件的理念
是 Unix 和类 Unix 系统的设计哲学之一,它反映了系统中的许多资源和设备都被抽象为文件,并通过文件系统进行管理和访问。这个理念的核心思想是将不同类型的资源统一抽象为文件,使得对这些资源的访问和操作变得简单和统一。
- 普通文件:普通文件是最基本的文件类型,包括文本文件、二进制文件等。用户可以对普通文件进行读取、写入、执行等操作。
- 目录文件:目录文件用于组织和管理其他文件和目录,用户可以在目录中创建、删除、移动文件和目录。
- 设备文件:设备文件是用来访问硬件设备的文件,包括硬盘、键盘、鼠标、打印机等。在 Linux 中,设备文件通常位于
/dev
目录下。 - 管道文件:管道文件用于进程间通信,允许一个进程的输出作为另一个进程的输入。在 Linux 中,管道文件通过命令行中的竖线符号
|
实现。 - 套接字文件:套接字文件用于进程间的网络通信,允许不同主机上的进程进行数据交换。在 Linux 中,套接字文件通常位于
/tmp
目录下。 - 符号链接文件:符号链接文件是指向另一个文件或目录的引用,用于创建文件和目录之间的软链接。用户可以通过符号链接文件快速访问其他文件或目录。
键盘敲入 A 字母时,操作系统期间发生了什么?
那当用户输入了键盘字符,键盘控制器就会产生扫描码数据,并将其缓冲在键盘控制器的寄存器中,紧接着键盘控制器通过总线给 CPU 发送中断请求。
CPU 收到中断请求后,操作系统会保存被中断进程的 CPU 上下文,然后调用键盘的中断处理程序。
键盘的中断处理程序是在键盘驱动程序初始化时注册的,那键盘中断处理函数的功能就是从键盘控制器的寄存器的缓冲区读取扫描码,再根据扫描码找到用户在键盘输入的字符,如果输入的字符是显示字符,那就会把扫描码翻译成对应显示字符的 ASCII 码,比如用户在键盘输入的是字母 A,是显示字符,于是就会把扫描码翻译成 A 字符的 ASCII 码。
得到了显示字符的 ASCII 码后,就会把 ASCII 码放到「读缓冲区队列」,接下来就是要把显示字符显示屏幕了,显示设备的驱动程序会定时从「读缓冲区队列」读取数据放到「写缓冲区队列」,最后把「写缓冲区队列」的数据一个一个写入到显示设备的控制器的寄存器中的数据缓冲区,最后将这些数据显示在屏幕里。
显示出结果后,恢复被中断进程的上下文。
零拷贝技术
**传统 IO 的工作方式,从硬盘读取数据,然后再通过网卡向外发送,我们需要进行 4 上下文切换,和 4 次数据拷贝,**其中 2 次数据拷贝发生在内存里的缓冲区和对应的硬件设备之间,这个是由 DMA 完成,另外 2 次则发生在内核态和用户态之间,这个数据搬移工作是由 CPU 完成的。
为了提高文件传输的性能,于是就出现了零拷贝技术,它通过一次系统调用(sendfile
方法)合并了磁盘读取与网络发送两个操作,降低了上下文切换次数。另外,拷贝数据都是发生在内核中的,天然就降低了数据拷贝的次数。
Kafka 和 Nginx 都有实现零拷贝技术,这将大大提高文件传输的性能。