操作系统基础知识大全
计算机系统中的一个系统软件,它是这样一些程序模块的集合——它们管理和控制计算机系统中的软硬件资源,合理地组织计算机的工作流程,以便有效地利用这些资源为用户提供一个功能强大、使用方便和可扩展的工作环境,从而在计算机与其用户之间起到接口的作用。下面就让小编带你去看看操作系统基础知识大全吧,希望能帮助到大家!
操作系统基础知识笔记
一、操作系统相关概念
计算机软件:系统软件和应用软件。
计算机系统资源:硬件资源、软件资源。
硬件资源:中央处理器、存储器、输入、输出等物理设备。
软件资源:以文件形式保存到存储器上的程序和数据信息。
定义:有效地组织和管理系统的各种软/硬件资源,合理组织计算机系统工作流程,控制程序的执行,并给用户提供一个良好的环境和友好的接口。
操作系统作用:通过资源管理提高计算机系统的效率、改善人家界面提高良好的工作环境。
吞吐量:计算机在单位时间内处理工作的能力。
二、操作系统的特征与功能
操作系统的特征:并发性、共享性、虚拟性、随机性。
2.1、 操作系统的功能
1、进程管理:实际上是对处理机的执行时间进行管理,采用多道程序等技术将CPU的时间合理分配给每个任务。比如:进程控制、进程同步、进程通信、进程调度。
2、文件管理:主要有存储空间管理、目录管理、文件读写。
3、存储管理:对主存储器空间进行管理,主要包括存储空间分配回收、存储保护、地址映射、主存扩充等。
4、设备管理:对硬件设备的管理。包括分配、启动、完成、回收。
5、作业管理:包括任务、界面管理、人机交互、语音控制、虚拟现实等。
三、操作系统分类
1、批处理操作系统
分为单道批处理、多道批处理。
单道批处理:早期的操作系统,一次只有一个作业装入内存执行。作业由用户程序、数据和作业说明书组成。一个作业运行结束后,自动调入同批的下一个作业。
多道批处理:允许多个作业装入内存执行,在任意时刻,作业都处于开始和结束点之间。
多道批处理系统特点:多道、宏观上并行运行、微观上串行运行。
2、分时操作系统
分时操作系统是将CPU的工作划分为很短的时间片。轮流为各个终端的用户服务。
分时操作系统特点:多路性、独立性、交互性、及时性。
3、实时操作系统
实时操作系统对交互能力要求不高,要能对外来信息足够快的速度响应和处理。分为实时控制系统和实时信息处理系统。
实时控制系统:主要用于生产过程的自动控制,比如自动采集、飞机的自动驾驶等。
实时信息处理系统:主要是实时信息处理,比如飞机订票系统、情报检索系统等。
4、网络操作系统
网络操作系统使互联网能方便有效的共享网络资源,为网络用户提供各种服务软件和有关协议的几何。比如电子邮件、文件传输、共享硬盘等。
网络操作系统分为如下三类:
1、集中式:系统的基本单元由一台主机和若干台主机相连的终端构成,将多台主机连接处理形成网络。比如UNI__。
2、客户端/服务器模式:该模式分为客户端和服务器。服务器是网络控制的中心,向客户端提供多种服务,客户端主要是访问服务端的资源。
3、对等模式(P2P):相当于每一台客户端都可以给其他客户端提供资源服务。
5、分布式操作系统
分布式操作系统是由多个分散的计算机经连接而成的计算机系统,系统中的计算机无主次之分,任意两台计算机都可以交换信息。分布式操作系统能直接对各类资源进行动态分配和调度、任务划分、信息传输协调工作,为用户提供一个统一的界面、标准的接口,用户通过这一界面实现所需要的操作和使用系统资源。
6、微机操作系统
目前主流的操作系统有Linu__、MacOS、Windows。
7、嵌入式操作系统
嵌入式操作系统运行在嵌入式智能芯片环境中,对整个智能芯片以及操作、控制、部件装置等资源进行统一协调、处理、指挥、控制。
嵌入式操作系统特点:微型化、可定制、实时性、可靠性、易移植性。
操作系统基础知识
一、概述
1. 操作系统基本特征
1. 并发
并发是指宏观上在一段时间内能同时运行多个程序,而并行则指同一时刻能运行多个指令。
并行需要硬件支持,如多流水线或者多处理器。
操作系统通过引入进程和线程,使得程序能够并发运行。
2. 共享
共享是指系统中的资源可以被多个并发进程共同使用。
有两种共享方式:互斥共享和同时共享。
互斥共享的资源称为临界资源,例如打印机等,在同一时间只允许一个进程访问,需要用同步机制来实现对临界资源的访问。
3. 虚拟
虚拟技术把一个物理实体转换为多个逻辑实体。
利用多道程序设计技术,让每个用户都觉得有一个计算机专门为他服务。
主要有两种虚拟技术:时分复用技术和空分复用技术。例如多个进程能在同一个处理器上并发执行使用了时分复用技术,让每个进程轮流占有处理器,每次只执行一小个时间片并快速切换。
4. 异步
异步指进程不是一次性执行完毕,而是走走停停,以不可知的速度向前推进。
但只要运行环境相同,OS需要保证程序运行的结果也要相同。
2. 操作系统基本功能
1. 进程管理
进程控制、进程同步、进程通信、死锁处理、处理机调度等。
2. 内存管理
内存分配、地址映射、内存保护与共享、虚拟内存等。
3. 文件管理
文件存储空间的管理、目录管理、文件读写管理和保护等。
4. 设备管理
完成用户的 I/O 请求,方便用户使用各种设备,并提高设备的利用率。
主要包括缓冲管理、设备分配、设备处理、虛拟设备等。
3. 系统调用
如果一个进程在用户态需要使用内核态的功能,就进行系统调用从而陷入内核,由操作系统代为完成。
4. 大内核和微内核
1. 大内核
大内核是将操作系统功能作为一个紧密结合的整体放到内核。
由于各模块共享信息,因此有很高的性能。
2. 微内核
由于操作系统不断复杂,因此将一部分操作系统功能移出内核,从而降低内核的复杂性。移出的部分根据分层的原则划分成若干服务,相互独立。
在微内核结构下,操作系统被划分成小的、定义良好的模块,只有微内核这一个模块运行在内核态,其余模块运行在用户态。
因为需要频繁地在用户态和核心态之间进行切换,所以会有一定的性能损失。
5. 中断分类
1. 外中断
由 CPU 执行指令以外的事件引起,如 I/O完成中断,表示设备输入/输出处理已经完成,处理器能够发送下一个输入/输出请求。此外还有时钟中断、控制台中断等。
2. 异常
由 CPU 执行指令的内部事件引起,如非法操作码、地址越界、算术溢出等。
6. 什么是堆和栈?说一下堆栈都存储哪些数据?
栈区(stack)— 由编译器自动分配释放 ,存放函数的参数值,局部变量的值等。其操作方式类似于数据结构中的栈。
堆区(heap) — 一般由程序员分配释放, 若程序员不释放,程序结束时可能由OS回收 。
数据结构中这两个完全就不放一块来讲,数据结构中栈和队列才是好基友,我想新手也很容易区分。
我想需要区分的情况肯定不是在数据结构话题下,而大多是在 OS 关于不同对象的内存分配这块上。
简单讲的话,在 C 语言中:
int a[N]; // go on a stackint__ a = (int __)malloc(sizeof(int) __ N); // go ona heap
7. 如何理解分布式锁?
分布式锁,是控制分布式系统之间同步访问共享资源的一种方式。在分布式系统中,常常需要协调他们的动作。如果不同的系统或是同一个系统的不同主机之间共享了一个或一组资源,那么访问这些资源的时候,往往需要互斥来防止彼此干扰来保证一致性,在这种情况下,便需要使用到分布式锁。
二、进程管理
1. 进程与线程
1. 进程
进程是资源分配的基本单位,用来管理资源(例如:内存,文件,网络等资源)
进程控制块 (Process Control Block, PCB) 描述进程的基本信息和运行状态,所谓的创建进程和撤销进程,都是指对 PCB的操作。(PCB是描述进程的数据结构)
下图显示了 4 个程序创建了 4 个进程,这 4 个进程可以并发地执行。
2. 线程
线程是独立调度的基本单位。
一个进程中可以有多个线程,它们共享进程资源。
QQ 和浏览器是两个进程,浏览器进程里面有很多线程,例如 HTTP请求线程、事件响应线程、渲染线程等等,线程的并发执行使得在浏览器中点击一个新链接从而发起 HTTP 请求时,浏览器还可以响应用户的其它事件。
3. 区别
(一)拥有资源
进程是资源分配的基本单位,但是线程不拥有资源,线程可以访问隶属进程的资源。
(二)调度
线程是独立调度的基本单位,在同一进程中,线程的切换不会引起进程切换,从一个进程内的线程切换到另一个进程中的线程时,会引起进程切换。
(三)系统开销
由于创建或撤销进程时,系统都要为之分配或回收资源,如内存空间、I/O设备等,所付出的开销远大于创建或撤销线程时的开销。类似地,在进行进程切换时,涉及当前执行进程 CPU 环境的保存及新调度进程 CPU环境的设置,而线程切换时只需保存和设置少量寄存器内容,开销很小。
(四)通信方面
进程间通信 (IPC)需要进程同步和互斥手段的辅助,以保证数据的一致性。而线程间可以通过直接读/写同一进程中的数据段(如全局变量)来进行通信。
2. 进程状态的切换(生命周期)
就绪状态(ready):等待被调度
运行状态(running)
阻塞状态(waiting):等待资源
应该注意以下内容:
只有就绪态和运行态可以相互转换,其它的都是单向转换。就绪状态的进程通过调度算法从而获得 CPU 时间,转为运行状态;而运行状态的进程,在分配给它的CPU 时间片用完之后就会转为就绪状态,等待下一次调度。
阻塞状态是缺少需要的资源从而由运行状态转换而来,但是该资源不包括 CPU 时间,缺少 CPU 时间会从运行态转换为就绪态。
进程只能自己阻塞自己,因为只有进程自身才知道何时需要等待某种事件的发生
3. 进程调度算法
不同环境的调度算法目标不同,因此需要针对不同环境来讨论调度算法。
1. 批处理系统
批处理系统没有太多的用户操作,在该系统中,调度算法目标是保证吞吐量和周转时间(从提交到终止的时间)。
1.1 先来先服务
先来先服务 first-come first-serverd(FCFS)
按照请求的顺序进行调度。
有利于长作业,但不利于短作业,因为短作业必须一直等待前面的长作业执行完毕才能执行,而长作业又需要执行很长时间,造成了短作业等待时间过长。
1.2 短作业优先
短作业优先 shortest job first(SJF)
按估计运行时间最短的顺序进行调度。
长作业有可能会饿死,处于一直等待短作业执行完毕的状态。因为如果一直有短作业到来,那么长作业永远得不到调度。
1.3 最短剩余时间优先
最短剩余时间优先 shortest remaining time ne__t(SRTN)
按估计剩余时间最短的顺序进行调度。
2. 交互式系统
交互式系统有大量的用户交互操作,在该系统中调度算法的目标是快速地进行响应。
2.1 时间片轮转
将所有就绪进程按 FCFS (先来先服务) 的原则排成一个队列,每次调度时,把 CPU时间分配给队首进程,该进程可以执行一个时间片。当时间片用完时,由计时器发出时钟中断,调度程序便停止该进程的执行,并将它送往就绪队列的末尾,同时继续把 CPU时间分配给队首的进程。
时间片轮转算法的效率和时间片的大小有很大关系。因为进程切换都要保存进程的信息并且载入新进程的信息,如果时间片太小,会导致进程切换得太频繁,在进程切换上就会花过多时间。
2.2 优先级调度
为每个进程分配一个优先级,按优先级进行调度。
为了防止低优先级的进程永远等不到调度,可以随着时间的推移增加等待进程的优先级。
2.3 多级反馈队列
如果一个进程需要执行 100 个时间片,如果采用时间片轮转调度算法,那么需要交换 100 次。
多级队列是为这种需要连续执行多个时间片的进程考虑,它设置了多个队列,每个队列时间片大小都不同,例如1,2,4,8,..。进程在第一个队列没执行完,就会被移到下一个队列。这种方式下,之前的进程只需要交换 7 次。
每个队列优先权也不同,最上面的优先权最高。因此只有上一个队列没有进程在排队,才能调度当前队列上的进程。
可以将这种调度算法看成是时间片轮转调度算法和优先级调度算法的结合。
3. 实时系统
实时系统要求一个请求在一个确定时间内得到响应。
分为硬实时和软实时,前者必须满足绝对的截止时间,后者可以容忍一定的超时。
参考资料:
操作系统典型调度算法_C语言中文网
4. 进程同步
1. 临界区
对临界资源进行访问的那段代码称为临界区。
为了互斥访问临界资源,每个进程在进入临界区之前,需要先进行检查。
// entry section// critical section;// e__it section
2. 同步与互斥
同步:多个进程按一定顺序执行;
互斥:多个进程在同一时刻只有一个进程能进入临界区。
3. 信号量
P 和 V 是来源于两个荷兰语词汇,P() ---prolaag (荷兰语,尝试减少的意思),V()---verhoog(荷兰语,增加的意思)
信号量(Semaphore)是一个整型变量,可以对其执行 down 和 up 操作,也就是常见的 P 和 V 操作。
down : 如果信号量大于 0 ,执行 -1 操作;如果信号量等于 0,进程睡眠,等待信号量大于 0;(阻塞)
up :对信号量执行 +1 操作,唤醒睡眠的进程让其完成 down 操作。(唤醒)
down 和 up 操作需要被设计成原语,不可分割,通常的做法是在执行这些操作的时候屏蔽中断。
如果信号量的取值只能为 0 或者 1,那么就成为了 互斥量(Mute__) ,0 表示临界区已经加锁,1 表示临界区解锁。
typedef int semaphore;semaphore mute__ = 1;void P1() { down(&mute__); //临界区 up(&mute__);}void P2() { down(&mute__); // 临界区 up(&mute__);}
使用信号量实现生产者-消费者问题
问题描述:使用一个缓冲区来保存物品,只有缓冲区没有满,生产者才可以放入物品;只有缓冲区不为空,消费者才可以拿走物品。
因为缓冲区属于临界资源,因此需要使用一个互斥量 mute__ 来控制对缓冲区的互斥访问。
为了同步生产者和消费者的行为,需要记录缓冲区中物品的数量。数量可以使用信号量来进行统计,这里需要使用两个信号量:empty记录空缓冲区的数量,full 记录满缓冲区的数量。其中,empty 信号量是在生产者进程中使用,当 empty 不为 0 时,生产者才可以放入物品;full信号量是在消费者进程中使用,当 full 信号量不为 0 时,消费者才可以取走物品。
注意,不能先对缓冲区进行加锁,再测试信号量。也就是说,不能先执行 down(mute__) 再执行down(empty)。如果这么做了,那么可能会出现这种情况:生产者对缓冲区加锁后,执行 down(empty) 操作,发现 empty =0,此时生产者睡眠。消费者不能进入临界区,因为生产者对缓冲区加锁了,也就无法执行 up(empty) 操作,empty 永远都为0,那么生产者和消费者就会一直等待下去,造成死锁。
#define N 100typedef int semaphore;semaphore mute__ = 1;semaphore empty =N;semaphore full = 0;void producer() { while(TRUE){ int item = produce_item();// 生产一个产品 // down(&empty) 和 down(&mute__) 不能交换位置,否则造成死锁 down(&empty);// 记录空缓冲区的数量,这里减少一个产品空间 down(&mute__); // 互斥锁 insert_item(item);up(&mute__); // 互斥锁 up(&full); // 记录满缓冲区的数量,这里增加一个产品 }}void consumer() {while(TRUE){ down(&full); // 记录满缓冲区的数量,减少一个产品 down(&mute__); // 互斥锁 intitem = remove_item(); up(&mute__); // 互斥锁 up(&empty); //记录空缓冲区的数量,这里增加一个产品空间 consume_item(item); }}
4. 管程
管程 (英语:Monitors,也称为监视器) 是一种程序结构,结构内的多个子程序(对象或模块)形成的多个工作线程互斥访问共享资源。
使用信号量机制实现的生产者消费者问题需要客户端代码做很多控制,而管程把控制的代码独立出来,不仅不容易出错,也使得客户端代码调用更容易。
管程是为了解决信号量在临界区的 PV 操作上的配对的麻烦,把配对的 PV操作集中在一起,生成的一种并发编程方法。其中使用了条件变量这种同步机制。
c 语言不支持管程,下面的示例代码使用了类 Pascal 语言来描述管程。示例代码的管程提供了 insert() 和 remove()方法,客户端代码通过调用这两个方法来解决生产者-消费者问题。
monitor ProducerConsumer integer i; condition c; procedure insert(); begin// ... end; procedure remove(); begin // ... end;end monitor;
管程有一个重要特性:在一个时刻只能有一个进程使用管程。进程在无法继续执行的时候不能一直占用管程,否者其它进程永远不能使用管程。
管程引入了 条件变量 以及相关的操作:wait() 和 signal() 来实现同步操作。对条件变量执行 wait()操作会导致调用进程阻塞,把管程让出来给另一个进程持有。signal() 操作用于唤醒被阻塞的进程。
使用管程实现生产者-消费者问题
// 管程monitor ProducerConsumer condition full, empty; integer count := 0;condition c; procedure insert(item: integer); begin if count = N thenwait(full); insert_item(item); count := count + 1; if count = 1 thensignal(empty); end; function remove: integer; begin if count = 0 thenwait(empty); remove = remove_item; count := count - 1; if count = N -1 thensignal(full); end;end monitor;// 生产者客户端procedure producerbegin while true dobegin item = produce_item; ProducerConsumer.insert(item); endend;//消费者客户端procedure consumerbegin while true do begin item =ProducerConsumer.remove; consume_item(item); endend;
5. 经典同步问题
生产者和消费者问题前面已经讨论过了。
1. 读者-写者问题
允许多个进程同时对数据进行读操作,但是不允许读和写以及写和写操作同时发生。读者优先策略
Rcount:读操作的进程数量(Rcount=0)
CountMute__:对于Rcount进行加锁(CountMute__=1)
WriteMute__:互斥量对于写操作的加锁(WriteMute__=1)
Rcount = 0;semaphore CountMute__ = 1;semaphore WriteMute__ = 1;void writer(){while(true){ sem_wait(WriteMute__); // TO DO write(); sem_post(WriteMute__); }}//读者优先策略void reader(){ while(true){ sem_wait(CountMute__); if(Rcount == 0)sem_wait(WriteMute__); Rcount++; sem_post(CountMute__); // TO DO read();sem_wait(CountMute__); Rcount--; if(Rcount == 0) sem_post(WriteMute__);sem_post(CountMute__); }}
2. 哲学家进餐问题
五个哲学家围着一张圆桌,每个哲学家面前放着食物。哲学家的生活有两种交替活动:吃饭以及思考。当一个哲学家吃饭时,需要先拿起自己左右两边的两根筷子,并且一次只能拿起一根筷子。
____方案一:____下面是一种错误的解法,考虑到如果所有哲学家同时拿起左手边的筷子,那么就无法拿起右手边的筷子,造成死锁。
#define N 5 // 哲学家个数void philosopher(int i) // 哲学家编号:0 - 4{ while(TRUE) {think(); // 哲学家在思考 take_fork(i); // 去拿左边的叉子 take_fork((i + 1) % N); // 去拿右边的叉子eat(); // 吃面条中…. put_fork(i); // 放下左边的叉子 put_fork((i + 1) % N); // 放下右边的叉子}}
方案二:对拿叉子的过程进行了改进,但仍不正确
#define N 5 // 哲学家个数while(1) // 去拿两把叉子{ take_fork(i); // 去拿左边的叉子if(fork((i+1)%N)) { // 右边叉子还在吗 take_fork((i + 1) % N);// 去拿右边的叉子 break; //两把叉子均到手 } else { // 右边叉子已不在 put_fork(i); // 放下左边的叉子 wait_some_time(); // 等待一会儿}}
方案三:等待时间随机变化。可行,但非万全之策
#define N 5 // 哲学家个数while(1) // 去拿两把叉子{ take_fork(i); // 去拿左边的叉子if(fork((i+1)%N)) { // 右边叉子还在吗 take_fork((i + 1) % N);// 去拿右边的叉子 break; //两把叉子均到手 } else { // 右边叉子已不在 put_fork(i); // 放下左边的叉子 wait_random_time( ); //等待随机长时间 }}
方案四:互斥访问。正确,但每次只允许一人进餐
semaphore mute__ // 互斥信号量,初值1void philosopher(int i) // 哲学家编号i:0-4 {while(TRUE){ think(); // 哲学家在思考 P(mute__); // 进入临界区 take_fork(i); // 去拿左边的叉子take_fork((i + 1) % N); // 去拿右边的叉子 eat(); // 吃面条中…. put_fork(i); // 放下左边的叉子put_fork((i + 1) % N); // 放下右边的叉子 V(mute__); // 退出临界区 }}
正确方案如下:
为了防止死锁的发生,可以设置两个条件(临界资源):
必须同时拿起左右两根筷子;
只有在两个邻居都没有进餐的情况下才允许进餐。
//1. 必须由一个数据结构,来描述每个哲学家当前的状态#define N 5#define LEFT i // 左邻居#define RIGHT(i + 1) % N // 右邻居#define THINKING 0#define HUNGRY 1#define EATING 2typedef intsemaphore;int state[N]; // 跟踪每个哲学家的状态//2. 该状态是一个临界资源,对它的访问应该互斥地进行semaphore mute__= 1; // 临界区的互斥//3. 一个哲学家吃饱后,可能要唤醒邻居,存在着同步关系semaphore s[N]; // 每个哲学家一个信号量voidphilosopher(int i) { while(TRUE) { think(); take_two(i); eat(); put_tow(i);}}void take_two(int i) { P(&mute__); // 进入临界区 state[i] = HUNGRY; // 我饿了test(i); // 试图拿两把叉子 V(&mute__); // 退出临界区 P(&s[i]); // 没有叉子便阻塞}voidput_tow(i) { P(&mute__); state[i] = THINKING; test(LEFT); test(RIGHT);V(&mute__);}void test(i) { // 尝试拿起两把筷子 if(state[i] == HUNGRY &&state[LEFT] != EATING && state[RIGHT] !=EATING) { state[i] = EATING;V(&s[i]); // 通知第i个人可以吃饭了 }}
6. 进程通信
进程同步与进程通信很容易混淆,它们的区别在于:
进程同步:控制多个进程按一定顺序执行
进程通信:进程间传输信息
进程通信是一种手段,而进程同步是一种目的。也可以说,为了能够达到进程同步的目的,需要让进程进行通信,传输一些进程同步所需要的信息。
__ 进程通信方式
直接通信
发送进程直接把消息发送给接收进程,并将它挂在接收进程的消息缓冲队列上,接收进程从消息缓冲队列中取得消息。
Send 和 Receive 原语的使用格式如下:
Send(Receiver,message);//发送一个消息message给接收进程ReceiverReceive(Sender,message);//接收Sender进程发送的消息message
间接通信
间接通信方式是指进程之间的通信需要通过作为共享数据结构的实体。该实体用来暂存发送进程发给目标进程的消息。
发送进程把消息发送到某个中间实体中,接收进程从中间实体中取得消息。这种中间实体一般称为信箱,这种通信方式又称为信箱通信方式。该通信方式广泛应用于计算机网络中,相应的通信系统称为电子邮件系统。
1. 管道
管道是通过调用 pipe 函数创建的,fd[0] 用于读,fd[1] 用于写。
#include int pipe(int fd[2]);
它具有以下限制:
只支持半双工通信(单向传输);
只能在父子进程中使用。
2. 命名管道
也称为命名管道,去除了管道只能在父子进程中使用的限制。
#include int mkfifo(const char __path, mode_t mode);int mkfifoat(int fd,const char __path, mode_t mode);
FIFO 常用于客户-服务器应用程序中,FIFO 用作汇聚点,在客户进程和服务器进程之间传递数据。
3. 消息队列
间接(内核)
相比于 FIFO,消息队列具有以下优点:
消息队列可以独立于读写进程存在,从而避免了 FIFO 中同步管道的打开和关闭时可能产生的困难;
避免了 FIFO 的同步阻塞问题,不需要进程自己提供同步方法;
读进程可以根据消息类型有选择地接收消息,而不像 FIFO 那样只能默认地接收。
4. 信号量
它是一个计数器,用于为多个进程提供对共享数据对象的访问。
5. 共享内存
允许多个进程共享一个给定的存储区。因为数据不需要在进程之间复制,所以这是最快的一种 IPC。
需要使用信号量用来同步对共享存储的访问。
多个进程可以将同一个文件映射到它们的地址空间从而实现共享内存。另外 __SI 共享内存不是使用文件,而是使用使用内存的匿名段。
6. 套接字
与其它通信机制不同的是,它可用于不同机器间的进程通信。
7. 线程间通信和进程间通信
线程间通信
synchronized同步
这种方式,本质上就是 “共享内存” 式的通信。多个线程需要访问同一个共享变量,谁拿到了锁(获得了访问权限),谁就可以执行。
while轮询的方式
在这种方式下,ThreadA 不断地改变条件,ThreadB 不停地通过 while 语句检测这个条件 (list.size()==5) 是否成立,从而实现了线程间的通信。但是这种方式会浪费 CPU 资源。
之所以说它浪费资源,是因为 JVM 调度器将 CPU 交给 ThreadB 执行时,它没做啥 “有用”的工作,只是在不断地测试某个条件是否成立。
就类似于现实生活中,某个人一直看着手机屏幕是否有电话来了,而不是:在干别的事情,当有电话来时,响铃通知TA电话来了。
wait/notify机制
当条件未满足时,ThreadA 调用 wait() 放弃 CPU,并进入阻塞状态。(不像 while 轮询那样占用 CPU)
当条件满足时,ThreadB 调用 notify() 通知线程 A,所谓通知线程 A,就是唤醒线程 A,并让它进入可运行状态。
管道通信
java.io.PipedInputStream 和 java.io.PipedOutputStream进行通信
进程间通信
管道(Pipe) :管道可用于具有亲缘关系进程间的通信,允许一个进程和另一个与它有共同祖先的进程之间进行通信。
命名管道(named pipe) :命名管道克服了管道没有名字的限制,因此,除具有管道所具有的功能外,它还允许无亲缘关 系进程间的通信。命名管道在文件系统中有对应的文件名。命名管道通过命令mkfifo或系统调用mkfifo来创建。
信号(Signal) :信号是比较复杂的通信方式,用于通知接受进程有某种事件发生,除了用于进程间通信外,进程还可以发送信号给进程本身;Linu__除了支持Uni__早期信号语义函数sigal外,还支持语义符合Posi__.1标准的信号函数sigaction(实际上,该函数是基于BSD的,BSD为了实现可靠信号机制,又能够统一对外接口,用sigaction函数重新实现了signal函数)。
消息(Message)队列 :消息队列是消息的链接表,包括Posi__消息队列systemV消息队列。有足够权限的进程可以向队列中添加消息,被赋予读权限的进程则可以读走队列中的消息。消息队列克服了信号承载信息量少,管道只能承载无格式字节流以及缓冲区大小受限等缺
共享内存:使得多个进程可以访问同一块内存空间,是最快的可用IPC形式。是针对其他通信机制运行效率较低而设计的。往往与其它通信机制,如信号量结合使用,来达到进程间的同步及互斥。
内存映射(mapped memory):内存映射允许任何多个进程间通信,每一个使用该机制的进程通过把一个共享的文件映射到自己的进程地址空间来实现它。
信号量(semaphore) :主要作为进程间以及同一进程不同线程之间的同步手段。
套接口(Socket):更为一般的进程间通信机制,可用于不同机器之间的进程间通信。起初是由Uni__系统的BSD分支开发出来的,但现在一般可以移植到其它类Uni__系统上:linu__和SystemV的变种都支持套接字。
8. 进程操作
Linu__进程结构可由三部分组成:
代码段(程序)
数据段(数据)
堆栈段(控制块PCB)
进程控制块是进程存在的惟一标识,系统通过PCB的存在而感知进程的存在。系统通过 PCB 对进程进行管理和调度。PCB包括创建进程、执行进程、退出进程以及改变进程的优先级等。
一般程序转换为进程分以下几个步骤:
内核将程序读入内存,为程序分配内存空间
内核为该进程分配进程标识符 PID 和其他所需资源
内核为进程保存 PID 及相应的状态信息,把进程放到运行队列中等待执行,程序转化为进程后可以被操作系统的调度程序调度执行了
在 UNI__ 里,除了进程 0(即 PID=0 的交换进程,Swapper Process)以外的所有进程都是由其他进程使用系统调用 fork创建的,这里调用 fork 创建新进程的进程即为父进程,而相对应的为其创建出的进程则为子进程,因而除了进程 0以外的进程都只有一个父进程,但一个进程可以有多个子进程。操作系统内核以进程标识符(Process Identifier,即 PID )来识别进程。进程 0是系统引导时创建的一个特殊进程,在其调用 fork 创建出一个子进程(即 PID=1 的进程 1,又称 init)后,进程 0就转为交换进程(有时也被称为空闲进程),而进程1(init进程)就是系统里其他所有进程的祖先。
进程0:Linu__引导中创建的第一个进程,完成加载系统后,演变为进程调度、交换及存储管理进程。 进程1:init进程,由0进程创建,完成系统的初始化. 是系统中所有其它用户进程的祖先进程。
Linu__中 1 号进程是由 0 号进程来创建的,因此必须要知道的是如何创建 0号进程,由于在创建进程时,程序一直运行在内核态,而进程运行在用户态,因此创建 0 号进程涉及到特权级的变化,即从特权级 0 变到特权级 3,Linu__是通过模拟中断返回来实现特权级的变化以及创建 0 号进程,通过将 0 号进程的代码段选择子以及程序计数器EIP直接压入内核态堆栈,然后利用 iret汇编指令中断返回跳转到 0 号进程运行。
创建一个进程
进程是系统中基本的执行单位。Linu__系统允许任何一个用户进程创建一个子进程,创建成功后,子进程存在于系统之中,并且独立于父进程。该子进程可以接受系统调度,可以得到分配的系统资源。系统也可以检测到子进程的存在,并且赋予它与父进程同样的权利。
Linu__系统下使用 fork() 函数创建一个子进程,其函数原型如下:
#include pid_t fork(void);
在讨论 fork() 函数之前,有必要先明确父进程和子进程两个概念。除了 0 号进程(该进程是系统自举时由系统创建的)以外,Linu__系统中的任何一个进程都是由其他进程创建的。创建新进程的进程,即调用 fork() 函数的进程就是父进程,而新创建的进程就是子进程。
fork() 函数不需要参数,返回值是一个进程标识符 (PID)。对于返回值,有以下 3 种情况:
对于父进程,fork() 函数返回新创建的子进程的 ID。
对于子进程,fork() 函数返回 0。由于系统的 0 号进程是内核进程,所以子进程的进程标识符不会是0,由此可以用来区别父进程和子进程。
如果创建出错,则 fork() 函数返回 -1。
fork() 函数会创建一个新的进程,并从内核中为此进程分配一个新的可用的进程标识符(PID),之后,为这个新进程分配进程空间,并将父进程的进程空间中的内容复制到子进程的进程空间中,包括父进程的数据段和堆栈段,并且和父进程共享代码段(写时复制)。这时候,系统中又多了一个进程,这个进程和父进程一模一样,两个进程都要接受系统的调度。
注意:由于在复制时复制了父进程的堆栈段,所以两个进程都停留在了 fork() 函数中,等待返回。因此,fork()函数会返回两次,一次是在父进程中返回,另一次是在子进程中返回,这两次的返回值是不一样的。
下面给出的示例程序用来创建一个子进程,该程序在父进程和子进程中分别输出不同的内容。
#include #include #include int main(void){ pid_t pid; // 保存进程ID pid =fork(); // 创建一个新进程 if(pid < 0){ // fork出错 printf("fail to fork\n"); e__it(1);} else if(pid == 0){ // 子进程 // 打印子进程的进程ID printf("this is child, pid is : %u\n",getpid()); } else{ // 打印父进程和其子进程的进程ID printf("this is parent, pid is : %u,child-pid is : %u\n", getpid(), pid); } return 0;}
程序运行结果如下:
$ ./forkParent, PID: 2598, Sub-process PID: 2599Sub-process, PID: 2599,PPID: 2598
由于创建的新进程和父进程在系统看来是地位平等的两个进程,所以运行机会也是一样的,我们不能够对其执行先后顺序进行假设,先执行哪一个进程取决于系统的调度算法。如果想要指定运行的顺序,则需要执行额外的操作。正因为如此,程序在运行时并不能保证输出顺序和上面所描述的一致。
getpid() 是获得当前进程的pid,而 getppid() 则是获得父进程的 id。
父子进程的共享资源
子进程完全复制了父进程的地址空间的内容,包括堆栈段和数据段的内容。子进程并没有复制代码段,而是和父进程共用代码段。这样做是存在其合理依据的,因为子进程可能执行不同的流程,那么就会改变数据段和堆栈段,因此需要分开存储父子进程各自的数据段和堆栈段。但是代码段是只读的,不存在被修改的问题,因此这一个段可以让父子进程共享,以节省存储空间,如下图所示。
下面给出一个示例来说明这个问题。该程序定义了一个全局变量 global、一个局部变量 stack 和一个指针heap。该指针用来指向一块动态分配的内存区域。之后,该程序创建一个子进程,在子进程中修改 global、stack和动态分配的内存中变量的值。然后在父子进程中分别打印出这些变量的值。由于父子进程的运行顺序是不确定的,因此我们先让父进程额外休眠2秒,以保证子进程先运行。
#include #include #include // 全局变量,在数据段中int global;int main(){ pid_t pid;int stack = 1; // 局部变量,在栈中 int __ heap; heap = (int __)malloc(sizeof(int)); //动态分配的内存,在堆中 __heap = 2; pid = fork(); // 创建一个子进程 if(pid < 0){ // 创建子进程失败printf("fail to fork\n"); e__it(1); } else if(pid == 0){ // 子进程,改变各变量的值 global++;// 修改栈、堆和数据段 stack++; (__heap)++; printf("the child, data : %d, stack : %d, heap: %d\n", global, stack, __heap); e__it(0); // 子进程运行结束 } // 父进程休眠2秒钟,保证子进程先运行sleep(2); // 输出结果 printf("the parent, data : %d, stack : %d, heap : %d\n",global, stack, __heap); return 0;}
程序运行效果如下:
$ ./forkIn sub-process, global: 2, stack: 2, heap: 3In parent-process,global: 1, stack: 1, heap: 2
由于父进程休眠了2秒钟,子进程先于父进程运行,因此会先在子进程中修改数据段和堆栈段中的内容。因此不难看出,子进程对这些数据段和堆栈段中内容的修改并不会影响到父进程的进程环境。
fork()函数的出错情况
有两种情况可能会导致fork()函数出错:
系统中已经有太多的进程存在了
调用fork()函数的用户进程太多了
一般情况下,系统都会对一个用户所创建的进程数加以限制。如果操作系统不对其加限制,那么恶意用户可以利用这一缺陷攻击系统。下面是一个利用进程的特性编写的一个病毒程序,该程序是一个死循环,在循环中不断调用fork()函数来创建子进程,直到系统中不能容纳如此多的进程而崩溃为止。下图展示了这种情况:
#include int main(){ while(1) fork(); /__ 不断地创建子进程,使系统中进程溢满 __/ return0;}
创建共享空间的子进程
进程在创建一个新的子进程之后,子进程的地址空间完全和父进程分开。父子进程是两个独立的进程,接受系统调度和分配系统资源的机会均等,因此父进程和子进程更像是一对兄弟。如果父子进程共用父进程的地址空间,则子进程就不是独立于父进程的。
Linu__环境下提供了一个与 fork()函数类似的函数,也可以用来创建一个子进程,只不过新进程与父进程共用父进程的地址空间,其函数原型如下:
#include pid_t vfork(void);
vfork() 和 fork() 函数的区别有以下两点:
vfork()函数产生的子进程和父进程完全共享地址空间,包括代码段、数据段和堆栈段,子进程对这些共享资源所做的修改,可以影响到父进程。由此可知,vfork()函数与其说是产生了一个进程,还不如说是产生了一个线程。
vfork() 函数产生的子进程一定比父进程先运行,也就是说父进程调用了 vfork() 函数后会等待子进程运行后再运行。
下面的示例程序用来验证以上两点。在子进程中,我们先让其休眠 2 秒以释放 CPU 控制权,在前面的 fork()示例代码中我们已经知道这样会导致其他线程先运行,也就是说如果休眠后父进程先运行的话,则第 1 点则为假;否则为真。第 2点为真,则会先执行子进程,那么全局变量便会被修改,如果第 1 点为真,那么后执行的父进程也会输出与子进程相同的内容。代码如下:
//@file vfork.c//@brief vfork() usage#include #include #include int global= 1;int main(void){ pid_t pid; int stack = 1; int __heap; heap = (int__)malloc(sizeof(int)); ____eap = 1; pid = vfork(); if (pid < 0) { perror("failto vfork"); ex___t(-1); } else if (pid == 0) { //sub-process, change valuessleep(2);//release cpu controlling global = 999; stack = 888; ____eap = 777;//print all values printf("In sub-process, global: %d, stack: %d, heap:%d\n",global,stack,____eap); ex___t(0); } else { //parent-process printf("Inparent-process, global: %d, stack: %d, heap: %d\n",global,stack,____eap); } return0;}
程序运行效果如下:
$ ./vforkIn sub-process, global: 999, stack: 888, heap: 777Inparent-process, global: 999, stack: 888, heap: 777
在函数内部调用vfork
在使用 vfork() 函数时应该注意不要在任何函数中调用 vfork() 函数。下面的示例是在一个非 main 函数中调用了 vfork()函数。该程序定义了一个函数 f1(),该函数内部调用了 vfork() 函数。之后,又定义了一个函数 f2(),这个函数没有实际的意义,只是用来覆盖函数f1() 调用时的栈帧。main 函数中先调用 f1() 函数,接着调用 f2() 函数。
#include #include #include int f1(void){ vfork(); return 0;}int f2(int a,int b){ return a+b;}int main(void){ int c; f1(); c = f2(1,2); printf("%d\n",c);return 0;}
程序运行效果如下:
$ ./vfork3Segmentation fault (core dumped)
通过上面的程序运行结果可以看出,一个进程运行正常,打印出了预期结果,而另一个进程似乎出了问题,发生了段错误。出现这种情况的原因可以用下图来分析一下:
左边这张图说明调用 vfork() 之后产生了一个子进程,并且和父进程共享堆栈段,两个进程都要从 f1()函数返回。由于子进程先于父进程运行,所以子进程先从 f1() 函数中返回,并且调用 f2() 函数,其栈帧覆盖了原来 f1()函数的栈帧。当子进程运行结束,父进程开始运行时,就出现了右图的情景,父进程需要从 f1() 函数返回,但是 f1() 函数的栈帧已经被 f2()函数的所替代,因此就会出现父进程返回出错,发生段错误的情况。
由此可知,使用 vfork() 函数之后,子进程对父进程的影响是巨大的,其同步措施势在必行。
退出进程
当一个进程需要退出时,需要调用退出函数。Linu__ 环境下使用 e__it() 函数退出进程,其函数原型如下:
#include void e__it(int status);
e__it() 函数的参数表示进程的退出状态,这个状态的值是一个整型,保存在全局变量 $ ? 中,在 shell 中可以通过 echo $?来检查退出状态值。
注意:这个退出函数会深入内核注销掉进程的内核数据结构,并且释放掉进程的资源。
e__it函数与内核函数的关系
e__it 函数是一个标准的库函数,其内部封装了 Linu__ 系统调用 e__it() 函数。两者的主要区别在于 e__it()函数会在用户空间做一些善后工作,例如清理用户的 I/O 缓冲区,将其内容写入 磁盘文件等,之后才进入内核释放用户进程的地址空间;而 e__it()函数直接进入内核释放用户进程的地址空间,所有用户空间的缓冲区内容都将丢失。
设置进程所有者
每个进程都有两个用户 ID,实际用户 ID 和有效用户 ID。通常这两个 ID 的值是相等的,其取值为进程所有者的用户ID。但是,在有些场合需要改变进程的有效用户 ID。Linu__ 环境下使用 setuid()函数改变一个进程的实际用户ID和有效用户ID,其函数原型如下:
#include int setuid(uid_t uid);
setuid() 函数的参数表示改变后的新用户 ID,如果成功修改当前进程的实际用户 ID 和有效用户 ID,函数返回值为 0;如果失败,则返回-1。只有两种用户可以修改进程的实际用户 ID 和有效用户 ID:
根用户:根用户可以将进程的实际用户 ID 和有效用户 ID 更换。
其他用户:其该用户的用户 ID 等于进程的实际用户 ID 或者保存的用户 ID。
也就是说,用户可以将自己的有效用户 ID 改回去。这种情况多出现于下面的情况:一个进程需要具有某种权限,所以将其有效用户 ID设置为具有这种权限的用户 ID,当进程不需要这种权限时,进程还原自己之前的有效用户 ID,使自己的权限复原。下面给出一个修改的示例:
#include #include #include int main(void){ FILE __fp; uid_t uid; uid_t euid;uid = getuid(); /__ 得到进程的实际用户ID __/ euid = geteuid(); /__ 得到进程的有效用户ID __/printf("the uid is : %d\n", uid); printf("the euid is : %d\n", euid);if(setuid(8000) == -1){ /__ 改变进程的实际用户ID和有效用户ID __/ perror("fail to set uid");e__it(1); } printf("after changing\n"); uid = getuid(); /__ 再次得到进程的实际用户ID __/ euid= geteuid(); /__ 再次得到进程的有效用户ID __/ printf("the uid is : %d\n", uid); printf("theeuid is : %d\n", euid); return 0;}
程序运行效果如下:
$./setuidthe uid is : 0the euid is : 0after changingthe uid is : 8000theeuid is : 8000
本节参考:
《后台开发:核心技术与应用实践》
《Linu__+C程序设计大全》十一章:进程控制
9. 孤儿进程和僵尸进程
基本概念
我们知道在 Uni__/Linu__中,正常情况下,子进程是通过父进程创建的,子进程在创建新的进程。子进程的结束和父进程的运行是一个异步过程,即父进程永远无法预测子进程到底什么时候结束。当一个进程完成它的工作终止之后,它的父进程需要调用 wait() 或者 waitpid() 系统调用取得子进程的终止状态。
孤儿进程:一个父进程退出,而它的一个或多个子进程还在运行,那么那些子进程将成为孤儿进程。孤儿进程将被 init 进程(进程号为1)所收养,并由init 进程对它们完成状态收集工作____。____
僵尸进程:一个进程使用 fork 创建子进程,如果子进程退出,而父进程并没有调用 wait 或 waitpid获取子进程的状态信息,那么子进程的进程描述符仍然保存在系统中。这种进程称之为僵尸进程。
问题及危害
Uni__提供了一种机制可以保证只要父进程想知道子进程结束时的状态信息,就可以得到。这种机制就是:在每个进程退出的时候,内核释放该进程所有的资源,包括打开的文件,占用的内存等。但是仍然为其保留一定的信息(包括进程号the process ID,退出状态 the termination status of the process,运行时间 the amount of CPUtime taken by the process 等)。直到父进程通过 wait / waitpid 来取时才释放。但这样就导致了问题,如果进程不调用wait / waitpid 的话,那么保留的那段信息就不会释放,其进程号就会一直被占用,但是系统所能使用的进程号是有限的,如果大量的产生僵死进程,将因为没有可用的进程号而导致系统不能产生新的进程。此即为僵尸进程的危害,应当避免。
孤儿进程是没有父进程的进程,孤儿进程这个重任就落到了 init 进程身上,init进程就好像是一个民政局,专门负责处理孤儿进程的善后工作。每当出现一个孤儿进程的时候,内核就把孤 儿进程的父进程设置为 init,而 init 进程会循环地wait() 它的已经退出的子进程。这样,当一个孤儿进程凄凉地结束了其生命周期的时候,init进程就会代表党和政府出面处理它的一切善后工作。因此孤儿进程并不会有什么危害。
任何一个子进程(init除外)在e__it() 之后,并非马上就消失掉,而是留下一个称为僵尸进程 (Zombie)的数据结构,等待父进程处理。这是每个子进程在结束时都要经过的阶段。如果子进程在e__it()之后,父进程没有来得及处理,这时用 ps 命令就能看到子进程的状态是Z。如果父进程能及时处理,可能用 ps 命令就来不及看到子进程的僵尸状态,但这并不等于子进程不经过僵尸状态。如果父进程在子进程结束之前退出,则子进程将由init 接管。init 将会以父进程的身份对僵尸状态的子进程进行处理。
僵尸进程危害场景:
例如有个进程,它定期的产生一个子进程,这个子进程需要做的事情很少,做完它该做的事情之后就退出了,因此这个子进程的生命周期很短,但是,父进程只管生成新的子进程,至于子进程退出之后的事情,则一概不闻不问,这样,系统运行上一段时间之后,系统中就会存在很多的僵死进程,倘若用ps 命令查看的话,就会看到很多状态为 Z的进程。严格地来说,僵死进程并不是问题的根源,罪魁祸首是产生出大量僵死进程的那个父进程。因此,当我们寻求如何消灭系统中大量的僵死进程时,答案就是把产生大量僵死进程的那个元凶枪毙掉(也就是通过 kill 发送 SIGTERM 或者 SIGKILL信号啦)。枪毙了元凶进程之后,它产生的僵死进程就变成了孤儿进程,这些孤儿进程会被 init 进程接管,init 进程会 wait()这些孤儿进程,释放它们占用的系统进程表中的资源,这样,这些已经僵死的孤儿进程就能瞑目而去了。
测试代码
孤儿进程测试程序如下所示:
#include #include #include #include int main(){ pid_t pid; //创建一个进程 pid =fork(); //创建失败 if (pid < 0) { perror("fork error:"); e__it(1); } //子进程 if (pid== 0) { printf("I am the child process.\n"); //输出进程ID和父进程ID printf("pid:%d\tppid:%d\n",getpid(),getppid()); printf("I will sleep five seconds.\n");//睡眠5s,保证父进程先退出 sleep(5); printf("pid: %d\tppid:%d\n",getpid(),getppid());printf("child process is e__ited.\n"); } //父进程 else { printf("I am fatherprocess.\n"); //父进程睡眠1s,保证子进程输出进程id sleep(1); printf("father process ise__ited."); } return 0;}
僵尸进程测试程序如下所示:
#include #include #include #include int main(){ pid_t pid; pid = fork(); if(pid < 0) { perror("fork error:"); e__it(1); } else if (pid == 0) { printf("Iam child process.I am e__iting.\n"); e__it(0); } printf("I am father process.Iwill sleep two seconds\n"); //等待子进程先退出 sleep(2); //输出进程信息 system("ps -opid,ppid,state,tty,command"); printf("father process is e__iting.\n"); return0;}
测试结果如下所示:
僵尸进程解决办法
通过信号机制
子进程退出时向父进程发送SIGCHILD信号,父进程处理SIGCHILD信号。在信号处理函数中调用wait进行处理僵尸进程
fork两次
将子进程成为孤儿进程,从而其的父进程变为 init 进程,通过 init 进程可以处理僵尸进程
10. 守护进程
Linu__Daemon(守护进程)是运行在后台的一种特殊进程。它独立于控制终端并且周期性地执行某种任务或等待处理某些发生的事件。它不需要用户输入就能运行而且提供某种服务,不是对整个系统就是对某个用户程序提供服务。Linu__系统的大多数服务器就是通过守护进程实现的。常见的守护进程包括系统日志进程syslogd、web服务器httpd、邮件服务器sendmail和数据库服务器mysqld等。
守护进程一般在系统启动时开始运行,除非强行终止,否则直到系统关机都保持运行。守护进程经常以超级用户(root)权限运行,因为它们要使用特殊的端口(1-1024)或访问某些特殊的资源。
一个守护进程的父进程是init进程,因为它真正的父进程在fork出子进程后就先于子进程e__it退出了,所以它是一个由init继承的孤儿进程。守护进程是非交互式程序,没有控制终端,所以任何输出,无论是向标准输出设备stdout还是标准出错设备stderr的输出都需要特殊处理。
守护进程的名称通常以d结尾,比如sshd、__inetd、crond等
编写守护进程的一般步骤步骤:
在父进程中执行 fork 并 e__it 推出;
在子进程中调用 setsid 函数创建新的会话;
在子进程中调用 chdir 函数,让根目录 / 成为子进程的工作目录;
在子进程中调用umask函数,设置进程的umask 为 0;
在子进程中关闭任何不需要的文件描述符。
11. 上下文切换
上下文切换,有时也称做进程切换或任务切换,是指CPU从一个进程或线程切换到另一个进程或线程。在操作系统中,CPU切换到另一个进程需要保存当前进程的状态并恢复另一个进程的状态:当前运行任务转为就绪(或者挂起、删除)状态,另一个被选定的就绪任务成为当前任务
三、死锁
资源分类:(1)可重用资源;(2)消耗资源
1. 什么是死锁
造成死锁的原因就是多个线程或进程对同一个资源的争抢或相互依赖。一个最简单的解释就是你去面试,面试官问你告诉我什么是死锁,我就录用你,你回答面试官你录用我,我告诉你。
如果一个进程集合里面的每个进程都在等待只能由这个集合中的其他一个进程(包括他自身)才能引发的事件,这种情况就是死锁。
这个定义可能有点拗口,下面用一个简单例子说明。
资源 A、B,进程 C、D 描述如下:
资源 A 和资源 B,都是不可剥夺资源,现在进程 C 已经申请了资源 A,进程 D 也申请了资源 B,进程 C 接下来的操作需要用到资源 B,而进程D 恰好也在申请资源A,进程 C、D 都得不到接下来的资源,那么就引发了死锁。
然后套用回去定义:如果一个进程集合里面(进程 C 和进程 D)的每个进程(进程 C 和进程 D)都在等待只能由这个集合中的其他一个进程(对于进程C,他在等进程 D;对于进程 D,他在等进程 C)才能引发的事件(释放相应资源)。
这里的资源包括了软的资源(代码块)和硬的资源(例如扫描仪)。资源一般可以分两种:可剥夺资源(Preemptable)和不可剥夺资源(Nonpreemptable)。一般来说对于由可剥夺资源引起的死锁可以由系统的重新分配资源来解决,所以一般来说大家说的死锁都是由于不可剥夺资源所引起的。
2. 死锁的必要条件
互斥:每个资源要么已经分配给了一个进程,要么就是可用的。
占有和等待:已经得到了某个资源的进程可以再请求新的资源。
不可抢占:已经分配给一个进程的资源不能强制性地被抢占,它只能被占有它的进程显式地释放。
循环等待:有两个或者两个以上的进程组成一条环路,该环路中的每个进程都在等待下一个进程所占有的资源。
3. 死锁的处理方法
1. 处理死锁的策略
鸵鸟策略
把头埋在沙子里,假装根本没发生问题。
因为解决死锁问题的代价很高,因此鸵鸟策略这种不采取任务措施的方案会获得更高的性能。当发生死锁时不会对用户造成多大影响,或发生死锁的概率很低,可以采用鸵鸟策略。
大多数操作系统,包括 Uni__,Linu__ 和 Windows,处理死锁问题的办法仅仅是忽略它。
检测死锁并且恢复。
仔细地对资源进行动态分配,以避免死锁。
通过破除死锁四个必要条件之一,来防止死锁产生。
2. 死锁检测与死锁恢复
不试图阻止死锁,而是当检测到死锁发生时,采取措施进行恢复。
(一)每种类型一个资源的死锁检测
上图为资源分配图,其中方框表示资源,圆圈表示进程。资源指向进程表示该资源已经分配给该进程,进程指向资源表示进程请求获取该资源。
图 a 可以抽取出环,如图 b,它满足了环路等待条件,因此会发生死锁。
每种类型一个资源的死锁检测算法是通过检测有向图是否存在环来实现,从一个节点出发进行深度优先搜索,对访问过的节点进行标记,如果访问了已经标记的节点,就表示有向图存在环,也就是检测到死锁的发生。
(二)每种类型多个资源的死锁检测
上图中,有三个进程四个资源,每个数据代表的含义如下:
E 向量:资源总量
A 向量:资源剩余量
C 矩阵:每个进程所拥有的资源数量,每一行都代表一个进程拥有资源的数量
R 矩阵:每个进程请求的资源数量
进程 P1 和 P2 所请求的资源都得不到满足,只有进程 P3 可以,让 P3 执行,之后释放 P3 拥有的资源,此时 A = (2 2 20)。P2 可以执行,执行后释放 P2 拥有的资源,A = (4 2 2 1) 。P1 也可以执行。所有进程都可以顺利执行,没有死锁。
算法总结如下:
每个进程最开始时都不被标记,执行过程有可能被标记。当算法结束时,任何没有被标记的进程都是死锁进程。
寻找一个没有标记的进程 Pi,它所请求的资源小于等于 A。
如果找到了这样一个进程,那么将 C 矩阵的第 i 行向量加到 A 中,标记该进程,并转回 1。
如果没有这样一个进程,算法终止。
(三)死锁恢复
利用抢占恢复
利用回滚恢复
通过杀死进程恢复
3. 死锁预防
在程序运行之前预防发生死锁,确保系统永远不会进入死锁状态。
(一)破坏互斥条件
例如假脱机打印机技术允许若干个进程同时输出,唯一真正请求物理打印机的进程是打印机守护进程。(把互斥地封装成可以同时访问的,例如:打印机的缓存)
(二)破坏占有和等待条件
一种实现方式是规定所有进程在开始执行前请求所需要的全部资源。
但是,这种策略也有如下缺点:
在许多情况下,一个进程在执行之前不可能知道它所需要的全部资源。这是由于进程在执行时是动态的,不可预测的;
资源利用率低。无论所分资源何时用到,一个进程只有在占有所需的全部资源后才能执行。即使有些资源最后才被该进程用到一次,但该进程在生存期间却一直占有它们,造成长期占着不用的状况。这显然是一种极大的资源浪费;
降低了进程的并发性。因为资源有限,又加上存在浪费,能分配到所需全部资源的进程个数就必然少了。
(三)破坏不可抢占条件
允许进程强行从占有者那里夺取某些资源。就是说,当一个进程已占有了某些资源,它又申请新的资源,但不能立即被满足时,它必须释放所占有的全部资源,以后再重新申请。它所释放的资源可以分配给其它进程。这就相当于该进程占有的资源被隐蔽地强占了。这种预防死锁的方法实现起来困难,会降低系统性能。
(四)破坏循环等待
实行资源有序分配策略。采用这种策略,即把资源事先分类编号,按号分配,使进程在申请,占用资源时不会形成环路。所有进程对资源的请求必须严格按资源序号递增的顺序提出。进程占用了小号资源,才能申请大号资源,就不会产生环路,从而预防了死锁。这种策略与前面的策略相比,资源的利用率和系统吞吐量都有很大提高,但是也存在以下缺点:
限制了进程对资源的请求,同时给系统中所有资源合理编号也是件困难事,并增加了系统开销;
为了遵循按编号申请的次序,暂不使用的资源也需要提前申请,从而增加了进程对资源的占用时间。
4. 死锁避免
在程序运行时避免发生死锁,在使用前进行判断,只允许不会出现死锁的进程请求资源。
(一)安全状态
图 a 的第二列 Has 表示已拥有的资源数,第三列 Ma__ 表示总共需要的资源数,Free 表示还有可以使用的资源数。从图 a 开始出发,先让 B拥有所需的所有资源(图 b),运行结束后释放 B,此时 Free 变为 5(图 c);接着以同样的方式运行 C 和 A,使得所有进程都能成功运行,因此可以称图a 所示的状态时安全的。
定义:如果没有死锁发生,并且即使所有进程突然请求对资源的最大需求,也仍然存在某种调度次序能够使得每一个进程运行完毕,则称该状态是安全的。
安全状态的检测与死锁的检测类似,因为安全状态必须要求不能发生死锁。下面的银行家算法与死锁检测算法非常类似,可以结合着做参考对比。
(二)单个资源的银行家算法
一个小城镇的银行家,他向一群客户分别承诺了一定的贷款额度,算法要做的是判断对请求的满足是否会进入不安全状态,如果是,就拒绝请求;否则予以分配。
不安全状态,因此算法会拒绝之前的请求,从而避免进入图 c 中的状态。
(三)多个资源的银行家算法
有五个进程,四个资源。左边的图表示已经分配的资源,右边的图表示还需要分配的资源。最右边的 E、P 以及 A分别表示:总资源、已分配资源以及可用资源,注意这三个为向量,而不是具体数值,例如 A=(1020),表示 4 个资源分别还剩下 1/0/2/0。
检查一个状态是否安全的算法如下:
查找右边的矩阵是否存在一行小于等于向量 A。如果不存在这样的行,那么系统将会发生死锁,状态是不安全的。
假若找到这样一行,将该进程标记为终止,并将其已分配资源加到 A 中。
重复以上两步,直到所有进程都标记为终止,则状态时安全的。
如果一个状态不是安全的,需要拒绝进入这个状态。
4. 如何在写程序的时候就避免死锁
所谓的死锁呢,发生的主要原因在于了有多个进程去竞争资源,也就是同时去抢占。
可以自己写一个支持多线程的消息管理类,单开一个线程访问独占资源,其它线程用消息交互实现间接访问。这种机制适应性强、效率高,更适合多核环境。
四、内存管理
1. 虚拟内存
虚拟内存的目的是为了让物理内存扩充成更大的逻辑内存,从而让程序获得更多的可用内存。
为了更好的管理内存,操作系统将内存抽象成地址空间。每个程序拥有自己的地址空间,这个地址空间被分割成多个块,每一块称为一页。这些页被映射到物理内存,但不需要映射到连续的物理内存,也不需要所有页都必须在物理内存中。当程序引用到一部分不在物理内存中的地址空间时,由硬件执行必要的映射,将缺失的部分装入物理内存并重新执行失败的指令。
从上面的描述中可以看出,虚拟内存允许程序不用将地址空间中的每一页都映射到物理内存,也就是说一个程序不需要全部调入内存就可以运行,这使得有限的内存运行大程序称为可能。例如有一台计算机可以产生16 位地址,那么一个程序的地址空间范围是 0~64K。该计算机只有 32KB 的物理内存,虚拟内存技术允许该计算机运行一个 64K 大小的程序。
2. 分页系统地址映射
内存管理单元(MMU):管理着地址空间和物理内存的转换。
页表(Page table):页(地址空间)和页框(物理内存空间)的映射表。例如下图中,页表的第 0 个表项为 010,表示第 0 个页映射到第 2个页框。页表项的最后一位用来标记页是否在内存中。
下图的页表存放着 16 个页,这 16 个页需要用 4 个比特位来进行索引定位。因此对于虚拟地址(0010 000000000100),前 4位是用来存储页面号,而后 12 位存储在页中的偏移量。
(0010 000000000100)根据前 4 位得到页号为 2,读取表项内容为(110 1),它的前 3 为为页框号,最后 1位表示该页在内存中。最后映射得到物理内存地址为(110 000000000100)。
3. 页面置换算法
在程序运行过程中,如果要访问的页面不在内存中,就发生缺页中断从而将该页调入内存中。此时如果内存已无空闲空间,系统必须从内存中调出一个页面到磁盘对换区中来腾出空间。
页面置换算法和缓存淘汰策略类似,可以将内存看成磁盘的缓存。在缓存系统中,缓存的大小有限,当有新的缓存到达时,需要淘汰一部分已经存在的缓存,这样才有空间存放新的缓存数据。
页面置换算法的主要目标是使页面置换频率最低(也可以说缺页率最低)。
1. 最佳
Optimal
所选择的被换出的页面将是最长时间内不再被访问,通常可以保证获得最低的缺页率。
是一种理论上的算法,因为无法知道一个页面多长时间不再被访问。
举例:一个系统为某进程分配了三个物理块,并有如下页面引用序列:
开始运行时,先将 7, 0, 1 三个页面装入内存。当进程要访问页面 2 时,产生缺页中断,会将页面 7 换出,因为页面 7再次被访问的时间最长。
2. 最近最久未使用
LRU, Least Recently Used
虽然无法知道将来要使用的页面情况,但是可以知道过去使用页面的情况。LRU 将最近最久未使用的页面换出。
为了实现LRU,需要在内存中维护一个所有页面的链表。当一个页面被访问时,将这个页面移到链表表头。这样就能保证链表表尾的页面时最近最久未访问的。
因为每次访问都需要更新链表,因此这种方式实现的 LRU 代价很高。
3. 最近未使用
NRU, Not Recently Used
每个页面都有两个状态位:R 与 M,当页面被访问时设置页面的 R=1,当页面被修改时设置 M=1。其中 R位会定时被清零。可以将页面分成以下四类:
R=0,M=0
R=0,M=1
R=1,M=0
R=1,M=1
当发生缺页中断时,NRU 算法随机地从类编号最小的非空类中挑选一个页面将它换出。
NRU 优先换出已经被修改的脏页面(R=0,M=1),而不是被频繁使用的干净页面(R=1,M=0)。
4. 先进先出
FIFO, First In First Out
选择换出的页面是最先进入的页面。
该算法会将那些经常被访问的页面也被换出,从而使缺页率升高。
5. 第二次机会算法
FIFO 算法可能会把经常使用的页面置换出去,为了避免这一问题,对该算法做一个简单的修改:
当页面被访问 (读或写) 时设置该页面的 R 位为 1。需要替换的时候,检查最老页面的 R 位。如果 R 位是0,那么这个页面既老又没有被使用,可以立刻置换掉;如果是 1,就将 R 位清0,并把该页面放到链表的尾端,修改它的装入时间使它就像刚装入的一样,然后继续从链表的头部开始搜索。
6. 时钟
Clock
第二次机会算法需要在链表中移动页面,降低了效率。时钟算法使用环形链表将页面链接起来,再使用一个指针指向最老的页面。
4. 分段
虚拟内存采用的是分页技术,也就是将地址空间划分成固定大小的页,每一页再与内存进行映射。
下图为一个编译器在编译过程中建立的多个表,有 4 个表是动态增长的,如果使用分页系统的一维地址空间,动态增长的特点会导致覆盖问题的出现。
分段的做法是把每个表分成段,一个段构成一个独立的地址空间。每个段的长度可以不同,并且可以动态增长。
5. 段页式
程序的地址空间划分成多个拥有独立地址空间的段,每个段上的地址空间划分成大小相同的页。这样既拥有分段系统的共享和保护,又拥有分页系统的虚拟内存功能。
6. 分页与分段的比较
对程序员的透明性:分页透明,但是分段需要程序员显示划分每个段。
地址空间的维度:分页是一维地址空间,分段是二维的。
大小是否可以改变:页的大小不可变,段的大小可以动态改变。
出现的原因:分页主要用于实现虚拟内存,从而获得更大的地址空间;分段主要是为了使程序和数据可以被划分为逻辑上独立的地址空间并且有助于共享和保护。
五、设备管理
1. 磁盘结构
盘面(Platter):一个磁盘有多个盘面;
磁道(Track):盘面上的圆形带状区域,一个盘面可以有多个磁道;
扇区(Track Sector):磁道上的一个弧段,一个磁道可以有多个扇区,它是最小的物理储存单位,目前主要有 512 bytes 与 4 K两种大小;
磁头(Head):与盘面非常接近,能够将盘面上的磁场转换为电信号(读),或者将电信号转换为盘面的磁场(写);
制动手臂(Actuator arm):用于在磁道之间移动磁头;
主轴(Spindle):使整个盘面转动。
2. 磁盘调度算法
读写一个磁盘块的时间的影响因素有:
旋转时间(主轴旋转磁盘,使得磁头移动到适当的扇区上)
寻道时间(制动手臂移动,使得磁头移动到适当的磁道上)
实际的数据传输时间
其中,寻道时间最长,因此磁盘调度的主要目标是使磁盘的平均寻道时间最短。
1. 先来先服务
FCFS, First Come First Served
按照磁盘请求的顺序进行调度
公平对待所有进程
在有很多进程的情况下,接近随机调度的性能
优点是公平和简单。缺点也很明显,因为未对寻道做任何优化,使平均寻道时间可能较长。
2. 最短寻道时间优先
SSTF, Shortest Seek Time First
优先调度与当前磁头所在磁道距离最近的磁道。
虽然平均寻道时间比较低,但是不够公平。如果新到达的磁道请求总是比一个在等待的磁道请求近,那么在等待的磁道请求会一直等待下去,也就是出现饥饿现象。具体来说,两边的磁道请求更容易出现饥饿现象。
3. 电梯算法
SCAN
电梯总是保持一个方向运行,直到该方向没有请求为止,然后改变运行方向。
电梯算法(扫描算法)和电梯的运行过程类似,总是按一个方向来进行磁盘调度,直到该方向上没有未完成的磁盘请求,然后改变方向。
因为考虑了移动方向,因此所有的磁盘请求都会被满足,解决了 SSTF 的饥饿问题。
六、链接
1. 编译系统
以下是一个 hello.c 程序:
#include int main(){ printf("hello, world\n"); return 0;}
在 Uni__ 系统上,由编译器把源文件转换为目标文件。
gcc -o hello hello.c
这个过程大致如下:
1. 预处理阶段 (Preprocessing phase)
预处理(cpp)根据以字符 # 开头的命令,修改原始的 C 程序,生成扩展名为 .i 的文件。
$ gcc -E hello.c -o hello.i
2. 编译阶段 (Compilation phase)
编译器(cc1)将文本文件 hello.i 翻译成文本文件 hello.s,它包含一个汇编语言程序。
$ gcc -S hello.i -o hello.s
3. 汇编阶段 (Assembly phase)
编译器(as)将 hello.s 翻译成机器语言指令,把这些指令打包成一种叫做可重定位目标程序(relocatable objectprogram)的格式,并将结果保存在目标文件 hello.o 中。
$ as hello.s -o hello.o
4. 链接阶段 (Linking phase)
printf 函数是标准 C 库中的一个函数,在 printf.o 这个单独预编译好的目标文件中。连接器(ld)将 printf.o 和hello.o 合并,结果得到 hello 可执行目标文件。
$ gcc hello.o -o hello
2. 静态链接
静态连接器以一组可重定向目标文件为输入,生成一个完全链接的可执行目标文件作为输出。链接器主要完成以下两个任务:
符号解析:每个符号对应于一个函数、一个全局变量或一个静态变量,符号解析的目的是将每个符号引用与一个符号定义关联起来。
重定位:链接器通过把每个符号定义与一个内存位置关联起来,然后修改所有对这些符号的引用,使得它们指向这个内存位置。
3. 目标文件
可执行目标文件:可以直接在内存中执行;
可重定向目标文件:可与其它可重定向目标文件在链接阶段合并,创建一个可执行目标文件;
共享目标文件:这是一种特殊的可重定向目标文件,可以在运行时被动态加载进内存并链接;
4. 动态链接
静态库有以下两个问题:
当静态库更新时那么整个程序都要重新进行链接;
对于 printf 这种标准函数库,如果每个程序都要有代码,这会极大浪费资源。
共享库是为了解决静态库的这两个问题而设计的,在 Linu__ 系统中通常用 .so 后缀来表示,Windows 系统上它们被称为DLL。它具有以下特点:
在给定的文件系统中一个库只有一个文件,所有引用该库的可执行目标文件都共享这个文件,它不会被复制到引用它的可执行文件中;
在内存中,一个共享库的 .te__t 节(已编译程序的机器代码)的一个副本可以被不同的正在运行的进程共享。
学完这份计算机基础知识与操作系统后,帅地飘了
1、操作系统的四个特征
有以下四个特征:
并发
共享
虚拟
异步
接下来,我们分别来搞定每一个特征。
1.1 并发是什么?和并行有啥区别?
举个例子,假如你在语音跟同学玩英雄联盟:
你一边用鼠标移动打游戏,同时语音嘴里说"队友挂机,真坑!", 这叫并行(边移动鼠标边语音BB)
你一边用鼠标移动打游戏,然后离开鼠标,去砸键盘, 这叫并发(先离开鼠标然后砸键盘)
并发只是把时间分成若干段,使多个任务交替的执行。
并行的关键是你有同时处理多个任务的能力。
所以我认为它们最关键的点就是:是否是『同时』
那么对于操作系统而言,操作系统的并发性指计算机系统中同时存在多个运行着的程序。
比如说以前的计算机是单核CPU,那么如何在操作系统上同时运行QQ、浏览器,记事本、ppt等多个程序呢,这就需要操作系统具有并发性
CPU时间片(操作系统分配给每个正在运行的进程微观上的一段CPU时间)轮着给进程执行的时间,因为执行速度很快,看起来就像浏览器能同时执行任务一样。
有人会说,现在都多核CPU了,还需要并发吗,答案肯定是需要的,比如你有8核CPU,但是桌面要执行的任务很可能超过8个。
1.2 共享是什么?共享和并发有什么关系?
举一个例子:
你同时用QQ和微信发"年终述职.ppt"文件给领导,这时候QQ和微信都在读取这个ppt文件
两个进程正在并发执行(并发性)
需要共享地访问硬盘资源(共享性)
如果没有并发,也就是只有一个进程在运行,那就没有共享了。如果没有共享,QQ和微信就不能同时发文件,无法同时访问硬盘资源,也就无法并发。
其中共享分为两种情况:
上面的例子,QQ和微信都要访问同一个文件,属于同时共享。
对于互斥共享,比如打印机,只能同一时刻被一个进程控制,如打印机,虽然他可以提供多个进程使用,但是试想,同时打印多个东西,会造成打印结果的混乱,因此规定,某些资源在进行使用的时候,必须要先让某进程先使用,等使用完之后,再同一其他进程进行访问。
我们把一段时间内只允许一个进程访问的资源称为独占资源,或临界资源。
1.3 虚拟是啥?
先举例,再说定义。
假如一个叫范桶的货车司机在玩英雄联盟,平时因为酒驾太多,自己装了很多次别人的车,住院也花了不少钱,所以家里没钱,只能买个1G内存的二手电脑玩游戏。可英雄联盟至少需要2G内存,这就奇怪了,老司机虽然一到团战就卡死,但是还是能运行英雄联盟。为什么需要2G内存的游戏,1G电脑还能运行呢?
这就是虚拟存储器技术。实际上只有1G内存,在用户看来远远大于1G。
还有,范桶的电脑还是单核的,但范桶居然能一边迅雷下着爱情动作片,一边听着网易云音乐,还在QQ上撩妹子,既然一个程序要被分配CPU才能正常执行,按道理来说同一时间只有1个程序在运行,为啥电脑能同时运行这么多程序呢?
这就是虚拟处理器技术。实际上只有一个CPU,在用户看来有3个CPU在同时服务。(因为CPU来回切换进程的速度特别块,感觉就像很多CPU在为我们服务)
虚拟这块的总结如下:
1.4 异步性是啥?
异步在JS里是很常见的,比如aja__请求,我们发出请求后并不是立马得到信息,也不会去等待aja__结果返回,而是继续执行下面的代码,等aja__结果回来,通知JS线程。这就跟CPU处理进程很类似。
比如,CPU正在执行一个进程,进程需要读取文件,读取文件可能要1个小时,那CPU不可能一直等一个小时,CPU会继续把时间片分给别的进程,等文件读取完成了(类似aja__返回结果了),CPU再继续执行之前被中断的进程。
所以异步性就是描述进程这种以不可预知的速度走走停停、何时开始何时暂停何时结束不可预知的性质。
2、操作系统运行机制和体系结构
预备知识:什么是指令
比如说,如下图(简单扫一下即可):
a+b是一段程序代码,a+b在CPU看来并不能一步完成,可以翻译成如下:
// 意思是将内存的16号单元数据,放到A寄存器,LOAD A, 16// 意思是将内存的16号单元数据,放到B寄存器LOAD B, 17//存器里的A,B数据相加,得到CADD C, A, B
这里就可以看得出来,指令是CPU能识别和执行的最基本命令。
2.1 两种指令、两种处理器状态、两种程序
假如说一个用户可以随意把服务器上的所有文件删光,这是很危险的。所以有些指令普通用户是不能使用的,只能是权限较高的用户能使用。此时指令就分为了两种,如下图:
这就引出一个问题:CPU如何判断当前是否可以执行特权指令?
如下图:
CPU通常有两种工作模式即:内核态和用户态,而在PSW(这个不用管,就知道有一个寄存器的标志位0表示用户态,1表示核心态)中有一个二进制位控制这两种模式。
对于应用程序而言,有的程序能执行特权指令,有的程序只能执行非特权指令。所以操作系统里的程序又分为两种:
2.2 操作系统内核简单介绍
从下图,我们先看看操作系统内核包含哪些
操作系统内核中跟硬件紧密相关的部分有:
时钟管理。操作系统的时钟管理是依靠硬件定时器的(具体硬件怎么实现我也不太清楚,好像是靠硬件周期性的产生一个脉冲信号实现的)。时钟管理相当重要,比如我们获取时间信息,进程切换等等都是要依靠时钟管理。
中断处理(下一小节会详细介绍)。
原语(后面会有案例提到)。现在可以简单理解为用来实现某个特定功能,在执行过程中不可被中断的指令集合。原语有一个非常重要的特性,就是原子性(其运行一气呵成,不可中断)。
2.3 中断
在程序运行过程中,系统出现了一个必须由CPU立即处理的情况,此时,CPU暂时中止程序的执行转而处理这个新的情况的过程就叫做中断。
下面举一个例子:
第一个应用程序在用户态执行了一段时间后
接着操作系统切换到核心态,处理中断信号
操作系统发现中断的信号是第一个程序的时间片(每个程序不能一直执行,CPU会给每个程序一定的执行时间,这段时间就是时间片)用完了,应该换第二个应用程序执行了
切换到第2个进程后,操作系统会将CPU的使用权交换给第二个应用程序,接着第二个应用程序就在用户态下开始执行。
进程2需要调用打印机资源,这时会执行一个系统调用(后面会讲系统调用,这里简单理解为需要操作系统进入核心态处理的函数),让操作系统进入核心态,去调用打印机资源
打印机开始工作,此时进程2因为要等待打印机启动,操作系统就不等待了(等到打印机准备好了,再回来执行程序2),直接切换到第三个应用程序执行
等到打印机准备好了,此时打印机通过I/O控制器会给操作系统发出一个中断信号,操作系统又进入到核心态,发现这个中断是因为程序2等待打印机资源,现在打印机准备好了,就切换到程序2,切换到用户态,把CPU给程序2继续执行。
好了,现在可以给出一个结论,就是用户态、核心态之间的切换是怎么实现的?
"用户态 ---> 核心态"是通过中断实现的。并且中断时唯一途径。
"核心态 ---> 用户态"的切换时通过执行一个特权指令,将程序状态的标志位设为用户态。
2.4 中断的分类
举一个例子,什么是内中断和外中断:
接着说之前的范桶同学,小时候不爱学习,每次学习着学习着突然异想天开,回过神来已经过好好长一段时间,这是内部中断。想着想着老师走过来,给了范捅一嘴巴,这是外部中断。
官方解释如下:
内中断常见的情况如程序非法操作(比如你要拿的的数据的内存地址不是内存地址,是系统无法识别的地址),地址越界(比如系统给你的程序分配了一些内存,但是你访问的时候超出了你应该访问的内存范围)、浮点溢出(比如系统只能表示1.1到5.1的范围,你输入一个100,超出了计算机能处理的范围),或者异常,陷入trap(是指应用程序请求系统调用造成的,什么是系统调用,后面小节会举例讲)。
外中断常见的情况如I/O中断(由I/O控制器产生,用于发送信号通知操作完成等信号,比如进程需要请求打印机资源,打印机有一个启动准备的过程,准备好了就会给CPU一个I/O中断,告诉它已经准备好了)、时钟中断(由处理器内部的计时器产生,允许操作系统以一定规程执行函数,操作系统每过大约15ms会进行一次线程调度,就是利用时钟中断来实现的)。
2.5 系统调用
为什么需要系统调用?
比如你的程序需要读取文件信息,可读取文件属于读取硬盘里的数据,这个操作应该时CPU在内核态去完成的,我们的应用程序怎么让CPU去帮助我们切换到内核态完成这个工作呢,这里就需要系统调用了。
这里就引出系统调用的概念和作用。
应用程序通过系统调用请求操作系统的服务。系统中的各种共享资源都由操作系统统一管理,因此在用户程序中,凡是与资源有关的操作(如存储分配、I/O操作、文件管理等),都必须通过系统调用的方式向操作系统提出服务请求,由操作系统代为完成。
以下内容简单看一下即可,系统调用的分类:
需要注意的是,库函数和系统调用容易混淆。
库是可重用的模块 处于用户态
进程通过系统调用从用户态进入内核态, 库函数中有很大部分是对系统调用的封装
举个例子:比如windows和linu__中,创建进程的系统调用方法是不一样的。但在node中的只需要调用相同函数方法就可以创建一个进程。例如
// 引入创建子进程的模块const childProcess = require('child_process')// 获取cpu的数量constcpuNum = require('os').cpus().length// 创建与cpu数量一样的子进程for (let i = 0; i <cpuNum; ++i) { childProcess.fork('./worker.js')}
2.6 进程的定义、组成、组织方式、状态与转换
2.6.1 为什么要引入进程的概念呢?
早期的计算机只支持单道程序(是指所有进程一个一个排队执行,A进程执行时,CPU、内存、I/O设备全是A进程控制的,等A进程执行完了,才换B进程,然后对应的资源比如CPU、内存这些才能换B用)。
现代计算机是多道程序执行,就是同时看起来有多个程序在一起执行,那每个程序执行都需要系统分配给它资源来执行,比如CPU、内存。
拿内存来说,操作系统要知道给A程序分配的内存有哪些,给B程序分配的内存有哪些,这些都要有小本本记录下来,这个小本本就是进程的一部分,进程的一大职责就是记录目前程序运行的状态。
系统为每个运行的程序配置一个数据结构,称为进程控制块(PCB),用来描述进程的各种信息(比如代码段放在哪)。
2.6.2 进程的定义?
简要的说,进程就是具有独立功能的程序在数据集合上运行的过程。(强调动态性)
2.6.3 PCB有哪些组成
如下图,分别说明一下
进程标识符PID相当于身份证。是在进程被创建时,操作系统会为该进程分配一个唯一的、不重复的ID,用于区分不同的进程。
用户标识符UID用来表示这个进程所属的用户是谁。
进程当前状态和优先级下一小节会详细介绍
程序段指针是指当前进程的程序在内存的什么地方。
数据段指针是指当前进程的数据在内存的什么地方。
键盘和鼠标是指进程被分配得到的I/O设备。
各种寄存器值是指比如把程序计数器的值,比如有些计算的结果算到一半,进程切换时需要把这些值保存下来。
2.6.4 进程的组织
在一个系统中,通常由数十、数百乃至数千个PCB。为了对他们加以有效的管理,应该用适当的方式把这些PCB组织起来。这里介绍一种组织方式,类似数据结构里的链表。
2.6.5 进程的状态
进程是程序的一次执行。在这个执行过程中,有时进程正在被CPU处理,有时又需要等待CPU服务,可见,进程的状态是会有各种变化。为了方便对各个进程的管理,操作系统需要将进程合理地划分为几种状态。
进程的三种基本状态:
进程的另外两种状态:
2.6.6 进程状态的转换
进程的状态并不是一成不变的,在一定情况下会动态转换。
以上的这些进程状态的转换是如何实现的呢,这就要引出下一个角色了,叫原语。
原语是不可被中断的原子操作。我们举一个例子看看原语是怎么保证不可中断的。
原语采用关中断指令和开中断指令实现。
首先执行关中断指令
然后外部来了中断信号,不予以处理
等到开中断指令执行后,其他中断信号才有机会处理。
¨K46K ¨K11K因为进程是分配系统资源的单位(包括内存地址空间),因此各进程拥有的内存地址空间相互独立。!w=1679&h=683&f=png&s=326726)¨K47K因为两个进程的存储空间不能相互访问,所以操作系统就提供的一个内存空间让彼此都能访问,这就是共享存储的原理。其中,介绍一下基于存储区的共享。
在内存中画出一块共享存储区,数据的形式、存放位置都是由进程控制,而不是操作系统。
管道数据是以字符流(注意不是字节流)的形式写入管道,当管道写满时,写进程的write()系统调用将被阻塞,等待读进程将数据取走。当读进程将数据全部取走后,管道变空,此时读进程的read()系统调用将被阻塞。
如果没写满就不允许读。如果都没空就不允许写。
数据一旦被读出,就从管道中被丢弃,这就意味着读进程最多只能有一个。
¨K49K进程间的数据交换以格式化的消息为单位。进程通过操作系统提供的"发送消息/接收消息"两个原语进行数据交换。其中消息是什么意思呢?就好像你发QQ消息,消息头的来源是你,消息体是你发的内容。
比如你在玩QQ的时候,QQ是一个进程,如果QQ的进程里没有多线程并发,那么QQ进程就只能同一时间做一件事情(比如QQ打字聊天)
但是我们真实的场景是QQ聊天的同时,还可以发文件,还可以视频聊天,这说明如果QQ没有多线程并发能力,QQ能够的实用性就大大降低了。所以我们需要线程,也就是需要进程拥有能够并发多个事件的能力。
信号量主要是来解决进程的同步和互斥的,我们前端需要了解,如果涉及到同步和互斥的关系(我们编程大多数关于流程的逻辑问题,本质不就是同步和互斥吗?)在操作系统中,常用P、V信号量来实现进程间的同步和互斥,我们简单了解一下一种常用的信号量,记录型信号量来简单了解一下信号量本质是怎样的。(c语言来表示,会有备注)¨G2G 意思是信号量的结构有两部分组成,一部分是剩余资源value,比如目前有两台打印机空闲,那么剩余资源就是2,谁正在使用打印机,剩余资源就减1。Struct process __L意思是,比如2台打印机都有人在用,这时候你的要用打印机,此时会把这个打印机资源的请求放入阻塞队列,L就是阻塞队列的地址。¨G3G需要注意的是,如果剩余资源数不够,使用block原语使进程从运行态进入阻塞态,并把挂到信号量S的等待队列中。¨G4G释放资源后,若还有别的进程在等待这个资源,比如打印机资源,则使用wakeup原语唤醒等待队列中的一个进程,该进程从阻塞态变为继续态。¨K53K为什么要讲这个呢,主要是node的流的机制,本质就是生产者消费者问题,可以简单的看看这个问题如何解决。!生产者的主要作用是生成一定量的数据放到缓冲区中,然后重复此过程。与此同时,消费者也在缓冲区消耗这些数据。该问题的关键就是要保证生产者不会在缓冲区满时加入数据,消费者也不会在缓冲区中空时消耗数据。这里我们需要两个同步信号量和一个互斥信号量¨G5G 生产者代码如下 ¨G6G 消费者代码如下 ¨G7G ¨K54K ¨K19K内存是计算机其它硬件设备与``CPU沟通的桥梁、中转站。程序执行前需要先放到内存中才能被CPU处理。
4.1 cpu如何区分执行程序的数据在内存的什么地方
是通过给内存的存储单元编址实现的。(存储单元一般是以字节为单位)
如下图,内存的存储单元,就像一个酒店的房间,都有编号,比如程序一的数据都在1楼,1楼1号存储着程序里let a = 1这段代码。
4.2 内存管理-内存空间的分配与回收
内存分配分为连续分配和非连续分配,连续分配是指用户进程分配的必须是一个连续的内存空间。
这里我们只讲连续分配中的动态分区分配。
什么是动态分区分配呢,这种分配方式不会预先划分内存分区,而是在进程装入内存时,根据进程的大小动态地建立分区,并使分区的大小正好适合进程的需要。(比如,某计算机内存大小64MB,系统区8MB,用户区56MB…,现在我们有几个进程要装入内存,如下图)
随之而来的问题就是,如果此时进程1使用完了,相应在内存上的数据也被删除了,那么空闲的区域,后面该怎么分配(也就是说随着进程退出,会有很多空闲的内存区域出现)
我们讲一种较为简单的处理方法叫空闲分区表法来解决这个问题。如下图,右侧的表格就是一个空闲分区表。
当很多个空闲分区都能满足需求时,应该选择哪个分区进行分配呢,例如下图,分别有20MB,10MB,4MB三个空闲分区块,现在进程5需要4MB空闲分区,改怎么分配呢?
我们需要按照一定的动态分区分配算法,比如有首次适应算法,指的是每次都从低地址开始查找,找到第一个能满足大小的空闲分区。还有比如最佳适应算法,指的是从空闲分区表中找到最小的适合分配的分区块来满足需求。
连续分配缺点很明显,大多数情况,需要分配的进程大小,不能跟空闲分区剩下的大小完全一样,这样就产生很多很难利用的内存碎片。
这里我们介绍一种更好的空闲分区的分配方法,基本分页存储。如下图
将内存空间分为一个个大小相等的分区(比如:每个分区4KB).每个分区就是一个“页框”。页框号从0开始。
将用户进程的地址空间分为与页框大小相等的一个个区域,称为“页”。每个页也是从0开始。
进程的页与内存的页框有着一一对应的关系。各个页不必连续存放,也不必按先后顺序来,可以放到不相邻的各个页框中。
5 文件管理
文件是什么?
文件就是一组有意义的信息/数据集合。
计算机中存放了各种各样的文件,一个文件有哪些属性呢?文件内部的数据应该怎样组织起来?文件之间又该怎么组织起来?
5.1 文件的属性
文件名。即文件的名字,需要注意的是,同一目录下不允许有重名的文件。
标识符。操作系统用于区分各个文件的一种内部的名称。
类型。文件的类型。
位置。文件存放的路径,同时也是在硬盘里的位置(需要转换成物理硬盘上的地址)
创建时间、上次修改时间、文件所有者就是字面意思。
保护信息。比如对这个文件的执行权限,是否有删除文件权限,修改文件权限等等。
5.2 文件内部数据如何组织在一起
如下图,文件主要分为有结构文件和无结构文件。
5.3 文件之间如何组织起来
通过树状结构组织的。例如windows的文件间的组织关系如下:
接下来我们详细的了解一下文件的逻辑结构
5.4 文件的逻辑结构
逻辑结构是指,在用户看来,文件内部的数据是如何组织起来的,而“物理结构”是在操作系统看来,文件是如何保存在外存,比如硬盘中的。
比如,“线性表”就是一种逻辑结构,在用户看来,线性表就是一组有先后关系的元素序列,如:a,b,c,d,e....
“线性表”这种逻辑结构可以用不同的物理结构实现,比如:顺序表/链表。顺序表的各个元素在逻辑上相邻,在物理上也相邻:而链表的各个元素在物理上可以是不相邻的。
因此,顺序表可以实现“随机访问”,而“链表”无法实现随机访问。
接下来我了解一下有结构文件的三种逻辑结构
5.4.1 顺序文件
什么是顺序文件
指的是文件中的记录一个接一个地在逻辑上是顺序排列,记录可以是定长或变长,各个记录在物理上可以顺序存储或链式存储
顺序文件按结构来划分,可以分为串结构和顺序结构。
串结构是指记录之间的顺序与关键字无关,通常都是按照记录的时间决定记录的顺序。
顺序结构就必须保证记录之间的先后顺序按关键字排列。
这里需要注意的知识点是,顺序文件的存储方式和是否按关键字排列,会影响数据是否支持随机存取和是否可以快速按关键字找到对应记录的功能。
5.4.2 索引文件
对于可变长记录文件,要找到第i个记录,必须先顺序查找前i-1个记录,但是很多场景中又必须使用可变长记录,如何解决这个问题呢?这就引出来马上讲的索引文件
给这些变长的记录都用一张索引表来记录,一个索引表项包括了索引号,长度和指针。
其中,可以将关键字作为索引号的内容,如果关键字本身的排列是有序的,那么还可以按照关键字进行折半查找。
但是建立索引表的问题也很明显,首先若要删除/增加一个记录,同时也要对索引表操作,其次,如果增加一条记录才1KB,但是索引表增加i一条记录可能有8KB,以至于索引表的体积大大多于记录。存储空间的利用率就比较低。
5.4.3 索引顺序文件
索引顺序文件是索引文件和顺序文件思想的结合。索引顺序文件中,同样会为文件建立一张索引表,但不同的是,并不是每个记录对应一个索引表项,而是一组记录对应一个索引表项。
5.5 文件目录
首先,我们需要了解一下文件控制块是什么。我们假设目前在windows的D盘,如下图
可以看到,目录本身就是一种有结构的文件,记录了目录里的文件和目录的信息,比如名称和类型。而这些一条条的记录就是一个个的“文件控制块”(FCB)。
文件目录的结构通常是树状的,例如linu__里/是指根路径,/home是根路径下的二级目录
需要注意的是,树状目录不容易实现文件共享,所以在树形目录结构的基础上,增加了一些指向同一节点的有向边(可以简单理解为引用关系,就跟js里的对象一样)
也就是说需要为每个共享节点设置一个共享计数器,用于记录此时有多少个地方在共享该结点。只有共享计数器减为0,才删除该节点。
5.6 文件存储空间管理
首先,我们了解一下磁盘分为目录区和文件区。
接着,我们了解一下常见的两种文件存储空间的管理算法,如下图,假如硬盘上空闲的数据块是蓝色,非空闲的数据块是橙色。
对分配连续的存储空间,可以采用空闲表法(只讲这种较简单的方法)来分配和回收磁盘块。对于分配,可以采用首次适应,最佳适应等算法来决定要为文件分配哪个区间。(空闲表表示如下)
首次适应是指当要插入数据的时候,空闲表会依次检查空闲表中的表项,然后找到第一个满足条件的空闲区间。
最佳适应算法是指当要插入数据的时候,空闲表会依次检查空闲表中的表项,然后找到满足条件而且空闲块最小的空闲区间。
如何回收磁盘块呢,主要分为以下4中情况
回收区的前后没有相邻空闲区
回收区前后都是空闲区
回收区前面是空前去
回收区后面是空闲区
最重要的是要注意表项合并的问题。(比如说回收区前后都有空闲区就将其一起合并为一个空闲区)
5.7 文件共享
文件共享分为两种
注意,多个用户共享同一个文件,意味着系统只有“一份”文件数据。并且只要某个用户修改了该文件的数据,其他用户也可以看到文件的变化。
软连接可以理解为windows里的快捷方式。
硬链接可以理解为js里的引用计数,只有引用为0的时候,才会真正删除这个文件。
5.8 文件保护
操作系统需要保护文件的安全,一般有如下3种方式:
口令保护。是指为文件设置一个“口令”(比如123),用户请求访问该文件时必须提供对应的口令。口令一般放在文件对应的FCB或者索引结点上。
加密保护。使用某个"密码"对文件进行加密,在访问文件时需要提供正确的“密码”才能对文件进行正确的解密。
访问控制。在每个文件的FCB或者索引节点种增加一个访问控制列表,该表中记录了各个用户可以对该文件执行哪些操作。
6 I/O设备
什么是I/O设备
I/O就是输入输出(Input/Output)的意思,I/O设备就是可以将数据输入到计算机,或者可以接收计算机输出数据的外部设备,属于计算机中的硬件部件。
6.1 I/O设备分类--按使用特性
人机交互类设备,这类设备传输数据的速度慢
存储设备,这类设备传输数据的速度较快
网络通信设备,这类设备的传输速度介于人机交互设备和存储设备之间
6.2 I/O控制器
CPU无法直接控制I/O设备的机械部件,因此I/O设备还要有一个电子部件作为CPU和I/O设备机械部件之间的“中介”,用于实现CPU对设备的控制。这个电子部件就是I/O控制器。
接收和识别CPU发出的指令是指,比如CPU发来读取文件的命令,I/O控制器中会有相应的控制寄存器来存放命令和参数
向cpu报告设备的状态是指,I/O控制器会有相应的状态寄存器,用来记录I/O设备是否空闲或者忙碌
数据交换是指I/O控制器会设置相应的数据寄存器。输出时,数据寄存器用于暂存CPU发来的数据,之后再由控制器传送给设备。
地址识别是指,为了区分设备控制器中的各个寄存器中的各个寄存器,也需要给各个寄存器设置一个特性的“地址”。I/O控制器通过CPU提供的“地址”来判断CPU要读写的是哪个寄存器
6.3 I/O控制方式
这里我们指讲一下目前比较先进的方式,通道控制方式。
通道可以理解为一种“弱鸡版CPU”。通道可以识别并执行一系列通道指令。
通道最大的优点是极大的减少了CPU的干预频率,I/O设备完成任务,通道会向CPU发出中断,不需要轮询来问I/O设备是否完成CPU下达的任务。