字节面试杂谈——操作系统

目录

一、操作系统的定义

二、系统调用、用户态和核心态

三、进程和线程的区别,结合JAVA JVM运行时内存

四、进程的状态

五、进程间的通信方式

六、线程间的同步方式

七、进程的调度算法

八、内存管理的介绍、常见的几种内存管理机制

九、快表、多级页表

十、分页机制与分段机制

十一、逻辑地址和物理地址

十二、CPU寻址,虚拟地址空间

十三、虚拟内存

十四、局部性原理

十五、虚拟存储器(=虚拟内存)

十六、虚拟内存的技术实现

十七、页面置换算法

十八、基本概念:并行,并发,同步,异步,阻塞,非阻塞

十九、为什么有了进程还需要线程

二十、死锁:产生死锁的原因、死锁产生的必要条件、解决死锁的基本方法、预防死锁、避免死锁、解除死锁

二十一、缓冲区溢出

二十二、物理地址、逻辑地址、虚拟内存

二十三、动态链接库和静态链接库

二十四、外中断和异常

二十五、一个程序从开始运行到结束

二十六、典型的锁

二十七、进程终止的方式

二十八、守护进程、僵尸进程、孤儿进程

二十九、如何避免僵尸进程

三十、常见内存分配错误

三十一、内存交换中,被换出的内存保存在哪里

三十二、原子操作是怎么实现的

三十三、抖动

三十四、协程、管程

三十五、同步IO,异步IO、阻塞IO、非阻塞IO

三十六、I/O控制方式

三十七、一次IO过程

三十八、为什么进程上下文切换开销大

三十九、虚拟空间如何优化进程的上下文切换

四十、伪代码写个死锁


一、操作系统的定义

(1)定义:

        操作系统(Operating System,简称 OS)是管理计算机硬件与软件资源的程序,是计算机的基石。
        操作系统本质上是一个运行在计算机上的软件程序 ,用于管理计算机硬件和软件资源。 
        操作系统存在屏蔽了硬件层的复杂性。
        操作系统的内核(Kernel)是操作系统的核心部分,它负责系统的内存管理,硬件设备的管理,文件系统的管理以及应用程序的管理。 内核是连接应用程序和硬件的桥梁,决定着系统的性能和稳定性。

(2)操作系统的特征:并发性,共享性,虚拟性,异步性

(3)操作系统的功能:处理器管理,存储器管理,设备管理,文件管理,用户接口

二、系统调用、用户态和核心态

(1)核心态:核心态又称管态、系统态,是操作系统管理程序执行时机器所处的状态。它具有较高的特权,能执行包括特权指令的一切指令,能访问所有的寄存器和存储区。

(2)用户态:用户态又称目态,是用户程序执行时机器所处的状态。是具有较低特权的执行状态,它只能执行规定的指令,只能访问指定的寄存器和存储区。

(3)特权指令:只能由操作系统内核部分使用,不允许用户直接使用的指令。如IO指令、设置中断屏蔽指令、清内存指令、存储保护指令、设置时钟指令。

(4)操作系统内核的指令操作工作在核心态:时钟管理、中断机制、原语、系统控制的数据结构及处理。

(5)系统调用:

        系统调用是操作系统提供给用户的一种服务,程序设计人员在编写程序的时候可以用来请求操作系统的服务。

        由操作系统实现提供的所有系统调用所构成的集合即程序接口或应用编程接口(Application Programming Interface,API)。是应用程序同系统之间的接口。    

        在我们运行的用户程序中,凡是与系统态级别的资源有关的操作(如文件管理、进程控
制、内存管理等),都必须通过系统调用方式向操作系统提出服务请求,并由操作系统代为完成。    

        功能:设备管理、文件管理、进程控制、进程通信、内存管理

三、进程和线程的区别,结合JAVA JVM运行时内存

(1)进程是拥有资源的基本单位,线程是独立调度的基本单位

(2)不仅进程之间可以并发,同一进程的多个线程之间也可以并发,使得操作系统有很好的并发性

(3)创建进程或者撤销进程时,系统都要为之分配或回收资源,如内存空间、IO设备等,操作系统所付出的开销远大于创建或撤销线程时的开销。

(4)不同进程地址空间相互独立,同一进程内的线程共享同一地址空间。⼀个进程的线程在另⼀个进程内是不可见的;进程间不会相互影响,而⼀个线程挂掉将可能导致整个进程挂掉

(5)以JAVA虚拟机为例:⼀个进程中可以有多个线程,多个线程共享进程的堆和方法区 (JDK1.8 之后的元空间)资源,但是每个线程有自己的程序计数器、虚拟机栈 和 本地⽅法栈。

四、进程的状态

(1)进程的状态:创建状态、就绪状态运行状态阻塞状态、结束状态

(2)进程间的状态转换:就绪-->执行、执行-->阻塞、阻塞-->就绪、执行-->就绪

五、进程间的通信方式

(1)定义:进程通信是指进程之间的信息交换。进程的互斥与同步就是一种进程间的通信方式。由于进程互斥与同步交换的信息量较少且效率较低,因此称这两种通信方式为低级进程通信方式。

(2)通信方式:

        1. 管道/匿名管道(Pipes) :它是半双工的,具有固定的读端和写端;它只能用于父子进程或者兄弟进程之间的进程的通信; 它可以看成是⼀种特殊的文件,对于它的读写也可以使用普通的 read、write 等函数。但是它不是普通的文件,并不属于其他任何文件系统,并且只存在于内存中。

        2. 有名管道(Names Pipes) : 有名管道严格遵循先进先出(first in first out)。有名管道以磁盘文件的方式存在,可以实现本机任意两个进程通信。有路径名与之相关联,它以一种特殊设备文件形式存在于文件系统中。

        3. 信号(Signal) :信号是⼀种比较复杂的通信方式,用于通知接收进程某个事件已经发⽣;

        4. 消息队列(Message Queuing) :消息队列存放在内核中,只有在内核重启(即,操作系统重启)或者显示地删除一个消息队列时,该消息队列才会被真正的删除。消息队列克服了信号承载信
息量少,管道只能承载无格式字节流以及缓冲区大小受限等缺。

        消息队列,是消息的链接表,存放在内核中。一个消息队列由一个标识符 ID 来标识;消息队列是面向记录的,其中的消息具有特定的格式以及特定的优先级;消息队列独立于发送与接收进程。进程终止时,消息队列及其内容并不会被删除;消息队列可以实现消息的随机查询,消息不一定要以先进先出的次序读取,也可以按消息的类型读取。

        5. 信号量(Semaphores) :信号量(semaphore)是一个计数器。用于实现进程间的互斥与同步,而不是用于存储进程间通信数据;信号量用于进程间同步,若要在进程间传递数据需要结合共享内存;信号量基于操作系统的 PV 操作,程序对信号量的操作都是原子操作;每次对信号量的PV 操作不仅限于对信号量值加 1 或减 1,而且可以加减任意正整数;支持信号量组。

        6. 共享内存(Shared memory) :使得多个进程可以访问同⼀块内存空间,不同进程可以及时
看到对方进程中对共享内存中数据的更新。这种方式需要依靠某种同步操作,如互斥锁和信号量等。可以说这是最有用的进程间通信方式。

        共享内存是最快的一种进程中的通信方式,因为进程是直接对内存进行存取的。

        7. 套接字(Sockets) : 此方法主要用于在客户端和服务器之间通过网络进⾏通信。套接字是支
持 TCP/IP 的网络通信的基本操作单元,可以看做是不同主机之间的进程进行双向通信的端点,简单的说就是通信的两⽅的⼀种约定,⽤套接字中的相关函数来完成通信过程。

Java线程的通信方式

        volatile

        等待/通知机制

        join方式

        threadLocal

volatile关键字方式​​​​​​​

volatile有两大特性,一是可见性,二是有序性,禁止指令重排序,其中可见性就是可以让线程之间进行通信。

volatile语义保证线程可见性有两个原则保证

  • 所有volatile修饰的变量一旦被某个线程更改,必须立即刷新到主内存
  • 所有volatile修饰的变量在使用之前必须重新读取主内存的值

 

六、线程间的同步方式

(1)间接相互制约关系(互斥)—— 进程—资源—进程

        要求:空闲让进、忙则等待、有限等待、让权等待

(2)直接相互制约关系(同步)—— 进程—进程

(3)临界资源:同时仅允许一个进程使用的资源

(4)临界区:进程中用于访问临界资源的代码

(5)线程间同步的方式:

        临界区:当多个线程访问一个独占性共享资源时,可以使用临界区对象。拥有临界区的线程可以访问被保护起来的资源或代码段,其他线程若想访问,则被挂起,直到拥有临界区的线程放弃临界区为止。(临界区只能同一进程中线程使用,不能跨进程使用)。临界区是进程维护的。

        以下都是内核对象,操作系统维护。

        互斥量(Mutex):采用互斥对象机制,只有拥有互斥对象的线程才有访问公共资源的权限。因为互斥对象只有一个,所以可以保证公共资源不会被多个线程同时访问。比如 Java 中的synchronized 关键词和各种 Lock 都是这种机制。

        信号量(Semphares):它允许同一时刻多个线程访问同一资源,但是需要控制同一时刻访
问此资源的最⼤线程数量。

        事件(Event):Wait/Notify:通过通知操作的方式来保持多线程同步,还可以方便的实现多线程优先级的比较操作。

七、进程的调度算法

(1)处理器的三级调度:

        高级调度——作业调度

        中级调度

        低级调度——进程调度

(2)进程调度算法:

        先来先服务调度算法(FCFS)—— 作业调度、进程调度

        短作业优先调度算法(SJF)—— 作业调度、进程调度

        优先级调度算法 —— 作业调度、进程调度 —— 非抢占、抢占

        时间片轮转调度算法 —— 进程调度

        高响应比优先调度算法 —— 作业调度

        多级队列调度算法 —— 进程调度

        多级反馈队列调度算法 —— 进程调度 —— 多级反馈队列调度算法是时间片轮转调度算法和优先级调度算法的综合与发展

        

八、内存管理的介绍、常见的几种内存管理机制

(1)功能:内存的分配与回收、地址变换、扩充内存、存储保护

(2)内存保护:

        界限寄存器方法:上下界寄存器方法、基址和限长寄存器方法

        存储保护键方法

(3)

        连续分配管理方式:单一连续分配、固定分区分配、动态分区分配

        非连续分配管理方式:基本分页存储管理方式、基本分段存储管理方式、基本段页式存储管理方式(先分段,段内分页)

        动态分区分配算法:首次适应算法FF、下次适应算法NF、最佳适应算法BF、最差适应算法WF

九、快表、多级页表

(1)

        快表:解决虚拟地址到物理地址的转换速度问题

        多级页表:解决虚拟地址空间大,页表也会很大的问题

(2)快表

        为了解决虚拟地址到物理地址的转换速度,操作系统在 页表⽅案 基础之上引入了 快表 来加速虚拟地址到物理地址的转换。

        我们可以把快表理解为一种特殊的高速缓冲存储器(Cache),其中的内容是页表的一部分或者全部内容。作为页表的 Cache,它的作用与页表相似,但是提⾼了访问速率。由于采⽤页表做地址转换,读写内存数据时 CPU 要访问两次主存。有了快表,有时只要访问一次⾼速缓冲存储器,一次主存,这样可加速查找并提高指令执行速度。
        使用快表之后的地址转换流程是这样的:
                1. 根据虚拟地址中的页号查快表;
                2. 如果该页在快表中,直接从快表中读取相应的物理地址;
                3. 如果该页不在快表中,就访问内存中的页表,再从页表中得到物理地址,同时将页表中的该映射表项添加到快表中;
                4. 当快表填满后,⼜要登记新页时,就按照⼀定的淘汰策略淘汰掉快表中的⼀个页

(3)多级页表:页表过大,将页表分页存储。

十、分页机制与分段机制

(1)分页机制:

        优点:内存利用率高、实现了离散分配、便于存储访问控制、无外部碎片

        缺点:需要硬件支持(尤其是快表)、内存访问效率下降、共享困难、内部碎片

(2)分段机制:

        优点:便于程序模块化处理和处理变换的数据结构、便于动态链接和共享、无内部碎片

        缺点:需要硬件支持、为满足分段的动态增长和减少外部碎片需要拼接技术、分段最大尺寸收到主存可用空间的限制、有外部碎片

(3)相同点:

        分页机制和分段机制都是为了提高内存利⽤率。
        页和段都是离散存储的,所以两者都是离散分配内存的方式。但是,每个页和段中的内
存是连续的。

(4)不同点:

        1. 段是信息的逻辑单位,它是根据用户的需要划分的,为了更好地满足用户的需要,段对用户是可见的 ;页是信息的物理单位,是为了管理主存的方便而划分的,是系统管理所需,为了提高内存利用率,对用户是透明的;
        2. 段的大小不固定,由用户编写的程序决定的;页的大小固定,由操作系统决定;
        3. 段向用户提供⼆维地址空间(段名+段内位移);页向用户提供的是一维地址空间(给定一个地址即可计算出页号+页内位移);

        4. 分段无内部碎片,有外部碎片;分页有内部碎片,无外部碎片。
        5. 段是信息的逻辑单位,便于存储保护和信息的共享,页的保护和共享受到限制。分页存储管理系统中将作业的地址空间划分成页面的做法对于用户是透明的,同时作业的地址空间是线性连续的,当系统将作业的地址空间分成大小相同的页面时,被共享的部分不一定包含在一个完整的页面中,这样不应共享的数据也被共享了,不利于保密。另外,共享部分的起始地址在各作业的地址空间划分成页的过程中,在各自页面中的业内位移可能不同,这也使得共享比较难。

十一、逻辑地址和物理地址

