操作系统
本文最后更新于 2025-03-24,文章超过7天没更新,应该是已完结了~
大学学的操作系统快忘记了....记录一下还记得的知识
操作系统的四个特性
并发:同一段时间内多个程序执行(与并行区分,并行指的是同一时刻有多个事件,多处理器系统可以使程序并行执行)
共享:系统中的资源可以被内存中多个 并发执行的进程线程共同使用
虚拟:通过时分复用以及空分复用技术把一个物理实体虚拟为多个
异步:系统进程用一种走走停停的方式执行,进程什么时候以怎么样的速度向前推进是不可预知的。
时分复用和空分复用
时分复用:多个程序在cpu中以一段时间间隔内进行轮转。每个程序依次执行一段时间。宏观上所有程序同时执行,其实内部还是每时处理一个程序。
空分复用:多个程序或用户同时使用一个资源的不同部分。为了使CPU实现多程序并发,需要将多个程序同时放入内存中。这些程序拥有不同的内存块,互不影响。
进程、线程、协程
进程是指一个内存中运行的应用程序
线程是比进程更小的执行单位,它是在一个进程中独立的控制流,一个进程可以启动多个线程,每条线程执行不同的任务
协程:
这是一段普通函数:
def func():
print("a")
print("b")
print("c")
根据这段普通函数,我们来说明一下协程的特点。
函数只有一个返回点,而协程有多个返回点。并且我们在协程返回后还能继续调用该协程,从协程上一个返回点继续执行。
void func() {
print("a")
yield
print("b")
yield
print("c")
}
和普通函数不同的是,协程能知道上一次执行到哪
进程,线程和协程的比较
进程与线程比较(重点):
进程有独立的地址空间,线程有自己的堆栈和局部变量,但线程之间没有单独的地址空间;
进程和线程切换时,需要切换进程和线程的上下文,进程的上下文切换时间开销远远大于线程上下文切换时间,耗费资源较大,效率要差一些;
进程的并发性较低,线程的并发性较高;
每个独立的进程有一个程序运行的入口、顺序执行序列和程序的出口,但是线程不能够独立执行,必须依存在应用程序中,由应用程序提供多个线程执行控制;
系统在运行的时候会为每个进程分配不同的内存空间;而对线程而言,除了 CPU 外,系统不会为线程分配内存(线程所使用的资源来自其所属进程的资源),线程组之间只能共享资源;
一个进程崩溃后,在保护模式下不会对其他进程产生影响,但是一个线程崩溃整个进程都死掉。所以多进程要比多线程健壮。
协程和线程比较(重点):
线程是抢占式(即由操作系统分配执行权),而协程是非抢占式的(由程序控制)
需要用户自己切换到其他协程,因此同一时间其实只有一个协程拥有运行权, 也就是说多个线程是可以并行的,而协程是并发的。
线程是协程的资源。协程通过可以关联任意线程或线程池的执行器来间接使用线程的资源的。
线程由操作系统内核管理,协程由程序自身控制(即用户态管理),这样协程切换上下文时不需要在内核态进行上下文切换来消耗资源,所以协程的开销远远小于线程
进程的状态与切换
进程间的三种基本状态:
就绪状态:当进程分配到除CPU以外的所有必要的资源,只要获得处理机便可以立即执行。
执行状态:当进程已经获得处理机,其程序正在处理机上执行
阻塞状态:正在执行的进程,由于等待某个事件发生而无法执行时,便放弃处理及而处于阻塞状态。
进程三种状态的切换:
就绪---执行:就绪状态的进程分配到处理器
执行---就绪:处于执行状态的进程在其执行的过程中,因分配给它的一个时间片已用完不得不让出处理机
执行---阻塞:执行状态因发生某种事情无法继续执行
阻塞---就绪:阻塞状态的原因条件已解决
进程控制
父子进程关系
允许一个进程创建另一个进程,此时创建者成为父进程,被创建的进程成为子进程。子进程可以继承父进程所拥有的资源。当子进程被撤销时,应将其从父进程那里获得的资源归还给父进程。此外,在撤销父进程时,也必须同时撤销其所有的子进程。
创建(creat):
1.为新进程分配一个唯一的进程标识号,并申请一个空白的PCB(进程控制块:PCB的作用是使一个在多道程序环境下不能独立运行的程序,使其成为一个能与其他进程并发执行的进程。OS是根据PCB来对并发执行的进程进行控制和管理的。),若PCB申请失败则创建失败
2.为进程分配资源和为新的程序和数据分配必要的内存空间。若资源不足,如内存不够,并不会创建失败,而是处于等待状态或阻塞状态。
3.初始化PCB,主要包括初始进程标识符,进程当前状态,进程相应的程序和数据的地址--->以便把PCB与其程序和数据联系起来,进程资源清单以及设置进程的优先级等
4.如果进程就绪队列能接纳新进程,就将新进程插入到就绪队列,等待被调度运行。
进程的终止:
撤销(kill):
1.根据被终止进程的标识符,检索PCB,然后从中读出该进程的状态。
2.若被终止进程处于执行状态,立即终止该进程的执行,将处理机资源分配给其它进程
3.若该进程还有子进程,则将其所有子进程终止
4.将该进程所拥有的全部资源,归还给其父进程或操作系统
5.将该PCB所在队列中删除
进程的阻塞:
进程的阻塞是进程自身的一种主动行为,也因此只有运行态的进程才可能转为阻塞状态
阻塞(block):
1.找到将要被阻塞进程的标识号对应的PCB
2.若该进程为运行状态,则保护其现场,将其状态转为阻塞状态,停止运行
3.把该PCB插入到等待队列中
进程的唤醒
唤醒(wackup):
1.在该事件的等待队列中找到相应进程的PCB
2.将其从等待队列中移除,并把状态置为就绪
3.把该PCB插入就绪队列中,等待调度程序调度
进程的切换
保存处理机上下文,包括程序计数器和其它寄存器
更新PCB信息
把进程的PCB移入相应的队列,如就绪,阻塞
选择另一个进程执行,并更新其PCB
更新内存管理的数据结构
恢复处理机上下文
进程的组织
管理进程就是管理进程的PCB。一个系统中通常可能拥有数百乃至上千个进程,为了有效地管理如此多的PCB,系统需要采用适当的方式将它们组织在一起。通常采用的组织结构有数组、散列表和链表3种方式。
1.数组方式是将所有的PCB顺序存在一个一维数组中。这种方式简单,但操作起来效率低。
2.链表方式是将PCB链接形成一个链表。链式结构的特点就是灵活,并于插入删除PCB
3.散列表方式就是通过PCB数组或链表上设置散列表,以加快访问速度
实际的系统中通常会综合采用这些方法,以达到最好的效率。
Linux系统采用多种方式来组织进程的PCB(在Linux叫task_struct结构体),主要有:
1.进程链表-->即PCB链表
系统将所有的PCB链成一个双向循环链表
2.PID散列表
用于将PID映射到进程的PCB,是一个链式散列表
3.进程树链表
进程中存在父子与兄弟结点,系统中所有进程形成一颗进程树,每个进程都是树的一个结点
进程的通信(重点)
管道通信
匿名管道是半双工的,数据只能向一个方向流动,只能用于父子进程之间;命名管道同匿名管道相比,允许无亲缘关系进程间的通信
管道本质其实是内核中维护的一块内存缓冲区,进程以先进先出的方式从缓冲区存取数据
有名管道(FIFO)不同于匿名管道之处在于它提供了一个路径名与之关联,以文件形式存在于文件系统中,并且其打开方式与打开一个普通文件是一样的,这样即使与有名管道的创建进程不存在亲缘关系的进程,只要可以访问该路径,就能够彼此通过有名管道相互通信,因此,通过有名管道不相关的进程也能交换数据
消息队列
与管道(无名管道:只存在于内存中的文件;命名管道:存在于实际的磁盘介质或者文件系统)不同的是消息队列存放在内核中,但只有在内核重启(即,操作系统重启)或者显示地删除一个消息队列时,该消息队列才会被真正的删除。
消息队列克服了信号承载信息量少,管道只能承载无格式字节流以及缓冲区大小受限等缺陷
共享内存
共享内存就是映射一段能被其他进程所访问的内存,这段共享内存由一个线程创建,但多个进程都可以访问。共享内存是最快的IPC(进程间通信)方式,它是针对其他进程间通信方式运行效率低而专门设计,常常与信号量配合使用来实现进程的同步与通信
信号量
是一个计数器,可以用来控制多线程对共享资源访问,它常常作为一种锁机制, 操作分为P 操作和 V 操作,P 操作是将信号量的值减 1,V 操作是将信号量的值加 1。如小于0则阻塞等待,大于0表示该内存可以访问,作为进程间或不同线程的同步手段
套接字
套接字是一种通信机制,凭借这种机制,客户/服务器(即要进行通信的进程)系统的开发工作既可以在本地单机上进行,也可以跨网络进行。也就是说它可以让不在同一台计算机但通过网络连接计算机上的进程进行通信。
处理机调度
进程调度算法/方式
非抢占方式
当某一进程正在处理机上执行时,即使由某个更为重要或紧迫的进程进入就绪队列,该进程仍继续执行直到完成或进入阻塞状态
适用于大多数批处理系统环境
抢占方式
当某一进程正在处理机上执行时,由某个更重要的进程进入就绪队列,则立即停止正在执行的进程,将处理机分给这个更重要的进程
适用时间要求严格的实时系统
调度算法
先来先服务(FCFS)算法
该算法既可用于作业调度,可也用于进程调度。在作业调度中,从后背作业队列中选择一个或多个最先进入该队列的作业,调入内存分配资源,创建进程,然后放入就绪队列。在进程调度中,则从就绪队列中选择一个最先进入到该队列的进程,为之分配处理机。
短作业(进程)优先调度SJF算法
同FCFS一样,区别在于对比原则是运行时间短
时间片轮转算法
给队列中的每个任务设置一个时间片,第一个任务先执行,时间片到了之后,将此任务放到队列尾部,切换到下个任务执行,这样能解决SJF中耗时长任务饥饿的问题。算法介于FIFO和SJF之间,若时间片足够大,则退化到FCFS,若分片足够小 (假设不考虑任务切换的开销),则任务的完成时间顺序是以耗时从小到大排列 (相比SFJ,任务执行的绝对时间会长,取决于队列中任务的个数)。
优先级调度算法
在进程等待队列中选择优先级最高的来执行
多级反馈队列调度算法
有多个Level,从上到下,优先级越来越低,分片时长越来越大。 位于高优先级Level的任务(新进来的任务)可以抢占低优先级Level的任务。
新任务首先位于高优先级Level,当一个时间片用完之后,若任务结束,则正常退出系统,若任务还没有结束,则下滑到低一等级的Level的队尾。若是因新进程进来导致被抢占处理机而主动让出CPU,则停留在当前Level并重新放回队尾。 同一Level的任务采用FCFS算法。只有第n级队列都处理完为空时,才会为第n+1级队列分配时间片。
死锁
死锁原因
死锁的必要条件,缺一就不会发生死锁:
互斥条件:资源不能被共享,只能由一个进程使用。
请求和保持条件:一个进程因请求资源而阻塞,并且对已经获得的资源保持不放
非剥夺条件:已经分配的资源在未使用完之前不能从相应的进程中被强制地剥夺
循环等待条件:若干进程之间形成一种头尾相连的循环等待资源关系
处理死锁的几种方式
1.预防死锁(没啥用)
基本思想是要求进程申请资源时遵循某种协议,从而打破产生死锁的四个必要条件中的一个或几个。
1.破坏互斥条件。允许进程同时访问某些资源,但是有些资源不允许被同时访问,如打印机啊,所以该办法无实用价值
2.破坏不可剥夺条件。当一个进程占有某些资源,又申请新的资源,但不能马上满足,它必须释放所占有的资源再重新申请。无实用价值。
3.破坏请求和保持条件。进程在运行前一次性地向系统申请它需要的全部资源,若满足不了则暂时不运行
但在进程执行前不可能直到它所需要的全部资源、资源利用率低,无论所分的资源何时用到,要占有所需的全部资源才执行、降低进程并发性。
4.破坏循环等待条件,实行资源有序分配策略。把资源事先分类编号,使所有进程对资源的请求必须按资源序号递增的顺序提出。
这种策略相比前面的策略有很大提高,但限制了进程对资源的请求,同时给系统资源合理编号不简单,为了遵循规则,暂时用不着的资源也得提前申请,增加了进程对资源的占用时间
2.避免死锁
死锁的避免,它不限制进程有关申请资源的命令,而是对进程所发出的每一个申请资源命令加以动态地检查,并根据检查结果决定是否进行资源分配。就是说,在资源分配过程中若预测有发生死锁的可能性,则加以避免。这种方法的关键是确定资源分配的安全性。
判断系统安全状态法
在进行系统资源分配之前,计算此次资源分配的安全性,若此次分配不会导致系统进入不安全状态,则将资源分配给进程,否则让进程等待。
虽然处在安全状态时一定不会有死锁发生,但是系统进入不安全状态(四个死锁的必要条件没有同时发生)也未必会产生死锁。当然,产生死锁后,系统一定处于不安全状态。
银行家算法
银行家算法数据结构:
1.可利用资源向量Available 全部可用资源的数目
2.最大需求矩阵Max 对资源的最大需求
3.分配矩阵 Allocation 系统中已分配给每一进程的资源数
4.需求矩阵Need 每一进程尚需要的资源
可用资源+已分配资源>=最大需求 --->系统安全
3检测死锁
一般来说,由于操作系统有并发,共享以及随机性等特点,通过预防和避免的手段达到排除死锁的目的是很困难的。这需要较大的系统开销,而且不能充分利用资源。为此,一种简便的方法是系统为进程分配资源时,不采取任何限制性措施,但是提供了检测和解脱死锁的手段:能发现死锁并从死锁状态中恢复出来。因此,在实际的操作系统中往往采用死锁的检测+恢复方法来排除死锁
画出资源分配图
系统死锁,可利用资源分配图来描述。如下图所示,用长方形代表一个进程,用框代表一类资源。由于一种类型的资源可能有多个,用框中的一个点代表一类资源中的一个资源。从进程到资源的有向边叫请求边,表示该进程申请一个单位的该类资源;从资源到进程的边叫分配边,表示该类资源已经有一个资源被分配给了该进程
简化资源分配图
第一步:先看A资源,它有三个箭头是向外的,因此它一共给进程分配了3个资源,此时,A没有空闲的资源剩余。
第二步:再看B资源,它有一个箭头是向外的,因此它一共给进程分配了1个资源,此时,B还剩余一个空闲的资源没分配。
第三步:看完资源,再来看进程,先看进程P2,它只申请一个A资源,但此时A资源已经用光了,所以,进程P2进入阻塞状态,因此,进程P2暂时不能化成孤立的点。
第四步:再看进程P1,它只申请一个B资源,此时,系统还剩余一个B资源没分配,因此,可以满足P1的申请。这样,进程P1便得到了它的全部所需资源,所以它不会进入阻塞状态,可以一直运行,等它运行完后,我们再把它的所有的资源释放。相当于:可以把P1的所有的边去掉,变成一个孤立的点,如下图所示:
第五步:进程P1运行完后,释放其所占有的资源(2个A资源和1个B资源),系统回收这些资源后,空闲的资源便变成2个A资源和1个B资源,由于进程P2一直在申请一个A资源,所以此时,系统能满足它的申请。这样,进程P2便得到了它的全部所需资源,所以它不会进入阻塞状态,可以一直运行,等它运行完后,我们再把它的所有的资源释放。相当于:可以把P2的所有的边都去掉,化成一个孤立的点,变成下图:
若能消去图中所有的边,则称该图是可完全简化的,如上图)
3.使用死锁定理判断
死锁定理:
①如果资源分配图中没有环路,则系统没有死锁②如果资源分配图中出现了环路,则系统可能有死锁。
或者说:
当且仅当s状态的资源分配图是不可完全简化的时候,系统状态则是死锁状态
3解除死锁
资源剥夺法
挂起某些死锁进程,抢占它的资源分配给其他死锁进程
撤销进程法
强制撤销部分甚至全部死锁进程并剥夺这些死锁进程的资源,撤销的原则可以按优先级和撤销进程代价的高低进行
进程回退法
让一(多)个进程回退到足以回避死锁的地步,进程回退时自愿释放资源而不是被剥夺。要求系统保持进程的历史信息,设置还原点。
内存分配管理
分配方式
一、连续内存分配
1.固定分区分配
将内存划分若干个固定大小的块,一个程序装入一个块,块大小不再改变。划分方式可以是大小相等和大小不等。(有内外碎片。内:分区没完全用掉 外:分区外没用到)
2.动态分区分配
需要一个空闲表或者空闲链来记录目前系统中空间的内存区域。在内存分配时,需要查找空闲表或空闲链找到一块内存分配给当前进程。
动态分区分配具体操作和相关算法
内存分配具体操作:
系统利用某种分配算法,从空闲分区表/链中找到所需大小的分区。设请求的分区大小为u.size,表中每个空闲分区的大小表示m.size。若m.size-u.size<=size(规定不再切割的剩余分区大小),说明多余部分太小不再切割,将整个分区分配给请求者。否则边从该分区划分出一块内存分配出去,余下仍留在空闲分区链表中。(导致内碎片)
动态分区回收内存方式:
(1)回收区有相邻空闲区,则合并
(2)回收区没有相邻空闲区,则为回收区单独建立一个新表项,插入空闲链表
动态分区分配内存相关算法:
a)首次适应法FF:FF算法要求空闲分区链表以地址递增的次序链接,在分配内存时,从链首开始顺序查找,直到找到一个大小能满足要求的空闲分区为止。然后按照作业大小,从该分区中划出一块内存空间,剩下的空闲分区仍留在空闲链中--->不管大小,能放就给
b)循环首次适应法NF:从上次找到的空闲分区的下一个空闲分区开始找,直到能找到一个满足要求的空闲分区,从中划出一块与请求大小相等的内存空间分配给作业。
c)最佳适应法BF:把既满足要求,又最小的空闲分区分配给作业
d)最坏适应法WF:选最大的空闲区划分给作业用
e)快速适应法:将空闲分区按大小分类,对每一类分区设立一个空闲分区链表。检索该表选能满足且最小的使用 --->比BF快
补充:可重定位分区分配:由于若干次内存分配与回收之后,各个空闲的内存块不连续了。通过“重定位”,将已经分配的内存“紧凑”在一块(就类似于JVM垃圾回收中的复制算法)从而空出一大块空闲的内存出来。
二、离散内存分配方式
1.分页存储管理方式:
将进程的数据存储在不同的页面;同时,也将物理内存分成相等大小且固定的块。在为进程分配内存时,以块为单位,将进程的若干页可以装入到内存中多个不邻接的物理块中。
由于进程的最后一页经常装不满一块而形成了不可利用的碎片,称之为“页内碎片”。
页面大小应适中,一般1kb-8kb。过大则导致页内碎片大,过小导致每个进程占用过多页面导致页表过长。
现在考虑32位系统,每个物理块的大小为4KB。如何把逻辑地址 转换成 物理地址?
对每个进程而言,都有着自己的页表。页表的本质就是逻辑地址到物理地址的映射。
分页存储中的逻辑地址的结构如下:
1)由于进程的逻辑页面大小与物理块大小相同,故都为4K,因此需要12个位表示4K的大小(2^12=4K),即图中的【0-11】
2)【12-31】表示的是页号。一共有20个位表示页号,也即:对于一个进程而言,最多可以有1M(2^20=1M)个页。
页表:页表中有一个页表项,其中记录了相应页在内存中对应的物理块号,实现页号到物理块号的地址映射
CPU每存取一个数据时,需要两次访问主存。一次是访问页表项中对应页的物理块号,得到了数据的物理块地址。第二次拿着物理块地址去取数据。
快表
快表:在分页存储管理方式下:由于取一个数据,需要二次访存,CPU处理速度降低了一半,正由于这个原因:引入了“快表”(又称TLB(Translation Lookaside Buffer)),快表是个寄存器,用来保存那些当前访问过的页表项。从而,读页表项时,不需要再访存了,而是直接从寄存器中读取。
2.分段存储管理方式
分页是为了提高内存利用率,而分段是为了满足程序员在编写代码的时候的一些逻辑需求(比如数据共享,数据保护,动态链接等)
分段内存管理当中,地址是二维的,一维是段号,二维是段内地址;其中每个段的长度是不一样的,而且每个段的内部都是从0开始编址的。
内存分配规则:以段为单位进行分配,每个段在内存中占据连续空间,但各段之间可以不相邻。
举例说明,进程A大小为16KB,其中main函数占7KB,某个子函数占3KB,用于保存全局变量的占6KB,所以按照逻辑功能将进程A划分为3个段,每个段的逻辑地址都是从0开始的。程序运行时各个段在内存中占用连续的空间,但是各个段之间可以不相邻。
段表:每个段对应一个段表项,其中记录了该段在内存中的起始位置和段的长度。
段号的位数决定了每个进程最多可以分几个段。
段内地址位数决定了每个段的最大长度。
3.段页式内存管理(重点)
页式存储管理能有效地提高内存利用率(解决内存碎片),而分段存储管理能反映程序的逻辑结构并有利于段的共享。将这两种存储管理方法结合起来,就形成了段页式存储管理方式。 段页式存储管理方式即先将用户程序分成若干个段,再把每个段分成若干个页,并为每一个段赋予一个段名。在段页式系统中,为了实现从逻辑地址到物理地址的转换,系统中需要同时配置段表和页表,利用段表和页表进行从用户地址空间到物理内存空间的映射。 系统为每一个进程建立一张段表,每个分段有一张页表。段表表项中至少包括段号、页表长度和页表始址,页表表项中至少包括页号和块号。在进行地址转换时,首先通过段表查到页表始址,然后通过页表找到页帧号,最终形成物理地址。
分页和分段有什么区别?
分页对程序员是透明的,但是分段需要程序员显式划分每个段。
分页的地址空间是一维地址空间,分段是二维的。
页的大小不可变。段的大小可以动态改变。
分页主要用于实现虚拟内存,从而获得更大的地址空间;分段主要是为了使程序和数据可以被划分为逻辑上独立的地址空间并且有助于共享和保护。
虚拟内存(重点)
内存是有限的,作业初始时保存在磁盘上的,如果要运行,必须得将相应的程序(数据)加载到内存中。那如果要运行的作业特别多,无法一下子装入内存,怎么办?
一种方式是加内存条,另一种方式是将当前要运行的少数页面或段先装入内存保证可运行,其余部分暂留在盘上,如果程序在运行时发现索要访问的页/段还没调入内存,便发出缺页/段中断,如果此时内存已满,无法再装入新的页面/段,OS还可以利用页/段置换功能将内存中暂时不用的页/段调到盘上。这种想法就产生了虚拟存储器,是指具有请求调入功能和置换功能,能从逻辑上对内存的容量加以扩充的一种存储器系统。
将虚拟存储器这种思想与分页存储管理结合,一次只将作业的部分页面加载到内存中,形成了一个强大的内存分配与管理系统了。引入了虚拟存储器,同样需要有页表,记录逻辑地址到物理地址的映射,只不过此时的页表更复杂了,因为,有些页可能还在磁盘上。还需要有缺页中断处理机构,因为毕竟只将一部分数据装入内存,会引起缺页中断嘛,就需要处理中断嘛;还需要地址变换机构,这里的地址变换机构功能更多,因为需要处理中断情况下的地址变换。
小补充:
Linux下,进程不能直接读写内存物理地址,只能访问【虚拟内存地址】。操作系统会把虚拟内存地址-->物理地址。
页面置换算法
最佳置换算法(理想):将当前页面中在未来最长时间内不会被访问的页置换出去。
先进先出
最近最久未使用LRU
时钟算法clock(也称为最近未使用算法NRU):将页面链接为一个环形链表,每个页有一个访问位0、1,页面置换的时候,如果当前指针的访问位为0,置换,如果为1,那么置为0,继续循环直到遇到访问位为0的页面。
改进型Clock算法:在clock算法的基础上添加一个修改位,优先替代访问位和修改位都是0的页面,其次再替代访问位为0修改位为1的页面。
最少使用算法LFU:设置寄存器记录页面被访问的次数,每次置换当前访问次数最少的。
用户态和内核态
内核态:cpu可以访问内存中的所有数据,包括外围设备,例如硬盘,网卡,cpu也可以将自己从一个程序切换到另一个程序。
用户态:只能受限得访问内存,且不允许访问外围设备,占用cpu的能力被剥夺,cpu资源可以被其他程序获取。
最大的区别就是权限不同,在运行在用户态下的程序不能直接访问操作系统内核数据结构和程序。
在Linux/UNIX 下,有下面这五种I/O 操作方式:
阻塞I/O
非阻塞I/O
异步I/O
I/O 多路复用
信号驱动I/O(SIGIO)
一般来说,程序进行输入操作有两步:
1. 等待有数据可以读
2. 将数据从系统内核中拷贝到程序的数据区。(称标准I/O)
一、阻塞I/O(BIO)
在linux中,默认情况下所有的套接字都是阻塞的,一个典型的读操作流程大概是这样
第一阶段:
准备接收数据(对于网络IO来说,很多时候数据在一开始还没有到达,比如还没有收到一个完整的UDP包。这个时候内核就要等待足够的数据到来)。这个过程需要等待,也就是说数据被拷贝到操作系统内核的缓冲区时需要一个过程的。
第二阶段:
在用户进程这边,整个进程会被阻塞(当然时进程自己阻塞)。当内核一直等待数据准备好后,它就会将数据从内核中拷贝到用户内存,然后内核返回,用户进程才结束阻塞状态,重新运行起来。
二、非阻塞I/O(NIO)
当用户进程调用了recvform这个系统调用,如果内核中的数据还没有准备好,那么它并不会阻塞用户进程,而是立刻返回一个error。从用户进程角度讲,它发起了比如read操作后,并不需要等待,而是马上就得到了一个结果。用户进程判断结果是一个error时,它就知道数据还没有准备好,于是它可以再次发送read操作。一旦内核中的数据准备好了,并且再次收到用户进程的命令,那么它马上将数据拷贝到了用户内存然后返回。
用户进程不断主动询问内核数据好了没。
三、异步非阻塞I/O模式(AIO)
用户进程发起read操作之后,立刻就可以开始去做其它的事。而另一方面,从内核的角度, 当它受到一个asynchronous read之后,首先它会立刻返回,所以不会对用户进程产生任何 阻塞。然后,内核会等待数据准备完成,然后将数据拷贝到用户内存,当这一切都完成之后,内核会给用户进程发送一个信号,告诉它read操作完成了。
四、I/O多路复用
文件描述符
在Linux操作系统中,你将一切都抽象为文件,那么对于一个打开的文件,应用程序怎么对应上呢?
文件描述符:File descriptor,简称fd,当应用程序请求内核打开/新建一个文件时,内核会返回一个文件描述符对应这个打开/新建的文件,其文件描述符本质上就是一个非负整数。实际上,它是一个索引值,指向内核为每一个进程所维护的 该进程打开文件的记录表。在程序设计中,一些涉及底层的程序编写往往会围绕着文件描述符展开。但是文件描述符只适用UNIX,Linux这样的操作系统。
既然为每个请求分配一个进程/线程的方式来处理socket不合适,那有没有可能只使用一个进程来维护多个Socket呢。那就是I/O多路复用技术。
一个进程虽然任一时刻只能处理一个请求,但是处理每个请求的事件时,耗时控制在1毫秒以内,这样1秒内就可以处理上千个请求,把时间拉长看,多个请求复用了一个进程,这就是多路复用,类似一个cpu并发多个进程,所以也叫时分多路复用。
内核提供select/poll/epoll 这三个API给用户态的多路复用系统调用。
select/poll:
select实现多路复用的方式是,内核将已连接的Socket都放到一个文件描述符集合,然后调用select函数将文件描述符集合拷贝到内核里,让内核来检查是否有网络事件发生,检查的方式是通过遍历文件描述符集合,当检查到有事件产生后,将该Socket标记为可读/写,接着再把整个文件描述符集合拷贝回用户态,然后用户态还需要再遍历该集合找到可读/写的Socket,然后再进行处理。
对于select这种方式,需要进行两次遍历文件描述符集合,还会发生两次拷贝文件描述符集合。
select使用long类型的数组来表示文件描述符集合,poll使用链表形式来表示文件描述符集合,突破了select的文件描述符个数限制
但是poll和select并没有太大的本质区别,都是使用线性结构存储进程关注的Socket集合,因此都需要遍历描述符集合来找到可读/写的Socket,而且还要再用户态和内核态之间拷贝文件描述符集合,这种方式随着并发数上来,性能损耗会呈指数增长。
epoll:
epoll通过两个方面,很好解决了select/poll的问题。
第一点,epoll直接在内核中使用红黑树来跟踪进程所有待检测的文件描述符,把需要监控的Socket通过epoll_ctl()函数加入内核中的红黑树里(红黑树增删查时间复杂度O(logn))--->唯一需要复制的地方。
通过对这颗红黑树进行操作,就不需要每次操作时都传入整个socket集合,只需要传入一个需要检测的socket就好
第二点:epoll使用事件驱动的机制,内核里维护了一个链表来记录就绪事件,当某个socket有事件发生时,通过回调函数内核会将其加入到这个就绪事件队列中,当用户调用epoll_wait()函数时,只会返回有事件发生的Socket,不需要像select\poll那样轮询扫描。
- 感谢你赐予我前进的力量