Linux操作系统之进程

目录


进程相关概念

进程是操作系统中最核心的概念,它是对正在运行的程序的一个抽象。操作系统中其他的所有的内容都是围绕进程的概念来展开的,所以透彻的理解进程概念是十分重要的。

进程使得即使可以使用的CPU只有一个,系统也具有(伪)并发的能力,它们将一个CPU变成多个虚幻的CPU。

我们当然知道,我们使用的计算机系统是可以同时执行多个任务的,例如我们可以在敲代码的时候去听歌,如果没有某种并发的能力的话,我们就需要把一首歌播放完成才能去敲代码,这对用户显然是不能接收的。因此操作系统利用CPU效率极高的特性,在多道程序设计系统中,CPU由一个进程快速的切换到另外一个进程,每一个进程只占有几毫秒的时间,也就是说,CPU在同一时刻只能运行一个进程,但是切换速度极快,导致用户肉眼不可见,于是造成了一个伪并发的情形。

当然,我们本篇文章的主角 -- 进程,就是造成这种伪并发的一个不可或缺的系统设计。

书本上进程的概念是:加载到内存的程序,叫做进程。在《现代操作系统》中进程概念是:计算机上所有可以运行的软件,通常也包括操作系统,被组织成若干个顺序进程,简称进程。

你可以理解为,每一个进程都对应着一个虚拟CPU。这个理解十分重要。因为进程这个概念,这个设定的目的是引入伪并发这个效果的。因此在设计进程的时候,进程认为自己是独自享用所有的CPU资源的,并且对其他的进程毫无感知。正因为这样,进程可以不考虑其他进程的执行自己的任务,就好像只有只有自己这一个任务,并且CPU只有一个一样。真正与实际的物理内存对应关系由页表来完成。

进程不仅仅是程序,进程是某种类型的活动,它有程序,输入,输出,状态等。就算两个进程的程序完全一样,那么这两个进程也不可以说是同一个进程。

说到进程,系统中有没有可能存在大量的进程呢?这是当然的,那么根据我们前一篇文章,Linux操作系统之操作系统概论以及操作系统设计思想里面的先描述再组织原则。我们的操作系统为了管理多个进程,会给进程创建一个结构体,然后用某种数据结构把这些结构体组织起来,结构体和进程一一对应。当我们对进程进行操作,进行查找的时候,实际上就是对结构体进行操作和查找结构体的所处位置。这个结构体我们的官方把它叫做PCB(程序控制块),结构体的名字我们把它称作tast_struct。

有了PCB之后,你可以认为操作系统就只认识PCB了,它会把PCB当作进程,对PCB进行一系列的操作,PCB内部有一个指针,指向了对应的程序,因此对PCB操作可以等同于间接的对进程中的程序进行操作。

就像是这样。

因此,对进程的管理完全转换成了对进程控制块PCB的管理。

 因此我们学习进程,就是学习它的PCB了。

在正式学习之前,我们需要区分一个概念:

进程和程序

实际上进程 = 程序 + PCB。进程就是一个运行的程序,但是在建立进程的时候,系统会自动创建PCB,因此进程 = 程序 + PCB。

进程具有独立性

进程之间具有独立性,互相的工作个不干扰,以此来提高系统效率。 

进程创建

进程的创建使用fork函数。在Linux操作系统中,我们的进程fork之后会生成一个子进程。这两个进程拥有相同的内存映像,同样的环境字符串和同样打开的文件,简而言之,是完全一样的,没错,完全一样。但是我们创建子进程是为了什么呢?当然是为了执行任务了,于是子进程会被分配和父进程不一样的任务,当任务(你可以理解为一段程序,一段代码)被分配之后,子进程会执行execve或一个类似的系统调用,以修改内存映像并运行一个新的程序。exe型函数就像一个加载器,把程序从磁盘加载到内存,然后由于写时拷贝技术的执行,会导致此时子进程和父进程的内存映射不一样。

当fork完之后,如果子进程有执行其他任务的需要的话,我们会把这一段代码使用exe系列函数拉取过来,然后由写时拷贝生成不同的内存映像,如图:

 