(1)逻辑地址:

        逻辑地址(Logical Address)是指由程序产生的与段(与页无关,因为只有段对用户可见)相关的偏移地址部分。源代码在经过编译后,目标程序中所用的地址就是逻辑地址,而逻辑地址的范围就是逻辑地址空间。在编译程序对源代码进行编译时,总是从0号单元开始编址,地址空间中的地址都是相对于0开始的,因此逻辑地址也称为相对地址。在系统中运行的多个进程可能会有相同的逻辑地址,但这些逻辑地址映射到物理地址上时就变为了不同的位置。

(2)物理地址:

        物理地址(Physical Address)是指出现在CPU外部地址总线上的寻址物理内存的地址信号,是逻辑地址变换后的最终结果地址,物理地址空间是指内存中物理地址单元的集合。进程在运行过程中需要访问存取指令或数据时,都是根据物理地址从主存中取得。物理地址对于一般的用户来说是完全透明的,用户只需要关心程序的逻辑地址就可以了。从逻辑地址到物理地址的转换过程由硬件自动完成,这个转换过程叫作地址重定位。        

十二、CPU寻址,虚拟地址空间

(1)CPU寻址:现代处理器使用的是⼀种称为 虚拟寻址(Virtual Addressing) 的寻址方式。使用虚拟寻址,CPU需要将虚拟地址翻译成物理地址,这样才能访问到真实的物理内存。 实际上完成虚拟地址转换为物理地址转换的硬件是 CPU 中含有⼀个被称为 内存管理单元(Memory Management Unit,MMU) 的硬件。

(2)寻址方式:

        指令寻址:顺序寻址、跳跃寻址

        操作数寻址:隐含寻址、立即寻址、直接寻址、间接寻址、寄存器寻址、寄存器间接寻址、相对寻址、基址寻址、变址寻址

(3)虚拟地址空间:

        不适用虚拟地址空间的缺点:用户程序可以访问任意内存,寻址内存的每个字节,对操作系统造成损害。对同时运行多个程序造成困难。

        使用虚拟地址空间的优点:

                程序可以使用一系列相邻的虚拟地址来访问物理内存中不相邻的大内存缓冲区。        
                程序可以使用一系列虚拟地址来访问大于可用物理内存的内存缓冲区。当物理内存的供应量变小时,内存管理器会将物理内存页(通常大小为 4 KB)保存到磁盘⽂件。数据或代码页会根据需要在物理内存与磁盘之间移动。
                不同进程使用的虚拟地址彼此隔离。一个进程中的代码无法更改正在由另一进程或操作系统使用的物理内存

                用户程序只能访问虚拟地址,不会对操作系统造成损害。多个程序可以同时使用相同的虚拟地址同时运行。

十三、虚拟内存

        虚拟内存使得应用程序认为它拥有连续的可用的内存(⼀个连续完整的地址空间),而实
际上,它通常是被分隔成多个物理内存碎片,还有部分暂时存储在外部磁盘存储器上,在需
要时进行数据交换。

        简单概括:部分装入、请求调入、置换功能、逻辑扩充内存

        意义:让程序存在的地址空间与运行时的存储空间分开,程序员可以完全不考虑实际内存的大小,而在地址空间内编写程序。

        虚拟存储器的容量由计算机的地址解构决定,并不是无限大。

十四、局部性原理

局部性原理分为时间局部性和空间局部性:

        时间局部性 :如果程序中的某条指令一旦执行,不久以后该指令可能再次执行;如果某数据
被访问过,不久以后该数据可能再次被访问。产⽣时间局部性的典型原因,是由于在程序中存在着⼤量的循环操作。
        空间局部性 :一旦程序访问了某个存储单元,在不久之后,其附近的存储单元也将被访问,
即程序在一段时间内所访问的地址,可能集中在一定的范围之内,这是因为指令通常是顺序存放、顺序执行的,数据也一般是以向量、数组、表等形式簇聚存储的。


        时间局部性是通过将近来使用的指令和数据保存到发哦速缓存存储器中,并使用高速缓存的层次结构实现。空间局部性通常是使用较大的高速缓存,并将预取机制集成到高速缓存控制逻辑中实现。虚拟内存技术实际上就是建立了 “内存⼀外存”的两级存储器的结构,利用局部性原理实现髙
速缓存。

十五、虚拟存储器(=虚拟内存)

        基于局部性原理,在程序装入时,可以将程序的一部分装入内存,⽽将其他部分留在外存,就可以启动程序执行。由于外存往往比内存大很多,所以我们运行的软件的内存大小实际上是可以比计算机系统实际的内存大小大的。在程序执行过程中,当所访问的信息不在内存时,由操作系统
将所需要的部分调入内存,然后继续执行程序。另一方面,操作系统将内存中暂时不使用的内容换到外存上,从而腾出空间存放将要调入内存的信息。这样,计算机好像为用户提供了一个比实际内存大的多的存储器——虚拟存储器。

十六、虚拟内存的技术实现

        虚拟内存的实现需要建立在离散分配的内存管理方式的基础上。 虚拟内存的实现有以下三种方式:
        1. 请求分页存储管理 :建立在分页管理之上,为了支持虚拟存储器功能而增加了 请求调页功能 和 页面置换功能。请求分页是目前最常用的一种实现虚拟存储器的方法。请求分页存储管理系统中,在作业开始运行之前,仅装入当前要执行的部分段即可运行。假如在作业运行的过程中发现要访问的页面不在内存,则由处理器通知操作系统按照对应的页面置换算法将相应的页面调⼊到主存,同时操作系统也可以将暂时不用的页面置换到外存中。

        2. 请求分段存储管理 :建⽴在分段存储管理之上,增加了请求调段功能、分段置换功能。请求分段储存管理方式就如同请求分页储存管理方式⼀样,在作业开始运行之前,仅装入当前要执行的部分段即可运行;在执行过程中,可使用请求调入中断动态装入要访问但又不在内存的程序段;当内存空间已满,而又需要装入新的段时,根据置换功能适当调出某个段,以便腾出空间而装⼊新的段。

        3. 请求段页式存储管理

硬件和软件支持: 

        1. ⼀定容量的内存和外存:在载入程序的时候,只需要将程序的⼀部分装入内存,而将其他部分留在外存,然后程序就可以执行了;

          2. 缺页中断(中断机构):如果需执行的指令或访问的数据尚未在内存(称为缺页或缺段),则由处理器通知操作系统将相应的页面或段调⼊到内存,然后继续执行程序;
        3. 地址变换机构:以动态实现虚地址到实地址的地址变换   
        
        4. 相关数据结构:段表或页表

十七、页面置换算法

(1)最佳置换算法OPT:每次淘汰以后不再使用或者最迟再被使用的页面

(2)先进先出算法FIFO:每次淘汰先进入内存的页面

(3)最近最少使用算法LRU:选择最近最长时间没有被使用的页面予以淘汰

(4)时钟置换算法CLOCK——最近未使用NRU算法:循环链表遍历,淘汰访问位为0的。(访问位置0)

(5)改进型时钟算法——淘汰:未被访问+未被修改、未被访问+已修改、已访问+未被修改、已访问+已修改(第二圈访问位置0)

(6)最不常用置换算法LFU:淘汰到目前为止访问次数最少的页面,每次发生缺页中断时,淘汰计数值最小的页面,并将所有计数器清零。

(7)页面缓冲算法PBA:PBA算法用FIFO选择被置换页。空闲链表和修改链表。PBA算法是对FIFO算法的发展,通过建立置换页面的缓冲,找回刚被置换的页面,从而减少系统I/O的消耗。PBA算法用FIFO算法选择被置换页,选择出的页面不是立即换出,而是放入两个链表之一中。如果页面未被修改,就将其归入到空闲页面链表的末尾,否则将其归入到已修改页面链表的末尾。这些空闲页面和已修改页面会在内存中停留一段时间。 如果这些页面被再次访问,只需将其从相应链表中移出,就可以返回给进程,从而减少了一次磁盘IO。需要调入新的物理页时,将新页面读入到空闲页面链表的第一个页面中,然后将其从该链表中移出。当已修改页达到一定数目后,再将其一 起写入磁盘,然后将它们归入空闲页面链表。这样能大大减少I/O 操作的次数。

Belady现象:

        所谓Belady现象是指:在分页式虚拟存储器管理中,发生缺页时的置换算法采用FIFO(先进先出)算法时,如果对一个进程未分配它所要求的全部页面,有时就会出现分配的页面数增多但缺页率反而提高的异常现象。

例子:1,2,3,4,1,2,5,1,2,3,4,5。一共五页,分配3页帧缺页9次,分配4页帧缺页10次

十八、基本概念:并行,并发,同步,异步,阻塞,非阻塞

        1. 并行是指两个或者多个事件在同⼀时刻发生;并发是指两个或多个事件在同⼀时间间隔发生;
        2. 并行是在不同实体上的多个事件,并发是在同⼀实体上的多个事件;
        
        同步:当一个同步调用发出后,调用者要一直等待返回结果,才能进行后续的执行。
        异步:当⼀个异步过程调用发出后,调用者不能⽴刻得到返回结果。实际处理这个调用的部件在完成后,通过状态、通知 和 回调来通知调用者。
        阻塞:是指调用结果返回前,当前线程会被挂起,即阻塞。
        非阻塞:是指即使调用结果没返回,也不会阻塞当前线程。

十九、为什么有了进程还需要线程

        进程可以使多个程序并发执行,以提⾼资源的利用率和系统的吞吐量,但是其带来了一些缺点:
        1. 进程在同一时间只能⼲一件事情;
        2. 进程在执行的过程中如果阻塞,整个进程就会被挂起,即使进程中有些⼯作不依赖于等待的资源,仍然不会执行。
        基于以上的缺点,操作系统引入了比进程粒度更小的线程,作为并发执行的基本单位,从而减少程序在并发执行时所付出的时间和空间开销,提高并发性能。

二十、死锁:产生死锁的原因、死锁产生的必要条件、解决死锁的基本方法、预防死锁、避免死锁、解除死锁

(1)死锁:当多个进程因竞争系统资源或相互通信而处于永久阻塞状态时,若无外力作用,这些进程都将无法向前推进。这些进程中的每一个进程,均无限期地等待此组进程中某个其他进程占有的、自己永远无法得到的资源,这种现象称为死锁。

(2)产生死锁的原因:资源竞争、推进顺序不当(系统资源不足、推进顺序不当)系统资源不足是产生死锁的根本原因。

可剥夺资源:虽然资源占有者进程需要使用该资源,但是另一个进程可以强行把该资源从占有者进程处剥夺来归自己使用。

不可剥夺资源:除占有者进程不再需要使用该资源而主动释放资源,其他进程不得在占有者进程使用资源的过程中强行剥夺。

(3)死锁产生的必要条件

        互斥条件:进程要求对所分配的资源进行排他性控制,即在一段时间内某种资源仅为一个进程所占有。

        不可剥夺条件:进程所获得的资源在未使用完毕之前,不能被其他进程强行夺走,即只能由获得该资源的进程自己来释放。

        请求和保持条件:进程每次申请它所需的一部分资源。在等待分配新资源的同时,进程继续占有已经分配到的资源。请求与保持条件也成为部分分配条件。

        环路等待条件(循环等待):存在一种进程资源的循环等待链,而链中的每一个进程已经获得的资源同时被链中的下一个进程所请求。

(4)解决死锁的基本方法:鸵鸟算法(忽略死锁)、预防死锁、避免死锁、检测及解除死锁

(5)预防死锁:

        破坏互斥条件:允许多个进程同时访问资源。不太可能。

        破坏不可剥夺条件:对于一个已经获得了某些资源的进程,若新的资源请求不能立即得到满足,则它必须释放所有自己已经获得的资源,以后需要资源时再重新申请。

        破坏请求与保持条件:采用预先静态分配方法。要求进程在其运行之前一次性申请所需要的全部资源,在它的资源为满足之前,不投入运行。一旦投入运行后,这些资源就一直归它所有,也不再提出其他资源请求。

        破坏环路等待条件:有序资源分配法。将系统中的所有资源都按类型赋予一个编号,要求每一个进程均严格按照编号递增的次序请求资源,同类资源一次申请完。由于对各种资源编号后不宜修改,从而限制了新设备的增加;不同作业对资源使用的顺序也不完全相同,从而造成了资源浪费;对资源按序使用会增加程序编写的复杂性。

(6)避免死锁:

        允许进程动态地申请资源,系统在进行资源分配之前,先计算资源分配地安全性。若此次分配不会导致系统进入不安全状态,便将资源分配给进程,否则进程必须等待。

        安全状态:在某一时刻,系统能按照某种顺序来为每个进程分配其所需地资源,直至最大需求,使每个进程都可顺利完成。

        不安全状态:不存在一个安全序列,系统可能发生死锁。

银行家算法:

        可利用资源向量:Available

        最大需求矩阵:Max

        分配矩阵:Allocation

        需求矩阵:Need

过程:Request (需求向量)

        1. Request <= Need

        2. Request <= Available

        3. 资源预分配

        4. 调用安全性算法计算系统是否处于安全状态:

                Work(当前可分配资源向量)、Need、Allocation、Work+Allocation、Finish(状态)

        5. 系统处于安全状态则分配资源,否则不分配

(7)死锁检测和解除:

        死锁检测:资源分配图

        死锁定理:系统状态S为死锁状态的条件是:当且仅当S状态的资源分配图是不可完全简化的。

        死锁解除:剥夺资源、撤销进程、进程回退。

                1. 资源剥夺:挂起某些死锁进程,并抢占它的资源,将这些资源分配给其他死锁进程(但应该防止被挂起的进程长时间得不到资源);
                2. 撤销进程:强制撤销部分、甚⾄全部死锁进程并剥夺这些进程的资源(撤销的原则可以按进程优先级和撤销进 程代价的高低进行);
                3. 进程回退:让⼀个或多个进程回退到⾜以避免死锁的地步。进程回退时⾃愿释放资源而不是被剥夺。要求系统 保持进程的历史信息,设置还原点。

