进程与线程
1. 什么是进程?
什么是程序?什么是进程?
程序
:程序是程序员编写的源代码,一般来说在计算机中以文本的方式存在,在编译之后,就会生成一个二进制的可执行文件,当运行这个文件的时候,它就会被装载
到内存中,然后CPU就会执行其中的每一条指令,这个运行中的程序就是进程
什么是进程中断?
首先讲讲为什么需要进程中断
,进程的中断通常来讲是这样的,在一个多级批处理系统中,同一个时刻有多个进程被创建,并且通过PCB以及进程状态队列管理这些进程,但是CPU只有一个,这也就意味着,只一个进程获得了CPU之后,其他进程就无法获得CPU了,假设这样一个场景,就是进程在执行过程中发起了IO请求,那么进程在只有获得了IO完成之后的响应之后才能够继续运行,如果这时候还让这个进程独占CPU,其他进程需要CPU又用不到了
基于这个情况,提出了中断的概念,这个概念是说,当一个进程因为阻塞或者其他原因退出CPU的时候,在之后需要重新调度这个进程的时候,就会向CPU发起一个中断,在发起这个中断之后,就会重新调度相应的进程执行
这样做的优点主要是在进程陷入阻塞之后,可以让其他进程运行,而在阻塞解除之后,CPU有能力继续这个进程的执行。
什么是并发?什么是并行?
并发
:并发指的是同一个时间段内有,有多个进程/线程交错
运行
并行
:并行指的是同一时刻有,多个进程/线程同时
运行
2. 进程的状态模型
对于进程的状态,实际上有最简单的三状态模型,还有完整的五状态模型
什么是三状态模型?
三状态模型描述了进程的部分最重要的生命周期,他们分别是:
- 运行状态
(Running)
:它表示着进程获得了CPU,并且在CPU的调配下运行代码 - 就绪状态
(Ready)
:进程获得了除了CPU之外的所有资源,比如说完成了进程内存空间的分配等 - 阻塞状态
(Blocking)
:进程可能因为发起了IO请求等,需要等待内核缓冲区
数据准备完毕而暂停运行的状态,这时候它需要等待这个事件发生了回调之后才能继续执行
什么是五状态模型
关于五状态模型,这个模型描述了进程完整的生命周期
- 新建状态
(New)
:它表示的是这个进程的PCB已经被创建好了,但是还没有分配具体的内存空间,还不能被调度上CPU - 终止状态
(Terminated)
:它表示的是进程正在从操作系统中取消注册,这个过程中执行例如回收内存空间的相关操作,但是PCB这个数据结构没有完全从操作系统中删除
简述状态轮换
状态轮换,在操作系统的实现中,一般是通过一个队列来实现的,在操作系统中,分别有:
就绪队列
:就绪队列指的是进程在新建完毕后,就会被送入这个队列中,然后在CPU调度的时候,就会从这个队列中选中进程送入CPU
阻塞队列
:阻塞队列指的是进程在运行过程中,发起阻塞请求之后就会送入这个队列,然后在收到中断的时候,就会从这个队列中取出相应的进程执行,注意这个队列会分类的,比如说IO请求吧,可能会分成打印机设备的IO请求的队列,网络设备的IO请求的队列,或者是某某事件请求的队列
进程的生命周期:
首先操作系统从后备作业井中取出一个作业
,这个作业不是进程,而是一个进程的模板,也就是说要基于这个作业的描述,来创建你的进程
第一个过程,基于作业的描述创建进程,这时候进入进程从NULL->新建状态
,这一步的工作其实就是创建了一个PCB,并且交给了操作系统管辖,但是此时还没有被进程分配空间,比如说分配虚拟内存的页表空间,以及相关的寄存器等
第二个过程,基于进程所需要的空间,为进程分配空间以及一系列的进程初始化工作,在完成之后,这个进程就会被插入到就绪队列中,这时候进入进程从新建状态->就绪状态
第三个过程,基于CPU的调度策略,从就绪队列
中挑选出一个进程,上CPU执行,这时候进程就进入到了运行态
第四个过程,基于CPU的调度策略,当运行态的进程运行到中途发起了IO请求陷入阻塞的时候,这时候就会退出CPU,然后将其插入到IO请求对应的阻塞等待中,进入一个运行状态->阻塞状态
第五个过程,基于内存的管理策略,当操作系统发现内存不足的时候,这时候会将阻塞态的进程所分配的空间换入到磁盘中,进入到一个阻塞状态->阻塞挂起态
第六个过程,基于内存的管理策略,当操作系统发现需要调度挂起态的进程的时候,这时候会将阻塞挂起态
的进程换入到内存中,然后进入到一个阻塞态
第七个过程,基于CPU的调度策略,当阻塞态的进程需要调度上CPU的时候,这时候就进入到一个运行态
第八个过程,基于内存的管理策略,当操作系统发现内存不足的时候,这时候会将就绪态的进程所分配的空间换入到磁盘中,进入到一个就绪状态->就绪挂起态
第九个过程,基于内存的管理策略,当操作系统发现需要调度挂起态的进程的时候,这时候会将就绪挂起态
的进程换入到内存中,然后进入到一个就绪态
,然后重新参加进程调度
第十个过程,当运行结束之后,这时候就会执行相关的进程收尾工作,这时候PCB还没有从操作系统中删除,这时候是一个终止态
3. 进程在操作系统中的存在形式
为什么需要有阻塞队列和就绪队列?
这是因为在执行CPU调度的时候,是要从特定的状态的进程集中进行检索的,如果没有这个队列,那么就意味着要遍历所有注册在PCB上的进程,在这样的情况下,将会极大降低CPU调度的效率,基于这个情况,设定了阻塞队列和就绪队列
在Linux
源代码中,进程的描述是通过结构体PCB
的形式进行说明的,主要包含了有什么信息呢?这个问题可以这样来说明
进程在操作系统中工作,关联到的有:
- 进程调度子系统,因此存储有进程的一些基本信息,比如说有进程的唯一标识符,用来在PCB表中标识这个进程,有进程的归属用户,表示这个用户是谁创建出来的。还有进程控制的一些相关信息,比如说进程分配的优先级,它在调度的时候占的权重是多少,进程当前的状态
- 进程在工作时,是基于进程中分配的空间和操作系统分配的资源进行工作的,因此它内部有一张资源分配清单,比如说这个进程打开了什么文件,正在使用什么IO设备,分配的内存空间地址在什么地方
- 进程的运行和CPU脱不开关系,因此CPU中寄存器中的值,当进程被切换的时候,CPU状态的相关信息都会被保存到PCB中,这个东西就叫做现场,在进程被换下到的时候,这时候就会将寄存器中的值全部记录到PCB中,这样的话就进程恢复运行的时候,就能够将之前的运行状态恢复下来了
PCB是如何组织的
通过进程队列的方式,比如说有等待队列,就绪队列,运行队列(实际上是运行指针,通常来说一个CPU只能运行一个进程,因此它是一个指针,指向一个进程实例),那么新建队列呢?
新建队列可以理解就是等待队列,因为新建的过程就是要等待操作系统为止分配资源,在分配完毕之后,就将这个进程从队列中弹出,然后插入到就绪队列中
4. 进程的控制原理
进程的控制原理主要可以从每个状态的转移都发生了什么来讲述
进程新建的时候,都发生了什么?:进程新建的时候,首先操作系统会为其创建一个PCB
,然后在这个PCB上填入相关信息,然后等待操作系统为其分配资源,这个期间都处于一个新建态,这时候其实是处于一个等待队列中的,然后在创建完毕之后,就会从等待队列中弹出,然后插入到就绪队列中,供CPU挑选调度
进程被调度上CPU的时候,都发生了什么?:进程被调度的时候,一般来说需要更改PCB头部信息,将原本在CPU上的进程插入到指定的队列中,然后操作系统中的运行指针指向被调度的进程。
进程被阻塞的时候,都发生了什么?:进程被阻塞的时候,这时候进程就会退出CPU,这时候就会将当前CPU的运行环境,比如说有程序计数器,寄存器(比如说有栈顶寄存器%rbp,%rsp)等这些寄存器中的值,全部保存到PCB中,然后将这个PCB插入到一个等待队列中,然后就会运行指针就会指向新调度的进程了
进程被唤醒的时候,都发生了什么?:进程被唤醒的时候,这时候进程会从原来的队列中被弹出,然后运行指针指向这个进程,接着将PCB中的CPU相关信息恢复到CPU的寄存器上,然后CPU开始执行相关的指令
进程被终止的时候,都发生了什么?:进程可以有三种终止方式,分别是:
- 正常的终止,当子进程终止的时候,它在父进程继承的资源就会被回收,如果是父进程被终止了,那么子进程就会变成一个孤儿进程,操作系统会将这个进程托管给1号进程,父进程的资源就会被回收,然后将这个对应的进程的PCB中队列中删除
- 异常终止
- 外界干预(kill)
5. 进程的上下文切换发生了什么?
首先要搞清楚为什么需要进程上下文切换
,进程上下文切换发生的时机有:
- 按照CPU的调度策略,在一定时间片结束之后,会换用新的进程执行
- 当前执行中的进程陷入了一个阻塞的状态
CPU的上下文切换
CPU在执行的时候,必须知道当前进程的执行环境,比如说进程执行中的函数调用地址,函数返回地址,函数执行过程中的中间操作数栈,为了解决这个问题,而每个进程的执行过程又都是不一样的,如果没有一个数据结构来存储当前的执行情况,那么在发生了进程切换之后,就无法找回原来的执行状态了,为了解决这个问题,设计了CPU上下文切换这种机制
简单来说就是将CPU的相关寄存器信息,以及当前页表地址等环境信息存储到PCB中,这个叫做保护现场
将CPU的相关寄存器信息,恢复到CPU上,这个叫做恢复现场
,恢复现场的过程就是说将相关的信息恢复到CPU上,好让CPU知道那些执行信息从哪里加载,从哪里执行
CPU的上下文切换有哪几种?
- 进程间上下文切换
- 线程间上下文切换
- 中断上下文切换
其实中断上下文切换可以看作是进程间上下文切换的一种,因为从本质上讲,中断实际上是调用了内核程序的函数,你可以理解成内核程序和用户程序之间发生了一次上下文切换
因此系统调用的时候,会引起一定的额外开销
6. 什么是线程?
为什么需要线程?只有进程不行吗?
证明一个设计不行的办法,就是假设没有这个设计会发生什么?假设我们要设计一个视频播放程序,这个程序要完成三件事情,分别是:
- 从网络或者本地磁盘中读取文件信息(IO事件,引发阻塞)到用户缓冲区中
- 用户缓冲区中的信息被解码为可播放的文件信息(CPU运算)
- 播放这些东西
(I/O)
如果只有进程,那么我们可以启动三个进程,这三个进程分别执行这三个任务,但是问题是:如何来共享数据?效率会不会太低了?
我们这样分析,首先共享数据,意味着我们需要通过fork()
或者共享内存的方式来读取缓冲区中的数据,要对共享内存区域施加保护
其次,效率问题,进程间的切换是重量级的,尤其是在内存占用大的进程中,切换页表是十分重量级的操作,引起效率降低
可以看出,首先就是效率低下的问题,如果我们可以创建一个结构,这个结构可以在不引发进程切换的情况下,同时安排多种任务的执行,尤其是可以同时执行IO任务和CPU运算任务的这种模型,而且这些任务都是共用一块内存空间的就好了
因此提出了线程
线程之间可以并发执行,并且共享相同的内存空间,线程是进程间执行的一条流程,线程的切换代价很小,只需要维持线程执行过程中的寄存器相关数据就可以了,线程之间可以并发执行,只要保证并发安全,就可以实现对同一个进程空间内的数据访问,他们可以共享代码段,但是每个线程都各自有一套独立的寄存器和栈,这样就可以确保控制流是相对独立的
线程对比进程,有什么优缺点?
- 首先线程它是轻量级的,在上下文切换调度的时候,维持的现场是少量的
- 各个线程之间可以并发执行,一个进程中可以划分多个子任务,提高效率
- 线程之间可以共享文件资源,不需要通过消息队列等需要经过内核的方式来执行
缺点:进程
中的一个线程崩溃的时候,其他线程就都会崩溃,这是因语言而异的,具体来说,就是用户级线程和内核级线程的区别
7. 线程和进程有什么区别?
区别主要体现在创建的开销,执行时的开销,销毁时的开销
(1)创建的开销,进程的创建涉及到一个内存的分配和相关资源平台的创建,比如说有文件管理相关结构的创建,内存相关结构的创建,而线程的创建只需要创建TCB即可,资源管理机构是共享的,因此创建线程要比创建进程快得多,同时也可以看出,进程是资源调度的基本单位,而线程是是CPU调度执行程序流的基本单位
(2)执行时的开销,在执行的时候,进程执行上下文切换可能会导致页表这样重量级数据结构的切换,而线程的上下文切换只需要保存少量信息,比如说寄存器,栈等相关信息就可以完成切换了,同时线程间的通信或者数据共享,因为是处在同一个地址空间的,因此不需要内核的干预就可以共享数据,而进程间的通信,比如说IPC中的消息队列/信号量这样的机制,都是基于内核实现的,在这样的情况下,引起了更大的开销
(3)销毁时的开销,进程在销毁的时候,需要完成相关资源的释放,比如说有文件的关闭,内存的回收(具体表现为空闲列表/内存分区整理)等工作,这些都需要一定的开销,而线程的创建,只是删除TCB的相关信息而已,一般来说就是释放掉几个变量值,非常块
8. 线程在操作系统中是如何实现的?
用户级线程
:在用户空间实现的线程,不是由内核管理的线程,是由用户态的线程库来实现线程的管理
内核线程
:在内核中实现的线程,是由内核管理的线程
轻量级线程
:在内核中用来支持用户线程的
这个知识点比较晦涩,我们以实例来了解,区分用户级线程和内核级线程,可以从这些线程在什么地方调度进行区分,也就是说,用户级线程的调度发生在用户态下,而内核态线程的调度发生在内核态下,还是很难懂,下面举个例子
以Go语言中的协程为例,它就是一个用户级线程,假设我们在一个执行线程中开辟了5个协程,那么这5个协程就是在这个线程执行的过程中执行程序流的,你可以理解成,有五条不同的程序流,在一个线程间来回切换,在操作系统内核程序看来,尽管有五条不同的程序流,但是始终只有一个线程(或者是一个进程)在执行,这种模型称为是用户线程:内核线程= N:1的模型
以Java语言中的线程为例,底层是使用C/C++中的pthread来实现的,每创建一个线程,操作系统都能感知到,因此在执行调度的时候,每个线程都对于一个内核级线程,这种模型称为是用户线程:内核线程 = 1:1的模型
总结:如何来理解用户级线程
用户级线程
:它是由语言本身的函数库来实现的,线程的调度发生在用户态,可能在程序方面创建了N
个线程,但是在操作系统内核感知中,就只是一个进程被创建了或者是一个线程被创建了,当发生了内核级的线程调度的时候,这N个用户级线程全部都会被挂起
用户级线程有什么优缺点?
优点:多个用户级线程对应一个内核级线程,因此在这些用户级线程不停调度的时候,这时候不会触发系统调用,就好像只是发生了函数执行到一半然后切换一样,在实现上就是在会在进程内部维护一张TCB表,通过这个TCB表来完成进程内部的用户级线程的调度
缺点:由于操作系统不参与用户级线程的调度,如果一个用户级线程发生了阻塞,那么这就意味着共用同一个内核线程的这些用户级线程就会被全部阻塞,当一个线程开始运行之后,除非它主动交出CPU的使用权,否则它所在的进程当中的其他线程无法运行。同时运行会比较慢,因为这多个用户级线程共用的一个内核级线程的时间片
内核级线程有什么优缺点?
内核级线程只有在支持线程技术的操作系统中有,那么这张TCB表就是操作系统亲自维护的,于是在这样的情况下,就是Java
中的线程实现模型,具体来讲,就是Java中new Thread().start()
之后,在操作系统中就真的会创建一个新的线程然后执行
优点:(1)在一个进程当中,如果某个内核线程被阻塞了,不会引发其他线程的阻塞(2)分配给线程,多线程的进程获得更多的CPU运行时间
缺点:(1)需要维护TCB表,当TCB表中的元素过多的时候,可能导致性能下降(2)线程的切换都是基于内核来完成的,上下文切换开销巨大
什么是轻量级进程
轻量级进程
是操作系统内核支持的用户线程,首先要理解上面讲的这个内核级线程和用户级线程都是在同一个进程间调度的,那么在不同进程间的线程间调度,无论是哪种调度,都会调度进程的切换,所以在这样的情况下,提出了轻量级进程
轻量级进程的概念,就是进程的概念模糊了,就好像CPU调度上轻量级LWP的时候,就好像在调度同一个进程中的线程一样,这样的话就减少了上下文的开销
9. 进程/线程调度算法有哪些?
从总体上来看,进程的调度算法有抢占式的调度算法
、非抢占式的调度算法
- 抢占式的调度算法:根据一定的算法,计算出这个进程所需要执行的时间片,在运行完这个时间片之后,强制让其退出CPU
- 非抢占式的调度算法:挑选一个进程运行,直到这个进程运行完毕后/阻塞,才让它退出CPU
下面简述一下相关算法:
(1)先来先服务算法
:简单来说,就是根据插入就绪队列的先后顺序,来决定谁先上CPU,这种算法最好要搭配时间片来使用,就是说在一个进程运行一定时间片之后,将其加入到队列的尾部,它对长作业是有利的,不适合IO密集型的系统,因为假想一下,一个线程需要大量IO,但是IO时间非常短,一IO就会插到尾部去了,这样的话获得CPU的机会很少
(2)最短作业优先算法
:最短作业优先算法理论上可以,但是实际上因为无法估算一个作业运行时间而无法执行这种算法,这种算法的初衷就是提供系统的吞吐量,因为单位时间完成的任务数量越多,那么吞吐量就越高,那么将CPU有限分配给那些运行时间短的任务,就能尽可能短的时间内完成尽可能多的任务,但是可能导致长任务饥饿
(3)最高响应比优先调度算法
:它是根据一个响应比这个指标来动态调整谁先上CPU的,它通过(等待时间+要求服务的时间)/(要求服务的时间)
这个比例来判断,谁相对来说等的最久,这样的话就能够避免饥饿的问题,因为等待时间越长,要求服务的时间越短,这个比例就会越大,因此照这样来看,就能够避免等待时间太长的问题发生
(4)时间片轮转算法
:每个进程都被分配一个时间片,在时间片运行完毕后自动退出,重点是如何设计CPU的时间片,如果时间片太长,那么就可能导致进程饥饿,如果太长,那么就会导致频繁的上下文切换调度
(5)最高优先级算法
:根据进程的优先级,优先挑选那些优先级高的进程上CPU,静态的优先级,提前设定好的优先级,动态的优先级,比如说最高响应比优先,其实就是一种动态优先级的思路
(6)多级反馈队列优先算法
:我理解它就是先来先服务算法+最高优先级算法结合,它设置了多个队列,每个队列的运行时间片都是不一样的,假设一个进程刚来,那么它就会进入一级队列,然后它会执行一级队列中规定的时长,如果执行完了这个时长还没执行完,就会推到第二集队列,不断推,直到执行完毕为止,反馈意思是讲,当一个进程到了更高级的队列的时候,这时间就执行更高级队列中的进程
判断调度算法好坏的指标有哪些?
CPU的利用率
:调度算法能不能保证CPU始终是匆忙的状态
系统的吞吐量
:吞吐量表示的是单位时间内CPU完成进程的数量,长作业的进程会占用较多的CPU资源,因此会降低吞吐量
周转时间
:进程运行+阻塞+等待的总和,越短越好
等待时间
:这个等待时间不是阻塞等待的时间,而是进程处于就绪队列中的时间
响应时间
:用户提交请求到系统第一次产生响应所花费的时间,在交互式系统中,响应时间是衡量调度算法好坏的主要标准,尤其是客户端相关的开发,要重视这个指标
进程间的通信方式(IPC)
进程间的通信方式大致有如下的方式
1. 基于管道的通信方式
list | grep inst
这个|
就是一个管道,它的作用就是将上一个进程的输出作为参数输入到下一个进程中,这个过程就是一个进程间通信的过程,它的底层是基于fork()
和文件描述符共享这样的方式来实现的
如果要彻底理解管道通信方式,那么就先要理解什么是fd
什么是fd?
关于什么是fd的问题,可以用这张图来表示:
可以看到fd
表中存储了大量的fd索引值,通过这个索引指向文件表,然后文件表中存储了真正的文件地址,最终对这个文件进行读写
那么管道它在linux
源码的底层中,就是在File table
中注册了<操作方式,文件地址>
这样的键值对,通过这个fd,就可以基于这个fd对文件进行相应的读写操作了
那么接下来看管道相关的操作,它底层源代码是通过void pipe(int[2] fd)
这个系统调用来实现的,通过这个系统调用,在File table
上注册上read&write
的fd,指向的是同一个inode
,那么管道的话就可以通过那个read
的fd来从文件中读取数据,可以通过write
的fd来从文件中写入数据,那么这仅仅只是进程中对这个文件的管理而已
然后在使用了管道之后,它其实还做了fork()
,简单来说就是创建一个子进程,让这个子进程也拥有这个fd
的信息,这样的话,父进程就可以基于这个fd
来向文件中写入数据,然后子进程就可以基于这个fd
来向文件中读取数据了
如果需要双向通信怎么办?
双向通信的话,首先我们要了解,单向通信的时候,这时候父进程会关闭读取的fd
,子进程会关闭写入的fd
,如果是双向通信的话,那么就需要创建两条管道,然后父进程关闭写入的fd
,子进程关闭读取的fd
,这样的话就可以实现两条管道通信,互不干扰了
关于管道通信的弊端
基于fork()
实现的,如果父进程具有的数据非常多,那么就可能导致进程缓慢,而且一次fork()
,我们真正想要的数据就是两个索引值,开销和收益不成正比.
2. 基于消息队列的通信方式
消息队列
:管道通信的弊端在于,每一次通信都需要执行fork()
,效率低下,如果需要频繁地交换数据的话,那么这就意味着需要不断地fork()
,频繁地执行这个系统调用,然后复制数据,每一次消息通信都这样的话,那未免效率太低下了
所以的话,一种思路就是设置一个缓冲区,通过这个缓冲区,双方进程从这个缓冲区中读取数据就可以了,这其实就相当于一个生产者/消费者模型,生产者进程往这个缓冲区中丢数据,消费者进程从这个缓冲区中读取数据,Linux的实现方式是在内核中设置一条链表,然后生产者执行push_back()
,消费者执行pop_back()
,但是由于是内核缓冲区中的数据,因此每次取数据都需要执行上下文的切换(执行系统调用),所以在这样的情况下,也会造成一定的开销,但是相比起fork()
的方式,已经是非常轻量级的了
消息队列的弊端?
- 通信不及时,首先,生产者的投递需要将数据从用户缓 冲区拷贝到内核缓冲区,这存在一定的时延
- 没有持久化,消息存在的是内核缓冲区中,如果操作系统挂掉了,那么消息就丢失了
- 不适合大数据量的传输,首先就是时延很高,操作系统还对消息做了一个限制,不能发送消息长度大于
length()
的消息
3. 基于共享内存的通信方式
上面讲到了消息队列这样的方式,它的弊端在于每次读取数据都需要陷入一次系统调用,而且对于大数据量的数据处理的时候,有明显的瓶颈,所以在这样的情况下,提出了共享内存的方式
共享内存
的原理,首先要搞清楚共享内存的支撑是什么,共享内存的支撑首先是虚拟内存技术,虚拟内存技术使得每一个进程都有自己的一个虚拟内存空间,然后每个进程都可以基于自己的虚拟内存空间执行内存的操作,它底层是基于一个逻辑地址=>物理地址
的映射来实现的,具体来说,就是在程序中划定的地址,每个进程可能是一样的,比如说进程A可能有一个变量地址是0x01
,进程B也可能有一个变量地址是0x01
,但是在具体的物理内存中,这两个变量的物理地址是不一样的。
那么共享内存就是抓住了这一点,它将映射的物理内存地址设置为了同一块,这样的话另一个进程对这块内存的操作,另一个进程就能马上看到了,这样做的好处是:
- 提高了通信效率,避免了数据来回拷贝
缺点是:
- 可能产生并发安全的问题,需要通过信号量/加锁的方式来实现线程安全
4. 基于信号量的通信方式
上面讲到了共享内存来实现进程间的通信,那么在这样的情况下,需要保证并发安全,就可以基于信号量来实现,信号量在操作系统底层中是这样定义的
struct seamphore{
int value;
queue blockQueue;
}
当需要对共享内存执行互斥保护的时候,这个value
就会被初始化为1,当有一个进程需要获取资源的时候,就需要执行acquire()
操作,然后就会这个value--
,如果value == 1
,那么代表获取成功
如果value <=0
,那么就代表获取失败,就会将当前这个进程加入到这个blockQueue
中
当对资源操作完毕后,就需要执行release()
的操作,这时候会检查,如果value == 0
,那么就代表只有自己获取到这把锁,于是直接value++
,如果value < 0
,那么就证明有人因为获取到这把锁被阻塞了,就需要唤醒blockQueue
中的进程,然后value++
当需要对共享内存执行一个同步保护的时候,这个value
就会被初始化为0
,比如说生产者消费者模型吧,A是生产者,A在生产区满的时候会将这个semaphore
执行一个release()
的操作,然后消费者B才能去消费
如果消费者B提前去消费了,那么在value == 0
的情况下就会被阻塞,只有A生产完毕后,B才会被唤醒,这样就可以实现同步的效果了
5. 基于信号的通信方式
信号
:在Linux
操作系统中就是kill
指令,它可以在任何时刻给任何进程发送一个信号,如果进程设置了信号回调函数的话,那么就会执行这个信号回调函数,对信号的处理主要有:
- 执行默认操作,比如说终止进程
- 捕捉信号,其实就是在程序中注册一个信号回调函数,当收到这个信号的时候执行某某操作
- 忽略信号,
SIGKILL
和SEGSTOP
无法忽略。
有没有无法杀死进程的情况?
要了解这个问题,首先要明白什么是僵尸进程
?什么是孤儿进程
?
这两种进程是特殊状态下的进程,当在执行fork()
的时候,会出现父子进程这一说,但是实际上,父子进程之间退出顺序是不确定的,因为fork()
其实就是复制出来了两个进程
第一种情况,父进程早于子进程结束,这时候子进程就变成了孤儿进程,这时候操作系统会将这个子进程托管到init
这个一号进程上,然后init
进程会定期执行wait()/waitPid()
来执行子进程的资源回收,主要是删除PCB,然后还原PCB表中可用的PID
第二种情况,父进程晚于子进程结束,这时候子进程就变成了僵尸进程,它说的是子进程虽然释放了自己全部资源,但是子进程的PCB信息还没有被回收,比如说还占用着PID
,如果出现了大量的僵尸进程,那么就会导致无法再创建新的进程了,这种危害是比较大的,解决办法是这样的:
将这种出现大量僵尸子进程的父进程干掉,然后全部转移给init进程,这样的话就会回收掉这些PCB
或者就是加强编码意识,在父进程相关代码中添加定时
wait()/waitPid()
的方式来回收掉这些进程或者是注册信号函数,通过注册
SIGCHILD
信号处理程序,当进程结束的时候向这个父进程发送信号,父进程随即调用wait()/waitPid()
回收资源fork()
两次,意思是讲,有一个P进程
,然后通过fork()
,产生C1进程
,然后在C1进程再执行fork()
,产生C2进程
,然后C1进程马上退出,C2
就会被init进程
托管了
回到问题,无法杀死进程其实就是杀死子进程的情况,子进程虽然资源被释放了,但是由于PID还注册在进程表中,看起来就好像没有杀死一样,方法如上。
6. 基于Socket的通信方式
网络通信,从最本质上来讲,其实也就是分布在不同网络上的主机上的进程进行通信,如果需要跨主机通信的时候就需要用这个Socket
进行通信了,下面来讲讲socket
的创建原理
int socket(int domain,int type,int protocol)
在C/C++
中使用Socket
通信的时候,分别有三个参数,主要有:
- domain:指定协议族,比如说有
AF_INET表示IPV4
、AF_INET6表IPV6
、AF_LOCAL/AF_UNIX
用于本机通信 - type:用来指定通信的特性,比如说有
SOCK_STREAM
表示字节流,对应的TCP这样的连接方式,还有的有SOCK_DGRAM
表示的是数据报,对应的是UDP
、SOCK_RAW
表示的是原始套接字 - protocol参数表示的是用来指定通信协议的,但是目前基本废弃了
那么实现不同的协议的通信参数配合有
- 实现TCP字节流通信:AF_INET、SOCK_STREAM
- 实现UDP字节流通信:AF_INET、SOCK_DGRAM
- 实现本地字节流,只需要将协议换成是
AF_LOCAL/AF_UNIX
就可以了
说说使用TCP字节流的底层实现
第一步,服务端和客户端初始化一个socket
,这时候会在底层中创建文件,然后返回读写相关的fd
第二步,服务端调用bind
,设置socket的IP和端口
第三步,服务端调用listen
,进行监听
第四步,服务端调用accept
,这一步其实就是不断轮询是否有socket完成了连接初始化,然后它就会取出这个socket对应的fd,监听其中的数据变化
第五步,客户端调用connect()
,执行经典的三次握手
第六步,服务端accept()
,然后告诉客户端,我本地有一个文件索引是fd
,专门用来给你写数据的,你下次发数据包的时候,就在这个数据包上标明要写入服务端上哪个文件fd
,然后协议栈就会根据这个信息,将相关字节写入这个文件中
第七步,客户端write
、服务端read
第八步,户端断开连接时,会调用 close
,那么服务端 read
读取数据的时候,就会读取到了 EOF
,待处理完数据后,服务端调用 close
,表示连接关闭。
说说使用UDP数据报的底层实现
网络通信部分是这样实现的
第一步,客户端和服务端都执行new socket()
,然后执行bind()
,监听端口上是否有数据到来
第二步,客户端执行sendTo()
,然后等待数据的返回,也就是执行recvfrom()
第三步,服务端执行recvFrom()
,然后执行sendTo()
本地 socket 被用于在同一台主机上进程间通信的场景:
- 本地 socket 的编程接口和 IPv4 、IPv6 套接字编程接口是一致的,可以支持「字节流」和「数据报」两种协议;
- 本地 socket 的实现效率大大高于 IPv4 和 IPv6 的字节流、数据报 socket 实现;
对于本地字节流 socket,其 socket 类型是 AF_LOCAL 和 SOCK_STREAM。
对于本地数据报 socket,其 socket 类型是 AF_LOCAL 和 SOCK_DGRAM。
本地字节流 socket 和 本地数据报 socket 在 bind 的时候,不像 TCP 和 UDP 要绑定 IP 地址和端口,而是绑定一个本地文件,这也就是它们之间的最大区别。
死锁篇
1. 什么是死锁?
死锁从现象上来说,就是在做并发安全控制的时候,因为控制不当而导致的,假设这样一个场景,有线程A和线程B同时需要资源1和资源2
然后线程A申请资源1成功了,占用了资源1,然后申请资源2的时候发生了时间片超时
然后线程B申请资源2成功了,然后申请资源1的时候,因为不够,陷入了阻塞
然后线程A恢复,继续申请,发现被线程B占用,于是这就出现了线程A等待线程B,线程B等待线程A的情况
从发生的因果关系上来看,要满足下面的四个条件,分别是:
对临界资源的控制是互斥的
占有而且等待,线程在获取了资源之后,在申请新的资源的时候如果不足的时候就会被阻塞,同时不会释放手上的资源
不可剥夺,线程在被获取的之后,除非自动释放,不然不会被剥夺
形成循环等待
2. 死锁的解决办法
死锁的解决办法通常有预防
、避免
、检测
和解除
死锁预防
预防
:是采用某种策略,限制对并发资源的请求,重点是使得死锁条件在系统执行的时间点上,都不同时成立
首先互斥条件
是无法破坏的,因为破坏了互斥条件的话,就无法做到资源的保护了
其次,不可剥夺
这个条件也可以使用,但是剥夺式的算法可能导致系统处理效率下降,举个例子,一个进程在获取了打印机资源之后,如果这时候有其他进程强行剥夺了这个资源的话,就有可能导致出现异常现象,因此这个不可剥夺并不适用于全部的资源
因此可以从占有且等待
和形成循环等待链
的这两个条件入手
有两种策略:
- 静态资源分配策略:这种策略说的是在进程执行的时候,提前就将资源一次性全部分配给它,然后才开始执行,这样的话就可以避免占有而且等待的情况,静态资源分配策略避免了一个进程占有了资源而无法启动的情况发生
- 层次分配策略:它破坏了形成循环等待链的情况发生,这种策略是将资源进行分级,只有申请了低级的资源才能申请高级的资源,需要理解的是,为什么这种策略能够解决形成循环等待链的问题?这是因为循环等待的情况是这样的,线程A获取了高级的资源,反而去申请低级的资源,线程B获取了低级的资源,去申请高级的资源,症结在于线程A的这种情况,如果是按照层次进行分配的话,那么就出现拥有低级的资源去申请高级的资源的情况,这样就可以避免了交叉申请,最终避免了循环等待的情况发生
死锁避免
死锁的预防可以有效解决相关的问题,它有点像悲观锁,它料定系统中一定会产生死锁,于是以最悲观的方式来限定资源的申请,这样做就会导致系统的运行效率降低,那么死锁避免就是讲
假设系统中不经常发生死锁,系统只有在分配资源时,检测到可能发生死锁的时候,出手干预
有一种经典的策略可以实现死锁避免,也就是银行家算法,银行家算法的核心概念有:
- 安全状态:分配之后,能否在当前的剩余进程中找到一个执行顺序,使得全部进程运行完毕,如果存在这样的方式,那么就将资源分配给它
- 不安全状态:剩余资源无法满足就绪队列中的任意一个进程,也就是无法产生任意一个安全队列
核心思路是这样的:先试探性地将资源分配给该进程,然后执行检测算法,如果安全的话,那么就执行分配,否则的话就取消分配
死锁检测
死锁的检测算法是基于图算法
来实现的,具体地来说,就是会将进程和资源抽象成图,然后根据成环的情况来检测死锁,算法的流程如下:
- 如果进程-资源图中没有环路,这时候就没有发生死锁
如果有环的话,那么就要执行这个算法,简单来说就是这样的,首先我们先将那些既没有阻塞等待也不是孤立的节点找出来,然后把它的分配边和请求边消去,如果一直这样消下去,最终图上所有进程都是孤立的,那么就证明没有死锁
原理是什么?
首先,为什么要消去那些没有阻塞等待的节点?这是因为它不阻塞等待,证明它可以运行完,运行完了之后自然就会释放资源,然后就可以把分配边和申请边给去掉,然后看看还有哪些进程能继续运行的,如果最终没有边了,证明大家都能运行完,因此这个算法是可以成立的
死锁解除
死锁解除一般来说就要配合死锁检测算法来使用的,当操作系统检测到有死锁发生的实时,就会启动死锁解除的流程:
- 立即结束所有进程的执行,重启操作系统
- 撤销所有涉及到死锁的进程,解除死锁后继续执行
- 逐个撤销涉及到死锁的进程,回收资源直到解除
- 抢占资源,从相关进程中剥夺资源给这些进程使用,从而解除死锁
3. 实际编程中如何解决死锁的问题?
死锁一般现象是程序的执行时间过长,这时候就要到服务器上查看了
第一步,查看相关进程,使用top -n1 | grep xxx
这样的关键字查看PID
第二步,使用jstack PID
查看是否发生死锁
内存管理篇
1. 什么是虚拟内存?
什么是虚拟内存地址?什么是物理内存地址?
首先先来讲讲为什么需要有这两种地址的划分,这是操作系统中运行了多个进程,如果这多个进程同时操作物理内存地址的话,就会难以管理:
- 如果只有物理内存地址,那么在编写程序的时候,就需要在程序中将物理内存地址写死,但是这是一件不可能的事情,你开发一个程序,是给很多人用的,每个人的机器上用的内存地址都是不同的,不能行
- 进程的挂起和恢复的时候,需要重写程序中大量的地址,效率太低
因此引入了虚拟内存地址,虚拟内存地址对于程序执行来说,是透明的,它就好像在操作一块只属于自己的内存地址一样,比如说进程A定义了一个变量a,进程B定义了一个变量b,他们的虚拟内存地址都是0x1
,但是他们对这个变量的操作互相都是不可见的,那么实际上是在操作什么呢?实际上是在操作经过MMU
翻译之后的物理内存地址,通过MMU
这个桥梁,完成虚拟内存地址对物理内存地址映射,从这个角度上来说,实现了进程间操作内存的一个隔离
操作系统是如何来管理虚拟内存地址和物理内存地址之间的关系的?
这个问题简单来说,就是如何基于虚拟内存地址得到物理内存地址,并且能够保证各个进程之间所持有的物理内存地址的操作不会引发冲突?
2. 什么是内存分段?
内存分段,简单来说就是将进程中各个数据段,按照它的含义执行分段,比如说有代码段
、数据分段
、栈段
、堆段
,不同的段是有不同的属性的,所以就有分段
的形式将这些段分离出来管理
内存分段的情况下,是如何完成翻译的?
内存分段的关机数据结构是虚拟内存地址
和段表
,通过这两个数据结构来实现段地址的翻译,具体来说就是:
首先拿到一个虚拟内存地址,然后通过高x位得到这个地址在段表中的下标,比如说是1
然后它就会拿着这个下标1,去查询段表,得到这个段在物理内存中的实际地址,然后再用剩余的低x位,通过实际物理地址+这个段内偏移量
,就得到了一个实际物理内存地址
如何完成内存保护的?
段表中实际上是一个二元组,里面存储的是<段表的下标>:<段的基地址,段的界限>
,通过下标寻址,就可以知道这个段的界限,如果翻译所得的地址大于了这个界限,就会抛出段错误的异常
分段有什么缺点?
分段的缺点主要有:
- 换入换出的效率比较低,因为要执行换入和换出的时候,是一整段换入换出的,而一段可能会很长,所以涉及到的数据拷贝可能效率会比较低
- 存在内存碎片,比如说段在内存中随机分配,产生了
[512,128,256,128]
这样的布局,然后一个进程退出两个128
的段被释放了,于是在这样的情况下,如果来了一个256MB
的段需要分配,这样就会导致段无法被分配下来,而内存中明明就有256MB
的空余,这样的话就导致内存利用率低了,办法也是有的,就是执行内存整理,但是需要一定的内存开销
3. 什么是内存分页?
- 什么是
页框
:页框是一个分页概念,它将内存中划分成了一个个大小相同的单元,然后装有数据的页
就会装入这个页框中,页框是内存划分的逻辑概念 - 什么是
页
:实际装数据的内存单元,页是实际存储数据的物理概念
分页技术的情况下,是如何完成翻译的?
同理,每个进程都有一张页表,<虚拟页号>:<实际页号,页内偏移量>
,首先拿到一个虚拟地址,从高位切分出一个虚拟页号,然后拿着这个虚拟页号到页表中查询,拿到这个二元组之后,就到实际内存中查询实际的页号,然后根据页内偏移量,就能够找到实际的地址了
如何完成内存保护的?
内存保护
:主要也是通过页表界限寄存器
来判断的,如果虚拟页号大于其中存储的值的话,那么就证明越界,如果偏移量大于页大小的时候,那么也证明越界了
分页技术有什么优缺点?
先说优点:
- 内存的换入和换出的效率提高了,由于分页是固定大小的,因此在这种情况下,只需要换入和换出小部分的进程就可以了
- 解决了内存外碎片的问题,因为分页技术的存在,使得内存的分配是以页为单位的,因此离散化的分配方式可以使得内存页干掉了内存外碎片
缺点:
- 会产生内存内碎片的问题,尽管数据不足
4KB
,最后也是会分配完整的一个4KB
的页给这个进程的 - 页表内存占用高,假设一个内存为
32
位的机器,它所代表的虚拟空间是4GB
,那么就需要2^20
个页来存储页表,大约就需要4MB
的内存来存储页表,这样的开销是很巨大的。
4. 什么是多级页表技术?
多级页表相当于说给页表再建一个页表,比如说本来一个页表要要存储2^20
个页表项,那么在多级页表中,它对这些页表项进行再次划分,比如说我将0~1023
虚拟页号的这个页表记录一个起始地址,然后当我的虚拟页号落在这个区间的时候,我就去查找二级页表,然后发现0~1023
页表的起始地址为0x00
,然后将这个起始地址拿出来,然后起始地址+偏移量,就可以找到具体的页表项了,这是地址翻译的原理
二级页表的开销不是更大的吗?
确实是,但是根据程序的局部性原理,一级页表中的数据只有少部分会被加载到内存中,而只有二级页表是常驻在内存中的,而二级页表的存储开销是非常小的,这样就起到了使用更小的空间,可以存储更大搜索区间的效果
5. 什么是TLB?
多级页表
解决了页表空间占用太大的问题,但是多级页表的翻译技术使得翻译这个过程更加耗时了,于是带来了一定时间上的开,为了解决这个问题,引入了硬件机构TLB快表
,快表相当于是一个缓存,它的工作原理是这样的:
由于TLB的占用很小,而且引入了并行匹配技术,因此在其中查询某一个页表项的时候是很快的,是纳秒级的查询,在CPU寻址的时候,会先查询TLB中有没有想要的页表项,如果TLB没有命中,才会查询到在内存中的页表
TLB的高效的原因是程序的局部性原理,被存入到TLB中的页表项在最近一段时间内被多次访问。
6. 什么是段页式内存管理?
段页式内存管理
:所谓段页式的内存管理,是集成了段式和页式的管理优点的一种管理方式,主要的工作原理如下:
- 页式管理的缺点主要是它将本来具有一定含义的进程数据段拆分了无意义的页,这样的话就丢失了一部分信息
因此在这种情况下,段页式的内存管理将进程划分成了很多个具有一定逻辑意义的数据段,这些数据段具有一定的信息,比如说代码段,数据段等等,然后再把这些段分成一页一页,通过这样的设计,就可以将内存的管理单位变成了页,同时还能将进程的段记录下来,这样的话可以保留最大的信息,那么段页式内存管理的翻译流程是怎么样的呢?
首先会将一个地址划分成三个部分:
第一部分,段选择因子,其实就是这个地址在段表中的索引,它的存储格式是<段选择因子>:<页表起始地址>
第二部分,页选择因子,当得到了页表起始地址后,就基于页选择因子,得到具体的页表项在页表中的哪一个位置
第三部分,页偏移量,当得到了页偏移量之后,就可以定位这个虚拟地址的实际物理地址了
7. 虚拟内存的作用是什么?
首先,虚拟内存技术可以使得进程的占用总内存远远超过物理设备的内存,因为程序的运行符合局部性原理,CPU的访问呈现出一个28
原则,也就是只有百分之20的内存是这个时间段内才会被频繁访问的,而其他那些80%的内存就可以被放到磁盘上,节省内存
第二,通过页表分配技术,使得每个进程操作的内存不会相互冲突
第三,页表里的页表项中除了物理地址之外,还有一些标记属性的比特,比如控制一个页的读写权限,标记该页是否存在等。在内存访问方面,操作系统提供了更好的安全性。
系统内核篇
1. 谈谈什么是内核?
计算机是由软件和硬件组成的,但是实际上运行的还是底层的硬件,硬件会提供一些接口为我们提供服务,但是如果每一个开发的软件都需要操作这些硬件提供的接口,首先第一个问题就是引起很多冗余的代码,第二个问题就是在操作这些硬件的时候,可能会导致不同的开发者对这些硬件不熟悉,或者程序写死对一些硬件的操作,在更换一个设备之后,这个软件就跑不起来了,为了解决这个问题,就提出了内核程序
实际上,内核程序
就是操作系统的核心,通过内核程序,用户软件不必直接与硬件交互,而是通过操作系统提供的接口,通过这些接口来操作实际的硬件,从而发挥计算机的功能,内核的功能有:管理进程
、管理内存
、管理硬件设备
、提供系统调用
2. 谈谈什么是用户态和内核态?
在CPU的所有指令中,有一些指令设计到非常重要的功能,比如有直接操作物理内存,重置时钟等,如果直接让用户执行这些指令,就非常有可能导致系统崩溃,因此为了避免这样的情况,就将指令划分成了特权指令和非特权指令
特权指令:特权指令就是一些危险的指令,只有内核程序才能执行
非特权指令:用户态的程序就能够执行
基于安全的考虑,就将这些指令进行了分支,分成了4个ring
操作系统根据特权分级,将进程分成了内核空间和用户空间,内核空间对应的是Ring0
,用户空间对应的是Ring3
Ring3
只能访问受限的资源,不能够直接访问内存等设备,必须通过系统调用才能够陷入到内核态,然后让内核态的程序去执行相关特权指令
3. 什么时候会从用户态陷入到内核态?
系统调用
异常:当发生了异常(例如缺页异常)的时候,这时候就会触发由当前运行进程切换到处理此异常程序的程序中,也就转换到了内核态
外围设备的中断:当外围设备完成了用户的操作请求之后,这时候就会向CPU发出相应的中断信号,这时候CPU就会停下正在做的事情,然后执行一个中断处理程序,比如说正在执行一个用户态的程序,当外围设备发出了读写IO事件完毕了之后,这时候CPU就会停下来了,然后执行一个中断处理程序(这个程序是内核程序执行的)
4. 当触发了一个系统调用,这时候会发生什么?
当发生了系统调用,这时候如果本来是用户态的,会发生以下的过程:
从用户态跳转到内核态:当应用程序执行系统调用的时候,会先将系统调用的名称翻译成系统调用号,然后将系统调用号和请求参数放入到寄存器中,然后执行中断指令(int $0x80指令)
,产生一个中断,CPU陷入到了内核态中
执行内核态的逻辑:CPU跳转到中断处理程序,这时候会先执行一个现场的保存,简单来说就是将当前CPU的上下文保存到内核栈中,比如说栈顶寄存器的值,中间计算寄存器的值等等。然后这时候就会将刚才的系统调用号(其实就是中断向量号),从中断向量表中取出来一个函数入口地址,然后将函数入参带过去执行
返回到用户态:执行完系统调用之后,就会执行一个中断返回指令(iret指令)
,将原来用户态的线程恢复回来,其实就是将原本在内核栈中的现场值都恢复到CPU上,恢复执行
可以看出,一次系统调用将会导致两次CPU的上下文切换
5. 用户态和内核态是如何切换的?
在程序执行的过程中,栈是不可缺少的,因为栈是进程执行过程中的活动记录,缺少了栈,就无法执行函数返回等操作了
在Linux中,函数栈可以分为是用户栈和内核栈,通过用户栈和内核栈来实现用户进程的执行和内核程序的执行
用户栈切换到内核栈:执行中断指令(int $0x80指令)
,当中断发生了之后,CPU会到Linux中的一个特定机构(TSS)
中获取这个内核栈的内存地址,也就是获取一个段选择子(内核程序的段表中,用来标志栈段的那个索引)
、栈顶指针
,有了这两个值就能定位内核栈了,然后送入到ss
寄存器和rsp
寄存器,这时候CPU就指向了内核栈的栈顶位置了,这就完成了用户态到内核态的一次切换,其中执行栈的切换就是标志
跳入中断处理函数开始执行:简单来说就是将当前用户态执行程序中的相关寄存器的值全部压入到内核栈中
内核态跳转到用户栈:当中断结束的实时,就会将寄存器中的值全部弹出,然后恢复到CPU上下文中,执行一个iret
的指令,最重要的是将ss
寄存器和rsp
寄存器恢复到用户态执行的情况
切换上下文会使得TLB中的缓存全部失效
6. 什么是中断?中断有哪些分类?
中断是系统从来响应硬件请求的一种机制,操作系统收到了硬件的请求中断的时候,会打断当前正常执行的进程,然后调用当前内核中的处理程序来响应设备的请求
中断:由硬件设备触发,这时候外部设备会给内核发送去一个中断向量号,属于一个异步的事件
异常:CPU在执行指令的时候检测到的反常条件,比如说有除法异常等
INT指令:INT指令后面接一个数字,就相当于直接用指令的形式,告诉CPU一个中断号,比如INT 80
,这样就去执行一个系统调用
根据实现的方式不同,将中断分成了
硬中断
和软中断
硬中断是在硬件CPU执行指令的时候去检测中断,具体来说就是在执行每一条这个CPU指令的最后,都会去检查是否有中断发生,如果有中断发生了,那么就会把中断号取出来,然后执行中断处理程序,然后跳过去执行中断处理程序
软中断就是一个单独的守护进程,不断轮询一组标志位,如果哪个标志位有值了,那么就去执行哪一个中断处理函数
7. 都是定期检查中断,为什么还需要有软中断呢?
这是因为硬中断通常来说都是非常重要的操作,通常有可能会临时关闭中断,如果长时间占用硬中断函数不返回(当前中断处理程序没有执行完毕之前,都不能处理别的中断请求),那么就有可能导致其他硬中断请求被搁置太长时间,甚至产生丢失,在这样的情况下,我们希望硬中断的持续时间越快越好,Linux为了解决这个问题,将中断过程分成了两个阶段
- 第一个阶段:快速响应中断,主要负责处理那些和硬件紧密相关或者时间敏感的事件
- 第二个阶段:延迟处理上半部分没有完成的工作,一般是由内核守护进程来执行的,相当于说将中断处理程序比较复杂的部分都拆分出来了,通过异步的方式来处理这些任务
举个例子,执行一个TCP连接,然后网卡接收到一个数据报,通过DMA技术发送到内核缓冲区中,接收完毕后,网卡就会触发一个硬中断,CPU在收到这个中断之后,就会马上执行中断处理程序,第一个阶段,它会将软中断标记数组中的某一个位置标记一下,然后就完成了本次硬中断
第二个阶段,内核中的守护进程会去轮询这个标记数组,看哪个位置被标记为1了,然后执行相应的中断处理函数,以网络包的这个例子为例,这个中断就是将内核缓冲区中的数据包交付内核协议栈,内核协议栈拆包,然后读取数据,然后根据端口号等信息交付到对应的用户进程的用户缓冲区
软中断的作用就是承接中断处理函数中比较复杂而且耗时的操作,让硬中断的中断处理函数尽可能地简单,从而提高系统的响应速度
补充知识点
1. 什么是mmap?有什么应用?
mmap
是一种内存映射技术方法,将一个文件或者其他对象映射到进程的地址空间,实现文件磁盘地址和进程虚拟地址空间中一段虚拟地址的一一对映射关系,mmap
的实现是这样的,mmap
系统调用会在内核中创建一个虚拟内存的结构,这个结构会指向内存中的某个内存区,然后应用程序访问这个虚拟内存地址的时候,就会产生缺页异常,然后触发一个缺页异常的处理函数,然后读取文件数据到物理内存中,将这一块物理内存与刚才mmap
创建的虚拟内存进行映射。
实现这样的映射关系之后,进程不仅可以像访问内存一样读写文件,多个进程映射同一个文件,还能保证虚拟地址空间映射到同一块内存中,达到一个共享内存的作用
共享内存实际上是把不同进程的虚拟地址空间映射当中,不同的进程直接通过修改各自的虚拟地址空间中的内容就可以实现通信了
共享内存几乎不需要进行内存数据的拷贝就能够实现,也就是说数据从进程A的虚拟内存中写入数据,立即就能够被B所感知到,其他进程间通信的机制,需要通过Linux内核程序的多次拷贝才可以执行,因此使用共享内存比较高效
传统传输文件的补足:如果原本要实现一个文件的传输,那么要完成以下的操作,比如说要把本地主机上的文件基于socket
技术传输到远程,那么需要完成什么事情呢?
首先第一步,在设备支持的前提下,基于DMA
技术将磁盘中文件拷贝到内核缓冲区中,这首先是一次数据的拷贝
第二步,用户程序无法直接使用内核缓冲区的数据,于是需要CPU的干预,将数据从内核缓冲区中搬运到用户缓冲区中,这时候就是陷入一个内核态,内核程序将数据从内核缓冲区中拷贝到用户空间中
第三步,用户程序可以使用用户缓冲区中的数据了,然后在CPU的干预下,发起内核态的切换,然后就是将用户数据拷贝到socket缓冲区中
第四步,通过DMA技术,将socket缓冲区的数据拷贝到网卡设备的缓冲区中
可以看出,明明只是发送一份数据,却在Linux中拷贝了四次
mmap+write
这种技术可以直接将内核缓冲区中的数据映射到用户空间,这样的话,操作系统内核和用户程序之间的数据交互就不需要拷贝来拷贝去了,应用程序调用了mmap()
之后,DMA
会把磁盘缓冲区中的数据拷贝到内核缓冲区中,然后应用程序跟操作系统内核共享这个缓冲区,应用程序再调用write()
,将内核缓冲区中的数据直接拷贝到socket
缓冲区中,这一切都发生在内核态,由CPU来搬运数据
最后把内核的socket
缓冲区中的数据拷贝到网卡的缓冲区中,这个过程是由DMA
搬运的
2. 什么是零拷贝?
零拷贝技术并不说不进行拷贝,而是说没有发生不同数据空间之间的拷贝,比如说:
在上面的例子中,内核缓冲区=>Socket缓冲区
的时候有发生数据的拷贝操作,但是他们都是在内核中的,因此在这样的情况下,没有发生上下文的切换调度,这其实就是一个零拷贝的技术
3. top指令会采集什么信息?
4. free命令会有什么信息?
Mem
内存的使用信息 Swap
交换空间的使用信息 第一行 total
系统总的可用物理内存大小 used
已被使用的物理内存大小 free
还有多少物理内存可用 shared
被共享使用的物理内存大小 buff/cache
被 buffer 和 cache 使用的物理内存大小 available
还可以被 应用程序 使用的物理内存大小
free 与 available 的区别
free
是真正尚未被使用的物理内存数量。 available
是应用程序认为可用内存数量,available = free + buffer + cache
(注:只是大概的计算方法)
Linux 为了提升读写性能,会消耗一部分内存资源缓存磁盘数据,对于内核来说,buffer 和 cache 其实都属于已经被使用的内存。但当应用程序申请内存时,如果 free 内存不够,内核就会回收 buffer 和 cache 的内存来满足应用程序的请求。