Linux中把创建子进程,和使用execve函数让子进程执行对应的任务分成了两个步骤,原因是为了在fork之后但是在execve之前允许该进程处理其文件描述符,这样可以完成对标准输入文件,标准输出文件和标准错误文件的重定向。而windows操作系统这两个操作则是一起完成的,没有分开,因此它的调用函数有10个,用户必须详细的指定相关的信息。 

进程的层次结构

在Linux系统中,当进程创建了另外一个进程之后,父进程就和子进程就以某种形式继续保持关联了。子进程也可以继续fork它的子进程。因此在Linux中有一个bash进程,也就是最初始的父进程,它会fork子进程,用户也可以继续fork子进程,最终形成一颗以init为根的进程树

但是在windows中不存在父进程和子进程的概念,每一个进程的地位都是相同的,,唯一相同的就是在创建进程的时候,父进程会得到一个特殊的令牌(简称句柄),获得这个句柄就可以控制子进程,然而这个句柄是可以转让给其他进程的,这样就不存在进程的层次结构了。

PCB的内部构成

我们学习进程实际上就是学习PCB,因此,接下来我们需要深入的学习PCB的内部构成。

PCB的内部大概有哪些属性呢?

  • 标识符
  • 状态
  • 优先级
  • 程序计数器
  • 内存指针
  • 上下文数据
  • I/O状态信息
  • 记账信息
  • 其他信息

标识符

我们知道,系统中存在大量的进程,一旦存在多个的问题,就必然要进行管理,一旦管理,就一定有编号,就像工厂的员工有自己的编号一样。这个标识符就是描述本进程的唯一描述给,用来区分其他进程,就像是工人的编号一样。

pid_t getpid(void); // 子进程
pid_t getppid(void); // 父进程

我们可以通过这两个系统调用接口来得到当前进程的标识符pid_t。

状态

进程的状态便涉及到了进程的调度了。

基于进程的操作系统中最底层的是中断和调度处理程序,在该层之上的才是顺序进程。所以我们接下来要学习的进程状态就是被调度程序调用的时候所产生的状态。 

状态是指进程的任务状态,退出代码,退出信号等等。

你们有没有想过,为什么自己的代码在结束的时候必须要返回return 0呢?其实这个0是进程的退出码。0在OS严重代码的是正常退出,因此我们在程序结束的时候默认返回的就是0.

我们输入kill -l,得到的其实就是不同种类的退出码,不同的退出码会像操作系统发送不同的退出信号,从而造成操作系统不同的决策。

 那么任务状态是什么呢?

Linux中也把进程叫做任务,因此任务状态就是进程状态。

进程状态可以方便OS快速判断进程,完成待定功能,比如调度,本质上是一种分类

我们来看一下Linux内核是怎么描述任务状态的。 

/*
* The task state array is a strange "bitmap" of
* reasons to sleep. Thus "running" is zero, and
* you can test for combinations of others with
* simple bit tests.
*/
static const char * const task_state_array[] = {
"R (running)", /* 0 */
"S (sleeping)", /* 1 */
"D (disk sleep)", /* 2 */
"T (stopped)", /* 4 */
"t (tracing stop)", /* 8 */
"X (dead)", /* 16 */
"Z (zombie)", /* 32 */
};

可以看到操作系统把进程的状态存放在一个数组里面,数据的名字叫task_state_array。而且这个数组是指针数组,会指向相应的进程。这么多状态之间有什么联系呢?

我们先来看一张图,弄清楚他们之间的联系之后我们再逐一的阐述他们各自的含义:、

R状态(Running): 代表的是这个进程此时在run_queue里面,随时可以被调度。什么是run_queue?我们知道,系统中有很多进程,而每一个进程一定处于某一个状态,而OS需要把每一个状态的进程进行一个分类方便管理和调度。例如处于R状态的进程就全部放在运行队列里面,简称run_queue,处于Z的就在Zombie_queue里面。

当我们处于某一个状态的时候,就说明我们在某一个状态对应的队列里面。当我们处于R状态的时候,是运行状态,代表随时可以运行。而不是正在运行。因此处于R状态的时候,不一定占用了CPU资源,也可能在占用CPU资源等待的路上。我们CPU选取被调度的进程的时候,会直接在run_queue里面选取。

