OS八股文
操作系统基础
用户态和内核态
操作系统有两种状态:用户态和内核态;操作系统需要让应用程序运行,但又需要防止应用程序随便修改内存 / 杀死进程等,因此区分了两种状态,目的是保护操作系统的安全
用户态:
用户应用程序运行的状态。用户态的权限受限,不能直接操作硬件,也不能直接访问其他进程的内存和操作系统的内存
内核态:
操作系统内核运行的状态。内核态拥有最高权限,可以操作硬件,访问所有内存,可以进行进程调度,内存管理等
系统调用
系统调用是指用户应用程序请求调用操作系统内核的接口
典型的系统调用有:
| 功能 | 接口 |
|---|---|
| 文件读写 | read / write |
| 网络通信 | socket |
| 进程创建 | fork |
| 内存申请 | mmap |
系统调用为什么慢?
- 需要进行用户态 / 内核态的切换
- CPU上下文切换
- 权限切换
中断与异常
中断和异常都是CPU响应特殊事件的机制,会打断CPU处理当前程序的流程,跳转到对应的中断 / 异常处理程序
- 中断:来自CPU外部的异步事件,如网卡收到数据包、键盘输入、磁盘完成IO
- 异常:来自CPU内部的同步事件,CPU执行命令发生错误
中断 / 异常处理流程:
1 | CPU暂停当前程序 |
什么是原语?
操作系统提供的原子性操作,在执行过程中不会被进程调度和其他并发流打断,满足原子性和一致性,通常运行在内核态
什么是软中断?
一般指Linux中的softirq:内核中一种延后执行中断处理逻辑的机制
硬件中断到来时,CPU会立刻打断当前程序,进入中断处理程序。但是中断处理程序不能执行太久,否则会影响系统对其他中断的响应,所以Linux会把中断处理拆成两部分:
- 上半部:硬中断处理,要求尽快执行完,只做最紧急的事情,如读取设备状态、确认中断、通知内核有事件发生
- 下半部:软中断处理,延后执行相对耗时的工作,如网络协议栈处理、定时器处理等
软中断并不是由硬件直接触发的,而是由内核在合适的时机触发执行。常见的软中断场景是网络IO:网卡收到数据包后触发硬中断,硬中断只负责快速确认事件并唤起后续处理,协议栈解析、包处理等工作交给软中断完成
为什么需要软中断?
- 减少硬中断处理时间,避免CPU长时间处于中断上下文
- 提高系统响应能力,让CPU可以更快响应其他硬件中断
- 把耗时工作延后处理,提升整体吞吐量
进程管理
并发与并行
- 并发:在一段时间内,有多个任务同时运行(一段时间内CPU调度,任务轮换执行)
- 并行:在统一时刻,有多个任务真正在同时运行
进程
进程是操作系统分配资源的基本单位,进程拥有自己独立的资源:虚拟地址空间、堆、栈、寄存器上下文、代码段等。进程之间默认是资源隔离的,一个进程无法随意访问另一个进程的资源,需要依靠进程间通信(IPC)
进程有就绪、运行、阻塞三种基本状态,操作系统可能会将处于就绪和阻塞状态的进程的物理内存空间换出到硬盘,防止浪费内存空间,该操作称为挂起;当进程需要再次运行时,再将其从硬盘换入到内存,该操作称为激活
进程的状态可参考下图:
线程
线程是CPU调度的基本单位,一个进程可以有多个线程,实现程序的并发执行。线程可以共享进程的资源:全局变量、代码段等,因此可以通过共享内存直接通信。线程也拥有少量的独立资源,如栈等
线程的实现方式有三种:
- 用户级线程
- 内核级线程
- 混合实现线程
用户级线程
由用户态线程库管理,操作系统内核不知道用户级线程的存在
N:1模型:多个用户线程 -> 一个内核线程
- 优点:创建快切换快,用户态完成,不需要内核参与
- 缺点:一个用户线程阻塞,可能导致整个进程都阻塞;无法利用多核CPU
内核级线程
由操作系统内核管理和调度,是现代操作系统的主流方案
1:1模型:一个用户线程 -> 一个内核线程
- 优点:一个线程阻塞,不影响其他线程;可以利用多核CPU并行
- 缺点:创建与切换成本高,存在用户态 / 内核态切换,系统调用
混合实现线程
用户级线程和内核级线程的结合
M:N模型:多个用户线程 -> 多个内核线程
兼顾了轻量和并行性,但实现起来复杂
除了用户线程与内核线程之外,还存在一种轻量级进程(LWP),它是由内核支持的,能被CPU真正调度的实体。用户线程本身无法被CPU直接调度,必须绑定到LWP,一个LWP可以对应一个或多个用户线程
同样存在1:1,N:1,M:N三种模式,这三种模式也可以同时存在
进程是如何创建的?运行可执行文件时发生了什么?
fork-exec模型。当用户运行一个可执行文件时,首先shell会解析命令,调用fork创建子进程,创建PCB,fork并不会立刻复制整个父进程内存,而是父子进程先共享物理页(写时复制)。调用exec加载新程序,此时内核会先销毁子进程旧的地址空间(exec之后子进程会被新的程序替代,就没必要复制物理页了),创建新的虚拟地址空间,建立页表,加载代码段、数据段、动态库等。现代Linux一般采用按需分页,不会一次性把整个程序读入物理内存,而是在访问页面时通过缺页异常再加载。之后把进程加入到就绪队列,等待CPU调度执行
进程间通信
共享内存
把同一块物理内存映射到多个进程的虚拟地址空间中,使多个进程可以同时读写这块内存
- 共享数据结构:进程间共享同一个数据结构,编程时需考虑进程间的同步互斥问题;例如生产者消费者模型中的共享缓冲区
- 共享存储区:操作系统在每个进程的虚拟内存中划分出一块区域作为共享存储区,本质是一块被多个进程的虚拟地址空间映射的物理内存
共享内存通常与信号量一起使用,用于实现进程间的互斥与同步机制;信号量本质是一个整型的资源数量计数器,控制信号量的两个原语操作:
P操作:获取资源信号量,信号量数量减1;之后如果信号量 >= 0,表明有资源可用,进程正常执行;如果信号量 < 0,表明资源全被占用,进程需阻塞等待V操作:释放资源信号量,信号量数量加1;之后如果信号量 <= 0,表明当前有阻塞中的进程,于是会将该阻塞进程唤醒;如果信号量 > 0,则表明没有阻塞中的进程
管道 Pipe
管道在Linux中本质是一个特殊的文件,他没有磁盘中的对应实体,适合进程间的简单数据传输,分为两类:
- 匿名管道:半双工通信,用于父子进程或者兄弟进程,若要相互通信需要两个匿名管道;匿名管道在文件系统中不存在路径名,只能通过fd访问,因此只有继承相同fd的亲缘进程才能用匿名管道通信
- 命名管道:全双工通信,用于没有亲缘关系的进程;命名管道在文件系统中存在路径名,因此可以用于没有亲缘关系的进程通信
消息传递
进程之间调用由操作系统实现的通信命令来传递消息,用操作系统提供的
send()/receive()原语实现消息的发送和接收,分为两类:- 直接通信方式:发送进程直接将消息发送给接收进程,接收进程从自己的缓冲队列中获取消息;通信时需要指明对方进程的PID
- 间接通信方式:操作系统单独创建一片空间用于存储消息,发送进程和接收进程依靠这个中间实体实现消息的发送和接收;例如信箱和消息队列;通信时只需要指明共同的中间介质,耦合低
Socket
最通用的IPC方式
既能用于本机进程通信:
1
2
3Unix Domain Socket
// 通过socket文件进行通信,例如docker:/var/run/docker.sock
// docker cli用它与docker通信也能用于跨机器的网络通信:
1
TCP/UDP Socket
进程调度算法
先来先服务(FCFS)
按照进程到达就绪队列的顺序执行,先到先执行,非抢占式
- 优点:实现简单
- 缺点:如果前面有长任务,后面的短任务会等待很久,容易出现队头阻塞问题
短作业优先(SJF)
优先执行运行时间最短的任务,有抢占式(剩余最短时间优先)和非抢占式
- 优点:平均等待时间
- 缺点:需要提前知道任务运行时间;长任务可能长期得不到执行,出现饥饿
优先级调度
每个进程有一个优先级,优先级高的进程先执行
- 优点:可以让重要的任务先执行
- 缺点:低优先级进程可能饥饿;可以通过动态优先级来解决
优先级调度分为静态优先级和动态优先级,高响应比优先属于一种动态优先级调度算法,综合考虑等待时间和执行时间,响应比高的进程优先执行
1
响应比 = (等待时间 + 要求服务时间) / 要求服务时间
既照顾了短任务,也能避免长任务一直饥饿
时间片轮转(RR)
每个进程分配一个固定时间片,时间片用完还没执行完就重新放回就绪队列末尾,抢占式
- 优点:公平,适合分时系统
- 缺点:时间片太小会导致上下文切换频繁;时间片太大又会退化成先来先服务
多级队列
将进程按照优先级或者类型分为多个队列,每个队列采用不同的调度算法,队列之间可以有优先级,优先级高的队列先执行
- 优点:灵活
- 缺点:可能导致低优先级队列的进程饥饿
多级反馈队列
设置多个优先级队列,不同队列时间片不同。新进程首次到达先进入高优先级队列,每个队列内部使用时间片轮转,如果时间片用完进程还没执行完就降级到下一级队列末尾。一般低优先级队列的进程用时更长,时间片更大
- 优点:多级反馈队列兼顾了短任务响应快和长任务最终能执行,是比较经典的综合调度算法
- 缺点:实现复杂,开销较大
单核CPU是如何跑多线程任务的?
单核CPU同一时刻只能真正执行一个线程,但操作系统会通过时间片轮转和上下文切换让多个线程交替执行。由于切换速度很快,从用户角度看就像多个线程在同时运行,这就是并发
大致流程:
1 | 线程A运行一个时间片 |
线程上下文主要包括程序计数器、寄存器、栈指针等信息。切换过于频繁会带来额外开销
需要注意:
- 单核CPU上的多线程是并发,不是并行
- 如果线程执行IO操作进入阻塞,CPU可以切换去执行其他线程,提高CPU利用率
- 如果多个线程都是纯CPU计算任务,单核CPU下多线程不一定更快,反而可能因为上下文切换变慢
死锁
什么是死锁?
死锁是指多个进程 / 线程在执行过程中,因为互相等待对方持有的资源,导致所有进程 / 线程都无法继续向前执行
例如:
1 | 线程A持有锁1,等待锁2 |
此时线程A和线程B都在等待对方释放锁,但双方都不会主动释放自己已经持有的锁,于是程序就卡住了
死锁产生的四个必要条件
死锁产生需要同时满足四个条件:
互斥条件
请求的资源是互斥的,一次只能被一个进程 / 线程使用,例如互斥锁 / 打印机
请求与保持条件
进程 / 线程已经持有了至少一个资源,同时又去请求新的资源,并且在等待新资源时不释放自己已经持有的资源
不可剥夺条件
资源在使用完之前不能被强制抢走,只能由持有者主动释放
循环等待条件
多个进程 / 线程之间形成一个资源等待环
1
2
3A等待B持有的资源
B等待C持有的资源
C等待A持有的资源
只要破坏其中任意一个条件,就可以避免死锁
死锁的解决方法
解决死锁的思路主要有四种:
死锁预防
破坏死锁产生的四个必要条件之一
破坏互斥:允许资源被多个进程共享;例如只读文件
破坏请求并保持:一次性分配策略,或者请求资源时先释放已持有的资源
破坏不可剥夺:强制回收资源;例如请求新的资源失败时强制释放已持有的资源
破坏循环等待:规定资源申请顺序,所有线程都按固定顺序加锁
实际开发中最常用的是破坏循环等待,比如多个锁都按相同顺序获取
一个经典例子是哲学家就餐问题:有5个哲学家围坐在圆桌旁,每两个哲学家之间放一根筷子。每个哲学家吃饭时都需要同时拿到左右两根筷子,如果所有哲学家都先拿起左边的筷子,再等待右边的筷子,就会形成循环等待,导致死锁
常见解决方法:
- 限制同时拿筷子的人数:比如最多允许4个哲学家同时尝试拿筷子,这样至少有一个哲学家可以拿到两根筷子,完成就餐后释放资源,避免循环等待
- 按顺序申请资源:给筷子按顺序编号,规定哲学家必须先获取编号较小的筷子,再拿编号较大的筷子,避免循环等待
- 不同哲学家采用不同顺序:奇数号哲学家先拿左手边的筷子,偶数号哲学家先拿右手边的筷子,避免循环等待
- 一次性申请所有资源:哲学家同时获取左右两根筷子,如果有一根不可用就都不拿,破坏请求与保持
- 使用服务员机制:让服务员统一分配筷子,哲学家获取筷子需要经过服务员的同意(例如服务员可以限制同时拿筷子的人数,申请资源的顺序等)
死锁避免
安全状态:是指系统能按照某种进程顺序(称为安全序列)来为每个进程分配所需资源,满足每个进程对资源的最大需求,使每个进程都可以顺利完成;反之如果无法找到这样的一个安全序列,则系统为不安全状态。处于安全状态的系统,一定不会发生死锁;处于不安全状态的系统,可能会发生死锁
在资源分配前判断这次分配是否可能导致系统进入不安全状态,如果可能就不分配
典型算法是银行家算法:银行在发放一批贷款前,需要预测这笔贷款是否会引起银行资金周转问题。在操作系统中,就是在一次分配资源前,需要预测这次资源分配会不会导致系统进入不安全状态(即是否存在安全序列),这要求每个进程声明其最大资源需求;如果存在安全序列则分配资源,否则就拒绝分配
死锁避免的优点是不需要限制资源分配,但需要预先知道进程的资源需求,且计算的开销较大
死锁检测与恢复
系统允许死锁发生,但会定期检测是否存在死锁。如果检测到死锁,再通过回滚、终止进程、抢占资源等方式恢复
死锁忽略(鸵鸟策略)
如果死锁发生概率很低,处理成本又很高,系统可以选择不主动处理。很多通用操作系统采用这种策略,把死锁问题交给应用程序的开发者自己处理
后端开发中更常见的是锁使用不当导致的死锁,解决时通常关注:
- 加锁顺序是否一致
- 是否在持有锁时进行阻塞IO / RPC调用
- 是否忘记释放锁
- 是否可以缩小锁粒度或使用超时锁
内存管理
虚拟内存
虚拟内存是操作系统为每个进程提供的一套独立的虚拟内存空间,每个进程都看到的都是虚拟地址,真正访问时由操作系统和CPU中的MMU(内存管理单元)进行虚拟地址 -> 物理地址的转化
虚拟内存的作用:
- 进程隔离,避免互相破坏内存
- 简化内存管理,程序无需关心真实的物理内存地址
- 提高内存利用率,有效利用内存碎片
- 支持swap,将部分页面换出到swap区,实现内存扩展
虚拟内存的实现方式:
内存分段
将虚拟内存根据程序需要分为不同大小的段,通过段表映射虚拟地址和物理地址。通过查询段表,根据段号找到段基地址,加上偏移量,就能访问到物理内存中的地址
存在的问题:
内存分段会产生外部内存碎片(外零头),由于每段的内存大小是按需分配的,所以不会产生内部内存碎片(内零头)。解决外部内存碎片的方法是内存紧凑,移动内存中的段,使空闲空间变得连续。操作系统可能会把部分内存数据换出到swap区,再重新换入到新的连续位置,内存交换涉及磁盘IO,会带来较大的性能开销
内存分页
现代操作系统的主流虚拟内存实现方式是内存分页
将虚拟内存和物理内存划分为固定大小的页,通过页表映射虚拟地址和物理地址。通过查询页表,根据页号找到页基地址,加上偏移量,就能访问到物理内存中的地址
内存分页的页与页之间是紧密排列的,所以不会产生外部内存碎片,但由于分页机制即使程序内存大小不足一页,而分配的最小内存单位也是一页,所以会产生内部内存碎片
- 简单的内存分页可能会出现页表过大,导致查询效率低下,因此操作系统会使用多级页表
- 此外,CPU会维护快表(TLB),用于缓存常见的页表项。页表由操作系统维护,快表则由硬件直接维护
- 当访问的物理页不存在时,会触发缺页异常,此时操作系统会将所需的页从磁盘加载到内存
- 如果内存空间不够,操作系统会把其他进程中暂时不使用的页面换出到swap区,需要的时候再换入到内存
段页式内存管理
- 先把程序划分成多个有逻辑意义大小不同的段
- 再把每个段划分为多个大小固定的页