二十一、缓冲区溢出

        缓冲区为暂时置放输出或输⼊资料的内存。
        缓冲区溢出是指当计算机向缓冲区填充数据时超出了缓冲区本身的容 量,溢出的数据覆盖在合法数据上。
        造成缓冲区溢出的主要原因是程序中没有仔细检查⽤户输⼊是否合理。
        计算机 中,缓冲区溢出会造成的危害主要有以下两点:程序崩溃导致拒绝服务、跳转并且执⾏⼀段恶意代码。

二十二、物理地址、逻辑地址、虚拟内存

(1)逻辑地址:

        逻辑地址(Logical Address)是指由程序产生的与段(与页无关,因为只有段对用户可见)相关的偏移地址部分。源代码在经过编译后,目标程序中所用的地址就是逻辑地址,而逻辑地址的范围就是逻辑地址空间。在编译程序对源代码进行编译时,总是从0号单元开始编址,地址空间中的地址都是相对于0开始的,因此逻辑地址也称为相对地址。在系统中运行的多个进程可能会有相同的逻辑地址,但这些逻辑地址映射到物理地址上时就变为了不同的位置。

(2)物理地址:

        物理地址(Physical Address)是指出现在CPU外部地址总线上的寻址物理内存的地址信号,是逻辑地址变换后的最终结果地址,物理地址空间是指内存中物理地址单元的集合。进程在运行过程中需要访问存取指令或数据时,都是根据物理地址从主存中取得。物理地址对于一般的用户来说是完全透明的,用户只需要关心程序的逻辑地址就可以了。从逻辑地址到物理地址的转换过程由硬件自动完成,这个转换过程叫作地址重定位。     

(3)虚拟内存:

        虚拟内存使得应用程序认为它拥有连续的可用的内存(⼀个连续完整的地址空间),而实
际上,它通常是被分隔成多个物理内存碎片,还有部分暂时存储在外部磁盘存储器上,在需
要时进行数据交换。

        简单概括:部分装入、请求调入、置换功能、逻辑扩充内存

        意义:让程序存在的地址空间与运行时的存储空间分开,程序员可以完全不考虑实际内存的大小,而在地址空间内编写程序。

        虚拟存储器的容量由计算机的地址解构决定,并不是无限大。

二十三、动态链接库和静态链接库

        (1)静态链接就是在编译链接时直接将需要的执行代码拷贝到调用处,优点就是在程序发布的时候就不需要的依赖库,也就是不再需要带着库一块发布,程序可以独立执行,但是体积可能会相对⼤一些。
        

        静态库对函数库的链接是放在编译时期完成的。

        程序在运行时与函数库再无瓜葛,移植方便。

        浪费空间和资源,因为所有相关的目标文件与牵涉到的函数库被链接合成一个可执行文件。

       
        (2)动态链接就是在编译的时候不直接拷贝可执行代码,而是通过记录一系列符号和参数,在程序运行或加载时将这些信息传递给操作系统,操作系统负责将需要的动态库加载到内存中,然后程序在运行到指定的代码时,去共享执行内存中已经加载的动态库可执行代码,最终达到运行时连接的目的。优点是多个程序可以共享同一段代码,而不需要在磁盘上存储多个拷贝,缺点是由于是运行时加载,可能会影响程序的前期执行性能。
        动态库在程序编译时并不会被连接到目标代码中,而是在程序运行是才被载入。不同的应用程序如果调用相同的库,那么在内存里只需要有一份该共享库的实例,规避了空间浪费问题。动态库在程序运行是才被载入,也解决了静态库对程序的更新、部署和发布页会带来麻烦。用户只需要更新动态库即可,增量更新。
        

        动态库把对一些库函数的链接载入推迟到程序运行的时期。

        可以实现进程之间的资源共享。(因此动态库也称为共享库)

        将一些程序升级变得简单。

        甚至可以真正做到链接载入完全由程序员在程序代码中控制(显示调用)。

二十四、外中断和异常

        I/O控制方式:程序直接控制方式、中断控制方式、DMA控制方式、通道控制方式。

        外中断是指由 CPU 执⾏指令以外的事件引起,如 I/O 完成中断,表示设备输入 /输出处理已经完成,处理器能够发送下一个输入 / 输出请求。此外还有时钟中断、控制台中断等。
        异常是由 CPU 执⾏指令的内部事件引起,如非法操作码、地址越界、算术溢出等。

中断:        

        1.中断分为内中断和外中断。内中断(也称异常、例外、陷入)信号来源是CPU内部,与当前执行的指令有关,外中断(狭义的中断)信号的来源是CPU外部,与当前执行的指令无关。

        2.内中断可以分为自愿中断和强迫中断,自愿中断是指指令中断,如系统调用时使用的访管指令(又叫陷入指令、trap指令),强迫中断是指硬件故和软件故障(如整数除0)。

        3.内中断还可以分为陷入(trap)、故障(fault)和终止(abort)。陷入指有意而为之的异常,如系统调用。故障指由错误条件引起的,可能被故障处理程序修复,如缺页。终止指不可恢复的致命错误造成的结果,终止处理程序不再将控制返回给引发终止的应用程序,如整数除0。

        4.外中断可以分为外设请求(如I/O操作完成发出的中断信号)和人工干预(如用户强行终止进程)。

二十五、一个程序从开始运行到结束

四个过程:
        (1 )预编译 主要处理源代码文件中的以 “#” 开头的预编译指令。处理规则见下
                1、删除所有的#define ,展开所有的宏定义。
                2、处理所有的条件预编译指令,如“#if” “#endif” “#ifdef” “#elif” “#else”
                3、处理“#include” 预编译指令,将文件内容替换到它的位置,这个过程是递归进行的,文件中包含其他文件。
                4、删除所有的注释,“//” “/**/”
                5、保留所有的#pragma 编译器指令,编译器需要用到他们,如: #pragma once 是为了防止有文件被重复引用。
                6、添加行号和文件标识,便于编译时编译器产⽣调试用的行号信息,和编译时产生编译错误或警告能够显示行号。
        (2 )编译 把预编译之后⽣成的 xxx.i xxx.ii⽂件,进⾏⼀系列词法分析、语法分析、语义分析及优化后,生成相应的汇编代码文件。
                1、词法分析:利⽤类似于“ 有限状态机 ”的算法,将源代码程序输入到扫描机中,将其中的字符序列分割成⼀系列的记号。
                2、语法分析:语法分析器对由扫描器产生的记号,进行语法分析,产⽣语法树。由语法分析器输出的语法树是一种以表达式为节点的树。
                3、语义分析:语法分析器只是完成了对表达式语法层面的分析,语义分析器则对表达式是否有意义进行判断,其 分析的语义是静态语义 —— 在编译期能分期的语义,相对应的动态语义是在运行期才能确定的语义。
                4、优化:源代码级别的一个优化过程。
                5、目标代码生成:由代码生成器将中间代码转换成目标机器代码,生成一系列的代码序列—— 汇编语言表示。
                6、目标代码优化:目标代码优化器对上述的目标机器代码进⾏优化:寻找合适的寻址方式、使用位移来替代乘法 运算、删除多余的指令等。
        (3 )汇编
                将汇编代码转变成机器可以执行的指令(机器码文件)。 汇编器的汇编过程相对于编译器来说更简单,没有复杂的语法,也没有语义,更不需要做指令优化,只是根据汇编指令和机器指令的对照表一一翻译过来,汇编过程有汇编器as 完成。
                经汇编之后,产生目标⽂件(与可执行文件格式几乎⼀样 )xxx.o(Linux ) xxx.obj(Windows )
        (4 )链接
                将不同的源文件产⽣的目标文件进⾏链接,从而形成⼀个可以执行的程序。链接分为静态链接和动态链接:
                1、静态链接: 函数和数据被编译进一个⼆进制⽂件。在使用静态库的情况下,在编译链接可执行文件时,链接器 从库中复制这些函数和数据并把它们和应⽤程序的其它模块组合起来创建最终的可执行文件。
                空间浪费:因为每个可执行程序中对所有需要的目标文件都要有一份副本,所以如果多个程序对同一个目标文件都有依赖,会出现同一个目标文件都在内存存在多个副本。 
                更新困难:每当库函数的代码修改了,这个时候就需要重新进行编译链接形成可执行程序。
                运行速度快:但是静态链接的优点就是,在可执行程序中已经具备了所有执行程序所需要的任何东西,在执行的时候运行速度快。
                2、动态链接: 动态链接的基本思想是把程序按照模块拆分成各个相对独立部分,在程序运行时才将它们链接在一起形成一个完整的程序,而不是像静态链接一样把所有程序模块都链接成一个单独的可执行文件。
                共享库:就是即使需要每个程序都依赖同一个库,但是该库不会像静态链接那样在内存中存在多份副本,而是这多个程序在执行时共享同一份副本。
                更新方便:更新时只需要替换原来的目标文件,而无需将所有的程序再重新链接⼀遍。当程序下⼀次运行时,新版本的目标文件会被自动加载到内存并且链接起来,程序就完成了升级的⽬标。
                性能损耗:因为把链接推迟到了程序运行时,所以每次执⾏程序都需要进行链接,所以性能会有一定损失。

二十六、典型的锁

(1)悲观锁:

        在每次取数据时,总是担心数据会被其他线程修改,所以会在取数据前先加锁(读锁,写锁,行锁等),当其他线程想要访问数据时,被阻塞挂起。可以依靠数据库实现,如行锁,读锁,写锁等。Java中synchronized也是悲观锁思想。

(2)乐观锁:

        每次取数据时候,总是乐观的认为数据不会被其他线程修改,因此不上锁。但是在更新数据前,会判断其他数据在更新前有没有对数据进行修改。主要采用两种方式:版本号机制和CAS操作。

        Version机制:在数据库表中加一个数据version字段,表示数据被修改的次数。当数据被修改时version加一。读取数据同时也会读取到version值,当提交更新时,如果刚才读取的version值和当前数据库的version值相等才更新。否则重试更新操作,直到更新成功。

        CAS操作:当需要更新数据时,判断当前内存值和之前取得的值是否相等。如果相等则用新值更新。若不等则失败,失败则重试,一般是一个自旋的过程,即不断重试。

        CAS比较交换的过程可以通俗的理解为CAS(V,O,N),包含三个值分别为:V 内存地址存放的实际值;O 预期的值(旧值);N 更新的新值。当V和O相同时,也就是说旧值和内存中实际的值相同表明该值没有被其他线程更改过,即该旧值O就是目前来说最新的值了,自然而然可以将新值N赋值给V。反之,V和O不相同,表明该值已经被其他线程改过了则该旧值O不是最新版本的值了,所以不能将新值N赋给V,返回V即可。当多个线程使用CAS操作一个变量是,只有一个线程会成功,并成功更新,其余会失败。失败的线程会重新尝试,当然也可以选择挂起线程

(3)读写锁:

        多个读者可以同时进行读

        写者必须互斥(只允许一个写者写,也不能读者写者同时进行)

        写者优先于读者(一旦有写者,则后续读者必须等待,唤醒时优先考虑写者)

(4)互斥锁:

        一次只能一个线程拥有互斥锁,其他线程只有等待

        互斥锁是在抢锁失败的情况下主动放弃CPU进入睡眠状态直到锁的状态改变时再唤醒,而操作系统负责线程调度,为了实现锁的状态发生改变时唤醒阻塞的线程或者进程,需要把锁交给操作系统管理,所以互斥锁在加锁操作时涉及上下文的切换。互斥锁实际的效率还是可以让人接受的,加锁的时间大概 100ns左右,而实际上互斥锁的一种可能的实现是先自旋一段时间,当自旋的时间超过阀值之后再将线程投入睡眠中,因此在并发运算中使用互斥锁(每次占用锁的时间很短)的效果可能不亚于使用自旋锁

(5)条件变量:

        互斥锁一个明显的缺点是他只有两种状态:锁定和非锁定。而条件变量通过允许线程阻塞和等待另一个线程发送信号的方法弥补了互斥锁的不足,他常和互斥锁一起使用,以免出现竞态条件。当条件不满足时,线程往往解开相应的互斥锁并阻塞线程然后等待条件发生变化。一旦其他的某个线程改变了条件变量,他将通知相应的条件变量唤醒一个或多个正被此条件变量阻塞的线程。总的来说互斥锁是线程间互斥的机制,条件变量则是同步机制。

(6)自旋锁:        

        如果进线程无法取得锁,进线程不会立刻放弃CPU时间片,而是一直循环尝试获取锁,直到获取为止。 如果别的线程长时期占有锁,那么自旋就是在浪费CPU做无用功,但是自旋锁一般应用于加锁时间很短的场景,这个时候效率比较高。

(7)可重入锁:

        可重入锁,指的是以线程为单位,当一个线程获取对象锁之后,这个线程可以再次获取本对象上的锁,而其他的线程是不可以的。synchronized 和   ReentrantLock 都是可重入锁。

        可重入锁的意义之一在于防止死锁。

        实现原理实现是通过为每个锁关联一个请求计数器和一个占有它的线程。当计数为0时,认为锁是未被占有的;线程请求一个未被占有的锁时,JVM将记录锁的占有者,并且将请求计数器置为1 。如果同一个线程再次请求这个锁,计数器将递增;每次占用线程退出同步块,计数器值将递减。直到计数器为0,锁被释放。

        关于父类和子类的锁的重入:子类覆写了父类的synchonized方法,然后调用父类中的方法,此时如果没有可重入的锁,那么这段代码将产生死锁

