今天给大家推荐两份大佬们总结的PDF,一份是计算机基础知识,一份是操作系统,反正帅地看完之后,和面试官聊天,都有点飘了,废话不多说,下面就让小编带你去看看哪些操作系统必备基础知识,希望能帮助到大家!
操作系统是计算机体系中必不可少的核心系统软件,其他软件(如编辑程序、汇编程序、编译程序、数据库管理系统等系统软件,以及大量应用软件)是建立在操作系统的基础上,并在操作系统的统一管理和支持下运行。操作系统是用户与计算机之间的桥梁,用户可以通过操作系统提供的功能访问计算机系统中的软硬件资源。操作系统的作用是通过资源管理提高计算机系统的效率,改善人机界面,为用户提供有好的工作环境。有效地组织和管理系统中的各种软硬件资源,合理的组织计算机系统工作流程,控制程序的执行,并且向用户提供一个良好的工作环境和友好的接口。
简单的说,操作系统就是运行在计算机硬件和软件(其他系统软件和应用软件)之间的一个系统软件,它的主要作用就是让计算机能够运行的很好的同时让你觉得也不错。
操作系统分为这么几种:批处理操作系统、分时操作系统、实时操作系统、网络操作系统、分布式操作系统、嵌入式操作系统、微机操作系统(这个我们就比较常见了,比如Linux、Windows、Unix、手机上的基于Unix的安卓系统等等)。
操作系统的功能可分为5大部分:处理机(CPU)管理、文件管理、存储管理、设备管理和作业管理。下面说说处理机管理中的一些基础知识。
三态模型五态模型
在多道程序环境的系统中,存在多个可以一起进行(并发执行)的进程,因此必然会存在进程之间的通信问题。
进程间的通信主要有同步、互斥、调度、死锁、信号量机制等问题
进程间的同步多个进程都是独立进行的,有的时候需要在某些地方协调一下,比如进程A在生产一个原件,进程B要加工这个原件,这时候就需要进程B等待进程A完成后才能开始进行,这就是进程之间的同步。
进程间的互斥这就是指两个进程都想用同一个资源,但是这个资源同时只能被一个进程使用。这就是进程之间的互斥,这些有限的资源叫做临界资源。要使用这些临界资源的程序段就叫做临界区,对临界区的管理原则是:有空即进(资源空闲就用)、无空则等(没有资源就等一会)、有限等待(不能一直等下去)、让权等待(实在进不去就走吧)。
为了解决进程间的同步与互斥的问题,荷兰学者Dijkstra提出了信号量的机制,发展到现在主要有整型信号量、记录型信号量和信号量集机制。在引入了信号量机制后,为了提高通信效率,能够大量传输数据,系统引入了高级通信方式,主要分为共享存储方式(找一块区域,把数据都放在这里)、消息传递模式(提供原语直接操作)和管道通信(在两个进程之间加个管道,有消息都放在那自取)。
死锁是指两个进程互相要求对方已经占用的资源,否则同时进入临界区的时候就会出现问题。就好像有的国家手里有技术但没有劳动力,别的国家有劳动力没有技术,两个国家都不让步,这不就出现问题了嘛。
由于进程是一个比较独立的单元,总是这么切换、创建、销毁的开销太大,所以引入线程这一概念,可以认为进程就是有好几个线程组成的,值得一提的是线程也有就绪、运行、阻塞三种状态,所以进程也被称为“轻量级进程”。
注意:操作系统是掌握计算机的核心知识,一定要好好学啊。
一、概述
1.操作系统基本特征
1.并发
并行需要硬件支持,如多流水线或者多处理器。
操作系统通过引入进程和线程,使得程序能够并发运行。
2.共享
共享是指系统中的资源可以被多个并发进程共同使用。
有两种共享方式:互斥共享和同时共享。
3.虚拟
虚拟技术把一个物理实体转换为多个逻辑实体。
利用多道程序设计技术,让每个用户都觉得有一个计算机专门为他服务。
4.异步
异步指进程不是一次性执行完毕,而是走走停停,以不可知的速度向前推进。
但只要运行环境相同,OS需要保证程序运行的结果也要相同。
2.操作系统基本功能
1.进程管理
进程控制、进程同步、进程通信、死锁处理、处理机调度等。
2.内存管理
内存分配、地址映射、内存保护与共享、虚拟内存等。
3.文件管理
文件存储空间的管理、目录管理、文件读写管理和保护等。
4.设备管理
完成用户的I/O请求,方便用户使用各种设备,并提高设备的利用率。
主要包括缓冲管理、设备分配、设备处理、虛拟设备等。
4.大内核和微内核
1.大内核
大内核是将操作系统功能作为一个紧密结合的整体放到内核。
由于各模块共享信息,因此有很高的性能。
2.微内核
由于操作系统不断复杂,因此将一部分操作系统功能移出内核,从而降低内核的复杂性。移出的部分根据分层的原则划分成若干服务,相互独立。
在微内核结构下,操作系统被划分成小的、定义良好的模块,只有微内核这一个模块运行在内核态,其余模块运行在用户态。
因为需要频繁地在用户态和核心态之间进行切换,所以会有一定的性能损失。
5.中断分类
1.外中断
由CPU执行指令以外的事件引起,如I/O完成中断,表示设备输入/输出处理已经完成,处理器能够发送下一个输入/输出请求。此外还有时钟中断、控制台中断等。
2.异常
由CPU执行指令的内部事件引起,如非法操作码、地址越界、算术溢出等。
6.什么是堆和栈说一下堆栈都存储哪些数据
栈区(stack)—由编译器自动分配释放,存放函数的参数值,局部变量的值等。其操作方式类似于数据结构中的栈。
堆区(heap)—一般由程序员分配释放,若程序员不释放,程序结束时可能由OS回收。
数据结构中这两个完全就不放一块来讲,数据结构中栈和队列才是好基友,我想新手也很容易区分。
我想需要区分的情况肯定不是在数据结构话题下,而大多是在OS关于不同对象的内存分配这块上。
简单讲的话,在C语言中:
inta[N];//goonastackint__a=(int__)malloc(sizeof(int)__N);//goonaheap
7.如何理解分布式锁
分布式锁,是控制分布式系统之间同步访问共享资源的一种方式。在分布式系统中,常常需要协调他们的动作。如果不同的系统或是同一个系统的不同主机之间共享了一个或一组资源,那么访问这些资源的时候,往往需要互斥来防止彼此干扰来保证一致性,在这种情况下,便需要使用到分布式锁。
二、进程管理
1.进程与线程
1.进程
进程是资源分配的基本单位,用来管理资源(例如:内存,文件,网络等资源)
进程控制块(ProcessControlBlock,PCB)描述进程的基本信息和运行状态,所谓的创建进程和撤销进程,都是指对PCB的操作。(PCB是描述进程的数据结构)
下图显示了4个程序创建了4个进程,这4个进程可以并发地执行。
2.线程
线程是独立调度的基本单位。
一个进程中可以有多个线程,它们共享进程资源。
3.区别
(一)拥有资源
进程是资源分配的基本单位,但是线程不拥有资源,线程可以访问隶属进程的资源。
(二)调度
线程是独立调度的基本单位,在同一进程中,线程的切换不会引起进程切换,从一个进程内的线程切换到另一个进程中的线程时,会引起进程切换。
(三)系统开销
由于创建或撤销进程时,系统都要为之分配或回收资源,如内存空间、I/O设备等,所付出的开销远大于创建或撤销线程时的开销。类似地,在进行进程切换时,涉及当前执行进程CPU环境的保存及新调度进程CPU环境的设置,而线程切换时只需保存和设置少量寄存器内容,开销很小。
(四)通信方面
进程间通信(IPC)需要进程同步和互斥手段的辅助,以保证数据的一致性。而线程间可以通过直接读/写同一进程中的数据段(如全局变量)来进行通信。
2.进程状态的切换(生命周期)
就绪状态(ready):等待被调度
运行状态(running)
阻塞状态(waiting):等待资源
应该注意以下内容:
进程只能自己阻塞自己,因为只有进程自身才知道何时需要等待某种事件的发生
3.进程调度算法
不同环境的调度算法目标不同,因此需要针对不同环境来讨论调度算法。
1.批处理系统
1.1先来先服务
先来先服务first-comefirst-serverd(FCFS)
按照请求的顺序进行调度。
1.2短作业优先
短作业优先shortestjobfirst(SJF)
长作业有可能会饿死,处于一直等待短作业执行完毕的状态。因为如果一直有短作业到来,那么长作业永远得不到调度。
2.交互式系统
交互式系统有大量的用户交互操作,在该系统中调度算法的目标是快速地进行响应。
2.2优先级调度
为每个进程分配一个优先级,按优先级进行调度。
2.3多级反馈队列
每个队列优先权也不同,最上面的优先权最高。因此只有上一个队列没有进程在排队,才能调度当前队列上的进程。
3.实时系统
参考资料:
操作系统典型调度算法_C语言中文网
4.进程同步
1.临界区
对临界资源进行访问的那段代码称为临界区。
为了互斥访问临界资源,每个进程在进入临界区之前,需要先进行检查。
//entrysection//criticalsection;//exitsection
2.同步与互斥
同步:多个进程按一定顺序执行;
互斥:多个进程在同一时刻只有一个进程能进入临界区。
3.信号量
信号量(Semaphore)是一个整型变量,可以对其执行down和up操作,也就是常见的P和V操作。
down:如果信号量大于0,执行-1操作;如果信号量等于0,进程睡眠,等待信号量大于0;(阻塞)
up:对信号量执行+1操作,唤醒睡眠的进程让其完成down操作。(唤醒)
down和up操作需要被设计成原语,不可分割,通常的做法是在执行这些操作的时候屏蔽中断。
如果信号量的取值只能为0或者1,那么就成为了互斥量(Mutex),0表示临界区已经加锁,1表示临界区解锁。
typedefintsemaphore;semaphoremutex=1;voidP1(){down(&mutex);//临界区up(&mutex);}voidP2(){down(&mutex);//临界区up(&mutex);}
使用信号量实现生产者-消费者问题
问题描述:使用一个缓冲区来保存物品,只有缓冲区没有满,生产者才可以放入物品;只有缓冲区不为空,消费者才可以拿走物品。
因为缓冲区属于临界资源,因此需要使用一个互斥量mutex来控制对缓冲区的互斥访问。
为了同步生产者和消费者的行为,需要记录缓冲区中物品的数量。数量可以使用信号量来进行统计,这里需要使用两个信号量:empty记录空缓冲区的数量,full记录满缓冲区的数量。其中,empty信号量是在生产者进程中使用,当empty不为0时,生产者才可以放入物品;full信号量是在消费者进程中使用,当full信号量不为0时,消费者才可以取走物品。
注意,不能先对缓冲区进行加锁,再测试信号量。也就是说,不能先执行down(mutex)再执行down(empty)。如果这么做了,那么可能会出现这种情况:生产者对缓冲区加锁后,执行down(empty)操作,发现empty=0,此时生产者睡眠。消费者不能进入临界区,因为生产者对缓冲区加锁了,也就无法执行up(empty)操作,empty永远都为0,那么生产者和消费者就会一直等待下去,造成死锁。
#defineN100typedefintsemaphore;semaphoremutex=1;semaphoreempty=N;semaphorefull=0;voidproducer(){while(TRUE){intitem=produce_item();//生产一个产品//down(&empty)和down(&mutex)不能交换位置,否则造成死锁down(&empty);//记录空缓冲区的数量,这里减少一个产品空间down(&mutex);//互斥锁insert_item(item);up(&mutex);//互斥锁up(&full);//记录满缓冲区的数量,这里增加一个产品}}voidconsumer(){while(TRUE){down(&full);//记录满缓冲区的数量,减少一个产品down(&mutex);//互斥锁intitem=remove_item();up(&mutex);//互斥锁up(&empty);//记录空缓冲区的数量,这里增加一个产品空间consume_item(item);}}
4.管程
管程(英语:Monitors,也称为监视器)是一种程序结构,结构内的多个子程序(对象或模块)形成的多个工作线程互斥访问共享资源。
使用信号量机制实现的生产者消费者问题需要客户端代码做很多控制,而管程把控制的代码独立出来,不仅不容易出错,也使得客户端代码调用更容易。
管程是为了解决信号量在临界区的PV操作上的配对的麻烦,把配对的PV操作集中在一起,生成的一种并发编程方法。其中使用了条件变量这种同步机制。
c语言不支持管程,下面的示例代码使用了类Pascal语言来描述管程。示例代码的管程提供了insert()和remove()方法,客户端代码通过调用这两个方法来解决生产者-消费者问题。
monitorProducerConsumerintegeri;conditionc;procedureinsert();begin//...end;procedureremove();begin//...end;endmonitor;
管程有一个重要特性:在一个时刻只能有一个进程使用管程。进程在无法继续执行的时候不能一直占用管程,否者其它进程永远不能使用管程。
使用管程实现生产者-消费者问题
//管程monitorProducerConsumerconditionfull,empty;integercount:=0;conditionc;procedureinsert(item:integer);beginifcount=Nthenwait(full);insert_item(item);count:=count+1;ifcount=1thensignal(empty);end;functionremove:integer;beginifcount=0thenwait(empty);remove=remove_item;count:=count-1;ifcount=N-1thensignal(full);end;endmonitor;//生产者客户端procedureproducerbeginwhiletruedobeginitem=produce_item;ProducerConsumer.insert(item);endend;//消费者客户端procedureconsumerbeginwhiletruedobeginitem=ProducerConsumer.remove;consume_item(item);endend;
5.经典同步问题
生产者和消费者问题前面已经讨论过了。
1.读者-写者问题
允许多个进程同时对数据进行读操作,但是不允许读和写以及写和写操作同时发生。读者优先策略
Rcount:读操作的进程数量(Rcount=0)
CountMutex:对于Rcount进行加锁(CountMutex=1)
WriteMutex:互斥量对于写操作的加锁(WriteMutex=1)
Rcount=0;semaphoreCountMutex=1;semaphoreWriteMutex=1;voidwriter(){while(true){sem_wait(WriteMutex);//TODOwrite();sem_post(WriteMutex);}}//读者优先策略voidreader(){while(true){sem_wait(CountMutex);if(Rcount==0)sem_wait(WriteMutex);Rcount++;sem_post(CountMutex);//TODOread();sem_wait(CountMutex);Rcount--;if(Rcount==0)sem_post(WriteMutex);sem_post(CountMutex);}}
2.哲学家进餐问题
五个哲学家围着一张圆桌,每个哲学家面前放着食物。哲学家的生活有两种交替活动:吃饭以及思考。当一个哲学家吃饭时,需要先拿起自己左右两边的两根筷子,并且一次只能拿起一根筷子。
____方案一:____下面是一种错误的解法,考虑到如果所有哲学家同时拿起左手边的筷子,那么就无法拿起右手边的筷子,造成死锁。
#defineN5//哲学家个数voidphilosopher(inti)//哲学家编号:0-4{while(TRUE){think();//哲学家在思考take_fork(i);//去拿左边的叉子take_fork((i+1)%N);//去拿右边的叉子eat();//吃面条中….put_fork(i);//放下左边的叉子put_fork((i+1)%N);//放下右边的叉子}}
方案二:对拿叉子的过程进行了改进,但仍不正确
#defineN5//哲学家个数while(1)//去拿两把叉子{take_fork(i);//去拿左边的叉子if(fork((i+1)%N)){//右边叉子还在吗take_fork((i+1)%N);//去拿右边的叉子break;//两把叉子均到手}else{//右边叉子已不在put_fork(i);//放下左边的叉子wait_some_time();//等待一会儿}}
方案四:互斥访问。正确,但每次只允许一人进餐
semaphoremutex//互斥信号量,初值1voidphilosopher(inti)//哲学家编号i:0-4{while(TRUE){think();//哲学家在思考P(mutex);//进入临界区take_fork(i);//去拿左边的叉子take_fork((i+1)%N);//去拿右边的叉子eat();//吃面条中….put_fork(i);//放下左边的叉子put_fork((i+1)%N);//放下右边的叉子V(mutex);//退出临界区}}
正确方案如下:
为了防止死锁的发生,可以设置两个条件(临界资源):
必须同时拿起左右两根筷子;
只有在两个邻居都没有进餐的情况下才允许进餐。
//1.必须由一个数据结构,来描述每个哲学家当前的状态#defineN5#defineLEFTi//左邻居#defineRIGHT(i+1)%N//右邻居#defineTHINKING0#defineHUNGRY1#defineEATING2typedefintsemaphore;intstate[N];//跟踪每个哲学家的状态//2.该状态是一个临界资源,对它的访问应该互斥地进行semaphoremutex=1;//临界区的互斥//3.一个哲学家吃饱后,可能要唤醒邻居,存在着同步关系semaphores[N];//每个哲学家一个信号量voidphilosopher(inti){while(TRUE){think();take_two(i);eat();put_tow(i);}}voidtake_two(inti){P(&mutex);//进入临界区state[i]=HUNGRY;//我饿了test(i);//试图拿两把叉子V(&mutex);//退出临界区P(&s[i]);//没有叉子便阻塞}voidput_tow(i){P(&mutex);state[i]=THINKING;test(LEFT);test(RIGHT);V(&mutex);}voidtest(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]用于写。
#includeintpipe(intfd[2]);
它具有以下限制:
只支持半双工通信(单向传输);
只能在父子进程中使用。
2.命名管道
也称为命名管道,去除了管道只能在父子进程中使用的限制。
#includeintmkfifo(constchar__path,mode_tmode);intmkfifoat(intfd,constchar__path,mode_tmode);
FIFO常用于客户-服务器应用程序中,FIFO用作汇聚点,在客户进程和服务器进程之间传递数据。
3.消息队列
间接(内核)
相比于FIFO,消息队列具有以下优点:
消息队列可以独立于读写进程存在,从而避免了FIFO中同步管道的打开和关闭时可能产生的困难;
避免了FIFO的同步阻塞问题,不需要进程自己提供同步方法;
读进程可以根据消息类型有选择地接收消息,而不像FIFO那样只能默认地接收。
4.信号量
它是一个计数器,用于为多个进程提供对共享数据对象的访问。
5.共享内存
允许多个进程共享一个给定的存储区。因为数据不需要在进程之间复制,所以这是最快的一种IPC。
需要使用信号量用来同步对共享存储的访问。
多个进程可以将同一个文件映射到它们的地址空间从而实现共享内存。另外XSI共享内存不是使用文件,而是使用使用内存的匿名段。
6.套接字
与其它通信机制不同的是,它可用于不同机器间的进程通信。
7.线程间通信和进程间通信
线程间通信
synchronized同步
这种方式,本质上就是“共享内存”式的通信。多个线程需要访问同一个共享变量,谁拿到了锁(获得了访问权限),谁就可以执行。
while轮询的方式
在这种方式下,ThreadA不断地改变条件,ThreadB不停地通过while语句检测这个条件(list.size()==5)是否成立,从而实现了线程间的通信。但是这种方式会浪费CPU资源。
之所以说它浪费资源,是因为JVM调度器将CPU交给ThreadB执行时,它没做啥“有用”的工作,只是在不断地测试某个条件是否成立。
wait/notify机制
当条件未满足时,ThreadA调用wait()放弃CPU,并进入阻塞状态。(不像while轮询那样占用CPU)
当条件满足时,ThreadB调用notify()通知线程A,所谓通知线程A,就是唤醒线程A,并让它进入可运行状态。
管道通信
java.io.PipedInputStream和java.io.PipedOutputStream进行通信
进程间通信
管道(Pipe):管道可用于具有亲缘关系进程间的通信,允许一个进程和另一个与它有共同祖先的进程之间进行通信。
命名管道(namedpipe):命名管道克服了管道没有名字的限制,因此,除具有管道所具有的功能外,它还允许无亲缘关系进程间的通信。命名管道在文件系统中有对应的文件名。命名管道通过命令mkfifo或系统调用mkfifo来创建。
信号(Signal):信号是比较复杂的通信方式,用于通知接受进程有某种事件发生,除了用于进程间通信外,进程还可以发送信号给进程本身;Linux除了支持Unix早期信号语义函数sigal外,还支持语义符合Posix.1标准的信号函数sigaction(实际上,该函数是基于BSD的,BSD为了实现可靠信号机制,又能够统一对外接口,用sigaction函数重新实现了signal函数)。
消息(Message)队列:消息队列是消息的链接表,包括Posix消息队列systemV消息队列。有足够权限的进程可以向队列中添加消息,被赋予读权限的进程则可以读走队列中的消息。消息队列克服了信号承载信息量少,管道只能承载无格式字节流以及缓冲区大小受限等缺
共享内存:使得多个进程可以访问同一块内存空间,是最快的可用IPC形式。是针对其他通信机制运行效率较低而设计的。往往与其它通信机制,如信号量结合使用,来达到进程间的同步及互斥。
内存映射(mappedmemory):内存映射允许任何多个进程间通信,每一个使用该机制的进程通过把一个共享的文件映射到自己的进程地址空间来实现它。
信号量(semaphore):主要作为进程间以及同一进程不同线程之间的同步手段。
套接口(Socket):更为一般的进程间通信机制,可用于不同机器之间的进程间通信。起初是由Unix系统的BSD分支开发出来的,但现在一般可以移植到其它类Unix系统上:linux和SystemV的变种都支持套接字。
8.进程操作
Linux的进程结构可由三部分组成:
代码段(程序)
数据段(数据)
堆栈段(控制块PCB)
进程控制块是进程存在的惟一标识,系统通过PCB的存在而感知进程的存在。系统通过PCB对进程进行管理和调度。PCB包括创建进程、执行进程、退出进程以及改变进程的优先级等。
一般程序转换为进程分以下几个步骤:
内核将程序读入内存,为程序分配内存空间
内核为该进程分配进程标识符PID和其他所需资源
内核为进程保存PID及相应的状态信息,把进程放到运行队列中等待执行,程序转化为进程后可以被操作系统的调度程序调度执行了
在UNIX里,除了进程0(即PID=0的交换进程,SwapperProcess)以外的所有进程都是由其他进程使用系统调用fork创建的,这里调用fork创建新进程的进程即为父进程,而相对应的为其创建出的进程则为子进程,因而除了进程0以外的进程都只有一个父进程,但一个进程可以有多个子进程。操作系统内核以进程标识符(ProcessIdentifier,即PID)来识别进程。进程0是系统引导时创建的一个特殊进程,在其调用fork创建出一个子进程(即PID=1的进程1,又称init)后,进程0就转为交换进程(有时也被称为空闲进程),而进程1(init进程)就是系统里其他所有进程的祖先。
进程0:Linux引导中创建的第一个进程,完成加载系统后,演变为进程调度、交换及存储管理进程。进程1:init进程,由0进程创建,完成系统的初始化.是系统中所有其它用户进程的祖先进程。
Linux中1号进程是由0号进程来创建的,因此必须要知道的是如何创建0号进程,由于在创建进程时,程序一直运行在内核态,而进程运行在用户态,因此创建0号进程涉及到特权级的变化,即从特权级0变到特权级3,Linux是通过模拟中断返回来实现特权级的变化以及创建0号进程,通过将0号进程的代码段选择子以及程序计数器EIP直接压入内核态堆栈,然后利用iret汇编指令中断返回跳转到0号进程运行。
创建一个进程
进程是系统中基本的执行单位。Linux系统允许任何一个用户进程创建一个子进程,创建成功后,子进程存在于系统之中,并且独立于父进程。该子进程可以接受系统调度,可以得到分配的系统资源。系统也可以检测到子进程的存在,并且赋予它与父进程同样的权利。
Linux系统下使用fork()函数创建一个子进程,其函数原型如下:
#includepid_tfork(void);
在讨论fork()函数之前,有必要先明确父进程和子进程两个概念。除了0号进程(该进程是系统自举时由系统创建的)以外,Linux系统中的任何一个进程都是由其他进程创建的。创建新进程的进程,即调用fork()函数的进程就是父进程,而新创建的进程就是子进程。
fork()函数不需要参数,返回值是一个进程标识符(PID)。对于返回值,有以下3种情况:
对于父进程,fork()函数返回新创建的子进程的ID。
对于子进程,fork()函数返回0。由于系统的0号进程是内核进程,所以子进程的进程标识符不会是0,由此可以用来区别父进程和子进程。
如果创建出错,则fork()函数返回-1。
fork()函数会创建一个新的进程,并从内核中为此进程分配一个新的可用的进程标识符(PID),之后,为这个新进程分配进程空间,并将父进程的进程空间中的内容复制到子进程的进程空间中,包括父进程的数据段和堆栈段,并且和父进程共享代码段(写时复制)。这时候,系统中又多了一个进程,这个进程和父进程一模一样,两个进程都要接受系统的调度。
注意:由于在复制时复制了父进程的堆栈段,所以两个进程都停留在了fork()函数中,等待返回。因此,fork()函数会返回两次,一次是在父进程中返回,另一次是在子进程中返回,这两次的返回值是不一样的。
下面给出的示例程序用来创建一个子进程,该程序在父进程和子进程中分别输出不同的内容。
#include#include#includeintmain(void){pid_tpid;//保存进程IDpid=fork();//创建一个新进程if(pid<0){//fork出错printf("failtofork\n");exit(1);}elseif(pid==0){//子进程//打印子进程的进程IDprintf("thisischild,pidis:%u\n",getpid());}else{//打印父进程和其子进程的进程IDprintf("thisisparent,pidis:%u,child-pidis:%u\n",getpid(),pid);}return0;}
程序运行结果如下:
$./forkParent,PID:2598,Sub-processPID:2599Sub-process,PID:2599,PPID:2598
由于创建的新进程和父进程在系统看来是地位平等的两个进程,所以运行机会也是一样的,我们不能够对其执行先后顺序进行假设,先执行哪一个进程取决于系统的调度算法。如果想要指定运行的顺序,则需要执行额外的操作。正因为如此,程序在运行时并不能保证输出顺序和上面所描述的一致。
getpid()是获得当前进程的pid,而getppid()则是获得父进程的id。
父子进程的共享资源
子进程完全复制了父进程的地址空间的内容,包括堆栈段和数据段的内容。子进程并没有复制代码段,而是和父进程共用代码段。这样做是存在其合理依据的,因为子进程可能执行不同的流程,那么就会改变数据段和堆栈段,因此需要分开存储父子进程各自的数据段和堆栈段。但是代码段是只读的,不存在被修改的问题,因此这一个段可以让父子进程共享,以节省存储空间,如下图所示。
下面给出一个示例来说明这个问题。该程序定义了一个全局变量global、一个局部变量stack和一个指针heap。该指针用来指向一块动态分配的内存区域。之后,该程序创建一个子进程,在子进程中修改global、stack和动态分配的内存中变量的值。然后在父子进程中分别打印出这些变量的值。由于父子进程的运行顺序是不确定的,因此我们先让父进程额外休眠2秒,以保证子进程先运行。
#include#include#include//全局变量,在数据段中intglobal;intmain(){pid_tpid;intstack=1;//局部变量,在栈中int__heap;heap=(int__)malloc(sizeof(int));//动态分配的内存,在堆中__heap=2;pid=fork();//创建一个子进程if(pid<0){//创建子进程失败printf("failtofork\n");exit(1);}elseif(pid==0){//子进程,改变各变量的值global++;//修改栈、堆和数据段stack++;(__heap)++;printf("thechild,data:%d,stack:%d,heap:%d\n",global,stack,__heap);exit(0);//子进程运行结束}//父进程休眠2秒钟,保证子进程先运行sleep(2);//输出结果printf("theparent,data:%d,stack:%d,heap:%d\n",global,stack,__heap);return0;}
程序运行效果如下:
$./forkInsub-process,global:2,stack:2,heap:3Inparent-process,global:1,stack:1,heap:2
由于父进程休眠了2秒钟,子进程先于父进程运行,因此会先在子进程中修改数据段和堆栈段中的内容。因此不难看出,子进程对这些数据段和堆栈段中内容的修改并不会影响到父进程的进程环境。
fork()函数的出错情况
有两种情况可能会导致fork()函数出错:
系统中已经有太多的进程存在了
调用fork()函数的用户进程太多了
一般情况下,系统都会对一个用户所创建的进程数加以限制。如果操作系统不对其加限制,那么恶意用户可以利用这一缺陷攻击系统。下面是一个利用进程的特性编写的一个病毒程序,该程序是一个死循环,在循环中不断调用fork()函数来创建子进程,直到系统中不能容纳如此多的进程而崩溃为止。下图展示了这种情况:
#includeintmain(){while(1)fork();/__不断地创建子进程,使系统中进程溢满__/return0;}
创建共享空间的子进程
进程在创建一个新的子进程之后,子进程的地址空间完全和父进程分开。父子进程是两个独立的进程,接受系统调度和分配系统资源的机会均等,因此父进程和子进程更像是一对兄弟。如果父子进程共用父进程的地址空间,则子进程就不是独立于父进程的。
Linux环境下提供了一个与fork()函数类似的函数,也可以用来创建一个子进程,只不过新进程与父进程共用父进程的地址空间,其函数原型如下:
#includepid_tvfork(void);
vfork()和fork()函数的区别有以下两点:
vfork()函数产生的子进程和父进程完全共享地址空间,包括代码段、数据段和堆栈段,子进程对这些共享资源所做的修改,可以影响到父进程。由此可知,vfork()函数与其说是产生了一个进程,还不如说是产生了一个线程。
vfork()函数产生的子进程一定比父进程先运行,也就是说父进程调用了vfork()函数后会等待子进程运行后再运行。
下面的示例程序用来验证以上两点。在子进程中,我们先让其休眠2秒以释放CPU控制权,在前面的fork()示例代码中我们已经知道这样会导致其他线程先运行,也就是说如果休眠后父进程先运行的话,则第1点则为假;否则为真。第2点为真,则会先执行子进程,那么全局变量便会被修改,如果第1点为真,那么后执行的父进程也会输出与子进程相同的内容。代码如下:
//@filevfork.c//@briefvfork()usage#include#include#includeintglobal=1;intmain(void){pid_tpid;intstack=1;int__heap;heap=(int___________malloc(sizeof(int));___________eap=1;pid=vfork();if(pid<0){perror("failtovfork");exit(-1);}elseif(pid==0){//sub-process,changevaluessleep(2);//releasecpucontrollingglobal=999;stack=888;___________eap=777;//printallvaluesprintf("Insub-process,global:%d,stack:%d,heap:%d\n",global,stack,___________eap);exit(0);}else{//parent-processprintf("Inparent-process,global:%d,stack:%d,heap:%d\n",global,stack,___________eap);}return0;}
$./vforkInsub-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#includeintf1(void){vfork();return0;}intf2(inta,intb){returna+b;}intmain(void){intc;f1();c=f2(1,2);printf("%d\n",c);return0;}
$./vfork3Segmentationfault(coredumped)
通过上面的程序运行结果可以看出,一个进程运行正常,打印出了预期结果,而另一个进程似乎出了问题,发生了段错误。出现这种情况的原因可以用下图来分析一下:
左边这张图说明调用vfork()之后产生了一个子进程,并且和父进程共享堆栈段,两个进程都要从f1()函数返回。由于子进程先于父进程运行,所以子进程先从f1()函数中返回,并且调用f2()函数,其栈帧覆盖了原来f1()函数的栈帧。当子进程运行结束,父进程开始运行时,就出现了右图的情景,父进程需要从f1()函数返回,但是f1()函数的栈帧已经被f2()函数的所替代,因此就会出现父进程返回出错,发生段错误的情况。
由此可知,使用vfork()函数之后,子进程对父进程的影响是巨大的,其同步措施势在必行。
退出进程
当一个进程需要退出时,需要调用退出函数。Linux环境下使用exit()函数退出进程,其函数原型如下:
#includevoidexit(intstatus);
exit()函数的参数表示进程的退出状态,这个状态的值是一个整型,保存在全局变量$中,在shell中可以通过echo$来检查退出状态值。
注意:这个退出函数会深入内核注销掉进程的内核数据结构,并且释放掉进程的资源。
exit函数与内核函数的关系
exit函数是一个标准的库函数,其内部封装了Linux系统调用exit()函数。两者的主要区别在于exit()函数会在用户空间做一些善后工作,例如清理用户的I/O缓冲区,将其内容写入磁盘文件等,之后才进入内核释放用户进程的地址空间;而exit()函数直接进入内核释放用户进程的地址空间,所有用户空间的缓冲区内容都将丢失。
设置进程所有者
每个进程都有两个用户ID,实际用户ID和有效用户ID。通常这两个ID的值是相等的,其取值为进程所有者的用户ID。但是,在有些场合需要改变进程的有效用户ID。Linux环境下使用setuid()函数改变一个进程的实际用户ID和有效用户ID,其函数原型如下:
#includeintsetuid(uid_tuid);
setuid()函数的参数表示改变后的新用户ID,如果成功修改当前进程的实际用户ID和有效用户ID,函数返回值为0;如果失败,则返回-1。只有两种用户可以修改进程的实际用户ID和有效用户ID:
根用户:根用户可以将进程的实际用户ID和有效用户ID更换。
其他用户:其该用户的用户ID等于进程的实际用户ID或者保存的用户ID。
也就是说,用户可以将自己的有效用户ID改回去。这种情况多出现于下面的情况:一个进程需要具有某种权限,所以将其有效用户ID设置为具有这种权限的用户ID,当进程不需要这种权限时,进程还原自己之前的有效用户ID,使自己的权限复原。下面给出一个修改的示例:
#include#include#includeintmain(void){FILE__fp;uid_tuid;uid_teuid;uid=getuid();/__得到进程的实际用户ID__/euid=geteuid();/__得到进程的有效用户ID__/printf("theuidis:%d\n",uid);printf("theeuidis:%d\n",euid);if(setuid(8000)==-1){/__改变进程的实际用户ID和有效用户ID__/perror("failtosetuid");exit(1);}printf("afterchanging\n");uid=getuid();/__再次得到进程的实际用户ID__/euid=geteuid();/__再次得到进程的有效用户ID__/printf("theuidis:%d\n",uid);printf("theeuidis:%d\n",euid);return0;}
$./setuidtheuidis:0theeuidis:0afterchangingtheuidis:8000theeuidis:8000
本节参考:
《后台开发:核心技术与应用实践》
《Linux+C程序设计大全》十一章:进程控制
9.孤儿进程和僵尸进程
基本概念
我们知道在Unix/Linux中,正常情况下,子进程是通过父进程创建的,子进程在创建新的进程。子进程的结束和父进程的运行是一个异步过程,即父进程永远无法预测子进程到底什么时候结束。当一个进程完成它的工作终止之后,它的父进程需要调用wait()或者waitpid()系统调用取得子进程的终止状态。
孤儿进程:一个父进程退出,而它的一个或多个子进程还在运行,那么那些子进程将成为孤儿进程。孤儿进程将被init进程(进程号为1)所收养,并由init进程对它们完成状态收集工作____。____
僵尸进程:一个进程使用fork创建子进程,如果子进程退出,而父进程并没有调用wait或waitpid获取子进程的状态信息,那么子进程的进程描述符仍然保存在系统中。这种进程称之为僵尸进程。
问题及危害
孤儿进程是没有父进程的进程,孤儿进程这个重任就落到了init进程身上,init进程就好像是一个民政局,专门负责处理孤儿进程的善后工作。每当出现一个孤儿进程的时候,内核就把孤儿进程的父进程设置为init,而init进程会循环地wait()它的已经退出的子进程。这样,当一个孤儿进程凄凉地结束了其生命周期的时候,init进程就会代表党和政府出面处理它的一切善后工作。因此孤儿进程并不会有什么危害。
任何一个子进程(init除外)在exit()之后,并非马上就消失掉,而是留下一个称为僵尸进程(Zombie)的数据结构,等待父进程处理。这是每个子进程在结束时都要经过的阶段。如果子进程在exit()之后,父进程没有来得及处理,这时用ps命令就能看到子进程的状态是Z。如果父进程能及时处理,可能用ps命令就来不及看到子进程的僵尸状态,但这并不等于子进程不经过僵尸状态。如果父进程在子进程结束之前退出,则子进程将由init接管。init将会以父进程的身份对僵尸状态的子进程进行处理。
僵尸进程危害场景:
测试代码
孤儿进程测试程序如下所示:
#include#include#include#includeintmain(){pid_tpid;//创建一个进程pid=fork();//创建失败if(pid<0){perror("forkerror:");exit(1);}//子进程if(pid==0){printf("Iamthechildprocess.\n");//输出进程ID和父进程IDprintf("pid:%d\tppid:%d\n",getpid(),getppid());printf("Iwillsleepfiveseconds.\n");//睡眠5s,保证父进程先退出sleep(5);printf("pid:%d\tppid:%d\n",getpid(),getppid());printf("childprocessisexited.\n");}//父进程else{printf("Iamfatherprocess.\n");//父进程睡眠1s,保证子进程输出进程idsleep(1);printf("fatherprocessisexited.");}return0;}
僵尸进程测试程序如下所示:
#include#include#include#includeintmain(){pid_tpid;pid=fork();if(pid<0){perror("forkerror:");exit(1);}elseif(pid==0){printf("Iamchildprocess.Iamexiting.\n");exit(0);}printf("Iamfatherprocess.Iwillsleeptwoseconds\n");//等待子进程先退出sleep(2);//输出进程信息system("ps-opid,ppid,state,tty,command");printf("fatherprocessisexiting.\n");return0;}
测试结果如下所示:
僵尸进程解决办法
通过信号机制
子进程退出时向父进程发送SIGCHILD信号,父进程处理SIGCHILD信号。在信号处理函数中调用wait进行处理僵尸进程
fork两次
将子进程成为孤儿进程,从而其的父进程变为init进程,通过init进程可以处理僵尸进程
10.守护进程
守护进程一般在系统启动时开始运行,除非强行终止,否则直到系统关机都保持运行。守护进程经常以超级用户(root)权限运行,因为它们要使用特殊的端口(1-1024)或访问某些特殊的资源。
一个守护进程的父进程是init进程,因为它真正的父进程在fork出子进程后就先于子进程exit退出了,所以它是一个由init继承的孤儿进程。守护进程是非交互式程序,没有控制终端,所以任何输出,无论是向标准输出设备stdout还是标准出错设备stderr的输出都需要特殊处理。
守护进程的名称通常以d结尾,比如sshd、xinetd、crond等
编写守护进程的一般步骤步骤:
在父进程中执行fork并exit推出;
在子进程中调用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.处理死锁的策略
鸵鸟策略
把头埋在沙子里,假装根本没发生问题。
因为解决死锁问题的代价很高,因此鸵鸟策略这种不采取任务措施的方案会获得更高的性能。当发生死锁时不会对用户造成多大影响,或发生死锁的概率很低,可以采用鸵鸟策略。
大多数操作系统,包括Unix,Linux和Windows,处理死锁问题的办法仅仅是忽略它。
检测死锁并且恢复。
仔细地对资源进行动态分配,以避免死锁。
通过破除死锁四个必要条件之一,来防止死锁产生。
2.死锁检测与死锁恢复
不试图阻止死锁,而是当检测到死锁发生时,采取措施进行恢复。
(一)每种类型一个资源的死锁检测
上图为资源分配图,其中方框表示资源,圆圈表示进程。资源指向进程表示该资源已经分配给该进程,进程指向资源表示进程请求获取该资源。
图a可以抽取出环,__b,它满足了环路等待条件,因此会发生死锁。
每种类型一个资源的死锁检测算法是通过检测有向图是否存在环来实现,从一个节点出发进行深度优先搜索,对访问过的节点进行标记,如果访问了已经标记的节点,就表示有向图存在环,也就是检测到死锁的发生。
(二)每种类型多个资源的死锁检测
上图中,有三个进程四个资源,每个数据代表的含义如下:
E向量:资源总量
A向量:资源剩余量
C矩阵:每个进程所拥有的资源数量,每一行都代表一个进程拥有资源的数量
R矩阵:每个进程请求的资源数量
进程P1和P2所请求的资源都得不到满足,只有进程P3可以,让P3执行,之后释放P3拥有的资源,此时A=(2220)。P2可以执行,执行后释放P2拥有的资源,A=(4221)。P1也可以执行。所有进程都可以顺利执行,没有死锁。
算法总结如下:
每个进程最开始时都不被标记,执行过程有可能被标记。当算法结束时,任何没有被标记的进程都是死锁进程。
寻找一个没有标记的进程Pi,它所请求的资源小于等于A。
如果找到了这样一个进程,那么将C矩阵的第i行向量加到A中,标记该进程,并转回1。
如果没有这样一个进程,算法终止。
(三)死锁恢复
利用抢占恢复
利用回滚恢复
通过杀死进程恢复
3.死锁预防
在程序运行之前预防发生死锁,确保系统永远不会进入死锁状态。
(一)破坏互斥条件
例如假脱机打印机技术允许若干个进程同时输出,唯一真正请求物理打印机的进程是打印机守护进程。(把互斥地封装成可以同时访问的,例如:打印机的缓存)
(二)破坏占有和等待条件
一种实现方式是规定所有进程在开始执行前请求所需要的全部资源。
但是,这种策略也有如下缺点:
在许多情况下,一个进程在执行之前不可能知道它所需要的全部资源。这是由于进程在执行时是动态的,不可预测的;
资源利用率低。无论所分资源何时用到,一个进程只有在占有所需的全部资源后才能执行。即使有些资源最后才被该进程用到一次,但该进程在生存期间却一直占有它们,造成长期占着不用的状况。这显然是一种极大的资源浪费;
降低了进程的并发性。因为资源有限,又加上存在浪费,能分配到所需全部资源的进程个数就必然少了。
(三)破坏不可抢占条件
允许进程强行从占有者那里夺取某些资源。就是说,当一个进程已占有了某些资源,它又申请新的资源,但不能立即被满足时,它必须释放所占有的全部资源,以后再重新申请。它所释放的资源可以分配给其它进程。这就相当于该进程占有的资源被隐蔽地强占了。这种预防死锁的方法实现起来困难,会降低系统性能。
(四)破坏循环等待
实行资源有序分配策略。采用这种策略,即把资源事先分类编号,按号分配,使进程在申请,占用资源时不会形成环路。所有进程对资源的请求必须严格按资源序号递增的顺序提出。进程占用了小号资源,才能申请大号资源,就不会产生环路,从而预防了死锁。这种策略与前面的策略相比,资源的利用率和系统吞吐量都有很大提高,但是也存在以下缺点:
限制了进程对资源的请求,同时给系统中所有资源合理编号也是件困难事,并增加了系统开销;
4.死锁避免
在程序运行时避免发生死锁,在使用前进行判断,只允许不会出现死锁的进程请求资源。
(一)安全状态
图a的第二列Has表示已拥有的资源数,第三列Max表示总共需要的资源数,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):管理着地址空间和物理内存的转换。
页表(Pagetable):页(地址空间)和页框(物理内存空间)的映射表。例如下图中,页表的第0个表项为010,表示第0个页映射到第2个页框。页表项的最后一位用来标记页是否在内存中。
下图的页表存放着16个页,这16个页需要用4个比特位来进行索引定位。因此对于虚拟地址(0010000000000100),前4位是用来存储页面号,而后12位存储在页中的偏移量。
(0010000000000100)根据前4位得到页号为2,读取表项内容为(1101),它的前3为为页框号,最后1位表示该页在内存中。最后映射得到物理内存地址为(110000000000100)。
3.页面置换算法
在程序运行过程中,如果要访问的页面不在内存中,就发生缺页中断从而将该页调入内存中。此时如果内存已无空闲空间,系统必须从内存中调出一个页面到磁盘对换区中来腾出空间。
页面置换算法和缓存淘汰策略类似,可以将内存看成磁盘的缓存。在缓存系统中,缓存的大小有限,当有新的缓存到达时,需要淘汰一部分已经存在的缓存,这样才有空间存放新的缓存数据。
页面置换算法的主要目标是使页面置换频率最低(也可以说缺页率最低)。
1.最佳
Optimal
举例:一个系统为某进程分配了三个物理块,并有如下页面引用序列:
2.最近最久未使用
LRU,LeastRecentlyUsed
虽然无法知道将来要使用的页面情况,但是可以知道过去使用页面的情况。LRU将最近最久未使用的页面换出。
为了实现LRU,需要在内存中维护一个所有页面的链表。当一个页面被访问时,将这个页面移到链表表头。这样就能保证链表表尾的页面时最近最久未访问的。
因为每次访问都需要更新链表,因此这种方式实现的LRU代价很高。
3.最近未使用
NRU,NotRecentlyUsed
每个页面都有两个状态位: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,FirstInFirstOut
选择换出的页面是最先进入的页面。
该算法会将那些经常被访问的页面也被换出,从而使缺页率升高。
5.第二次机会算法
FIFO算法可能会把经常使用的页面置换出去,为了避免这一问题,对该算法做一个简单的修改:
6.时钟
Clock
第二次机会算法需要在链表中移动页面,降低了效率。时钟算法使用环形链表将页面链接起来,再使用一个指针指向最老的页面。
4.分段
虚拟内存采用的是分页技术,也就是将地址空间划分成固定大小的页,每一页再与内存进行映射。
下图为一个编译器在编译过程中建立的多个表,有4个表是动态增长的,如果使用分页系统的一维地址空间,动态增长的特点会导致覆盖问题的出现。
分段的做法是把每个表分成段,一个段构成一个独立的地址空间。每个段的长度可以不同,并且可以动态增长。
5.段页式
程序的地址空间划分成多个拥有独立地址空间的段,每个段上的地址空间划分成大小相同的页。这样既拥有分段系统的共享和保护,又拥有分页系统的虚拟内存功能。
6.分页与分段的比较
对程序员的透明性:分页透明,但是分段需要程序员显示划分每个段。
地址空间的维度:分页是一维地址空间,分段是二维的。
大小是否可以改变:页的大小不可变,段的大小可以动态改变。
出现的原因:分页主要用于实现虚拟内存,从而获得更大的地址空间;分段主要是为了使程序和数据可以被划分为逻辑上独立的地址空间并且有助于共享和保护。
五、设备管理
1.磁盘结构
盘面(Platter):一个磁盘有多个盘面;
磁道(Track):盘面上的圆形带状区域,一个盘面可以有多个磁道;
扇区(TrackSector):磁道上的一个弧段,一个磁道可以有多个扇区,它是最小的物理储存单位,目前主要有512bytes与4K两种大小;
磁头(Head):与盘面非常接近,能够将盘面上的磁场转换为电信号(读),或者将电信号转换为盘面的磁场(写);
制动手臂(Actuatorarm):用于在磁道之间移动磁头;
主轴(Spindle):使整个盘面转动。
2.磁盘调度算法
1.先来先服务
FCFS,FirstComeFirstServed
按照磁盘请求的顺序进行调度
公平对待所有进程
在有很多进程的情况下,接近随机调度的性能
SSTF,ShortestSeekTimeFirst
优先调度与当前磁头所在磁道距离最近的磁道。
3.电梯算法
SCAN
电梯总是保持一个方向运行,直到该方向没有请求为止,然后改变运行方向。
电梯算法(扫描算法)和电梯的运行过程类似,总是按一个方向来进行磁盘调度,直到该方向上没有未完成的磁盘请求,然后改变方向。
因为考虑了移动方向,因此所有的磁盘请求都会被满足,解决了SSTF的饥饿问题。
六、链接
1.编译系统
以下是一个hello.c程序:
#includeintmain(){printf("hello,world\n");return0;}
在Unix系统上,由编译器把源文件转换为目标文件。
gcc-ohellohello.c
这个过程大致如下:
1.预处理阶段(Preprocessingphase)
预处理(cpp)根据以字符#开头的命令,修改原始的C程序,生成扩展名为.i的文件。
$gcc-Ehello.c-ohello.i
2.编译阶段(Compilationphase)
编译器(cc1)将文本文件hello.i翻译成文本文件hello.s,它包含一个汇编语言程序。
$gcc-Shello.i-ohello.s
3.汇编阶段(Assemblyphase)
编译器(as)将hello.s翻译成机器语言指令,把这些指令打包成一种叫做可重定位目标程序(relocatableobjectprogram)的格式,并将结果保存在目标文件hello.o中。
$ashello.s-ohello.o
4.链接阶段(Linkingphase)
printf函数是标准C库中的一个函数,在printf.o这个单独预编译好的目标文件中。连接器(ld)将printf.o和hello.o合并,结果得到hello可执行目标文件。
$gcchello.o-ohello
2.静态链接
静态连接器以一组可重定向目标文件为输入,生成一个完全链接的可执行目标文件作为输出。链接器主要完成以下两个任务:
符号解析:每个符号对应于一个函数、一个全局变量或一个静态变量,符号解析的目的是将每个符号引用与一个符号定义关联起来。
重定位:链接器通过把每个符号定义与一个内存位置关联起来,然后修改所有对这些符号的引用,使得它们指向这个内存位置。
3.目标文件
可执行目标文件:可以直接在内存中执行;
可重定向目标文件:可与其它可重定向目标文件在链接阶段合并,创建一个可执行目标文件;
共享目标文件:这是一种特殊的可重定向目标文件,可以在运行时被动态加载进内存并链接;
4.动态链接
静态库有以下两个问题:
当静态库更新时那么整个程序都要重新进行链接;
对于printf这种标准函数库,如果每个程序都要有代码,这会极大浪费资源。
共享库是为了解决静态库的这两个问题而设计的,在Linux系统中通常用.so后缀来表示,Windows系统上它们被称为DLL。它具有以下特点:
在给定的文件系统中一个库只有一个文件,所有引用该库的可执行目标文件都共享这个文件,它不会被复制到引用它的可执行文件中;
在内存中,一个共享库的.text节(已编译程序的机器代码)的一个副本可以被不同的正在运行的进程共享。