S状态(sleeping):S状态称之为可中断睡眠,意味着改进程正在等待着某些事情的完成,处于浅度睡眠的状态可以随时被唤醒,也可以被kill -9 【pid_t】杀掉。

  1 #include <stdio.h>
  2 #include <unistd.h>
  3 
  4 int main()
  5 {
  6   printf("hello world");
  7   sleep(1000);                                                                                                                    
  8   return 0;                                                                                                             
  9 }  

例如这个sleep就是S状态。

D状态(disk sleep):D状态也叫做深度睡眠状态,任何东西都不能将其的行为取消,包括操作系统,kill -9也是杀不掉它的,唯一的方法就是等待该进程自动唤醒。这个状态的进程通常会等待IO的结束。为什么偏要叫做disk sleep呢?原因其实很简单,因为D状态主要发生在IO读写的时候,例如有的进程要进行磁盘的读写,在读或者写的过程中,它不可能被杀掉,进入D状态,因为如果在读写中途它的状态被改变或者杀掉的话,那么OS系统的数据的来源就已经收到了损坏,数据不完整,因此它要等待磁盘的写入或者读写情况的回复,才能继续做出反应。

注意:S状态和D状态全部都存放在wait_queue队列里面,而且这个队列为了不影响CPU的运行效率,不会存放在CPU内部,run_queue为了CPU可以随时调度,因此存放在CPU内部。

注意:因为D不可以被杀掉,所以当D进程大量存在的时候,会占用资源,但是无法清楚,最终造成系统的崩溃。

T状态(stopped):这个状态被称作为暂停状态,它跟睡眠状态不同的是。睡眠状态是因为某些条件不具备,或者有sleep的时候才会陷入睡眠,而T状态是因为OS向这个进程发送了SIGSTOP信号才会暂停,而且当收到了SIGCOUT才会继续运行。它是被信号强行指控的。

t状态(tracing stop):这个状态也是暂停状态的一种,只不过它的应用场景主要是GDB调试的时候,我们打一个断点导致的程序暂停。

X状态(dead):死亡状态,代表进程已经被杀死了,这个状态只是一个返回状态,你无法在控制台里面看到这个状态,因为进程死了之后相关的信息你是无法获取的。

Z状态(zombie):简称僵尸状态,为什么成为僵尸状态呢?

  • 僵死状态(Zombies)是一个比较特殊的状态。当进程退出并且父进程(使用wait()系统调用,后面讲)
  • 没有读取到子进程退出的返回代码时就会产生僵死()进程
  • 僵死进程会以终止状态保持在进程表中,并且会一直在等待父进程读取退出状态代码。 所以,只要子进程退出,父进程还在运行,但父进程没有读取子进程状态,子进程进入Z状态。

我们可以理解为僵尸状态是X死亡状态前短暂的一个状态。为什么在进程死亡之前会多一个Z状态呢?我们举一个例子,加入街上有一个人死了,那么我们一定不会把这个尸体马上搬走,一定会有专门的机构过来验尸,因为我们不能白白的让一个死了,要把原因查明白,那么OS也是一样的,当我们的进程被杀死的时候,会短暂的进入一个僵尸状态,这个状态意味着我进程的死亡并没有被操作系统查明。当具体原因被查明的时候,我们把进程退出的信息记录在PCB里面,供父进程进行读取,然后就会正式进入X,死亡状态。我们进程死亡退出的信息其实就是我们的退出码,kill -l可以看到很多退出码。我们的return 0的‘0’也是一个退出码。但是如果我连父进程都已经死了,那么进入Z的子进程也会OS想办法被回收掉。这里我们就会引出一个概念,孤儿进程。

孤儿进程
在Linux当中的进程关系大多数是父子关系,若子进程先退出而父进程没有对子进程的退出信息进行读取,那么我们称该进程为僵尸进程。但若是父进程先退出,那么将来子进程进入僵尸状态时就没有父进程对其进行处理,此时该子进程就称之为孤儿进程。
若是一直不处理孤儿进程的退出信息,那么孤儿进程就会一直占用资源,此时就会造成内存泄漏。因此,当出现孤儿进程的时候,孤儿进程会被1号init进程领养,此后当孤儿进程进入僵尸状态时就由int进程进行处理回收。
 