(8)公平锁:

        公平锁多个线程按照申请锁的顺序去获得锁,线程会直接进入队列去排队,永远都是队列的第一位才能得到锁。
        锁实现:ReentrantLock(true)锁实现
        优点:所有的线程都能得到资源,不会饿死在队列中。
        缺点:吞吐量会下降很多,队列里面除了第一个线程,其他的线程都会阻塞,cpu唤醒阻塞线程的开销会很大。

(9)非公平锁:

        非公平锁多个线程去获取锁的时候,会直接去尝试获取,获取不到,再去进入等待队列,如果能获取到,就直接获取到锁。
        优点:可以减少CPU唤醒线程的开销,整体的吞吐效率会高点,CPU也不必取唤醒所有线程,会减少唤起线程的数量。
        缺点:线程饿死

(10)偏向锁:

        在没有实际竞争的情况下,还能够针对部分场景继续优化。如果不仅仅没有实际竞争,自始至终,使用锁的线程都只有一个,那么,维护轻量级锁都是浪费的。偏向锁的目标是,减少无竞争且只有一个线程使用锁的情况下,使用轻量级锁产生的性能消耗。轻量级锁每次申请、释放锁都至少需要一次CAS,但偏向锁只有初始化时需要一次CAS。

        “偏向”的意思是,偏向锁假定将来只有第一个申请锁的线程会使用锁(不会有任何线程再来申请锁),因此,只需要在Mark Word中CAS记录owner(本质上也是更新,但初始值为空),如果记录成功,则偏向锁获取成功,记录锁状态为偏向锁,以后当前线程等于owner就可以零成本的直接获得锁;否则,说明有其他线程竞争,膨胀为轻量级锁。

        偏向锁无法使用自旋锁优化,因为一旦有其他线程申请锁,就破坏了偏向锁的假定。

(11)轻量级锁:

        自旋锁的目标是降低线程切换的成本。如果锁竞争激烈,我们不得不依赖于重量级锁,让竞争失败的线程阻塞;如果完全没有实际的锁竞争,那么申请重量级锁都是浪费的。轻量级锁的目标是,减少无实际竞争情况下,使用重量级锁产生的性能消耗,包括系统调用引起的内核态与用户态切换、线程阻塞造成的线程切换等。

        顾名思义,轻量级锁是相对于重量级锁而言的。使用轻量级锁时,不需要申请互斥量,仅仅将Mark Word中的部分字节CAS更新指向线程栈中的Lock Record,如果更新成功,则轻量级锁获取成功,记录锁状态为轻量级锁;否则,说明已经有线程获得了轻量级锁,目前发生了锁竞争(不适合继续使用轻量级锁),接下来膨胀为重量级锁。

        Mark Word是对象头的一部分;每个线程都拥有自己的线程栈(虚拟机栈),记录线程和函数调用的基本信息。二者属于JVM的基础内容,此处不做介绍。

        当然,由于轻量级锁天然瞄准不存在锁竞争的场景,如果存在锁竞争但不激烈,仍然可以用自旋锁优化,自旋失败后再膨胀为重量级锁。

(12)重量级锁:

        内置锁在Java中被抽象为监视器锁(monitor)。在JDK 1.6之前,监视器锁可以认为直接对应底层操作系统中的互斥量(mutex)。这种同步方式的成本非常高,包括系统调用引起的内核态与用户态切换、线程阻塞造成的线程切换等。因此,后来称这种锁为“重量级锁”。

偏向锁、轻量级锁、重量级锁适用于不同的并发场景:

  • 偏向锁:无实际竞争,且将来只有第一个申请锁的线程会使用锁。
  • 轻量级锁:无实际竞争,多个线程交替使用锁;允许短时间的锁竞争。
  • 重量级锁:有实际竞争,且锁竞争时间长。

二十七、进程终止的方式

进程的终止

        发生终止,通常是由于以下情况触发的
                正常退出(自愿的)
                错误退出(自愿的)
                严重错误(非自愿的)
                被其他进程杀死(非自愿的)

正常退出
        多数进程是由于完成了工作而终止。当编译器完成了所给定程序的编译之后,编译器会执行一个系统调用告诉操作系统它完成了工作。这个调用在 UNIX 中是 exit ,在 Windows 中是ExitProcess 。面向屏幕中的软件也支持自愿终止操作。字处理软件、Internet 浏览器和类似的程序中总有⼀个供用户点击的图标或菜单项,用来通知进程删除它锁打开的任何临时⽂件,然后终止。


错误退出
        进程终止的第二个原因是由进程引起的错误,通常是由于程序中的错误所导致的。例如,执行了⼀条⾮法指令,引用不存在的内存,或者除数是 0 等。在有些系统比如 UNIX 中,进程可以通知操作系统,它希望自行处理某种类型的错误,在这类错误中,进程会收到信号(中断),而不是在这类错误出现时直接终止进程。
 

严重错误

        进程发生终止的第三个原因是发现严重错误,例如,如果用户执行如下命令cc foo.c为了能够编译 foo.c 但是该文件不存在,于是编译器就会发出声明并退出。在给出了错误参数时,⾯向屏幕的交互式进程通常并不会直接退出,因为这从用户的角度来说并不合理,用户需要知道发生了什么并想要进行调试,所以这时候应用程序通常会弹出一个对话框告知用户发生了系统错误,是需要调试还是退出。

被其他进程杀死
        第四个终止进程的原因是,某个进程执行系统调用告诉操作系统杀死某个进程。在 UNIX 中,这个系统调⽤是kill。在 Win32 中对应的函数是 TerminateProcess (注意不是系统调用)。

其他:

《UNIX环境高级编程》的第七章的7.3《进程终止》说了八种情况:

正常终止五种:
1.从main返回。

进程的返回值是给其父进程看的。
 

2.调用exit。

执行exit函数会导致进程正常地终止,并且会将 status & 0377 这个值返回给父进程。
此外,exit()退出时会执行退出处理程序,如atexit()和on_exit()。

3.调用_exit或_Exit。

exit是库函数,_exit和_Exit是系统调用。
 

4.最后一个线程从其启动例程返回。


5.最后一个线程调用pthread_exit。

三种异常终止:
6.调用abort()。


7.接到一个信号并终止。


8.最后一个线程对取消请求作出响应。

二十八、守护进程、僵尸进程、孤儿进程

守护进程
        指在后台运行的,没有控制终端与之相连的进程。它独立于控制终端,周期性地执行某种任务。Linux的大多数服务器就是用守护进程的方式实现的,如web服务器进程http等


创建守护进程要点:
(1)让程序在后台执行。方法是调用fork()产⽣⼀个子进程,然后使父进程退出。
(2)调用setsid()创建⼀个新对话。控制终端、登录会话和进程组通常是从父进程继承下来的,守护进程要摆脱它们,不受它们的影响,方法是调用setsid()使进程成为⼀个会话组长。setsid()调用成功后,进程成为新的会话组长和进程组长,并与原来的登录会话、进程组和控制终端脱离。
(3)禁止进程重新打开控制终端。经过以上步骤,进程已经成为⼀个无终端的会话组长,但是它可以重新申请打开⼀个终端。为了避免这种情况发生,可以通过使进程不再是会话组长来实现。再⼀次通过fork()创建新的子进程,使调用fork的进程退出。
(4)关闭不再需要的文件描述符。子进程从父进程继承打开的文件描述符。如不关闭,将会浪费系统资源,造成进程所在的文件系统无法卸下以及引起无法预料的错误。首先获得最高文件描述符值,然后用一个循环程序,关闭0到最高文件描述符值的所有文件描述符。
(5)将当前目录更改为根目录。
(6)子进程从父进程继承的文件创建屏蔽字可能会拒绝某些许可权。为防止这一点,使用unmask(0)将屏蔽字清零。
(7)处理SIGCHLD信号。对于服务器进程,在请求到来时往往生成子进程处理请求。如果子进程等待父进程捕获状态,则子进程将成为僵尸进程(zombie),从而占用系统资源。如果父进程等待子进程结束,将增加父进程的负担,影响服务器进程的并发性能。在Linux下可以简单地将SIGCHLD信号的操作设为SIG_IGN。这样,子进程结束时不会产生僵尸进程。

孤而进程
        如果父进程先退出,子进程还没退出,那么子进程的父进程将变为init进程。(注:任何⼀个进程都必须有父进程)。
        ⼀个父进程退出,而它的⼀个或多个子进程还在运行,那么那些子进程将成为孤儿进程。孤儿进程将被init进程(进程号为1)所收养,并由init进程对它们完成状态收集工作。
 

僵尸进程
        如果子进程先退出,父进程还没退出,那么子进程必须等到父进程捕获到了子进程的退出状态才真正结束,否则这个时候子进程就成为僵尸进程。
        设置僵尸进程的目的是维护子进程的信息,以便父进程在以后某个时候获取。这些信息至少包括进程ID,进程的终止状态,以及该进程使用的CPU时间,所以当终止子进程的父进程调用wait或waitpid时就可以得到这些信息。如果⼀个进程终止,而该进程有子进程处于僵尸状态,那么它的所有僵尸子进程的父进程ID将被设置为1(init进程)。继承这些⼦进程的init进程将清理它们(也就是说init进程将wait它们,从⽽去除它们的僵⼫状态)。

相关概念:

进程组:

        每个进程都属于一个进程组。每个进程组都有一个组长进程,组长进程的进程号等于进程组ID。只要某个进程组中有一个进程存在,则该进程组就存在,与组长进程是否终止无关。从进程组创建开始到其中最后一个进程离开为止的时间区间成为进程组的生存期。进程组中最后一个进程可以终止或者转移到另一个进程组中。

会话:

        会话是一个或多个进程组的集合。通常,一个会话开始于用户登录,终止于用户退出,在此期间该用户运行的所有进程都属于这个会话期。

控制终端:

        会话的领头进程打开一个终端之后, 该终端就成为该会话的控制终端 (SVR4/Linux) 与控制终端建立连接的会话领头进程称为控制进程 (session leader) 。一个会话只能有一个控制终端 ,产生在控制终端上的输入和信号将发送给会话的前台进程组中的所有进程 ,终端上的连接断开时 (比如网络断开或 Modem 断开), 挂起信号将发送到控制进程(session leader) 。平时在X-window下是使用的terminal称为伪终端,但它也是一个控制终端。

二十九、如何避免僵尸进程

僵尸进程的危害:

        unix提供了一种机制可以保证只要父进程想知道子进程结束时的状态信息, 就可以得到。这种机制就是: 在每个进程退出的时候,内核释放该进程所有的资源,包括打开的文件,占用的内存等。 但是仍然为其保留一定的信息(包括进程号the process ID,退出状态the termination status of the process,运行时间the amount of CPU time taken by the process等)。直到父进程通过wait / waitpid来取时才释放。 但这样就导致了问题,如果进程不调用wait / waitpid的话, 那么保留的那段信息就不会释放,其进程号就会一直被占用,但是系统所能使用的进程号是有限的,如果大量的产生僵死进程,将因为没有可用的进程号而导致系统不能产生新的进程. 此即为僵尸进程的危害,应当避免。

        任何一个子进程(init除外)在exit()之后,并非马上就消失掉,而是留下一个称为僵尸进程(Zombie)的数据结构,等待父进程处理。这是每个 子进程在结束时都要经过的阶段。如果子进程在exit()之后,父进程没有来得及处理,这时用ps命令就能看到子进程的状态是“Z”。如果父进程能及时 处理,可能用ps命令就来不及看到子进程的僵尸状态,但这并不等于子进程不经过僵尸状态。 如果父进程在子进程结束之前退出,则子进程将由init接管。init将会以父进程的身份对僵尸状态的子进程进行处理。

        一个进程如果只复制fork子进程而不负责对子进程进行wait()或是waitpid()调用来释放其所占有资源的话,那么就会产生很多的僵死进程,如果要消灭系统中大量的僵死进程,只需要将其父进程杀死,此时所有的僵死进程就会编程孤儿进程,从而被init所收养,这样init就会释放所有的僵死进程所占有的资源,从而结束僵死进程。

        严格地来说,僵死进程并不是问题的根源,罪魁祸首是产生出大量僵死进程的那个父进程。因此,当我们寻求如何消灭系统中大量的僵死进程时,答案就是把产生大量僵死进程的那个元凶枪毙掉(也就是通过kill发送SIGTERM或者SIGKILL信号啦)。枪毙了元凶进程之后,它产生的僵死进程就变成了孤儿进 程,这些孤儿进程会被init进程接管,init进程会wait()这些孤儿进程,释放它们占用的系统进程表中的资源,这样,这些已经僵死的孤儿进程 就能瞑目而去了。

避免僵尸进程:

        ●通过signal(SIGCHLD, SIG_ IGN)通知内核对子进程的结束不关心,由内核回收。如果不想让父进程挂起,可以在父进程中加入一条语句: signal(SIGCHLD,SIG_ IGN); 表示父进程忽略SIGCHLD信号,该信号是子进程退出的时候向父进程发送的。
        ● 父进程调用wait/waitpid等函数等待子进程结束,如果尚无子进程退出wait会导致父进程阻塞。waitpid可以通过传递WNOHANG使父进程不阻塞立即返回。
        ●如果父进程很忙可 以用signal注册信号处理函数,在信号处理函数调用wait/waitpid等待子进程退出。
        ●通过两次调用fork。父进程首先调用fork创建一个子进程然后waitpid等待子进程退出,子进程再fork一个孙进程后退出。这样子进程退出后会被父进程等待回收,而对于孙子进程其父进程已经退出所以孙进程成为一个孤儿进程,孤儿进程由init进程接管,孙进程结束后,init会 等待回收。

第一种方法忽略SIGCHLD信号,这常用于并发服务器的性能的一个技巧因为并发服务器常常fork很多子进程,子进程终结之后需要服务器进程去wait清理资源。如果将此信号的处理方式设为忽略,可让内核把僵尸子进程转交给init进程去处理,省去了大量僵尸进程占用系统资源。