缺页异常
缺页异常是指进程访问某个虚拟地址时,发现对应的物理页不在内存中,CPU触发异常,交给操作系统处理
缺页异常并不一定是程序错误。在虚拟内存的机制下,程序启动时不会把所有页面都加载到物理内存,而是在真正访问某个页面时再加载
缺页异常的大致处理流程:
1 | 进程访问虚拟地址 |
如果访问的是非法地址,比如访问未映射的内存,操作系统不会为它加载页面,而是向进程发送信号,常见的就是段错误(Segmentation fault),例如数组索引越界、空指针等
什么是抖动?
抖动是指系统内存不足时,进程频繁发生缺页异常,操作系统不断把页面换出到磁盘,又不断从磁盘换入页面。此时系统的大量时间都消耗在磁盘IO和页面置换上,真正执行用户程序的时间反而很少
抖动通常会表现为:
- 缺页率升高
- 磁盘IO明显增加
- 进程响应变慢
- CPU利用率反而下降
CPU利用率陷阱
当系统发生抖动时,CPU利用率可能会下降,因为大量进程都在等待磁盘IO完成,而不是在CPU上执行。此时如果只看CPU利用率,可能会误以为系统CPU资源还有很多,于是继续增加并发量 / 增加进程数
但实际上问题不是CPU不够,而是内存不够。继续增加并发量会让更多进程竞争内存,导致缺页异常更多,换入换出更频繁,系统性能进一步下降
如何解决抖动?
- 增加物理内存,最直接
- 减少并发进程 / 线程数量,降低内存压力
- 改进页面置换算法,尽量保留近期会访问的页面
什么是脏页?
脏页是指内存中被修改过,但还没有同步写回磁盘的页面
例如某个文件页从磁盘加载到内存后,进程对这页数据进行了修改,此时内存中的数据和磁盘中的数据已经不一致,这个页面就是脏页。页表项中通常会有一个修改位,用来标记页面是否被修改过
脏页和页面置换关系很大:
- 如果淘汰的是干净页,说明内存中的内容和磁盘一致,可以直接丢弃
- 如果淘汰的是脏页,说明内存中的内容比磁盘新,淘汰前必须先写回磁盘
因此淘汰脏页的成本更高,因为会额外产生磁盘回写IO
页面置换算法
当发生缺页异常时,如果物理内存还有空闲页,就可以直接把需要的页面加载进来。如果物理内存已经满了,就需要选择一个页面换出到磁盘,给新页面腾出空间,这个选择策略就是页面置换算法
常见的页面置换算法:
最佳置换算法(OPT)
淘汰未来最长时间不会被访问的页面
- 优点:理论上缺页率最低
- 缺点:无法提前知道未来访问情况,因此只能作为理论最优参考
先进先出(FIFO)
淘汰最早进入内存的页面
- 优点:实现简单
- 缺点:可能把经常访问的页面淘汰掉,效果不稳定
LRU
淘汰最长时间没有被访问的页面
LRU基于局部性原理:最近被访问过的页面,未来可能还会被访问
- 优点:效果通常较好,缺页率低
- 缺点:实现成本高,需要维护页面访问顺序
LFU
淘汰访问次数最少的页面
- 优点:考虑访问频率
- 缺点:历史访问次数可能影响当前判断,早期热点页面即使之后不再使用,也可能不容易被淘汰
时钟置换算法(Clock)
时钟算法是LRU的近似实现。页面通过环形队列组织,每个页面有一个访问位(R),页面被访问时访问位设为1,页面置换时指针从上次停止的位置开始,循环遍历环形队列检查:
- 如果访问位为1,就把它置为0,给它第二次机会
- 如果访问位为0,就淘汰该页面
一旦找到并替换了某个页面,查询指针应移动到下一个位置,下次置换时指针从此位置开始
- 优点:Clock算法实现成本低,是实际系统中比较常用的页面置换思路
- 缺点:只能判断最近是否访问过,但具体的访问频率和热度未知,因此可能淘汰的不太准确
改进的时钟置换算法
在Clock算法的基础上,每个页面多增加一个修改位(M),用来记录页面在被换入内存后是否被修改过(M=1表示被修改,M=0表示没有被修改)。页面被淘汰的优先级按从高到低排列为:
- P=0,M=0:最近未被访问且未被修改,可直接淘汰
- P=0,M=1:最近未被访问但被修改,淘汰前需写回磁盘
- P=1,M=0:最近被访问但未被修改,未来可能还要访问
- P=1,M=1:最近被访问且被修改,不仅未来可能访问,淘汰前还需写回磁盘
与Clock算法一样,需要指针循环遍历环形队列,如果访问位为1,就把它设为0,给第二次机会。改进的Clock算法会优先寻找(P=0, M=0)的页面,找不到再考虑(P=0, M=1)的页面
- 优点:尽量避免淘汰脏页,比起Clock算法减少了回写磁盘的IO开销
- 缺点:实现更复杂,一般用于对IO性能要求较高的系统
Linux进程内存布局
Linux进程的虚拟地址空间分为用户空间和内核空间两部分
1 | 内核空间 // 高地址 |
所有进程共享内核空间,用户态只能访问用户空间,内核态可以访问内核空间和用户空间
进程的用户空间内存布局如下:
- 栈:存放局部变量、函数参数、返回地址;空间小、内存连续、自动管理、分配快,用于函数调用
- 文件映射(mmap):用于映射文件或共享库;把文件内容内容映射到虚拟内存空间,使操作系统可以像访问内存一样访问文件内容;真正读取时触发缺页异常,把文件内容加载到物理内存,更新页表
- 堆:用于动态内存分配,如C语言的
malloc();空间大,需手动管理 / GC、分配慢,用于动态对象 - BSS段:存放未初始化的全局变量 / 静态变量;零值不需要内存空间额外存储,只需要记录变量大小即可
- 数据段:存放已初始化的全局变量 / 静态变量;程序启动时分配,结束时释放,生命周期贯穿整个程序
- 代码段:存放CPU可以实际执行的机器指令,通常是只读的
注意如图中所示,分配内存时栈的内存向低地址方向增长、堆堆内存向高地址方向增长
堆与栈对比
| 对比 | 堆 | 栈 |
|---|---|---|
| 用途 | 动态分配对象 | 函数调用、局部变量 |
| 速度 | 较慢 | 较快 |
| 空间 | 大 | 小 |
| 管理方式 | 手动管理 / GC | 自动 |
栈为什么快?
- 栈内存分配只需要移动栈顶指针
- 资源自动管理,函数结束时自动释放
- 栈分配的内存通常连续,CPU Cache命中率高
堆为什么慢?
- 堆要支持任意时刻、任意大小的申请与释放
- 需要寻找合适大小的内存块,还要处理内存碎片、合并空闲块
- 堆内存是多线程共享的,多线程下可能会有锁的竞争
- 堆内存不够用时可能会触发系统调用,向操作系统申请更多内存
malloc()如何分配堆内存?
malloc()分配的是虚拟内存,而且不一定会发生系统调用,只有在用户态堆内存区的空闲区域不足时才会发生系统调用,向操作系统申请更多内存
malloc()向操作系统申请内存有两种系统调用:
- 在请求的内存比较小时,会使用
brk()系统调用,把堆顶上移 - 在请求的内存比较大时,会使用
mmao()系统调用,在文件映射区单独匿名映射一块虚拟内存区域用于分配对象
IO
IO控制方式
- 程序查询方式:CPU不断轮询设备,查询数据是否准备好;CPU忙等
- 中断驱动方式:CPU向设备发起IO请求,设备去准备数据,此时CPU可以执行其他任务,设备准备好数据后通过发起中断来通知CPU,之后CPU执行中断处理程序,把数据从设备搬运到内存,执行后续操作;CPU不需要一直等待
- DMA方式:CPU向DMA控制器发起IO请求,设备去准备数据,设备准备好数据后由DMA负责把数据从设备搬运到内存,DMA完成后通过发起中断来通知CPU,CPU再执行中断处理程序,执行后续操作;搬运数据由DMA负责,CPU不参与,现代的高速IO设备基本都适用DMA方式
阻塞 / 非阻塞 / IO多路复用使用场景
| 模型 | 特点 | 场景 |
|---|---|---|
| 阻塞IO | 简单 | 连接少 |
| 非阻塞IO | 轮询 | 很少单独使用;与IO多路复用一起使用 |
| IO多路复用 | 一个线程监听多个连接 | 高并发服务;Redis / Nginx |
IO多路复用
例如:服务器需要监听多个sokcet连接,可以有如下方案:
阻塞IO,一个线程负责处理一个socket连接
大量线程等待造成资源浪费,CPU调度时上下文切换频繁
非阻塞IO,轮询多个socke
会导致CPU疯狂空转
IO多路复用,让一个线程同时监听多个连接
IO多路复用是指让一个线程同时监听多个连接,本质上是由内核负责监听所有socket,当某个socket就绪时,内核通知线程进行处理,避免大量线程阻塞等待或者CPU频繁轮询,常见的实现方式有三种:
select把已连接的socket放到一个文件描述符集合,调用select函数将该文件描述符集合复制到内核里,让内核监听。检查的方式就是通过遍历fd集合,当检查到有事件发生后,就把该socket标记为可读 / 可写,然后把fd集合再复制回用户态里,用户态再通过遍历找到这个标记过的socket
select使用BitMap表示fd集合,而且由于bitmap大小固定,集合中fd的数量限制最大为1024
pollpoll使用动态数组和链表来代替bitmap表示fd集合,突破了集合中fd数量的限制
epoll:Linux高性能网络程序(如Nginx、Redis)底层使用epollepoll在内核里维护了一个红黑树,程序通过
epoll_ctl来向epoll中添加 / 修改 / 删除需要监控的fd,时间复杂度基本是O(log n)。epoll使用事件驱动的方式,用epoll_wait等待事件发生,当检查到有事件发生,内核会将就绪的fd存入到一个就绪fd列表中,只返回就绪的fd列表,并不会复制所有fd到用户态
为什么epoll比select/poll更快?
select/poll:整个fd集合的拷贝耗时,而且每次需要遍历所有fdepoll:事件驱动,就绪才通知,只返回就绪的fd队列
epoll有两种触发方式:**LT(水平触发)**和 ET(边缘触发)
- LT:只要缓冲区还有数据就会一直通知,不容易漏数据,但可能会重复通知,效率低下
- ET:只在缓冲区中有数据到来时通知一次,更加高效,但要求一次性读完,通常配合非阻塞IO实现
零拷贝
以服务器向客户端发送文件为例,传统IO:
1 | 磁盘 -> 内核缓冲区 -> 用户缓冲区 -> socket缓冲区 -> 网卡 |
- DMA将数据从磁盘拷贝到内核缓冲区
- CPU将数据从内核缓冲区拷贝到用户缓冲区
- CPU将数据从用户缓冲区拷贝到socket缓冲区
- DMA将数据从socket缓冲区拷贝到网卡发送出去
这其中涉及到CPU拷贝数据,以及系统调用,用户态和内核态的转换
零拷贝是减少用户态的参与,减少CPU的拷贝次数:
mmap实现:通过mmap把用户空间虚拟地址映射到内核缓冲区,减少了一次CPU从内核缓冲区到用户缓冲区的拷贝
sendfile实现:
1 | 磁盘 -> 内核缓冲区 -> socket缓冲区 -> 网卡 |
- DMA将数据从磁盘拷贝到内核缓冲区
- CPU将数据从内核缓冲区拷贝到socket缓冲区
- DMA将数据从socket缓冲区拷贝到网卡发送出去
零拷贝的适用场景:文件传输
- Nginx
- Kafka
零拷贝不适用的场景:数据需要经过用户态修改
- 加密
- 压缩
- 业务处理
磁盘调度算法
磁盘调度算法主要用于机械硬盘。机械硬盘读写数据时,需要移动磁头到目标磁道,寻道时间是主要开销,因此操作系统会通过调度算法优化磁头移动距离
常见的磁盘调度算法:
先来先服务(FCFS)
按照请求到达顺序依次处理
- 优点:简单,公平
- 缺点:磁头可能来回移动,平均寻道时间较长
最短寻道时间优先(SSTF)
每次选择距离当前磁头位置最近的请求
- 优点:可以减少平均寻道时间
- 缺点:远处请求可能长期得不到处理,出现饥饿
扫描算法(SCAN)
磁头固定按一个方向移动,在移动过程中处理沿途的请求;当该方向没有请求或者磁头已到达边缘就反向扫描;如此反复
- 优点:能缩短寻道时间,又能防止饥饿
- 缺点:磁盘边缘的请求可能等待时间过长,不够公平
循环扫描算法(C-SCAN)
磁头只沿一个方向处理请求,到达末端后立刻回到起点,再继续重复同方向扫描
- 优点:等待时间更均匀
- 缺点:磁头从末端回到起点的过程也有移动开销
N步扫描算法(N-Step-SCAN)
把请求分成若干个长度为N的子队列,按照先来先服务依次处理这些子队列,而在处理每一个子队列时使用SCAN算法
当正在处理某个子队列时,新到来的请求不会插入当前子队列,而是放到后面的子队列中,等下一轮再处理
- 优点:避免新请求不断插入当前扫描范围,导致某些旧请求长期等待
- 缺点:需要维护多个子队列,实现比普通SCAN复杂
FSCAN
FSCAN可以看作N步扫描的特例,它只维护两个队列:
- 当前队列:正在被SCAN算法处理
- 等待队列:保存处理期间新到来的请求
当前队列处理完后,等待队列变成新的当前队列,原来的当前队列清空后继续接收新请求
FSCAN的核心思想是:扫描期间不让新请求干扰当前扫描队列
- 优点:避免高负载下请求不断插队,使SCAN算法更稳定
- 缺点:新来的请求必须等下一轮扫描,响应时间可能变长
对于SSD来说,没有机械磁头移动,随机访问性能比机械硬盘好很多
文件系统
文件系统基础
文件系统是操作系统用来管理磁盘数据的一套机制。它屏蔽了磁盘扇区、块设备等底层细节,让用户可以通过文件名、目录来访问数据
Linux中一切皆文件,普通文件、目录、设备、管道、socket等都可以通过文件的方式进行读写和管理
文件系统通常会把磁盘划分为多个区域:
- 引导块:用于存放系统启动程序相关数据
- 超级块:保存文件系统的整体信息,如块大小、inode数量、空闲块数量等
- inode区:保存文件的元数据和数据块位置
- 数据块:保存文件的实际数据
- 位图:记录inode和数据块的使用情况
innode、文件、目录、目录项
inode
inode是文件系统中用来描述文件的数据结构,可以理解为文件的“身份证”。每个文件都有对应的inode,inode保存文件的元数据(如文件类型、大小、所有者、硬链接数量等)和数据块的位置。文件的真实内容保存在数据块中,而文件的名字保存在目录项中
文件
在文件系统中,普通文件由两部分组成:inode + 数据块,文件名不属于文件的一部分,文件名保存在目录项中
目录
目录本质上也是一种文件,只是普通文件的数据块中保存的是用户数据,而目录的数据块中保存的是一组目录项
目录项
目录项是目录中的一条记录,保存的是文件名 - inode的映射关系
1 | a.txt -> inode 100 |
因此文件名也保存在目录项中,用户通过文件名访问文件时,操作系统会现在目录中找到对应的目录项,通过目录项中的映射关系找到inode,进而找到数据块中的文件内容
文件描述符
文件描述符(fd)是进程访问文件时使用的一个整数,本质上是进程文件描述符表的下标
常见的三个标准文件描述符:
| 文件描述符 | 含义 |
|---|---|
| 0 | 标准输入 |
| 1 | 标准输出 |
| 2 | 标准错误 |
当进程调用open()打开文件时,内核会给这个文件分配一个文件描述符,之后进程通过这个文件描述符调用read()、write()、close()等接口操作文件
文件描述符表
文件描述符表是每个进程私有的,保存的是fd -> 打开文件对象的映射关系。文件描述符属于进程级别,所以同一个fd数字在不同进程里可以指向不同文件
硬链接和软链接
硬链接
硬链接是给同一个inode再创建一个文件名。多个硬链接指向同一个inode,因此它们本质上是同一个文件
1 | ln source.txt hard_link.txt |
硬链接的特点:
- 修改任意一个硬链接文件,其他硬链接看到的内容也会变化
- 删除其中一个文件名,不会真正删除文件;只有当硬链接数为0,并且没有进程打开该文件时,文件数据才会被释放
- 硬链接不能跨文件系统,因为不同文件系统的
inode编号体系不同 - 一般不能对目录创建硬链接,避免目录结构出现环
软链接
软链接也叫符号链接,本质上是一个独立的特殊文件,里面保存的是目标文件的路径
1 | ln -s source.txt soft_link.txt |
软链接的特点:
- 软链接有自己独立的
inode(因为软链接本质是一个文件,文件有自己的inode) - 可以跨文件系统,也可以链接目录
- 如果原文件被删除,软链接仍然存在,但会变成悬空链接,访问时会报错
- 软链接保存的是路径,因此目标文件移动或改名后,软链接可能失效
| 对比 | 硬链接 | 软链接 |
|---|---|---|
| 本质 | 同一个inode的多个名字 |
保存目标路径的特殊文件 |
| 是否有独立inode | 没有,和源文件相同 | 有 |
| 能否跨文件系统 | 不能 | 可以 |
| 能否链接目录 | 一般不能 | 可以 |
文件删除
Linux中删除文件本质上是删除目录项,也就是删除**文件名 - inode**的映射,并让inode的硬链接计数减1
文件真正被释放需要两个条件:
- 硬链接数量为0
- 没有进程打开文件
如果一个文件已经被删除,但仍有进程打开它,持有这个文件的fd,那么该文件在磁盘上不会被立刻释放,需要等待最后一个打开该文件的进程关闭文件描述符后,文件数据才会被真正释放
说些什么吧!