操作系统 进程和线程 进程和线程有什么区别? 进程间通信有哪些方式? 进程同步问题 进程有哪几种状态? 进程调度策略有哪些? 什么是僵尸进程? 线程同步有哪些方式? 什么是协程? 进程的异常控制流:陷阱、中断、异常和信号 什么是IO多路复用?怎么实现? 什么是用户态和内核态? 死锁 什么是死锁? 死锁产生的必要条件? 死锁有哪些处理方法? 内存管理 分页和分段有什么区别? 什么是虚拟内存? 有哪些页面置换算法? 缓冲区溢出问题 磁盘调度 参考 进程和线程有什么区别? 进程(Process)是系统进行资源分配和调度的基本单位,线程(Thread)是CPU调度和分派的基本单位; 线程依赖于进程而存在,一个进程至少有一个线程; 进程有自己的独立地址空间,线程共享所属进程的地址空间;
进程操作代码实现,可以参考:多进程 - 廖雪峰的官方网站
更详细的可以参考(待整理):
进程的同步是目的,而进程间通信是实现进程同步的手段
管程将共享变量以及对这些共享变量的操作封装起来,形成一个具有一定接口的功能模块,这样只能通过管程提供的某个过程才能访问管程中的资源。进程只能互斥地使用管程,使用完之后必须释放管程并唤醒入口等待队列中的进程。
当一个进程试图进入管程时,在入口等待队列等待。若P进程唤醒了Q进程,则Q进程先执行,P在紧急等待队列中等待。(HOARE管程)
wait操作:执行wait操作的进程进入条件变量链末尾,唤醒紧急等待队列或者入口队列中的进程;signal操作:唤醒条件变量链中的进程,自己进入紧急等待队列,若条件变量链为空,则继续执行。(HOARE管程)
MESA管程:将HOARE中的signal换成了notify(或者broadcast通知所有满足条件的),进行通知而不是立马交换管程的使用权,在合适的时候,条件队列首位的进程可以进入,进入之前必须用while检查条件是否合适。优点:没有额外的进程切换
问题描述:使用一个缓冲区来存放数据,只有缓冲区没有满,生产者才可以写入数据;只有缓冲区不为空,消费者才可以读出数据
代码实现:
// 伪代码描述 // 定义信号量 full记录缓冲区物品数量 empty代表缓冲区空位数量 mutex为互斥量 semaphore full = 0, empty = n, mutex = 1; // 生产者进程 void producer(){ do{ P(empty); P(mutex); // 生产者进行生产 V(mutex); V(full); } while(1); } void consumer(){ do{ P(full); P(mutex); // 消费者进行消费 V(mutex); V(empty); } while(1); }
问题描述:有五位哲学家围绕着餐桌坐,每一位哲学家要么思考,要么吃饭。为了吃饭,哲学家必须拿起两双筷子(分别放于左右两端)不幸的是,筷子的数量和哲学家相等,所以每只筷子必须由两位哲学家共享。
代码实现:
#define N 5 // number of philosopher #define LEFT (i + N - 1)%N // number of i's left neighbors #define RIGHT (i + 1)%N // number of i's right neighbors #define THINKING 0 #define HUNGRY 1 #define EATING 2 typedef int semaphore; int state[N]; // array to keep track of everyone's state semaphore mutex = 1; // mutual exclusion of critical region semaphore s[N]; void philosopher(int i) { while (TRUE) { think(); take_forks(i); eat(); put_forks(i); } } void take_forks(int i) { down(&mutex); // enter critical region state[i] = HUNGRY; // record that i is hungry test_forks(i); // try to acquire two forks up(&mutex); // exit critical region down(&s[i]); // block if forks are not acquired } void put_forks(int i) { down(&mutex); // enter critical region state[i] = THINKING; // record that has finished eating test_forks(LEFT); // see if left neighbor can now eat test_forks(RIGHT); // see if right neighbor can now eat up(&mutex); // exit critical region } void test_forks(int i) { if (state[i] == HUNGRY && state[LEFT] != EATING && state[RIGHT] != EATING) { state[i] = EATING; up(&s[i]); } }
各个进程中对临界资源(互斥资源/共享变量,一次只能给一个进程使用)进行操作的程序片段
并发:在一个时间段中同时有多个程序在运行,但其实任一时刻,只有一个程序在CPU上运行,宏观上的并发是通过不断的切换实现的;
多线程:并发运行的一段代码。是实现异步的手段
并行(和串行相比):在多CPU系统中,多个程序无论宏观还是微观上都是同时执行的
异步(和同步相比):同步是顺序执行,异步是在等待某个资源的时候继续做自己的事