三十、常见内存分配错误

内存分配方式:

内存分配方式有三种:

        (1) 从静态存储区域分配。内存在程序编译的时候就已经分配好,这块内存在程序的整个运行期间都存在。例如全局变量,static变量。

        (2) 在栈上创建。在执行函数时,函数内局部变量的存储单元都可以在栈上创建,函数执行结束时这些存储单元自动被释放。栈内存分配运算内置于处理器的指令集中,效率很高,但是分配的内存容量有限。

        (3) 从堆上分配,亦称动态内存分配。程序在运行的时候用malloc或new申请任意多少的内存,程序员自己负责在何时用free或delete释放内存。动态内存的生存期由我们决定,使用非常灵活,但问题也最多。

常见的内存分配错误:

        发生内存错误是件非常麻烦的事情。编译器不能自动发现这些错误,通常是在程序运行时才能捕捉到。而这些错误大多没有明显的症状,时隐时现,增加了改错的难度。有时用户怒气冲冲地把你找来,程序却没有发生任何问题,你一走,错误又发作了。

常见的内存错误及其对策如下:

  • 内存分配未成功,却使用了它。

        编程新手常犯这种错误,因为他们没有意识到内存分配会不成功。常用解决办法是,在使用内存之前检查指针是否为NULL。如果指针p是函数的参数,那么在函数的入口处用assert(p!=NULL)进行检查。如果是用malloc或new来申请内存,应该用if(p==NULL) 或if(p!=NULL)进行防错处理。

  • 内存分配虽然成功,但是尚未初始化就引用它。

        犯这种错误主要有两个起因:一是没有初始化的观念;二是误以为内存的缺省初值全为零,导致引用初值错误(例如数组)。

        内存的缺省初值究竟是什么并没有统一的标准,尽管有些时候为零值,我们宁可信其无不可信其有。所以无论用何种方式创建数组,都别忘了赋初值,即便是赋零值也不可省略,不要嫌麻烦。

  • 内存分配成功并且已经初始化,但操作越过了内存的边界。

        例如在使用数组时经常发生下标“多1”或者“少1”的操作。特别是在for循环语句中,循环次数很容易搞错,导致数组操作越界。

  • 忘记了释放内存,造成内存泄露。

        含有这种错误的函数每被调用一次就丢失一块内存。刚开始时系统的内存充足,你看不到错误。终有一次程序突然死掉,系统出现提示:内存耗尽。

        动态内存的申请与释放必须配对,程序中malloc与free的使用次数一定要相同,否则肯定有错误(new/delete同理)。

  • 释放了内存却继续使用它。

        有三种情况:

                (1)程序中的对象调用关系过于复杂,实在难以搞清楚某个对象究竟是否已经释放了内存,此时应该重新设计数据结构,从根本上解决对象管理的混乱局面。

                (2)函数的return语句写错了,注意不要返回指向“栈内存”的“指针”或者“引用”,因为该内存在函数体结束时被自动销毁。

                (3)使用free或delete释放了内存后,没有将指针设置为NULL。导致产生“野指针”。

内存分配规则:

【规则1用malloc或new申请内存之后,应该立即检查指针值是否为NULL。防止使用指针值为NULL的内存。

【规则2不要忘记为数组和动态内存赋初值。防止将未被初始化的内存作为右值使用。

【规则3避免数组或指针的下标越界,特别要当心发生“多1”或者“少1”操作。

【规则4动态内存的申请与释放必须配对,防止内存泄漏。

【规则5用free或delete释放了内存之后,立即将指针设置为NULL,防止产生“野指针”。

三十一、内存交换中,被换出的内存保存在哪里

内存交换:

        保存在磁盘中,也就是外存中。具有对换功能的操作系统中,通常把磁盘空间分为文件区和对换区两部分。

        文件区主要用于存放文件,主要追求存储空间的利用率,因此对文件区空间的管理采用离散分配方式;对换区空间只占磁盘空间的小部分,被换出的进程数据就存放在对换区。由于对换的速度直接影响到系统的整体速度,因此对换区空间的管理主要追求换入换出速度,因此通常对换区采用连续分配方式。总之,对换区的I/O速度比文件区的更快。

文件分配块的组织方式:

1、 连续分配:

        连续分配就是在磁盘上分配一组连续的块来存储一个文件,磁盘每个块都有一个地址,且按照线性排列。那么计算机存储文件就只需要存储文件名、文件首地址、文件长度。具体示例如下图:

        这样的分配方式的优点就在于:1、访问容易;2、由于文件系统会记住上一次访问的地址,所以该方式支持直接访问和顺序访问。

        缺点在于:

        1)、新文件分配和拓展问题:当文件被创建的时候,我们需要按照文件的大小分配空间,但是在文件的使用过程中,文件可能会变大,也可能会被删除。

如果文件紧贴着彼此,就很难进行拓展;一种解决方式是:当空间不够的时候,在最后一块加一个拓展指针,指向另一空块进行拓展,以此类推。

如果我们给每个文件分配一定的可拓展的空间,但是当用户不再拓展一些文件的时候,就会造成空间的浪费。

        2)、外部碎片(整个块没法使用)问题:在不断的增加和删除文件过程中,会造成一些集中的小块(比如图中的18、19块)没法用,它们周围的文件不扩展,也没有这么小的文件可以存储到其中,就会造成浪费。一种解决方案是定期合并这些小的碎片,通过移动其他文件块的位置使这些小碎片集中成一个大块,供以后使用。但是这种方式严重的时间代价,而且会使计算机在此期间无法工作。

2、链接分配:

这种分配方式的思想类似于链表,每个块就是一个节点,每个块的尾部存储下一个块的地址。文件访问时,当访问到一块的最后时,获取下一个块的地址,从而进行下一块的访问,当访问文件结束时,那一块的地址就为一个特定的终止符(图例中的终止符为-1)。那么文件的目录结构就只需要文件名、起始地址、终结地址了。

 链接分配的优点在于:

        1)、不会出现外碎片问题,因为这种分配方式没有位置的限制,任何一个块都可以指向任意块,也可以被任意块指向。

        2)、便于拓展:文件如果需要扩展,那么只需要在文件链的尾部添加节点,并且修改原尾节点和文件目录的终结地址即可。

但是链接分配也有它自身的缺陷:

        1)、只能顺序访问:由于这种磁盘分配方式采用的链表,所以只能顺序访问,要访问文件的第i块,就必须从头开始,跟着指针,找到第i块。

        2)、指针浪费内存:由于每一块都需要存储额外的指针,这样就造成了空间的浪费。一种优化方式是:多块共用一个地址,看成一块,称为簇。这样就可以有效的减少指针的数量,但是如果最后一个簇用不完,例如一个簇包含4个块,但是文件+指针一共需要13个块,那么就需要4个簇,但是最后一个簇就只使用了一个块,而一个簇公用一个地址,所以其他三块就没法分配出去,只能被浪费了。

3、索引分配:

为了解决上面两种方式的问题,可以采用索引分配。索引分配简单的说就是把链接分配的指针集中顺序存放在一个块中,那么文件目录就只需要存储文件名和索引块的地址,然后在这个块中按照地址依次访问,当然也可以通过偏移量直接查询到想访问的地址之后就直接访问。

这种访问方式有一个问题:就是一个块存储的索引地址的数量是有限的,那么分配该怎么把握呢?针对这一目的的机制包括如下:

        1)、链接方案:简单地说,就是用链接分配来拓展索引块,一个索引块用完了,就通过指针链接到下一块。

        2)、多层索引:就像树一样,目录的索引块指向根节点,然后块中的地址就指向另一个块,是第二层索引,以此类推。

        3)、组合方案:例如在如下的图中,一个索引块含有15个指针(直接块+间接块),其中的头12个指针是直接块;即它们包括了能存储文件数据的块的地址。因此,小文件不需要其他是索引块。一级间接块就是二层索引结构,二级间接块就是三层索引结构,三级间接块就是四层索引结构,通过这种分层的方式,可以使大小不同文件分配到合适的索引结构。

三十二、原子操作是怎么实现的

        处理器使用基于对缓存加锁或总线加锁的方式来实现多处理器之间的原子操作。首先处理器会自动保证基本的内存操作的原子性。处理器保证从系统内存中读取或者写入一个字节是原子的,意思是当一个处理器读取一个字节时,其他处理器不能访问这个字节的内存地址。Pentium 6和最新的处理器能自动保证单处理器对同一个缓存行里进行16/32/64位的操作是原子的,但是复杂的内存操作处理器是不能自动保证其原子性的,比如跨总线宽度、跨多个缓存行和跨页表的访问。但是,处理器提供总线锁定和缓存锁定两个机制来保证复杂内存操作的原子性。
        

        (1)使用总线锁保证原子性 第一个机制是通过总线锁保证原子性。如果多个处理器同时对共享变量进行读改写操作(i++就是经典的读改写操作),那么 共享变量就会被多个处理器同时进行操作,这样读改写操作就不是原子的,操作完之后共享变量的值会和期望的不一致。举个例子,如果i=1,我们进行两次i++操作,我们期望的结果是3,但是有可能结果是2,如图下图所示。

字节面试杂谈——操作系统_第1张图片

        原因可能是多个处理器同时从各自的缓存中读取变量i,分别进行加1操作,然后分别写入系统内存中。那么,想要保证读改写共享变量的操作是原子的,就必须保证CPU1 读改写共享变量的时候,CPU2不能操作缓存了该共享变量内存地址的缓存。
        处理器使用总线锁就是来解决这个问题的。所谓总线锁就是使用处理器提供的一个LOCK#信号,当一个处理器在总线上输出此信号时,其他处理器的请求将被阻塞住,那么该处理器可以独占共享内存。

(2)使用缓存锁保证原子性 第二个机制是通过缓存锁定来保证原子性。在同一时刻,我们只需保证对某个内存地址的操作是原子性即可,但总线锁定把CPU和内存之间的通信锁住了,这使得锁定期间,其他处理器不能操作其他内存地址的数据,所以总线锁定的开销比较大,目前处理器在某些场合下使用缓存锁定代替总线锁定来进行优化。
        频繁使用的内存会缓存在处理器的L1、L2和L3高速缓存里,那么原子操作就可以直接在处理器内部缓存中进行,并不需要声明总线锁,在Pentium 6和目前的处理器中可以使用“缓存锁定"的方式来实现复杂的原子性。

        所谓"缓存锁定”是指内存区域如果被缓存在处理器的缓存行中,并且在Lock操作期间被锁定,那么当它执行锁操作回写到内存时,处理器不在总线上声言LOCK#信号,而是修改内部的内存地址,并允许它的缓存一致性机制来保证操作的原子性,因为缓存一致性机制会阻 止同时修改由两个以上处理器缓存的内存区域数据,当其他处理器回写已被锁定的缓存行的数据时,会使缓存行无效,在如上图所示的例子中,当CPU1修改缓存行中的 i 时使用了缓存锁定,那么CPU2就不能使用同时缓存 i 的缓存行。
        但是有两种情况下处理器不会使用缓存锁定。第一种情况是:当操作的数据不能被缓存在处理器内部,或操作的数据跨多个缓存行(cache line)时,则处理器会调用总线锁定。第二 种情况是:有些处理器不支持缓存锁定。对于Intel 486和Pentium处理器,就算锁定的内存区域在处理器的缓存行中也会调用总线锁定。

三十三、抖动

(1)最小物理块数:最小物理块数是指能保证进程正常运行所需的最小物理块数,当系统为进程分配的物理块数少于此值时,进程将无法运行。

(2)内存分配策略:

        在请求分页系统中,可采取两种内存分配策略,即固定和可变分配策略。在进行置换时,也可采取两种策略,即全局置换和局部置换。于是可组合出以下三种适用的策略。
        1)固定分配局部置换(Fixed Allocation, Local Replacement)
                所谓固定分配,是指为每个进程分配一组固定数目的物理块,在进程运行期间不再改变。所谓局部置换,是指如果进程在运行中发现缺页,则只能从分配给该进程的n个页面中选出一页换出,然后再调入一页,以保证分配给该进程的内存空间不变。采用该策略时,为每个进程分配多少物理块是根据进程类型(交互型或批处理型等)或根据程序员、程序管理员的建议来确定的。实现这种策略的困难在于:应为每个进程分配多少个物理块难以确定。若太少,会频繁地出现缺页中断,降低了系统的吞吐量。若太多,又必然使内存中驻留的进程数目减少,进而可能造成CPU空闲或其它资源空闲的情况,而且在实现进程对换时,会花费更多的时间。


        2)可变分配全局置换(Variable Allocation, Global Replacement)
                所谓可变分配,是指先为每个进程分配一定数目的物理块;在进程运行期间,可根据情况做适当的增加或减少。所谓全局置换,是指如果进程在运行中发现缺页,则将os所保留的空闲物理块(一般组织为一个空闲物理块队列)取出一块 分配给该进程,或者以所有进程的全部物理块为标的,选择一块换出,然后将所缺之页调入。这样,分配给该进程的内存空间就随之增加。可变分配全局置换这可能是最易于实现的一种物理块分配和置换策略,已用于若千个OS中。在采用这种策略时,凡产生缺页(中断)的进程,都将获得新的物理块,仅当空闲物理块队列中的物理块用完时,OS才能从内存中选择一页调出。被选择调出的页可能是系统中任何一个进程中的页,因此这个被选中的进程拥有的物理块会减少,这将导致其缺页率增加。

        3)可变分配局部置换(Variable Allocation,Local Replacement)
                该策略同样是基于进程的类型或根据程序员的要求,为每个进程分配一定 数目的物理块,但当某进程发现缺页时,只允许从该进程在内存的页面中选择一页换出, 这样就不会影响其它进程的运行。如果进程在运行中频繁地发生缺页中断,则系统须再为该进程分配若千附加的物理块,直至该进程的缺页率减少到适当程度为止。反之,若一个进程在运行过程中的缺页率特别低,则此时可适当减少分配给该进程的物理块数,但不应引起其缺页率的明显增加。

