操作系统有一个非常关键的概念:“抽象”
抽象的具体应用如上图所示:
1.将I/O设备抽象成文件:
硬盘,光盘,输入输出设备等等存放数据的设备是很复杂的机械设备,
例如硬盘:里面有“磁盘块、扇区”等许多的物理模块,将硬盘抽象成文件后,我们就不用直接面对硬盘做操作了,硬盘内数据具体是如何传递的也不需要关注了,简化了对数据的操作。
2.将物理的主存和I\O设备抽象成一个虚拟存储器
他是对内存和硬盘的抽象,让操作系统认为他有一个超级大的内存空间,我们电脑的内存的大小是有限的,在多个进程同时运行的时候我们不可能将所有的进程都放入到内存中去,所以操作系统给每个进程都分配一个独立的,虚拟的内存地址空间,让每个进程都以为自己本身都被载入到内存中去了,实际上可能只有一块真正被执行的代码被调入到真实的内存空间去了
3.将Cpu抽象称为一组指令集
正常cpu内含有很多的电路结构,太过于复杂,我们将cpu对数据的操作抽象成一条条程序代码,使得我们对数据的操作变得容易起来
4.将指令集和虚拟存储器抽象成了进程
5.将操作系统和进程抽象成了虚拟机
上面所做的抽象是对物理机器所进行的“硬抽象”
而java虚拟机只是在操作系统上跑的一个运行java字节码的虚拟机,是一个“软的虚拟机”
下面将虚拟机比喻成了厨师做蛋糕,直观的展示了进程之间的切换。
下图为进程在虚拟存储器上的一个逻辑布局:
用户栈从上向下生长,堆从下向上生长,下面是将cpu抽象的程序数据和程序代码,我们的重点主要在“程序代码”上,程序代码中是一些二进制指令,我们将他们改写为如图所示的汇编,现在程序运行到地址为304处的指令,这时当前进程的cpu时间片用完了,会切换到另一个进程,此时操作系统会进行什么操作呢?
因为cpu中只有一套寄存器,每个进程中都会用到,所以在切换进程之前操作系统会将原进程所要用到的信息进行保存,包括使用的寄存器的信息,此进程执行到的代码位置,此进程之前所打开的文件,此进程所用到的代码地址、数据地址、堆栈地址......
上下文保存在内存中
进程之间的切换的时间仅仅只有几毫秒,所以当我们在电脑上即听歌有玩游戏的时候根本感受不到其实这两个进程在做频繁的切换。
下面图片展示了操作系统保存进程上下文的数据结构:
操作系统负责切换不同的进程,操作系统知道所有的信息
操作系统由“用户态”和核心态两部分组成,维持操作系统自身运行的信息都放在核心态中,我们用户使用的进程都放在用户态中
假如用户打开一个文件,这个操作的实现就放在核心态中
进程主要有以下几个状态:
一般进程会按照以下的步骤运行:
先新建一个进程,然后进入到就绪状态等待cpu给他分配时间片调度他,有了时间片就会进入到运行态,时间片用完了就会再次进入就绪态,当进程遇到了阻塞,例如等待键盘输入信息、等待打开文件......就会进入到等待状态,等待完成后就会进入到就绪态等待cpu调度。
当进程处于等待状态时,他会进入到cpu维护的一个队列中,不会占用cpu,只有运行状态才会占用cpu。
从上面可以看出操作系统对各个进程的调度是非常关键的问题
进程调度从一开始到现在一共有以下几种:
1.非抢占式调度:
调度进程一旦把cpu分配给某一进程后便让他一直运行下去,直到进程完成或发生某事件而不能运行下去时,才将cpu分给其他进程。
该调度方法适用于批处理系统这样的简单、系统开销小的进程。
批处理系统中的调度还分为以下两种:
先来先服务调度:
公平、简单(FIFO队列)、非抢占式、不适合交互式
最短作业优先调度:
系统的平均等待时间最短,但是需要预先制动啊每个任务的运行时间,这点一般是很难做到的。
2.抢占式调度:
到一个进程正在执行时,系统可以基于某种策略剥夺cpu给其他进程。剥夺的原则有:优先权原则、短进程原则、时间片原则
该调度方法适用于交互式系统。
交互式调度有以下几种策略:
转轮策略:
为每个进程分配一个固定的时间片,该策略的重点是对时间片长短的把控
假设进程切换一次的开销为1ms
若时间片为4ms,则20%的时间浪费在切换上
若时间片为100ms,浪费只有1%,但是假设有50个进程,最后一个需要等待5秒,这对于用户来说是无法忍受的
静态优先级策略:
为每一个进程都分配一个优先级,优先级高的进程先执行,优先级低的进程后执行,有的低优先级的进程可能会被“饿死”,即后面的进程优先级都很高,低优先级的进程永远也得不到执行。
多级队列反馈:(动态优先级)
低优先级进程的优先级会随着时间的增长得到提升,高优先级进程的优先级可能随着执行次数的增多降低优先级,这样优先级低的进程得不到执行的问题就得到了解决。
进程调度有以下几个评价标准:
1.公平
合理的分配cpu
2.响应时间短
响应时间:从用户输入到产生反应的时间
3.吞吐量大
吞吐量:单位时间完成的任务数量
但是这些目标是矛盾的
若想做到响应时间短,就不得不将公平这个原则破环掉,若想安全,就必然要加上各种检查判断,这样执行的效率就会慢下来
进程间的同步:
如上图所示:有一个打印机进程和一个待打印队列,该队列可以容纳5个文件,有产生文件的生产者(Word,Excel...)负责将产生的文件放入打印队列中,生产者和消费者进程之间存在互相制约的地方,当待打印队列中已经放满了文件,生产者进程就需要等待打印机进程打印文件。
反之若队列中没有文件,则打印机进程需要等待生产者进程产生文件。这就是一个消费者进程和多个生产者进程之间的同步问题:
下面是一个伪代码:
public class Item{......}// 生产文件的类
Item[] buffer = new Item[5];
int in = out = counter = 0;
//生产者 while(true){ while(counter == 5){ ; //啥也不干,继续循环 } buffer[in] = item; in = (in + 1) % 5; // 一个长度只有5的循环队列 counter++; }
//消费者 while(true){ while(counter == 0){ ; //啥也不干,继续循环 } item = buffer[out]; //打印文件 out = (out + 1) % 5; counter--; //文件数减一 }
//以上代码若在单线程下运行是没有问题的,但是在多进程的情况下,共享变量counter会出错!
代码中的counter++;在编译之后会类似的变成上面三条代码,有可能出现上面并发的操作的错误,最后的结果为二,所以我们要加锁
上面的问题可以总结为:
问题的核心:
1.不可控制的调度
2.在机器层面,counter++, counter--并不是原子操作
临界区:
1.访问/修改共享资源(变量,表,文件。。。)
2.当进程进入临界区时,不允许其他进程在临界区执行
解决临界区问题:
1.暴力手段,关闭中断
cpu收到时钟中断后,会检查当前进程的时间片是否用完,用完则切换。
那我们可以在进程p进去临界区之前,通知OS不要做进程切换就可以解决问题了
关闭时钟中断,这样cpu就不会被打断
离开临界区,一定要记住打开中断
但是,把中断操作开放给应用程序是非常危险的
2.用硬件指令来实现锁
我们将机器指令类比成下面的函数,便于理解
4.信号量
信号量S是个整数变量,除了初始化外,有两个操作,wait(), signal() 或者是P/V,或者是down/up
上面的方法虽然解决了共享变量的问题,但是还存在忙等的情况,假如进程A进入到了临界区之后时间片到了,进程B获得了时间片,运行到上面的while循环发现临界区资源被其他进程锁住了,那他就什么事都干不了了,只能就这样等到时间片到期,这是一件浪费资源的表现。
我们将上面的代码修改以下,解决忙等的问题
5.不能忙等
typedef struct{ int value; //信号量 struct process *list; //等待进程的集合 }semaphore;
wait(semaphore *S){ s->value--; if(s->value < 0){ //把当前进程加到s->list中 block();//将当前进程变为阻塞状态 } } signal(semaphore *S ){ s->value++; if(s->value >= 0){ //从s->list取出一个进程p wakup(p); //将进程从阻塞状态变为就绪状态 } }
6.用信号量解决打印问题
学自:“码农翻身”微信公共号Semaphore mutex = 1; //互斥状态 Semaphore empty = 5; //队列为空 Semaphore full = 0; //队列满了 //生产者 While(true){ wait(empty); wait(mutex); //把新产生的文件加入队列 signal(mutex); signal(full); } //消费者 While(true){ wait(full); wait(mutex); //把队列头的文件打印,删除 signal(mutex); signal(empty); }