按照请求的顺序进行调度。非抢占式,开销小,无饥饿问题,响应时间不确定(可能很慢);
对短进程不利,对IO密集型进程不利。
按估计运行时间最短的顺序进行调度。非抢占式,吞吐量高,开销可能较大,可能导致饥饿问题;
对短进程提供好的响应时间,对长进程不利。
按剩余运行时间的顺序进行调度。(最短作业优先的抢占式版本)。吞吐量高,开销可能较大,提供好的响应时间;
可能导致饥饿问题,对长进程不利。
响应比 = 1+ 等待时间/处理时间。同时考虑了等待时间的长短和估计需要的执行时间长短,很好的平衡了长短进程。非抢占,吞吐量高,开销可能较大,提供好的响应时间,无饥饿问题。
将所有就绪进程按 FCFS 的原则排成一个队列,用完时间片的进程排到队列最后。抢占式(时间片用完时),开销小,无饥饿问题,为短进程提供好的响应时间;
若时间片小,进程切换频繁,吞吐量低;若时间片太长,实时性得不到保证。
为每个进程分配一个优先级,按优先级进行调度。为了防止低优先级的进程永远等不到调度,可以随着时间的推移增加等待进程的优先级。
设置多个就绪队列1、2、3...,优先级递减,时间片递增。只有等到优先级更高的队列为空时才会调度当前队列中的进程。如果进程用完了当前队列的时间片还未执行完,则会被移到下一队列。
抢占式(时间片用完时),开销可能较大,对IO型进程有利,可能会出现饥饿问题。
高优先级的进程等待被一个低优先级进程占用的资源时,就会出现优先级反转,即优先级较低的进程比优先级较高的进程先执行。此处详细解释优先级反转带来的问题:如果有一个中等优先级的进程将低优先级的进程抢占,那么此时低优先级的进程无法正常进行并在后续释放被占用的资源,导致高优先级的任务一直被挂起,直到中等优先级的进程完成后,低优先级的进程才可以继续并在后续释放占用的资源,最后高优先级的进程才可以执行。导致的问题就是高优先级的进程在中等优先级的进程调度之后。
解决方法:
一个子进程结束后,它的父进程并没有等待它(调用wait或者waitpid),那么这个子进程将成为一个僵尸进程。僵尸进程是一个已经死亡的进程,但是并没有真正被销毁。它已经放弃了几乎所有内存空间,没有任何可执行代码,也不能被调度,仅仅在进程表中保留一个位置,记载该进程的进程ID、终止状态以及资源利用信息(CPU时间,内存使用量等等)供父进程收集,除此之外,僵尸进程不再占有任何内存空间。这个僵尸进程可能会一直留在系统中直到系统重启。
危害:占用进程号,而系统所能使用的进程号是有限的;占用内存。
以下情况不会产生僵尸进程:
WNOHANG(wait-no-hang)选项,如果没有发现结束的子进程,就会立即返回,不会将调用waitpid的进程阻塞。同时,waitpid还可以选择是等待任一子进程(同wait),还是等待指定pid的子进程,还是等待同一进程组下的任一子进程,还是等待组ID等于pid的任一子进程;SIGCHLD(signal-child)信号,可以注册一个信号处理函数,在该函数中调用waitpid,等待所有结束的子进程(注意:一般都需要循环调用waitpid,因为在信号处理函数开始执行之前,可能已经有多个子进程结束了,而信号处理函数只执行一次,所以要循环调用将所有结束的子进程回收);signal(SIGCLD, SIG_IGN)(signal-ignore)通知内核,表示忽略SIGCHLD信号,那么子进程结束后,内核会进行回收。一个父进程已经结束了,但是它的子进程还在运行,那么这些子进程将成为孤儿进程。孤儿进程会被Init(进程ID为1)接管,当这些孤儿进程结束时由Init完成状态收集工作。
为什么需要线程同步:线程有时候会和其他线程共享一些资源,比如内存、数据库等。当多个线程同时读写同一份共享资源的时候,可能会发生冲突。因此需要线程的同步,多个线程按顺序访问资源。
ReleaseSemaphore函数将当前可用资源数加1。如果信号量的取值只能为0或1,那么信号量就成为了互斥量;互斥量是可以命名的,可以用于不同进程之间的同步;而临界区只能用于同一进程中线程的同步。创建互斥量需要的资源更多,因此临界区的优势是速度快,节省资源。
协程是一种用户态的轻量级线程,协程的调度完全由用户控制。协程拥有自己的寄存器上下文和栈。协程调度切换时,将寄存器上下文和栈保存到其他地方,在切回来的时候,恢复先前保存的寄存器上下文和栈,直接操作栈则基本没有内核切换的开销,可以不加锁的访问全局变量,所以上下文的切换非常快。
一个线程可以拥有多个协程,一个进程也可以单独拥有多个协程,这样python中则能使用多核CPU。
线程进程都是同步机制,而协程则是异步
协程能保留上一次调用时的状态,每次过程重入时,就相当于进入上一次调用的状态
陷阱是有意造成的“异常”,是执行一条指令的结果。陷阱是同步的。陷阱的主要作用是实现系统调用。比如,进程可以执行 syscall n 指令向内核请求服务。当进程执行这条指令后,会中断当前的控制流,陷入到内核态,执行相应的系统调用。内核的处理程序在执行结束后,会将结果返回给进程,同时退回到用户态。进程此时继续执行下一条指令。
中断由处理器外部的硬件产生,不是执行某条指令的结果,也无法预测发生时机。由于中断独立于当前执行的程序,因此中断是异步事件。中断包括 I/O 设备发出的 I/O 中断、各种定时器引起的时钟中断、调试程序中设置的断点等引起的调试中断等。
异常是一种错误情况,是执行当前指令的结果,可能被错误处理程序修正,也可能直接终止应用程序。异常是同步的。这里特指因为执行当前指令而产生的错误情况,比如除法异常、缺页异常等。有些书上为了区分,也将这类“异常”称为**“故障”**。
信号是一种更高层的软件形式的异常,同样会中断进程的控制流,可以由进程进行处理。一个信号代表了一个消息。信号的作用是用来通知进程发生了某种系统事件。
更详细的可以参考:https://imageslr.github.io/2020/07/09/trap-interrupt-exception.html
IO多路复用(IO Multiplexing)是指单个进程/线程就可以同时处理多个IO请求。
实现原理:用户将想要监视的文件描述符(File Descriptor)添加到select/poll/epoll函数中,由内核监视,函数阻塞。一旦有文件描述符就绪(读就绪或写就绪),或者超时(设置timeout),函数就会返回,然后该进程可以进行相应的读/写操作。
select:将文件描述符放入一个集合中,调用select时,将这个集合从用户空间拷贝到内核空间(缺点1:每次都要复制,开销大),由内核根据就绪状态修改该集合的内容。(缺点2)集合大小有限制,32位机默认是1024(64位:2048);采用水平触发机制。select函数返回后,需要通过遍历这个集合,找到就绪的文件描述符(缺点3:轮询的方式效率较低),当文件描述符的数量增加时,效率会线性下降;poll:和select几乎没有区别,区别在于文件描述符的存储方式不同,poll采用链表的方式存储,没有最大存储数量的限制;epoll:通过内核和用户空间共享内存,避免了不断复制的问题;支持的同时连接数上限很高(1G左右的内存支持10W左右的连接数);文件描述符就绪时,采用回调机制,避免了轮询(回调函数将就绪的描述符添加到一个链表中,执行epoll_wait时,返回这个链表);支持水平触发和边缘触发,采用边缘触发机制时,只有活跃的描述符才会触发回调函数。总结,区别主要在于:
当连接数较多并且有很多的不活跃连接时,epoll的效率比其它两者高很多;但是当连接数较少并且都十分活跃的情况下,由于epoll需要很多回调,因此性能可能低于其它两者。
文件描述符在形式上是一个非负整数。实际上,它是一个索引值,指向内核为每一个进程所维护的该进程打开文件的记录表。当程序打开一个现有文件或者创建一个新文件时,内核向进程返回一个文件描述符。
内核通过文件描述符来访问文件。文件描述符指向一个文件。
为了限制不同程序的访问能力,防止一些程序访问其它程序的内存数据,CPU划分了用户态和内核态两个权限等级。
所有用户程序都运行在用户态,但有时需要进行一些内核态的操作,比如从硬盘或者键盘读数据,这时就需要进行系统调用,使用陷阱指令,CPU切换到内核态,执行相应的服务,再切换为用户态并返回系统调用的结果。
(我自己的见解:)
在两个或者多个并发进程中,每个进程持有某种资源而又等待其它进程释放它们现在保持着的资源,在未改变这种状态之前都不能向前推进,称这一组进程产生了死锁(deadlock)。
直接忽略死锁。因为解决死锁问题的代价很高,因此鸵鸟策略这种不采取任务措施的方案会获得更高的性能。当发生死锁时不会对用户造成多大影响,或发生死锁的概率很低,可以采用鸵鸟策略。
基本思想是破坏形成死锁的四个必要条件:
动态地检测资源分配状态,以确保系统处于安全状态,只有处于安全状态时才会进行资源的分配。所谓安全状态是指:即使所有进程突然请求需要的所有资源,也能存在某种对进程的资源分配顺序,使得每一个进程运行完毕。
银行家算法
如何检测死锁:检测有向图是否存在环;或者使用类似死锁避免的检测算法。
死锁解除的方法:
内存管理方式分为连续分配管理和非连续分配管理。连续分配管理分为单一连续分配、固定分区分配、动态分区分配以及动态重定位分区分配四种;非连续分配管理又叫离散分配方式,如果离散分配的基本单位是页Page,则称为分页存储管理方式;如果离散分配的基本单位是段Segment,则称为分段存储管理方式。
连续分配管理方式允许一个用户程序分配一个连续的内存空间。