(3)抖动:

        发生“抖动”的根本原因是,同时在系统中运行的进程太多,由此分配给每一个进程的物理块太少,不能满足进程正常运行的基本要求,致使每个进程在运行时,频繁地出现缺页,必须请求系统将所缺之页调入内存。这会使得在系统中排队等待页面调进/调出的进程数目增加。显然,对磁盘的有效访问时间也随之急剧增加,造成每个进程的大部分时间都用于页面的换进/换出,而几乎不能再去做任何有效的工作,从而导致发生处理机的利用率急剧下降并趋于0的情况。我们称此时的进程是处于“抖动”状态。

(4)工作集:

        所谓工作集,是指在某段时间间隔▲里,进程实际所要访问页面的集合。

(5)抖动的预防方法:

        为了保证系统具有较大的吞吐量,必须防止“抖动”的发生。目前已有许多防止“抖动”发生的方法。这些方法几乎都是采用调节多道程序度来控制“抖动”发生的。下面介绍几个较常用的预防“抖动”发生的方法。

        1.采取局部置换策略
                在页面分配和置换策略中,如果采取的是可变分配方式,则为了预防发生“抖动”,可采取局部置换策略。根据这种策略,当某进程发生缺页时,只能在分配给自己的内存空间内进行置换,不允许从其它进程去获得新的物理块。这样,即使该进程发生了“抖动”,也不会对其它进程产生影响,于是可把该进程“抖动”所造成的影响限制在较小的范围内。该方法虽然简单易行,但效果不是很好,因为在某进程发生“抖动”后,它还会长期处在磁盘I/O的等待队列中,使队列的长度增加,这会延长其它进程缺页中断的处理时间,也就是延长了其它进程对磁盘的访问时间。

        2.把工作集算法融入到处理机调度中
                当调度程序发现处理机利用率低下时,它将试图从外存调入一个 新作业进入内存,来
改善处理机的利用率。如果在调度中融入了工作集算法,则在调度程序从外存调入作业之前,必须先检查每个进程在内存的驻留页面是否足够多。如果都已足够多,此时便可以从外存调入新的作业,不会因新作业的调入而导致缺页率的增加;反之,如果有些进程的内存页面不足,则应首先为那些缺页率居高的作业增加新的物理块,此时将不再调入新的作业。

        3.利用“L=S"准则调节缺页率
                Denning于1980年提出了“L=S”的准则来调节多道程序度,其中L是缺页之间的平均时间,S是平均缺页服务时间,即用于置换一个页面所需的时间。如果是L远比S大,说明很少发生缺页,磁盘的能力尚未得到充分的利用;反之,如果是L比S小,则说明频繁发生缺页,缺页的速度已超过磁盘的处理能力。只有当L与S接近时,磁盘和处理机都可达到它们的最大利用率。理论和实践都已证明,利用“L=S”准则,对于调节缺页率是十分有效的。

        4.选择暂停的进程
                当多道程序度偏高时,已影响到处理机的利用率,为了防止发生“抖动”,系统必须减少多道程序的数目。此时应基于某种原则选择暂停某些当前活动的进程,将它们调出到磁盘上,以便把腾出的内存空间分配给缺页率发生偏高的进程。系统通常都是采取与调度程序一致的策略,即首先选择暂停优先级最低的进程,若需要,再选择优先级较低的进程。当内存还显拥挤时,还可进一步选择暂停一个并不十 分重要、但却较大的进程,以便能释放出较多的物理块,或者暂停剩余执行时间最多的进程等。

三十四、协程、管程

(1)协程:

        协程(Coroutine,又称微线程)是一种比线程更加轻量级的存在,协程不是被操作系统内核所管理,而完全是由程序所控制。协程与线程以及进程的关系见下图所示。

        协程可以比作子程序,但执行过程中,子程序内部可中断,然后转而执行别的子程序,在适当的时候再返回来接着执行。协程之间的切换不需要涉及任何系统调用或任何阻塞调用

        协程只在一个线程中执行,是子程序之间的切换,发生在用户态上。而且,线程的阻塞状态是由操作系统内核来完成,发生在内核态上,因此协程相比线程节省线程创建和切换的开销

        协程中不存在同时写变量冲突,因此,也就不需要用来守卫关键区块的同步性原语,比如互斥锁、信号量等,并且不需要来自操作系统的支持。

        协程适用于IO阻塞且需要大量并发的场景,当发生IO阻塞,由协程的调度器进行调度,通过将数据流yield掉,并且记录当前栈上的数据,阻塞完后立刻再通过线程恢复栈,并把阻塞的结果放到这个线程上去运行。

 (2)管程:

        系统中的各种硬件资源和软件资源均可用数据结构抽象地描述其资源特性,即用少量信息和对该资源所执行的操作来表征该资源,而忽略它们的内部结构和实现细节。因此,可以利用共享数据结构抽象地表示系统中的共享资源,并且将对该共享数据结构实施的特定操作定义为一组过程。进程对共享资源的申请、释放和其它操作必须通过这组过程,间接地对共享数据结构实现操作。对于请求访问共享资源的诸多并发进程,可以根据资源的情况接受或阻塞,确保每次仅有一个进程进入管程,执行这组过程,使用共享资源,达到对共享资源所有访问的统一管理, 有效地实现进程互斥。

        代表共享资源的数据结构以及由对该共享数据结构实施操作的一组过程所组成的资源管理程序共同构成了一个操作系统的资源管理模块,我们称之为管程。管程被请求和释放资源的进程所调用。Hansan为管程所下的定义是:“一个管程定义了一个数据结构和能为并发进程所执
行(在该数据结构.上)的一组操作,这组操作能同步进程和改变管程中的数据。由上述的定义可知,管程由四部分组成:①管程的名称:②局部于管程的共享数据结构说明;③对该数据结构进行操作的一组过程;④对局部于管程的共享数据设置初始值的语句。

        实际上,管程中包含了面向对象的思想,它将表征共享资源的数据结构及其对数据结构操作的一组过程, 包括同步机制,都集中并封装在一个对象内部,隐藏了实现细节。封装于管程内部的数据结构仅能被封装于管程内部的过程所访问,任何管程外的过程都不能访问它;反之,封装于管程内部的过程也仅能访问管程内的数据结构。所有进程要访问临界资源时,都只能通过管程间接访问,而管程每次只准许一个进程进入管程,执行管程内的过程,从而实现了进程互斥。


        管程是一种程序设计语言的结构成分,它和信号量有同等的表达能力,从语言的角度看,管程主要有以下特性:①模块化,即管程是一个基本程序单位,可以单独编译;②抽象数据类型,指管程中不仅有数据,而且有对数据的操作;③信息掩蔽,指管程中的数据结构只能被管程中的过程访问,这些过程也是在管程内部定义的,供管程外的进程调用,而管程中的数据结构以及过程(函数)的具体实现外部不可见。


        管程和进程不同:①虽然二者都定义了数据结构,但进程定义的是私有数据结构PCB,管程定义的是公共数据结构,如消息队列等;②二者都存在对各自数据结构上的操作,但进程是由顺序程序执行有关操作,而管程主要是进行同步操作和初始化操作;③设置进程的目的在于实现系统的并发性,而管程的设置则是解决共享资源的互斥使用问题;④进程通过调用管程中的过程对共享数据结构实行操作,该过程就如通常的子程序一样被调用,因而管程为被动工作方式,进程则为主动工作方式;⑤进程之间能并发执行,而管程则不能与其调用者并发;⑥进程具有动态性,由“创建”而诞生,由“撤消”而消亡,而管程则是操作系统中的一一个资源管理模块,供进程调用。

        在利用管程实现进程同步时,必须设置同步工具,如两个同步操作原语wait 和signal。当某进程通过管程请求获得临界资源而未能满足时,管程便调用wait原语使该进程等待,并将其排在等待队列上。仅当另一进程访 问完成并释放该资源之后,管程才又调用signal原语,唤醒等待队列中的队首进程。但是仅仅有上述的同步工具是不够的,考虑一种情况:当一个进程调用了管程,在管程中时被阻塞或挂起,直到阻塞或挂起的原因解除,而在此期间,如果该进程不释放管程,则其它进程无法进入管程,被迫长时间的等待。为了解决这个问题,引入了条件变量condition。通常,一个进程被阻塞或挂起的条件(原因)可有多个,因此在管程中设置了多个条件变量,对这些条件变量的访问只能在管程中进行。管程中对每个条件变量都须予以说明,其形式为:conditionx,y;对条件变量的操作仅仅是wait和signal,因此条件变量也是一种抽象数据类型,每个条件变量保存了一个链表,用于记录因该条件变量而阻塞的所有进程,同时提供的两个操作即可表示为x.wait和x.signal,其含义为:

        ①x.wait:正在调用管程的进程因x条件需要被阻塞或挂起,则调用x.wait将自己插入到x条件的等待队列上,并释放管程,直到x条件变化。此时其它进程可以使用该管程。
        ②x.signal: 正在调用管程的进程发现x条件发生了变化,则调用x.signal,重新启动一个因x条件而阻塞或挂起的进程,如果存在多个这样的进程,则选择其中的一个,如果没有,继续执行原进程,而不产生任何结果。这与信号量机制中的signal 操作不同。因为,后者总是要执行s:=s+1操作,因而总会改变信号量的状态。
        如果有进程Q因x条件处于阻塞状态,当正在调用管程的进程P执行了x.signal 操作后,进程Q被重新启动,此时两个进程P和Q,如何确定哪个执行哪个等待,可采用下述两种方式之一进行处理:
(1) P等待,直至Q离开管程或等待另一件。
(2) Q等待,直至P离开管程或等待另一条件。

三十五、同步IO,异步IO、阻塞IO、非阻塞IO

对于一次IO访问(这回以read举例),数据会先被拷贝到操作系统内核的缓冲区中,然后才会从操作系统内核的缓冲区拷贝到应用程序的缓冲区,最后交给进程。所以说,当一个read操作发生时,它会经历两个阶段:
  1. 等待数据准备 (Waiting for the data to be ready)
  2. 将数据从内核拷贝到进程中 (Copying the data from the kernel to the process)

正式因为这两个阶段,linux系统产生了下面五种网络模式的方案:
  1. 阻塞 I/O(blocking IO)
  2. 非阻塞 I/O(nonblocking IO)
  3. I/O 多路复用( IO multiplexing)
  4. 信号驱动 I/O( signal driven IO)
  5. 异步 I/O(asynchronous IO)

其中   阻塞 I/O(blocking IO)、非阻塞 I/O(nonblocking IO)、I/O 多路复用( IO multiplexing)、信号驱动 I/O( signal driven IO)是同步I/O。

阻塞程度:阻塞IO>非阻塞IO>多路转接IO>信号驱动IO>异步IO,效率是由低到高的。

对于socket流而言,

  1. 第一步:通常涉及等待网络上的数据分组到达,然后被复制到内核的某个缓冲区。

  2. 第二步:把数据从内核缓冲区复制到应用进程缓冲区。

(1)阻塞 I/O(blocking IO)

        所有套接字默认的都是阻塞的,以recvfrom系统调用为例子,它要等到有数据报到达且被复制到应用进程的缓冲区中或者发生了错误才返回。若没有数据到达那么将一直会阻塞。

字节面试杂谈——操作系统_第2张图片

        进程发起IO系统调用后,进程被阻塞,转到内核空间处理,整个IO处理完毕后返回进程。操作成功则进程获取到数据。

        最传统的一种IO模型,即在读写数据过程中会发生阻塞现象

  当用户线程发出IO请求之后,内核会去查看数据是否就绪,如果没有就绪就会等待数据就绪,而用户线程就会处于阻塞状态,用户线程交出CPU。当数据就绪之后,内核会将数据拷贝到用户线程,并返回结果给用户线程,用户线程才解除block状态。

1、典型应用:阻塞socket、Java BIO;

2、特点:

  • 进程阻塞挂起不消耗CPU资源,及时响应每个操作;
  • 实现难度低、开发应用较容易;
  • 适用并发量小的网络应用开发;

不适用并发量大的应用:因为一个请求IO会阻塞进程,所以,得为每请求分配一个处理进程(线程)以及时响应,系统开销大。

(2)非阻塞 I/O(nonblocking IO)

        进程将一个套接字设置为非阻塞就是通知内核:当前所请求的IO操作在请求的过程不需要把进程投入睡眠,而是返回一个错误。(注意这里是指请求IO操作,不是进行IO操作)

字节面试杂谈——操作系统_第3张图片

        当一个应用进程循环调用recvfrom的时候,这种操作叫做轮询。应用进程轮询内核,检查某个操作是否准备就绪,当IO操作准备就绪可以操作的时候就会进行真正的IO操作,就是将数据从内核写入用户空间的过程。但是这样做会导致CPU的大量耗费。

进程发起IO系统调用后,如果内核缓冲区没有数据,需要到IO设备中读取,进程返回一个错误而不会被阻塞;进程发起IO系统调用后,如果内核缓冲区有数据,内核就会把数据返回进程。

对于上面的阻塞IO模型来说,内核数据没准备好需要进程阻塞的时候,就返回一个错误,以使得进程不被阻塞。

1、典型应用:socket是非阻塞的方式(设置为NONBLOCK)

2、特点:

  • 进程轮询(重复)调用,消耗CPU的资源;
  • 实现难度低、开发应用相对阻塞IO模式较难;
  • 适用并发量较小、且不需要及时响应的网络应用开发;

        当用户线程发起一个read操作后,并不需要等待,而是马上就得到了一个结果。如果结果是一个error时,它就知道数据还没有准备好,于是它可以再次发送read操作。一旦内核中的数据准备好了,并且又再次收到了用户线程的请求,那么它马上就将数据拷贝到了用户线程,然后返回。

        所以事实上,在非阻塞IO模型中,用户线程需要不断地询问内核数据是否就绪,也就说非阻塞IO不会交出CPU,而会一直占用CPU。

(3)I/O 多路复用( IO multiplexing)

        我们可以通过系统调用select、poll、epoll、kqueue实现IO复用模型。此时进程就会阻塞在这些系统调用上,而不是阻塞在真正的IO操作上,直到有就绪事件了,这些系统调用就会返回哪些套接字可读写,然后就可以进行把数据包复制到应用进程缓冲区了。IO多路复用模型是建立在内核提供的多路分离函数select基础之上的,使用select函数可以避免同步非阻塞IO模型中轮询等待的问题。
        从流程上来看,使用select函数进行IO请求和同步阻塞模型没有太大的区别,甚至还多了添加监视socket,以及调用select函数的额外操作,效率更差。但是,使用select以后最大的优势是用户可以在一个线程内同时处理多个socket的IO请求。用户可以注册多个socket,然后不断地调用select读取被激活的socket,即可达到在同一个线程内同时处理多个IO请求的目的。而在同步阻塞模型中,必须通过多线程的方式才能达到这个目的。

        其中select是通过不断的轮询,查看是否有就绪事件。如果有的话,再把所有的流遍历一遍看是哪个流准备就绪。而poll也是采用这样的轮询,只不过poll采用的是链表存储,所以没有最大连接数的限制,epoll是even poll,和忙轮询、无差别轮询不一样,它会把哪个流发生了怎样的I/O事件通知我们,不用全都遍历一遍才知道是哪个流发生了。所以我们说epoll实际上是事件驱动(每个事件关联上fd)的,此时我们对这些流的操作都是有意义的。(复杂度降低到了O(1)),而select和poll查找复杂度都是O(n)。

字节面试杂谈——操作系统_第4张图片

多个的进程的IO可以注册到一个复用器(select)上,然后用一个进程调用该select, select会监听所有注册进来的IO;

如果select没有监听的IO在内核缓冲区都没有可读数据,select调用进程会被阻塞;而当任一IO在内核缓冲区中有可数据时,select调用就会返回;

而后select调用进程可以自己或通知另外的进程(注册进程)来再次发起读取IO,读取内核中准备好的数据。

可以看到,多个进程注册IO后,只有另一个select调用进程被阻塞。

1、典型应用:select、poll、epoll三种方案,nginx都可以选择使用这三个方案;Java NIO;

2、特点:

  • 专一进程解决多个进程IO的阻塞问题,性能好;Reactor模式;
  • 实现、开发应用难度较大;
  • 适用高并发服务应用开发:一个进程(线程)响应多个请求;

3、select、poll、epoll

  • Linux中IO复用的实现方式主要有select、poll和epoll:
  • Select:注册IO、阻塞扫描,监听的IO最大连接数不能多于FD_SIZE;
  • Poll:原理和Select相似,没有数量限制,但IO数量大扫描线性性能下降;
  • Epoll :事件驱动不阻塞,mmap实现内核与用户空间的消息传递,数量很大,Linux2.6后内核支持;

        多路复用IO模型是目前使用得比较多的模型。Java NIO实际上就是多路复用IO。

        在多路复用IO模型中,会有一个线程不断去轮询多个socket的状态,只有当socket真正有读写事件时,才真正调用实际的IO读写操作。因为在多路复用IO模型中,只需要使用一个线程就可以管理多个socket,系统不需要建立新的进程或者线程,也不必维护这些线程和进程,并且只有在真正有socket读写事件进行时,才会使用IO资源,所以它大大减少了资源占用。

        在Java NIO中,是通过selector.select()去查询每个通道是否有到达事件,如果没有事件,则一直阻塞在那里,因此这种方式会导致用户线程的阻塞。

        也许有朋友会说,我可以采用多线程+ 阻塞IO 达到类似的效果,但是由于在多线程 + 阻塞IO 中,每个socket对应一个线程,这样会造成很大的资源占用,并且尤其是对于长连接来说,线程的资源一直不会释放,如果后面陆续有很多连接的话,就会造成性能上的瓶颈。

        而多路复用IO模式,通过一个线程就可以管理多个socket,只有当socket真正有读写事件发生才会占用资源来进行实际的读写操作。因此,多路复用IO比较适合连接数比较多的情况。

        另外多路复用IO为何比非阻塞IO模型的效率高是因为在非阻塞IO中,不断地询问socket状态时通过用户线程去进行的,而在多路复用IO中,轮询每个socket状态是内核在进行的,这个效率要比用户线程要高的多。

        不过要注意的是,多路复用IO模型是通过轮询的方式来检测是否有事件到达,并且对到达的事件逐一进行响应。因此对于多路复用IO模型来说,一旦事件响应体很大,那么就会导致后续的事件迟迟得不到处理,并且会影响新的事件轮询。

字节面试杂谈——操作系统_第5张图片

(4)信号驱动 I/O( signal driven IO)

        我们也可以用信号让内核在文件描述符准备就绪的时候通知用户进程,即是告知我们什么时候可以启动IO操作。就如数据准备好了,内核就会以一种形式通知用户进程。

        这种模型的优势就在于数据到达之前不被阻塞,主循环可以继续执行,用户进程只需要等到着来自信号的处理函数的通知即可,其中既可以是数据已准备好被处理,也可以是数据报已准备好被读取。        

字节面试杂谈——操作系统_第6张图片

当进程发起一个IO操作,会向内核注册一个信号处理函数,然后进程返回不阻塞;当内核数据就绪时会发送一个信号给进程,进程便在信号处理函数中调用IO读取数据。

特点:回调机制,实现、开发应用难度大;        

        在信号驱动IO模型中,当用户线程发起一个IO请求操作,会给对应的socket注册一个信号函数,然后用户线程会继续执行,当内核数据就绪时会发送一个信号给用户线程,用户线程接收到信号之后,便在信号函数中调用IO读写操作来进行实际的IO请求操作。这个一般用于UDP中,对TCP套接口几乎是没用的,原因是该信号产生得过于频繁,并且该信号的出现并没有告诉我们发生了什么事情

字节面试杂谈——操作系统_第7张图片

(5) 异步 I/O(asynchronous IO)

        异步IO由POSIX规范定义的。一般地说,这些函数的工作机制就是:由用户进程告知内核启动一个操作,并且由内核去操作,操作完后给用户进程发一个通知,通知用户进程操作完了(包括数据从内核缓冲区拷贝到用户缓冲区的过程)。该模型与信号驱动式IO模型不同的就是,异步IO模型中,是由内核通知IO操作什么时候完成,而信号驱动式IO是由内核告知何时启动IO操作。

字节面试杂谈——操作系统_第8张图片

当进程发起一个IO操作,进程返回(不阻塞),但也不能返回结果;内核把整个IO处理完后,会通知进程结果。如果IO操作成功则进程直接获取到数据。

1、典型应用:JAVA7 AIO、高性能服务器应用

2、特点:

  • 不阻塞,数据一步到位;Proactor模式;
  • 需要操作系统的底层支持,LINUX 2.5 版本内核首现,2.6 版本产品的内核标准特性;
  • 实现、开发应用难度大;
  • 非常适合高性能高并发应用;

        异步IO模型才是最理想的IO模型,在异步IO模型中,当用户线程发起read操作之后,立刻就可以开始去做其它的事。而另一方面,从内核的角度,当它受到一个asynchronous read之后,它会立刻返回,说明read请求已经成功发起了,因此不会对用户线程产生任何block。然后,内核会等待数据准备完成,然后将数据拷贝到用户线程,当这一切都完成之后,内核会给用户线程发送一个信号,告诉它read操作完成了。也就说用户线程完全不需要关心实际的整个IO操作是如何进行的,只需要先发起一个请求,当接收内核返回的成功信号时表示IO操作已经完成,可以直接去使用数据了。

        也就说在异步IO模型中,IO操作的两个阶段都不会阻塞用户线程,这两个阶段都是由内核自动完成,然后发送一个信号告知用户线程操作已完成。用户线程中不需要再次调用IO函数进行具体的读写。这点是和信号驱动模型有所不同的,在信号驱动模型中,当用户线程接收到信号表示数据已经就绪,然后需要用户线程调用IO函数进行实际的读写操作;而在异步IO模型中,收到信号表示IO操作已经完成,不需要再在用户线程中调用iO函数进行实际的读写操作。

        注意,异步IO是需要操作系统的底层支持,在Java 7中,提供了Asynchronous IO。简称AIO

字节面试杂谈——操作系统_第9张图片

(6)同步、异步、阻塞、非阻塞

        同步: 执行一个操作后,进程触发IO操作(其中要么就是等待数据的到达,也就是阻塞模式;要么通过轮询去查看数据是否到达也就是非阻塞忙轮询模式的)接着数据到达后,便阻塞用户进程一直到IO操作完成。即第二步骤是的阻塞。

        异步: 执行一个操作后,触发IO操作后不会导致请求进程阻塞。也就是说数据从内核到用户缓冲区的整个过程都是交给内核去完成的,用户进程无需阻塞一直等到IO操作完成,它只要执行一个操作触发IO操作后就可以继续执行其他操作,直到IO操作结束后,等到被通知就可以了。所以从根本来说异步从等待数据到把数据从内核空间拷贝到用户空间的过程中没有阻塞,只有发起该操作,和被通知该操作完成。所以异步是真正的没有阻塞在IO操作上的。

        阻塞: 无数据准备好,系统调用比如read,recvfrom就会挂起,等到有数据准备好或者有数据了才继续执行系统调用,最后才从系统调用的函数中返回。


        非阻塞: 这里的非阻塞是通过忙轮询去检测是否有数据准备好,没有数据准备好就一直轮询,直到有数据准备好了可以开始进行数据的复制了为止。

        注意: 这里的阻塞和非阻塞是从第一个步骤来看的,而同步和异步的阻塞是从真正的IO操作来也就是第二步来看的。异步是真正意义上的非阻塞,所以异步不分阻塞和非阻塞,只有同步才分阻塞和非阻塞,同步中的阻塞和非阻塞是从第一个步骤来区分的。并非第二步骤,因为第二步骤中同步阻塞和非同步阻塞都将在真正的IO操作上被阻塞。

(7)读写(read write)与阻塞和非阻塞
        阻塞的read: 在我们一般用read中,如果内核的接收缓冲区没有数据到达,那么将会一直阻塞。所以read函数如果在没有数据的时候,是被挂起不返回的,如果有数据了那么就是可以读多少就读多少。
        阻塞的write:  write如果是在socket阻塞的情况下就是用户进程有多少数据就要将所有数据都写入内核的可写缓冲区中才返回,这时候就是多路复用中为什么要将socket设置为非阻塞的原因。如果是阻塞的,那么写阻塞的时候,此时内核可写缓冲区可以容纳N个字节,而需要发送的数据有N+1个字节的话,那么write是不会返回的,它会一直阻塞直到那多出来的一个字节装到内核缓冲区了才会返回。所以在select中,返回可写条件的时候,要限制将套接字设置为非阻塞,才可以说一次性写操作返回一个正值。

        非阻塞的read: 如果没有数据的话,那么read调用不会挂起,就会立即返回。如果有数据的话就是可以读多少就读多少
        非阻塞的write: 内核缓冲区够写多少就写多少,能够写多少要根据网路拥塞情况为标准,当拥塞严重的时候,没有足够的缓冲区去写的话,就会出现写不完的情况。
 

(8)总结:

字节面试杂谈——操作系统_第10张图片

阻塞和非阻塞,体现在当前进程是否可执行,是否能获取到CPU。

当阻塞和非阻塞的概念体现在IO模型上:

  • 阻塞IO:从开始发起IO操作开始就阻塞,直到IO完成才返回,所以进程会立即进入睡眠态

  • 非阻塞IO:发起IO操作时,如果当前数据已就绪,则切换到内核态由内核完成数据拷贝(从kernel buffer拷贝到app buffer),此时进程被阻塞,因为它的CPU已经被内核抢走了。如果发起IO操作时数据未就绪,则立即返回而不阻塞,即进程继续享有CPU,可以继续任务。但进程不知道数据何时就绪,所以通常会采用轮循代码(比如while循环)不断判断数据是否就绪,当数据最终就绪后,切换到内核态,进程仍然被阻塞

同步和异步,考虑的是两边数据是否同步(比如kernel buffer和app buffer之间数据是否同步)。同步和异步的区别体现在两边数据尚未完成同步时的行为:

  • 同步:在保持两边数据同步的过程中,进程被阻塞,由内核抢占其CPU去完成数据同步,直到两边数据同步,进程才被唤醒

  • 异步:在保持两边数据同步的过程中,由内核默默地在后台完成数据同步(如果不理解,可认为是单独开了一个内核线程负责数据同步),内核不会抢占进程的CPU,所以进程自身不被阻塞,当内核完成两端数据同步时,通知进程已同步完成

需要注意的是,无论是哪种IO模型,在将数据从kernel buffer拷贝到app buffer的这个阶段,都是需要CPU参与的。只不过,同步IO模型和异步IO模型中,CPU参与的方式不一样:

  • 同步IO模型中,调用read()的进程会切换到内核,由内核占用CPU来执行数据拷贝,所以原进程在此阶段一直被阻塞

  • 异步IO模型中,由内核在后台默默的执行数据拷贝,所以原进程在此阶段不被阻塞

三十六、I/O控制方式

(1)程序查询方式:

字节面试杂谈——操作系统_第11张图片

(2)中断:

字节面试杂谈——操作系统_第12张图片

字节面试杂谈——操作系统_第13张图片

字节面试杂谈——操作系统_第14张图片