僵尸进程的危害

  • 僵尸进程的退出状态必须一直维持下去,因为它要告诉其父进程相应的退出信息。可是父进程一直不读取,那么子进程也就一直处于僵尸状态。
  • 僵尸进程的退出信息被保存在task_struct(PCB)中,僵尸状态一直不退出,那么PCB就一直需要进行维护。
  • 若是一个父进程创建了很多子进程,但都不进行回收,那么就会造成资源浪费,因为数据结构对象本身就要占用内存。
  • 僵尸进程申请的资源无法进行回收,那么僵尸进程越多,实际可用的资源就越少,也就是说,僵尸进程会导致内存泄漏。

状态的演示

我们使用:

ps -axj | grep [pid_t]

可以查看进程的相关信息,我们查看一个进程会发现:

 这里显示的不是R状态,而是R+,这个+代表什么意思呢?+代码的是后台进程,如果没有+的话代表是前台进程,有+的话代表是后台进程。

 优先级

优先级是指进程调度的优先顺序。为什么会存在优先级这个东西?原因是我们的系统CPU的资源是有限的,但是我们的任务很多,因此我们要按照任务的重要程度来把进程按照一定的优先级进行调度。

我们使用系统命令:

ps- l

可以看到这样的界面。 有几个东西:UID,PID,PPID,PRI,NI。我们依次来解释一下:

UID是指用户ID,也就是执行者的身份。

PID是指这个进程的代号。

PPID是指当前进程的父进程的代号。

PRI是我们常说的优先级,其值越小,越先被执行。

NI是这个进程的nice值,是优先级的调正数据。PRI(new) = PRI(old) + NI。因此,我们可以通过调整NI的值,来间接的调整优先级。

那么问题来了,为什么操作系统不直接修改PRI,而是要通过NI来间接修改PRI呢?

因为NI的可以直观的感受到优先级的变化过程。

但是NI是有一个数值范围的[-20, 19],也就是说我们的NI的值不会超过这个范围,于此同时,我们的进程刚被创建出来的时候,PRI默认是80,也就是说PRI的范围可以达到[60, 99]。

为什么nice值要设置成一个相对比较小的范围呢?