动态分区分配:根据进程的需要,动态地分配内存空间。这样的分配方式涉及到分区中的数据结构、分区分配算法以及分区的分配和回收操作三个问题。
动态重定位分区分配:在连续分配方式中,必须把系统进程或者用户进程装入一个连续的内存空间中。这个时候可能会因为程序的大小与分区的大小不一致的问题产生内存碎片。这个时候我们要想插入新的进程,即使碎片空间总和支持进程,也无法再分配空间,所以我们要把内存空间进行一个整理。

整理内存地址,是将程序的内存地址整理成在物理上相邻的状态。程序使用的地址在分区装载之后仍然是相对地址,要想将相对地址转换为相邻物理地址,必然会影响到程序的执行。为了不影响程序的执行,需要在硬件上提供对程序的内存地址转换支持,于是引入重定位寄存器,用它来存放程序在内存中的起始地址。

非连续分配管理方式允许将一个程序分散地装入不相邻的内存分区。根据内存分区的大小分为分页式存储管理方式和分段式存储管理方式。
页存储:页存储首先需要将一个进程的逻辑内存空间划分为多个内存大小相等的页,然后将物理内存空间划分为相等的大小个数的物理块Block(页框Frame)。分配的时候将多个页面放入多个不相邻的物理块中。这样的划分之后,通常进程的最后一页存不满,会产生内存碎片——”页内碎片“。
页面大小:页面大小的选定需要适当。如果过小,虽然可以减少页内碎片的产生,但是需要更大的页表,而且页面切换更频繁;如果太大就会产生较大的内存碎片。所以大小的选择应该适中,通常为2的整数次幂,范围为512B至8KB。
页表:页表主要保存进程占用的页数,同时存储逻辑内存空间页到物理内存块的映射。