字节面试杂谈——操作系统_第15张图片

字节面试杂谈——操作系统_第16张图片

字节面试杂谈——操作系统_第17张图片

字节面试杂谈——操作系统_第18张图片

字节面试杂谈——操作系统_第19张图片

字节面试杂谈——操作系统_第20张图片

字节面试杂谈——操作系统_第21张图片

字节面试杂谈——操作系统_第22张图片

(3)DMA方式:

字节面试杂谈——操作系统_第23张图片字节面试杂谈——操作系统_第24张图片

字节面试杂谈——操作系统_第25张图片

字节面试杂谈——操作系统_第26张图片

字节面试杂谈——操作系统_第27张图片

三十七、一次IO过程

待学习。。。

三十八、为什么进程上下文切换开销大

进程、线程、协程三者的上下文切换

进程 线程 协程
切换者 操作系统 操作系统 用户(编程者/应用程序)
切换时机 根据操作系统自己的切换策略,用户不感知 根据操作系统自己的切换策略,用户不感知 用户自己(的程序)决定
切换内容

页全局目录

内核栈

硬件上下文

内核栈

硬件上下文

硬件上下文
切换内容的保存 保存于内核栈中 保存于内核栈中 保存于用户自己的变量(用户栈或者堆)
切换过程 用户态—内核态—用户态 用户态—内核态—用户态 用户态(没有陷入内核态)
切换效率

进程上下文包含了进程执行所需要的所有信息。

        用户地址空间:包括程序代码,数据,用户堆栈等;

        控制信息:进程描述符,内核栈等;

        硬件上下文:进程恢复前,必须装入寄存器的数据统称为硬件上下文。

进程切换分3步

        a.切换页目录以使用新的地址空间

        b.切换内核栈

        c.切换硬件上下文

        4、刷新TLB

        5、系统调度器的代码执行

对于linux来说,线程和进程的最大区别就在于地址空间。
对于线程切换,第1步是不需要做的,第2和3步是进程和线程切换都要做的。所以明显是进程切换代价大

        1.  线程上下文切换和进程上下文切换一个最主要的区别是线程的切换虚拟内存空间依然是相同的,但是进程切换是不同的。这两种上下文切换的处理都是通过操作系统内核来完成的。内核的这种切换过程伴随的最显著的性能损耗是将寄存器中的内容切换出。

        2.  另外一个隐藏的损耗是上下文的切换会扰乱处理器的缓存机制。简单的说,一旦去切换上下文,处理器中所有已经缓存的内存地址一瞬间都作废了。还有一个显著的区别是当你改变虚拟内存空间的时候,处理的页表缓冲(processor’s Translation Lookaside Buffer (TLB))会被全部刷新, 这将导致内存的访问在一段时间内相当的低效。但是在线程的切换中,不会出现这个问题。

进程和线程的区别:

1、调度:线程作为调度和分配的基本单位,进程作为拥有资源的基本单位。
2、并发性:不仅进程之间可以并发执行,同一个进程的多个线程之间也可以并发执行。
3、拥有资源:进程是拥有资源的一个独立单位,线程不拥有系统资源,但可以访问隶属于进程的资源。

(1)CPU上下文切换

        CPU 上下文切换,就是先把前一个任务的 CPU 上下文(也就是 CPU 寄存器和程序计数器)保存起来,然后加载新任务的上下文到这些寄存器和程序计数器,最后再跳转到程序计数器所指的新位置,运行新任务。

(2)CPU上下文切换的类别

        CPU 上下文切换根据任务的不同,可以分为以下三种类型 : 进程上下文切换 - 线程上下文切换 - 中断上下文切换

(3)引起上下文切换的原因

对于抢占式操作系统而言, 大体有几种:

    ​    ​1、当前任务的时间片用完之后,系统CPU正常调度下一个任务;

    ​    ​2、当前任务碰到IO阻塞,调度线程将挂起此任务,继续下一个任务;

    ​    ​3、多个任务抢占锁资源,当前任务没有抢到,被调度器挂起,继续下一个任务;

    ​    ​4、用户代码(yield()方法)挂起当前任务,让出CPU时间;

    ​    ​5、硬件中断;

(4)进程上下文切换

引起进程上下文切换的原因:

  • 进程时间片耗尽;
  • 系统资源不足(如内存不足);
  • 进程通过睡眠函数 sleep 把自己挂起来;
  • 当有优先级更高的进程运行时,为了去运行高优先级进程,当前进程会被挂起;
  • 发生硬中断,CPU 上的进程会被挂起,然后去执行内核中的中断服务进程。

        Linux 按照特权等级,把进程的运行空间分为内核空间和用户空间,CPU 特权等级的 Ring 0 和 Ring 3。

        内核空间(Ring 0))具有最高权限,可以直接访问所有资源; 用户空间(Ring 3)只能访问受限资源,不能直接访问内存等硬件设备,必须通过系统 调用陷入到内核中,才能访问这些特权资源。

        进程既可以在用户空间运行,又可以在内核空间中运行。进程在用户空间运行时,被称为进程的用户态,而陷入内核空间的时候,被称为进程的内核态。

        从用户态到内核态的转变,需要通过 系统调用 来完成。比如,当我们查看文件内容时,就需要多次系统调用来完成:首先调用 open() 打开文件,然后调用 read() 读取文件内容,并调用 write() 将内容写到标准输出,最后再调用 close() 关闭文件。

        系统调用的过程也会发生 CPU 上下文的切换

        CPU 寄存器里原来用户态的指令位置,需要先保存起来。接着,为了执行内核态代码,CPU 寄存器需要更新为内核态指令的新位置。最后才是跳转到内核态运行内核任务。

        而系统调用结束后,CPU 寄存器需要恢复原来保存的用户态,然后再切换到用户空间,继续运行进程。所以, 一次系统调用的过程,其实是发生了两次 CPU 上下文切换

        需要注意的是,系统调用过程中,并不会涉及到虚拟内存等进程用户态的资源,也 不会切换进程。这跟我们通常所说的进程上下文切换是不一样的: 进程上下文切换,是指从一个进程切换到另一个进程运行。而系统调用过程中一直是同一个进程在运行 。所以,系统调用过程通常称为特权模式切换,而不是上下文切换。但实际上,系统调用过程中,CPU 的上下文切换还是无法避免的。

        进程在什么时候才会被调度到 CPU 上运行

        最容易想到的一个时机,就是进程执行完终止了,它之前使用的 CPU 会释放出来,这个时候再从就绪队列里,拿一个新的进程过来运行。其实还有很多其他场景,也会触发进程调度。

        其一,为了保证所有进程可以得到公平调度,CPU 时间被划分为一段段的时间片,这些时间片再被轮流分配给各个进程。这样,当某个进程的时间片耗尽了,就会被系统挂起,切换到其它正在等待 CPU 的进程运行。

        其二,进程在系统资源不足(比如内存不足)时,要等到资源满足后才可以运行,这个时 候进程也会被挂起,并由系统调度其他进程运行。

        其三,当进程通过睡眠函数 sleep 这样的方法将自己主动挂起时,自然也会重新调度。

        其四,当有优先级更高的进程运行时,为了保证高优先级进程的运行,当前进程会被挂 起,由高优先级进程来运行。

        最后一个,发生硬件中断时,CPU 上的进程会被中断挂起,转而执行内核中的中断服务程序。

进程上下文切换和系统调用的区别
        进程是由内核来管理和调度的,进程的切换只能发生在内核态。所以,进程的上下文不仅包括了虚拟内存、栈、全局变量等用户空间的资源,还包括了内核堆栈、寄存器等内核空间的状态

        进程的上下文切换比系统调用多了一步:在保存当前进程的内核状态和 CPU 寄存器之前,需要先把该进程的虚拟内存、栈等保存下来;而加载了下一进程的内核态后,还需要刷新进程的虚拟内存和用户栈

进程上下文切换对性能的影响
        同时,保存上下文和恢复上下文的过程需要内核在CPU上运行才能完成,每次上下文切换都需要几十纳秒到数微秒的 CPU 时间。这个时间还是相当可观的,特别是在进程上下文切换次数较多的情况下,很容易导致 CPU 将大量时间耗费在寄存器、内核栈以及虚拟内存等资源的保存和恢复上,进而大大缩短了真正运行进程的时间。这也正是导致平均负载升高的一个重要因素。

        另外,我们知道, Linux 通过 TLB(Translation Lookaside Buffer)来管理虚拟内存到物理
内存的映射关系。当虚拟内存更新后,TLB 也需要刷新,内存的访问也会随之变慢。特别是
在多处理器系统上,缓存是被多个处理器共享的,刷新缓存不仅会影响当前处理器的进程,
还会影响共享缓存的其他处理器的进程

(5)线程上下文切换

引起线程上下文切换的原因如下

(1)当前正在执行的任务完成,系统的CPU正常调度下一个任务。

(2)当前正在执行的任务遇到I/O等阻塞操作,调度器挂起此任务,继续调度下一个任务。

(3)多个任务并发抢占锁资源,当前任务没有抢到锁资源,被调度器挂起,继续调度下一个任务。

(4)用户的代码挂起当前任务,比如线程执行yield()方法,让出CPU。

(5)硬件中断。

        线程与进程最大的区别在于,线程是调度的基本单位,而进程则是资源拥有的基本单位。说白了,所谓内核中的任务调度,实际上的调度对象是线程;而进程只是给线程提供了虚拟内存、全局变量等资源。

        所以,对于线程和进程,我们可以这么理解:当进程只有一个线程时,可以认为进程就等于线程。当进程拥有多个线程时,这些线程会共享相同的虚拟内存和全局变量等资源。这些资源在上下文切换时是不需要修改的。另外,线程也有自己的私有数据,比如栈和寄存器等,这些在上下文换时也是需要保存的。

        线程的上下文切换其实就可以分为两种情况:

        第一种, 前后两个线程属于不同进程。此时,因为资源不共享,所以切换过程就跟进程上下文切换是一样。

        第二种,前后两个线程属于同一个进程。此时,因为虚拟内存是共享的,所以在切换时, 虚拟内存这些资源就保持不动,只需要切换线程的私有数据、寄存器等不共享的数据。

        虽然同为上下文切换,但同进程内的线程切换,要比多进程间的切换消耗更少的资源,而这,也正是多线程代替多进程的一个优势。

(6)中断上下文切换

        这边中断更多的是硬件设备的发生的中断,如鼠标 单击,键盘按压等,叫硬中断。

        为了快速响应硬件的事件,中断处理会打断进程的正常调度和执行,转而调用中断处理程序,响应设备事件。而在打断其他进程时,就需要将进程当前的状态保存下来,这样在中断结束后,进程仍然可以从原来的状态恢复运行。

        跟进程上下文不同,中断上下文切换并不涉及到进程的用户态。所以,即便中断过程打断了一个正处在用户态的进程,也不需要保存和恢复这个进程的虚拟内存、全局变量等用户 态资源。中断上下文,其实只包括内核态中断服务程序执行所必需的状态,包括 CPU 寄存器、内核堆栈、硬件中断参数等。

        对同一个 CPU 来说,中断处理比进程拥有更高的优先级,所以中断上下文切换并不会与进程上下文切换同时发生。同样道理,由于中断会打断正常进程的调度和执行,所以大部分中断处理程序都短小精悍,以便尽可能快的执行结束。

(7)总结

        CPU上下文切换包括进程上下文切换、线程上下文切换及中断上下文切换,当任务进行io或发生时间片事件及发生中断(如硬件读取完成)时,就会进入内核态,发生CPU上下文切换。

        进程上下文切换,进程的上下文信息包括, 指向可执行文件的指针, 栈, 内存(数据段和堆), 进程状态, 优先级, 程序I/O的状态, 授予权限, 调度信息, 审计信息, 有关资源的信息(文件描述符和读/写指针), 关事件和信号的信息, 寄存器组(栈指针, 指令计数器)等等,当发生进程切换时,这些保存在寄存器或高速缓存的信息需要记录到内存,以便下次恢复进程的运行。

        线程上下文切换,同一个进程的线程切换,只需保存线程独有的信息,比如栈和寄存器,而共享的虚拟内存和全局变量则无需切换,因此切换开销比进程小。

        中断上下文切换,中断会打断一个正常执行的进程而运行中断处理程序,因为中断处理程序是内核态进程,而不涉及到用户态进程之间的切换,当被中断的是用户态进程时,不需保存和恢复这个进程的虚拟内存和全局变量,中断上下文只包括中断服务程序所需要的状态,比如CPU寄存器、内核堆栈、硬件中断等参数。

三十九、虚拟空间如何优化进程的上下文切换

待学习。。。

四十、伪代码写个死锁

简单死锁:

Thread 1  locks A, waits for B
Thread 2  locks B, waits for A

public class TestSynchronzied 
{

	   private static final Object a = new Object();
	   private static final Object b = new Object();

	    public static void main(String[] args) 
	    {
	        new Thread(new Syn(true)).start();
	        new Thread(new Syn(false)).start();
	    }

	    static class Syn implements Runnable
	    {

	        private boolean flag;

	        public Syn(boolean flag) 
	        {
	            this.flag = flag;
	        }

	        @Override
	        public void run() 
	        {

	            if(flag)
	            {
	                synchronized (a)
	                {
	                    System.out.println(Thread.currentThread().getName()+":抢到a");
	                    synchronized (b)
	                    {
	                        System.out.println(Thread.currentThread().getName()+":抢到b");
	                    }
	                }
	            }
	            else 
	            {
	                synchronized (b)
	                {
	                    System.out.println(Thread.currentThread().getName()+":抢到b");
	                    synchronized (a)
	                    {
	                        System.out.println(Thread.currentThread().getName()+":抢到a");
	                    }
	                }
	            }
	        }
	    }
	}

你可能感兴趣的:(#,计算机操作系统,面试,操作系统)