优先级再怎么设置,也只能是一种相对的优先级,不能出现绝对的优先级。否则会出现很严重的进程饥饿问题。(由于你设置的进程太夸张了,所以有些进程总是得不到cpu'资源导致进程饥饿,就像是我去食堂打饭,每一个人都卡队到我前面的话那么我永远就打不到饭了,会造成我的饥饿问题

top 命令更改已存在进程的 nice
top
进入 top 后按 “r”–> 输入进程 PID–> 输入 nice

四个重要概念


竞争性: 系统进程数目众多,而CPU资源只有少量,甚至1个,所以进程之间是具有竞争属性的。为了高效完成任务,更合理竞争相关资源,便有了优先级。

独立性: 多进程运行,需要独享各种资源,多进程运行期间互不干扰。

并行: 多个进程在多个CPU下分别同时进行运行,这称之为并行。

并发: 多个进程在一个CPU下采用进程切换的方式,在一段时间之内,让多个进程都得以推进,称之为并发。
 

程序计数器

程序中即将被执行的下一条指令的地址。这样我们才可以保证程序可以执行完。

内存指针

包括程序代码和进程相关的数据的指针,还要和其他进程共享的内存块(System V共享内存Linux操作系统之进程间通信)的指针。

上下文数据

进程的代码不可能在短时间内可以运行完成,因此我们的每一个进程都规定了运行的时间片,例如是10ms,如果10ms之内没有运行完,那么就去run_queue重新排队,不运行继续运行了。

时间片:进程运行的最长时间

时间片的好处就是:在CPU情况下,用户感受到的多个进程同时在运行。本质是通过CPU的快速切换完成的!!就是说,如果你只有一个CPU,那么你的程序不可能在同时运行

但是有一个严重的问题出现了,我的CPU只有一个,我的一个进程运行的临时数据全部保存在CPU的寄存器里面,如果这个时候我们换下一个进程,那么下一个进程的临时数据就会把我们本进程的临时数据覆盖掉,造成数据的丢失,因此我们为了避免这种情况发生,需要把所有的临时数据全部保存在自己的PCB里面,等再次轮到我的时候,把我保存的临时数据全部再拷贝到CPU的寄存器里面,依次可以按照上次进度继续进行,保证连续性。

I/O记账信息

包括显示的I/O请求,分配给进程的I/O设备和被进程使用的文件列表。

记账信息

可能包括处理器时间总和,使用的时钟数综合,时间限制,记帐号。

小总结

我们上面所说的一些PCB的组成内容本质上是为了:一个进程在执行的过程可能被中断数千次,单是关键是每次中断之后,被中断的进程都返回到与中断发生前完全相同的状态。这由中断向量来完成,中断向量是中断服务程序的入口地址,其作用是引导CPU去执行相应的中断服务程序。

fork()和写时拷贝

我们知道fork()函数有两个返回值,0返回给子进程,另外一个子进程的pid返回给父进程。当我们fork()出一个子进程的时候,子进程会按照父进程为模板,初始化自己的task_struct,并且继承父进程的代码和数据,但是父子进程的代码是属于文件系统的,只有一份,子进程从父进程继承的内存指针是父进程一样,都指向同一份文件也就是说父子的代码只有一份。

那么问题就来了,我们知道进程之间具有独立性,而我们的代码数据是只有一份,是共享的,那么父进程或者子进程的改变就会直接影响到对方,这还能叫做进程之间具有独立性吗?

答案是,我们的系统会使用一种写时拷贝的方式,来维护进程之间的独立性。

什么是写时拷贝呢?

写时拷贝(copy-on-write, COW)就是等到修改数据的时候才真正的分配内存空间,这是对程序性能的优化,可以延迟甚至是避免不必要的内存拷贝。

因此当数据有修改的时候,会不得不的拷贝一份数据,让子进程指向它。 

在Linux操作系统中,调用fork创建子进程的时候,并不会把父进程的的内存页复制一份,只有当内存页不得不修改的时候,才会把父进程的内存页复制一份给子进程。

 写时拷贝的底层原理是通过引用计数这个概念完成的,我们多开辟4个字节,来记录有多少个指针指向这片空间。当我们多开辟一份空间的时候,就让引用计数+1,如果有释放空间,那就让引用计数-1,当引用计数为0的时候,就把空间释放。如果有修改,或者读写的操作的时候,页让原空间的引用计数-1,然后马上开辟新的空间。

程序地址空间

我们只要学习过C语言,就一定见识过这一张图片:

 那么问题来了,这张图是我们所谓的内存吗?

答案是否定的,这张图其实根本就不是我们的内存分布图!!!

我们来一段奇怪的代码来验证我们说的观点:

  1 #include<stdio.h>                                                                                                                 
  2 #include<unistd.h>
  3                 
  4 int g_val = 100;
  5           
  6 int main()
  7 {
  8   pid_t id = fork();
  9   if (id == 0) { // child
 10     g_val = 200;
 11     printf("child:PID:%d, PPID:%d, g_val:%d, &g_val:%p\n", getpid(), getppid(),g_val, &g_val);
 12   } else if (id > 0) { // father
 13     sleep(3);
 14     printf("father:PID:%d, PPID:%d, g_val:%d, &g_val:%p\n", getpid(), getppid(),g_val, &g_val);
 15   } else {
 16     // fork error
 17   }
 18   return 0;
 19 }
~

最后我们的答案是:

可以发现这两个东西的值不同,理论上来说不可能是同一个地址,但实际上这个打印出来的地址却是一样的,这是怎么回事儿呢?

因此我们推断出了一个结论,我们打印的是不是真实的地址,是一个虚拟的地址。我们使用的页不是物理地址,而是一个虚拟地址。

 我们把这个虚拟地址叫做进程地址空间

这个概念很不好理解:我们怎么去理解“虚拟”这两个字?

这个时候我们需要举一个例子:

背景介绍:有一个大富翁,有100个亿,他有10个私生子,也就是说每一个孩子都以为自己的父亲只有一个,大富翁说你现在好好工作,以后这100个亿就是你的,也就是说给每一个人都画了一张大饼,每一个人都认为自己以后会拥有100个亿的财产。所以他们都有自己各自的规划,但是在给100亿之前,孩子向大富翁要钱,大富翁会给他们,这样他们仍然认为自己的父亲只有一个,而且以后100个亿是他的,当然如果你一次性要80个亿的话大富翁会直接拒绝掉。

这里的大富翁其实就是操作系统,100个亿就是物理内存,每个人一个的大饼就是程序地址空间,也就是虚拟内存,而私生子就是进程。

每个私生子都被花了一张饼,都任务自己有100亿,每个进程都有一个地址空间,都认为自己在独占物理内存。

而这个饼子,每一个进程都被画了一张,因此多个进程就存在多个饼子,这就导致了一个问题:我们的操作系统需要把这些饼子描述并组织起来,因此每一个进行都有一个描述大饼的结构体,struct mm_struct{ }。

这样我认为也是进程具有独立性的一种体现,因为,我们每一个进程都被画了一张大饼,每一个进程都认为自己操控了所有的物理内存,因此每一个进程都可以按照同一个方案来进行运行,不需要考虑其他进程的情况,这样不仅实现了解耦合,也是独立性的一个重要体现。

那么操作系统是怎么描述这个结构体的呢?

struct mm_struct {
    unsigned int code_start;
    unsigned int code_end;
    unsigned int init_data_start;
    unsigned int init_data_end;
    unsigned int heap_start;
    unsigned int heap_end;
    unsigned int stack_start;
    unsigned int stack_end;
    // ...
}

这就是大概的划分了!

因为每一个进程都以为我有4GB的空间,但实际上可能只有几kb,但是我们的结构体会按照假如我有4GB为标准进行设计。这个,就是我们的虚拟地址

此时,更关键的问题出现了,实际上是,我们的物理内存只有一份呀,我们如果把虚拟内存和物理内存建立起映射关系了,这个时候我们就要引出一个新概念了,叫做页表。

页表的本质就是一个哈希表,用来记录对应关系,页表可以虚拟内存的地址转换成物理地址的实际存储地址。

可以把页表想象成 char* table[4 * 1024 * 1024 * 1024],这个数组里面有4GB的大小,然后虚拟内存的划分是下标,下标对应的是一个指向物理内存的指针。但实际上页表的设计远比这个复杂

页表的意义

页表方便管理,同时可以保证操作系统的安全。试想一下,假如我们的虚拟内存和物理内存之间没有页表的话,那么我们的虚拟内存向物理内存写入数据就直接成功了,操作系统没有机会,也没有时间检查数据的正确性。

例如:在操作系统通过页表给你划分空间的时候,如果发现虚拟内存给的地址在我的物理内存中没有存放的为止,说明你不应该存在,内存越界了。

再比如:const char * str = "hello world";    *str = 'H';这明显是不允许的。页表有对应的策略,页表有一个权限管理区,如果你有const的话,我的代码就会打到页表的r区,然后r区再指向一块儿空间,如果我的页表发现你的数据更改了,并且在r区,那么我就会认为你做了违法的事情,没有页表,你就完全乱套了!

为什么要有程序地址空间

  1. 通过添加一层软件层,有效的对进程操作内存进行风险管理,本质目的是为了保护物理内存以及各个进程的数据安全。
  2. 基于缺页中断进行物理内存的申请,什么是缺页中断?​ 缺页中断(英语:Page fault,又名硬错误、硬中断、分页错误、寻页缺失、缺页中断、页故障等)指的是当软件试图访问已映射在虚拟地址空间中,但是目前并未被加载在物理内存中的一个分页时,由中央处理器的内存管理单元所发出的中断。  ​

    假如我们申请了1000字节,我们可能不会立刻使用这1000字节,在os的角度就是这一部分空间本来可以给别人的,但是你现在申请了,又闲置着不用。很离谱。

    那么策略是,我申请了1000字节之后,我暂时在虚拟地址空间里面把你的这个数据范围扩大一点。(把结构体里面的对应的数据范围扩大一点)然后告诉task_struct(PCB)我已经把内存分配了,但实际上现在还没有对应到物理内存里面去,这个时候等发现我要使用的时候,再通过页表与物理内存进行直接的映射。然后再使用。

  3. OS做的内存申请动作是透明的。将内存申请和内存使用的概念在时间上划分清楚。你如果要的太多,那么我会等你需要的时候再给你。通过虚拟地址空间,来屏蔽底层申请内存的过程,达到操作进程读写内存和OS进行内存管理操作,进行软件上面的分离。

    再次理解一下:假如我要找我爸要1万块钱报课,我爸说可以,这个时候你就已经认为钱已经借到了。也就是说我在虚拟地址空间里面已经给你开好了,task_struct也知道了。但是等你真的要交学费的时候,我确实获得了1万块钱,但是你不知道这1万块钱是怎么来的,可能是我爸的私房钱,可能是我爸打牌得到的,但是我统统不需要管,总之我已经得到了这1万块钱了,这就是分离的操作。

    有一个极端情况,我爸答应你把1万块钱给你了,但是其实已经没有钱了,也就是说我的虚拟地址空间已经安排好了,但是我的物理内存已经占满了,没有空间了,这个时候就会使用内存管理算法。

    内存管理算法:我看到这一块儿空间没怎么用过了,所以直接把这一块空间置换到磁盘里面去,然后把空余下来的给你用。

    但是我不知道是这么要过来的,我只知道我的目的达成了,我要到了这一块儿空间。所以起到了读写内存和内存管理分离的作用。这也叫做解耦合!!

  4. 地址空间 + 页表可以让程序以某种统一的形式来看待内存,让CPU不再凌乱,CPU你只管以最快的速度取,其他的工作全部交给其他人。

    我的物理内存里面放了各个进程的代码,比如A的,B的,C的.......但是CPU是怎么知道程序的入口在哪里的,每个进程的情况不一样,CPU无法以一个统一的方式去进行寻找。如果没有一个统一的标准的话,那么CPU每次都要以不同的方式去寻找,太慢了!!

    程序地址空间 + 页表可以解决这个问题!!我的代码和数据全部放在磁盘的某一个地方,我在地址空间上面有一个代码段,假设CPU固定找的地址是0x1234,那么CPU在地址空间里面找到代码段,代码段会直接对应到页表的一个位置,这个位置里面放的是0x1234,然后每一个进程的页表的0x1234后面直接放的是这个程序的起始位置,这样的话CPU每次都只读0x1234这个位置,就每次都可以直接找到我的代码起始处。

  5. 减少内存管理的负担,因为程序的代码和数据可以被加载到物理内存的任何位置,只要页表里面有对应的映射就可以找到。

最后,我们总结一下:

站在CPU和应用层的角度,进程统一可以看作统一使用4GB空间,而且每个空间区域的相对位置是比较确定的。

OS最终这样设计的目的,达到一个目标:每个进程都认为自己是独占系统资源的。

地址空间 + 页表本质可以让进程统一化,让管理更简单

进程 = 代码和数据 + PCB + 程序地址空间 + 页表 + 一些数据结构

回到之前的问题

我们的代码为什么值不一样了,但是打印出来的地址还是一样的。

原因就在上面那个图上,我们打印出来的地址不是物理地址,是虚拟地址,但是也就是我地址空间里面对应的地址,但是实际上每一个进程都有一个地址空间和对应的页表,所以打印出来的值成一样的了。

子进程的创建时以父进程为模板,但是一开始子进程和父进程在物理内存里面的对应的值都是一样的,因为还没有发生写时拷贝。程序继续运行,我把子进程的值给改了,因为进程具有独立性,所以我们在内存上的体现就是写时拷贝,我们这个时候发生了写时拷贝,于是在物理内存里面就有了子进程和父进程两个进程了,这地址明显不同,然后同时页表和地址空间随即发生改变,所以我们地址不变本质是虚拟地址不变罢了。在物理内存上本来就是不同的变量。

 之前我们说过父子代码是共享的,它的本质就是页表把我两个进程的代码区映射到了物理地址的同一份区域上面了。因此,所有的只读数据,我们只需要有一份就够了,由页表来我们进行映射到同一份内存空间上。

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

胡桃姓胡,蝴蝶也姓胡

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值