地址转换:页表要存储地址映射,那么首先得有一个地址变换机构。基本地址变换机构,主要用来建立逻辑地址到物理内存空间地址的映射。传统系统中,主要使用寄存器来存放页表(寄存器速度快,有利于变换地址),那么每一个页表都需要寄存器来存放,成本过高。进程数量过多的情况下,首先将页表的起始地址和页表长度存放在PCB中,之后在进程运行的时候再将数据读取到PTR(Page-Table Register,页表寄存器)。读取数据的时候,地址转换机构会将相对地址转换为页号以及页内地址两部分,之后根据页号检索页表。检索之前会将页号和页表长度进行比较,如果页号大于或等于页表长度,则说明出现了访问越界,会出现越界中断。

具有块表的地址转换机构:页表存放在内存中,那么将要先查页表,计算得出物理地址之后访问物理地址,是两次检索过程。
为了提高地址变换速度,可在地址变换机构中增设一个具有并行查寻能力的特殊高速 缓冲寄存器,又称为“联想寄存器”(Associative Memory),或称为“快表”。
对于32位操作系统,使用两级页表结构式合适的;但是对于64位操作系统,建议采用多级页表。
段存储:使用段存储而不再使用页存储,第一个原因是提高内存的利用率,第二个原因是满足开发者在编程中的多种需求。主要是满足编程中的几个新的需求:方便编程(简化逻辑地址访问方式)、信息共享(段式信息的逻辑单位)、信息保护(对信息的逻辑单位实现保护)、动态增长(分段更加满足动态增长的需求)、动态链接(链接装载使用段更符合需求)。
段页存储:分页存储能提高内存利用率,分段存储能满足用户的更多需求。为了各取所长,产生了段页存储的管理方式。
区别:
每个程序都拥有自己的地址空间,这个地址空间被分成大小相等的页,这些页被映射到物理内存;但不需要所有的页都在物理内存中,当程序引用到不在物理内存中的页时,由操作系统将缺失的部分装入物理内存。这样,对于程序来说,逻辑上似乎有很大的内存空间,只是实际上有一部分是存储在磁盘上,因此叫做虚拟内存。
虚拟内存的优点是让程序可以获得更多的可用内存。
虚拟内存的实现方式、页表/多级页表、缺页中断、不同的页面淘汰算法:答案。
内存管理单元(MMU)管理着逻辑地址和物理地址的转换,其中的页表(Page table)存储着页(逻辑地址)和页框(物理内存空间)的映射表,页表中还包含包含有效位(是在内存还是磁盘)、访问位(是否被访问过)、修改位(内存中是否被修改过)、保护位(只读还是可读写)。逻辑地址:页号+页内地址(偏移);每个进程一个页表,放在内存,页表起始地址在PCB/寄存器中。
在程序运行过程中,如果要访问的页面不在内存中,就发生缺页中断从而将该页调入内存中。此时如果内存已无空闲空间,系统必须从内存中调出一个页面到磁盘中来腾出空间。页面置换算法的主要目标是使页面置换频率最低(也可以说缺页率最低)。
颠簸本质上是指频繁的页调度行为。进程发生缺页中断时必须置换某一页。然而,其他所有的页都在使用,它置换一个页,但又立刻再次需要这个页。因此会不断产生缺页中断,导致整个系统的效率急剧下降,这种现象称为颠簸。内存颠簸的解决策略包括:
防范缓冲区溢出攻击的机制有三种:随机化、栈保护和限制可执行代码区域。
更详细的可以参考:https://imageslr.github.io/2020/07/08/tech-interview.html#stackoverflow
过程:磁头(找到对应的盘面);磁道(一个盘面上的同心圆环,寻道时间);扇区(旋转时间)。为减小寻道时间的调度算法: