进程概念 (两万多字超级详细版)

一. 冯诺依曼体系结构

我们常⻅的计算机,如笔记本。我们不常⻅的计算机,如服务器,⼤部分都遵守冯诺依曼体系。
        就是如上图的一个结构。
        
        储存器是内存,其他的都不属于储存器,上图大家好好看看。
        下面我们来想一个问题。
        为什么我们的c/c++的代码要加载到内存当中才能运行呢?
        
        就是因为这句话,cpu只和内存打交道,外设也和内存打交道,我们的cpu就是我们控制器和运算器等比较重要的东西组成的。
        我们的效率排行就是我们的cpu 内存  和其他东西了。
        
        这是我们的一个金字塔结构,大家可以好好看看。
        
        因为有了内存,才使得我们的计算机的价格变低,使得我们大部分人都可以使用得到计算机。
        硬件和内存打交道也叫io。
        数据流动的本质就是其实就是拷贝,我们写的代码,就是拷贝到内存,内存再拷贝到cpu中,计算完成之后,再拷贝到内存,内存再拷贝到输出设备显示。
        缓存就是cpu写完是先给内存而不是输出设备,所以才有缓存这个概念的。
        下面我们来个场景再来理解一下。
        我要通过vx给远方的朋友发送一个信息,这是怎么的一个工程呢?
        
        实际上就是两台冯诺依曼结构进行的东西,我们是先把vx加载到内存当中,打开vx,然后通过键盘写入到内存也就是vx中,内存再进入到我们的cpu中进行加密等一系列操作,返给我们的内存,然后再给输出设备,我们的显示屏上也能看到这个信息,然后我们的网卡通过网络发送到你朋友的输入设备也就是你朋友的网卡,然后你朋友也登陆vx本质就是把vx加载到了内存当中,你朋友的网卡接受信息之后,就要传入到内存中,内存再给cpu进行解密什么的,然后返给内存,内存再给你的输出设备,也就是你朋友的显示器,此时就完成了这个过程。
        如果两者进行文件传输,怎么理解?
        文件的本质也是数据啊,和我们聊天一样。
        只是输入设备改变,此时是我们的磁盘了,过程差不多一样。就是从你的磁盘到它的磁盘中间通过网络。
        总结一下就是,发信息就是把数据从你的键盘,键盘也是文件,把键盘文件拷贝到对方的显示器,传输数据是从你的磁盘拷贝到你朋友的磁盘的一个过程。
        

二. 操作系统(Operator System)

2-1 概念

任何计算机系统都包含⼀个基本的程序集合,称为操作系统(OS)。笼统的理解,操作系统包括:
内核(进程管理,内存管理,⽂件管理,驱动管理)
其他程序(例如函数库,shell程序等等)
这个主要是告诉我们操作系统是层状结构的。
        

2-2 设计OS的⽬的

        

对下,与硬件交互,管理所有的软硬件资源
对上,为⽤⼾程序(应⽤程序)提供⼀个良好的执⾏环境
        下面来讲个例子来理解一下我们的操作系统吧。
        就是我们的校长,辅导员和我们的关系,我们的校长是绝对的管理者,这是毫无疑问的,辅导员是我们校长决策的实行者,没有绝对的决策权,我们就是被管理者,我们的校长对我们的管理就是通过对我们数据的分析来制定的,就是通过我们各科成绩等一些因素来对我们实行一些管理的,这里的校长就是我们的操作系统的角色,我们的辅导员就是驱动程序,我们就是硬件。
        当我们学生少的时候,校长当然可以通过excel表格来对我们的信息进行管理,但是学生一多的话,你的查找某个学生的效率就会变得很低了,所以此时就有个办法就是通过我们的代码创建一个学生类,然后通过把学生的信息输入进去,来把我们的学生变成一个一个的变量,这样我们查找什么的功能就交给计算机了,方便了许多。
        类似于这样,通过一个指针把我们的学生表连接起来。
        我们的校长对我们的操作就会转换成对计算机中这些对象的操作,校长在创建这个类的时候,对我们的操作就是先描述我们的信息,就是通过这个学生表,然后再组织。
        操作系统是对软硬件资源管理的,通过上面这个例子我们也可以知道操作系统对软硬件的管理就相当于校长对我们的管理,操作系统对硬件的管理也是通过一些数据结构来实现的,所以我们可以说,操作系统的本质就是数据结构。
        管理的本质就是1.先描述  2.在组织。
        

2-3 系统调⽤和库函数概念

        下面我们来讲一下,操作系统对上是怎么方便我们使用的。

        我们仙举个现实中的例子。

        

        就是我们的银行系统,我们去银行存钱的时候,不可能让你自己去把你自己的钱放入到金库中,然后自己修改一下电脑上的信息的,这是肯定不可能的,就是为了防止群众中有坏人的情况,所以它会给我们提供不同的窗口窗口中都有工作人员帮助我们来完成这个操作。

        所以操作系统也就是给我们提供了接口不可能让我们直接访问内部,系统调用就是系统帮我们调用各个函数来完成相关的操作,系统调用的本质就是让用户安全访问操作系统。

        和操作系统交互只能通过系统调用,就相当于我们存钱的时候只能通过银行的工作人员。

        我们c语言和c++在写代码的时候也没有直接进行系统函数调用啊,它和操作系统又是一个什么关系呢?

        还是举个例子就是,你去银行的窗口你得有一定的只是储备,比如一个老大爷字也不会写,话都听不太清,此时你让大爷去窗口直接进行操作显然不太现实,此时就需要我们的大堂经理的,就是站在窗口外面的服务人员,此时它就会引导大爷帮助大爷完成一些操作,回到我们的c和c++上就是我们程序员不太了解你这个操作系统的底层啊,我没法直接使用你的系统调用来操作啊,所以此时就在系统调用的上层弄了一个软件层(c和c++的标准库),方便我们程序员的使用,软件层就相当于我们的大堂经理。

        

     我们的标准库和系统调用的关系就是上下层的关系。
      但是也存在不懂代码的啊,纯正的操作系统使用者或者小白,这时候又用语言写了个上层,就是我们的图形化界面,更加方便了我们小白等人的使用。
           
        所以你像我们的c语言开发和c++安卓开发等东西都是基于linux操作系统,他们只是lib中的依赖等内容的不同,都是基于linux的,大框架是相同的,所以会有不同的图形化界面什么的。
        

三. 进程

         3.1 什么是进程?

        我们在学习一个东西的时候首先就是先要了解它是什么,下面我们来讲解一下。

        我们书本中写的进程的概念就是

        它说这两种程序叫做进程,但是我们是不好理解的,或者说它说的不够全。

        我们举个例子,你在你的学校你的学校中存在你的各种信息,都在教务系统中,但是随便一个人进入到你们学校,他都是你们学校的学生吗,这显然肯定不是啊。

        

        我们把磁盘中的可执行文件加载到内存中,这些都是进程,所以我们的系统当中是存在大量的进程的,这是毫无疑问的。

        那这些进程受谁管理呢?

        当然是操作系统了,那么操作系统是怎么管理这些进程的呢?

        1.先描述   2.在组织。

        

        就是通过类似于这样的一个类来管理的,因为我们的linux底层是由c语言来编写的,我们只是举个例子,真正也可能不是通过链表管理的,我们这里举了个链表管理的例子。

        它就是你想是进程,你必须接受操作系统的管理啊,操作系统得有你的进程属性信息啊,这样你才能是进程,所以我们的进程就是你得是PCB结构体存入你的属性信息接受它的管理再加上你的代码和数据,这才是一个完整的进程。

        

        这是我们进程底层的源码,我只截图了一小部分,感兴趣的可以自己去网上查找,这个主要就是让大家看看进程的一些基本的属性。

        进程排队这个概念本质不是你的进程在排队进行运行了什么的,而是你的这个pcb节点进行排队,本质就是从一个数据结构,把节点拿走,放入新的队列数据结构中的过程。

        先明确:为什么需要 “排队”?

就像食堂只有 1 个打饭窗口(对应 1 个 CPU 核心),但有 100 个学生要吃饭(对应 100 个进程):

  • 资源有限:CPU、内存、磁盘 IO 等都是 “稀缺资源”,同一时间只能被少数进程使用(比如单核心 CPU 同一时间只能跑 1 个进程);
  • 避免冲突:如果不排队,所有进程 “争抢” 资源,会导致系统崩溃(比如两个进程同时写一块内存,数据会乱);
  • 公平与高效:既要让每个进程都有机会使用资源(公平),又要让资源不闲置(比如 CPU 不能等着某个 “慢进程”,要快速切换)。

        举个例子,你投简历的时候,建立被一个叠一个的放在桌子上,然后此时是你的简历在排队,而不是你这个人在排队。

        

3.2 task_ struct

        

内容分类

标⽰符: 描述本进程的唯⼀标⽰符,⽤来区别其他进程。

        这个就是一个变量,就是用于区分每个不同的进程的。

状态: 任务状态,退出代码,退出信号等。

        这些都是一些变量,这个退出代码,我们写的c语言代码,最后的return 0;这个0就被这个进程中的一个叫做exit_code的变量接受了,用于判断程序是否正常进行的。

优先级: 相对于其他进程的优先级。

        这个优先级的作用就是区别我们不同软硬件对于资源的请求重要程度,很重要的优先级肯定高啊,也是通过一个int类型的变量来区分的。

程序计数器: 程序中即将被执⾏的下⼀条指令的地址。

        

        看一下这个图,左边是一个cpu,右边是不同的进程,首先我们的cpu中存在一个pc和IR,pc就类似于我们函数栈帧中的pc指针,操作系统中不叫pc,这里我们叫pc方便我们理解,首先就是你的pc指针指向第一个代码,然后ir就问pc要执行哪个代码,pc就给ir说第一个代码,此时代码1进入到了ir中,此时pc直接++,指向了代码2了,这就是pc的作用。

内存指针: 包括程序代码和进程相关数据的指针,还有和其他进程共享的内存块的指针

        现在我们就认为它就是帮我们找到代码和数据的就行。

上下⽂数据: 进程执⾏时处理器的寄存器中的数据。

        进程关心的永远是我们寄存器里面的内容,因为内容是我进程自己的数据。

        进程在被执行的时候,是要被切换的,调度的!(动态属性)

        我们举个例子来帮助我们理解一下这两个概念之间的联系。

        就是我们正在上大学的时候,突然自己想去当兵并且也入选了,此时你就需要找到你的导员,让它把你的学籍信息保存备份一下,把你的学籍状态调整为休学的状态,当你回来的时候继续接着原来的进度继续上大学,不能直接把你的学籍弄没让你重新考大学啊,这是不合理的,我们的学籍档案等信息就是我们的上下文数据。

        我们的进程也是这样,当一个进程被调度的时候,不能直接把这个进程干掉,而是要备份一下,继续执行其他的进程,当你的进程再切换回来的时候,你要给我恢复一下啊,所以切换的时候包括了上下文数据的保存和恢复。

        我们把进程备份保存到哪合适呢?

        保存到我们的task_struct中,就是我们的结构体,这也就是我们为什么进程结构体中会有上下文数据的原因了。

        我们在task_struct中看一下吧。

        

        就是我们最后一个变量也就是我们的结构体变量里边,我们点进去看看。

        

        就是这个样子,它就是保存我们上下文数据的。

        

I∕O状态信息: 包括显⽰的I/O请求,分配给进程的I∕O设备和被进程使⽤的⽂件列表。

记账信息: 可能包括处理器时间总和,使⽤的时钟数总和,时间限制,记账号等。

        这个我们就举个例子,就是两个进程的优先级一样,但是其中一个已经执行了很长时间了,另外一个没有执行过,此时它就会先执行那个没有执行过的,优先给没有执行过的分配资源。

其他信息

具体详细信息后续会介绍

        我们写了一个函数,这个pid_t是我们标识符的类型,标识符就是我们的id,getpid函数就是获取我们当前这个进程的id,我们来运行一下,看看他是否是一个进程。

        

        这就表示它是一个进程了。

        

        我们的根目录下的proc目录下这些全都是我们正在运行的一些进程信息。

        只要我们的代码正在运行我们的这个代码的这个进程就会存在在这个目录下面。

        下面我们进入我们进程里面分析一下。

        我们cd /proc/我们此时进程的pid,此时就可以进入到下面这个页面了

        

        我们这个可以说明我们进程会记录我们的磁盘二进制可执行文件。

        

        这个表示我们当前进程的工作路径,默认是我们的代码的路径。

        

        你在你代码中加一个这个文件操作,它没有这个文件,但是你打开它了,它会默认在我们当前文件夹下创建一个打开,当前路径就是我们的cwd上面那个,默认是我们的二进制文件的路径。

        可以通过chdir改当前路径。

        此时你就把当前路径修改成了如图所示的新路径了。

        

        这三种方式都是我们的快速查找进程的方法,二三一摸一样和一的区别就是打印了头。

        

        这个就是表示显示屏,我们在之前的博客中也谈过,如果多个xshell都登陆你的这个账号,此时linux下一切皆文件,此时显示屏也是文件,此时我们每个人都对应着一个文件,也就是我们的显示屏。

        linux系统进程增多的方式是通过父进程创建子进程的方式,让linux中的进程变多的。

        

        我们的这个ppid就是我们父进程的id,它是怎么获取到的呢?

        它是通过一个parent的结构指针指向父进程拿到的。

        

        我们运行多遍发现只有子进程在改变,父进程的id并没有改变。

        为什么父进程不变啊?

        

        直接查一下它,看看她到底是谁。

        它就是我们的bash,结论来了:我们在命令中,启动命令/程序的时候,都会变成进程,他们的父进程都是bash。

        bash就是我们之前讲的媒婆的例子,我想帮你把事情办了,但是我又无法直接去,所以此时就开了个媒婆所,让实习生去办。

        bash就是我们的外壳程序就是媒婆所,由实习生去办就是由子进程去执行代码。

        这里先了解一下,后面会详细讲。

        bash怎么创建的子进程呢?

        它是通过一个fork函数创建的子进程。

         下面我们来验证一下。

        

        这是一个我们写的一个代码,我们来运行一下。

        为什么打印了两条这个你能看见我这条消息吗?

        这是因为我们此时创建了子进程,导致我们fork函数下面有两个分支一个是父进程走的,一个是子进程走的分支。

                

        我们给代码这样弄一下。

        

        此时父进程也不一样了,这是为什么呢?

        这是因为我们的第一条打印的是我们父进程的pid和父进程的父进程的pid,第二个是我们父进程创建的子进程的pid,ppid就是我们的父进程的pid你可以看一下对不对。

        

        现在的父子关系我给你捋一下,就是我们的这个程序的父进程是bash,这个pid是我们这个程序的,我们的ppid就是bash的,我们通过fork创建了子进程,此时你这个程序就是我们这个创建的进程的父进程了,此时bash就变成爷爷了,就是这样的。

        现在我们再来看一个东西,我们fork的用法。

        

        这个就说创建子进程成功了,就返回给我们父进程,子进程的pid,返回给子进程0,失败就返回给父进程-1,没有子进程。

        我们来验证一下。

        

        好像确实是这样啊,相信大家会很疑惑。

        

        这三个疑问马上就从脑子里蹦出来了。

        首先先解决第一个,就是为什么要给父进程返回子进程的pid,因为一个孩子只有一个父亲,但是一个父亲可以有多个孩子,孩子找父亲容易但是父亲找孩子很难,所以把孩子的pid返回给父亲方便我们父进程管理我们的子进程。

        下面解决第二个。

        

        我们的代码拿到内存中运行的时候,你创建进程肯定会有task_struct这个类,你创建的子进程,子进程肯定也要有这个类,上面是父进程的,下面是我们创建的子进程的,这是毫无疑问的。

        在Linux中,创建一个新的子进程,子进程的task_struct,也要被初始化,一父进程的task这个为模板初始化,它会修改pid和ppid等,大部分一样。

        你这个创建的子进程是fork数据在内存中直接创建的,默认是没有代码和数据来运行的啊。

        所以它会默认共享父进程的代码和数据,共享fork之后的代码和数据(后面修正),所以子进程进入到我们的内存也是要运行的,默认共享fork之后的,所以printf被打印了两次。

        我们一般把代码写成下面的形式。

        这样完全就和我们之前学的c语言不一样了,为什么既能既然else if又进入了else中了,我们目前理解的就是这两个的id不一样,所以父进程和子进程都会执行fork之后的代码,你看着像是执行了两个条件,其实在子进程和父进程中还是只是进入了一个条件判断当中,此时想要理解这个还是需要解决问题3,但是问题3我们目前还是无法解决的,以我们目前的知识储备,我会在合适的时候讲一下。

        返回两次怎么理解呢?

        

        我们首先要先明白一点,这个分进程执行是在什么时候分的呢?

        我们看着像是fork函数执行完成后分的其实是我们创建完子进程之后就分进程了,我们进入到fork函数内部,return的时候肯定是上面的语句全部执行完成了,所以在我们返回之前已经完成了子进程的创建,所以在返回之前就已经分进程了,所以我们在返回的时候就返回两次了。

        

        我们如何来创建多进程呢?

        

        这是我们创建多进程常用的方法,通过一个for循环来创建的,我们运行一下。

        

        运行起来就是这个样子的。

        最后会创建出来10个子进程了。

        这个父进程创建子进程的代码,我在创建完子进程之后,子进程会共享父进程fork之后的代码,如果没有这个while循环让子进程一直运行而是向后跑,此时子进程是否会共享这个for循环呢?

        答案是会的,此时他也会开始执行for循环创建子进程,子进程的子进程也会,此时就会呈现一个进程爆炸的场景。

        

        3-3 进程状态

        什么叫做进程状态呢?

        

        就是结构体中的一个整形变量,通过是不同的值表明是不同的状态,所以进程状态通俗来讲就是一个数字。

        先了解一下这几个状态。

        所有操作系统,在进程状态变化时候,都要遵守上面的理论。

        下面我们来讲一下这几个状态:运行,阻塞和挂起。

                

        为什么会有这两种状态呢?

        因为我们的资源主要分为两种,一种是外设资源就是我们的输入输出设备,一种叫做cpu的资源,我们的进程要对这两种资源做竞争的,通过右边的两种方法竞争。

        

        在我们的操作系统中,进程都通过双链表的形式连接起来,然后都要给我们的这些进程的cpu设置一个调度队列。

        

        调度队列的本质就是这样子的,一个num记录进程的数量,一个指针存放进程。

        我们的一个进程只要在这个调度队列当中,我们就可以说这个进程处于我们的运行状态,也就是r状态,他此时的任务就是等待cpu的调度。

        我们现在就要思考一个问题了,操作系统是如何实现这些进程双链表的管理的呢?

        

        是像我们传统实现双链表那样实现的吗?

        答案是不是的,我们看下图。

        

        它是这样实现的,就是通过一个list_node的结构体,它只管理我们的链表的链接关系,不关系数据什么的,然后在我们的task_struct中定义一个node,来帮助我们管理。

        按照我们之前传统的双链表写法。

        他连接的直接是你的结构体,你的这个对象,但是现在我们这个方式连接的只是我们的node对象。

        

        就是这样的,你的前一个的next指向我后一个的node,我们后一个的node的prev指向我前一个的node,就是这样子实现的。

        这是库中实现的。

       为什么要这样设计呢?

        下面我们来解决一下这个疑问,首先我们先思考一个问题,此时我们的list_head也就是我们上面举例子的list_node,此时你只知道我们的node的地址,你怎么获取我们这个task_struct这个结构体的地址呢?

        获取这个结构体的地址是为了方便我们访问task这个结构体中的其他元素和数据。

        下面我们看个例子。

        

        此时如果我们知道c的地址,怎么获取这个结构体的地址呢?

        我们可以想一下数组,你知道数组第三个元素的地址,你想获取数组的头地址,这是很简单的,就是拿这个地址减去我们的偏移量就可以了,此时我们要先明白一个事情,我们整形变量是四个字节,但是只有一个地址,这个地址是什么地址呢?

        就是我们最小的地址,你可以理解为第一个字节的地址,后三个字节继续开辟三个空间吗,也是存在地址的,我们理解完这个问题之后,我们也就知道了,结构体的地址,也就等于我们变量中的最小地址了,这是毫无疑问的。

        所以此时我们主要任务就是计算处我们的偏移量,然后用c的地址减去偏移量就行了。

        我们怎么计算偏移量呢?

        

        此时就衍生了一个这样子的公式,这个公式的意思就是假设我们结构体的地址是从0开始的,此时你的结构体的地址是0,此时你的c的地址应该是多少呢,就是c此时相对于我们地址0此时的一个地址,怎么理解呢?

        举个例子吧,你的其实高度是10,你的c的高度是20,你的偏移量就是10,但是我们并不知道起始高度是10,我们只知道这个c的高度是20,所以我们把起始高度设为0,此时这个公式的意思就是我的起始高度是10的时候,你是20,但是此时我是0的是你的起始高度又是多少呢?

        此时不就应该是10吗。此时c就变成了10了,此时这个c的地址也就是我们的偏移量了。

        所以我们用&c - 减去我们的这个图片计算出来的偏移量,此时就可以计算出结构体的起始地址了,此时你就可以访问到我们的其他的变量或者是数据了。

        所以就是这样计算的。

        此时我们知道了它是如何访问其他数据或者元素的了,此时我们就要思考一下为什么它要用两个结构体分开的形式来完成链表的操作了。

        

        我们先思考一下,你是如何把双链表中的进程放到我们的排队队列中等待cpu的调用,但是同时你有可以通过双联表的形式管理我们的进程呢?

        此时我们分开结构体实现双链表的好处就来了,你的task_struct结构体中不可能只有一个list_head对象啊,也就是我们的node,我们也可以创建node2和node3...的啊,此时你的node就连在我们的进程链表中方便我们管理我们的进程,你也可以用你的node1让它连在我们的队列中等待啊,此时你就可以连接到队列中,也不破坏我们的链表结构 啊。

        还有一个好处就是我们用传统的双链表结构,我们使用泛型,在定义的时候就确定了我们的类型,你的next和prev的类型也都确定了。

        

        我们传统的方式,你在定义的时候就确定了类型,此时你就只能连接你定义的T类型的链表了,但是我们分开之后,我不管你的T的类型是什么,我都可以连接啊,因为我的node都是list_head的类型啊,这符合我的语法标准啊,你的T类型不一样,不管我连接的事情啊,我的连接只关心我的next和prev指针的类型和我的node类型是否一样啊,这里很显然一样啊,都是list_head类型的,所以这样做还有一个好处就是我们不用再考虑类型了,只管连接就行。

        

        阻塞状态

        下面我们来讲一下阻塞状态。

        

        我们写了一个这样子的代码,我们运行一下。

        

        此时,它需要在键盘文件中读取数据,也就是你输入的值,但是此时你并没有输入任何值,此时这个状态就叫做阻塞状态。

        

        在我们的操作系统中,操作系统也要对硬件进行管理,就是通过通过创建struct hard_device对象进行管理,此时我们的进程在运行的时候会进入到一个调度队列当中,我们上面也讲过,当我们进程阻塞的时候,就是把我们的这个进程从调度队列中断链就行了,我们上面说了调度队列也是通过双链表的形式连接的,上面的结构体中都还存在这个list_node 的这个对象,只是我们没有写。

        等待队列就是在竞争cpu的资源。

        

        这个就是你的运行状态或者是阻塞状态都是通过修改task_struct的状态属性让它处于不同的队列中排队。

        从运行到阻塞的本质就是,把这个进程从cpu的调度队列中放到硬件的等待队列中,比如我们上面 说的scanf就是需要从键盘读入数据,刚开始运行的时候是存在cpu的调度队列中的,但是此时需要从键盘中读取数据,此时就去到了键盘的等待队列当中了,这就是从运行到阻塞。

        从阻塞到运行就反过来。

        

挂起状态

        下一个挂起状态。

        我们先谈一下什么是挂起状态。

        

        当我们的程序运行的时候,此时我们的进程的pcb也就是我们的这个节点都会被连入到我们的运行队列中等待调度,当我们这个进程需要读取硬件资源的时候,此时就会从运行队列中断链,然后进入到我们的相关硬件的等待队列中,此时如果这个硬件需要等待很长时间,此时你的pcb也就是你的这个节点虽然被连入到了这个硬件的等待队列当中,但是你的代码和数据还有这个节点都是存储在内存当中的,如果此时你的内存资源严重不足了的时候,此时为了避免一些悲剧的发生,你还在内存当中,但是你此时却不进行运行什么的,而是一直阻塞了,此时就需要给内存当中腾地方了,此时就会把你的代码和数据交换到磁盘当中的一个swap区中,先存放到这里,把内存的这一部分空间先释放,此时内存中只存在这个节点了,代码和数据都被释放了,此时空间就被腾出来了,此时这个被释放了的进程的状态就叫做阻塞挂起状态。

        但是我们在cpu的调度的等待队列中也是很浪费内存空间的,随意也会把这些内存放入磁盘的swap区中,调度到你了再换回来,这些进程的状态叫做就绪挂起状态。

        

        我们写了一个循环代码,我们查看一下它的状态。

        

        我们把打印语句去掉我们再来运行一下看看它的状态。

        

        我们再来查看一下。

        

        为什么此时变成R状态了呢?

        这个R叫做运行状态我们很好理解,因为这个程序在运行,但是上面的s是怎么回事呢,它是什么状态呢?

        S叫做浅度休眠状态也就是我们所说的阻塞状态。

        为什么会是阻塞状态呢?

        因为我们打印了,printf打印了,打印你要访问哪个硬件啊,当然是我们的显示器啊,此时它就要进入到我们显示器的等待队列当中等待显示器的调度了,此时就是阻塞状态了。

        有人可能会问了,为什么都在运行为什么是在这个队列当中啊,因为我们的显示屏一秒能打上亿次,我们这里一秒才打印了几次啊,几百次都不到,说明显示器这个硬件还在显示其他进程的东西,我们进去需要打印的时候,你肯定是需要排队的啊,所以我们这个代码大部分时间是在我们的显示器硬件的排队队列当中等待调度的。

        我们查很多次都是这个S状态,说明就是大部分时间都在等待,我们如果运气比较好的时候,当它刚进入while循环的时候,判断条件的时候,你查看了,此时就是R状态,但是几率非常小。

        为什么叫做浅度休眠状态呢?

        因为这些代码的状态都能响应我们的外部事件,比如我们使用Ctrl+c能直接结束程序,我们输入数据能直接打印,这就是能相应我们的外部事件。

        下面我们讲一个D状态就是我们的深度休眠状态。

        什么是深度休眠状态呢?

        我们讲个小故事来理解一下。

        

        我们有磁盘,进程,操作系统三个人,我们的进程的代码和数据和操作系统都是存放到内存中的,此时,我的进程需要给磁盘1GB的内存,让磁盘帮我存一下,因为你进程把数据给了磁盘磁盘需要看看自己的磁盘空间是否有地方放这一块数据啊,此时这个进程就需要等这个磁盘的反馈啊,但是此时操作系统过来了,因为内存的资源严重不足了,此时要是不删除东西,内存就要崩了,都要挂掉了,此时就会造成严重的事务,此时进程等待的时候处于的状态就是我们的S状态,就是阻塞状态,因为你要等吗,但是此时内存严重不足了,他们都是S状态,操作系统直接把进程删了,因为要保全自己的内存吗,只能删除进程了,此时进程不见了,磁盘空间又不足,此时磁盘给进程反馈,但是进程不见了被操作系统删除了,为了缓解内存资源,所以此时磁盘也没地方放这1GB的数据,所以只能释放了,所以只1GB的数据就丢失了,这1GB的数据可能是这一整天银行的存取款的记录,可能是1个亿,此时就造成了严重的后果了,此时行长把他们三个叫过去,看看谁背锅,此时行长看向了磁盘,磁盘就说自己磁盘内存不足是常有的事情,我让进程等我,如果内存不够我还把数据还给进程并给进程一个反馈让它反馈给用户,但是进程并没有等我,所以不怪我,此时行长看向了进程,进程就说不是我不想等,而是操作系统把我删除释放了,我没法等啊,此时行长又看向了我们的操作系统,操作系统就辩解着说,自己是为了保全其他更重要的数据迫不得已才把这个数据删除的,而且这个权力还是行长你给我的,此时行长发现所有人都没有错,但是为了避免此类事情的发生,指定了一个规则,就是说进程啊,你下次再给磁盘传数据,磁盘让你等他的时候,你把你的状态设为D状态,而不是S状态,又说操作系统啊,如果下次内存资源严重不足的时候,你删除S状态的进程不要删除D状态的进程,此时就解决了这个问题,此时这个D状态就叫做深度睡眠状态,操作系统无权删除,只能等进程自己醒来,进程要是醒不来,就只能拔电源了,重启也没有用,此时深度睡眠不对外部任何事件进行响应,操作系统没法删除它。

        所以我们的D状态和S状态都叫做我们的阻塞状态。

           我们还是先把我们的进程跑起来。

             

        我们通过这个指令,-19就是我们SIGSTOP信号,后面的是我们进程的pid。

     

     

        此时我们的这个进程就处于T状态了,也就是暂停状态。

        

        我们给进程一个18的信号,这个进程就恢复了。

        

        但是此时这个状态怎么叫R状态了啊,为什么不是R+状态了,这里我们来解释一下,R状态叫做后台进程无法通过Ctrl+c杀掉程序,只能通过我们的kill -9 +pid的方式结束掉进程,我们的R+叫做前台进程,可以通过Ctrll+c直接杀掉进程。

        

        t状态

        我们写了这样的一个代码我们来调试一下。

        我们通过调试打开一个代码,此时这个代码的状态就是S+状态的。

        下面第二个进程是我们生成的临时文件帮助我们调试用的一个进程,你先这样理解就行。

        我们在第五行打了个断点通过r让它跑起来。

        

        此时代码变成了t状态,这个是因为被追踪,因为断点而停下来的状态。

        结束状态,僵尸进程和孤儿进程

        下面我们来谈一下进程的结束状态,进程结束有两种状态(X和Z),一种是X状态就是进程结束了,是进程创建的反过程,下面讲个例子理解一下。

        进程创建出来就是为了完成任务的,进程结束的时候不能立即释放该程序的所有资源,就像,如果我们大街上,突然有一个人因为突发情况去世了,这时候110和120过来的时候,不会直接把人抬到车上,而是封锁现场,先调查死亡原因,我们的操作系统也一样啊,一个进程结束的时候,它的资源不会立即被释放掉,而是会处于一种僵尸状态也就是我们的Z状态,代码数据会被释放掉,但是这个task_struct会保留,也就是我们的这个节点会保留,来接收我们的退出信息,也就是我们的return 0相当于,然后通过退出信息看看进程是否是正常结束的,方便我们父进程读取退出码,读取完之后就会进入到我们的X状态了。

        下面我们模拟实现一下。

        

        

        我们运行了一下,并且一直看它的状态,发现前几次还是R+状态,后面变成Z+状态了。

        此时子进程虽然退出了,但是它的结点还存在,所以此时是僵尸进程,如果父进程不读取它的退出码,它的僵尸进程一直会存在,此时就会存在内存泄漏,这个问题需要我们后面用父进程读取他来解决,也可以通过进程回收来解决。

        如果我们的父进程先没了呢?

        

        我们把代码改了一下,此时我们再来运行看一下。

        

        进程=内核数据结构+代码和数据。

        先创建内核数据结构,再加载代码和数据,创建内核数据结构,没有加载代码和数据的时候,此时这个状态叫做新建状态。

        /

        为什么我们的子进程的父进程变为1了啊,这个1是谁啊?

        

        这个1就是我们的root,我们的这个子进程的父进程没了,此时这个子进程就会被root领养,就是为了处理这个子进程的,防止出现僵尸进程内存泄露的。

        此时这个进程就叫做孤儿进程,相信大家此时存在三个问题。

        

        我们此时已经解决两个了,此时我们来看一下第三个,就是父进程结束了,为什么没有存在僵尸状态呢?

        因为我们都知道,进程在开始运行的时候,所有进程的父进程都是我们的bash我们上面讲过,只有你通过fork创建的进程,它的父进程才不是bash,我们这里没有出现僵尸进程的原因就是我们的bash这个父进程直接帮我们处理了,防止内存泄漏,直接帮我们处理了这个进程。

        此时我们的子进程的状态变成了S,就是后台进程,我们上面也讲过。

        

四 进程优先级

      4.1 什么是优先级 

        进程得到某种资源的先后顺序,这个是很好理解的。

        优先级vs权限

        权限是我们能不能做某种事情,而优先级是我们能做了,只是先后顺序的问题。

        为什么会存在优先级呢?

        就是资源少,进程多。

        Linux优先级是怎么做的?

        大部分进程都是竞争cpu资源。

        我们看一下优先级。

        

        通过这个代码。

        通过ps -al这个命令来看我们的优先级。

        这个PRI就是我们的优先级,数字越小表明越靠前。

        

那NI呢?就是我们所要说的nice值了,其表⽰进程可被执⾏的优先级的修正数值

PRI值越⼩越快被执⾏,那么加⼊nice值后,将会使得PRI变为:PRI(new)=PRI(old)+nice

这样,当nice值为负值的时候,那么该程序将会优先级值将变⼩,即其优先级会变⾼,则其越快

被执⾏

所以,调整进程优先级,在Linux下,就是调整进程nice值

nice其取值范围是-20⾄19,⼀共40个级别。

        我们在代码中如何修改我们的优先级的值呢?

        通过top命令,先进入了。

        

        按下r回车此时你就可以设置你的nice值了,通过公式就可以计算出这个值了。

        

        我们输入了10。

        我们再来查看一下。

        

        此时优先级变成90了。

        我们再来设置一下。

        

        此时给个-10来看一下。

        我们期望的是变成80了。

        

        为什么变成70了呢?

        因为我们要理解一个概念,这个公式

        的old PRI你可以理解为值一直都是80不是我们理解的上一次的我们的PRI的值,因为这样方便我们的操作。

        

        为什么要有范围呢?

        所以我们进程的优先级范围就是[60,99]了。

        因为我们的操作系统是分时操作系统,尽可能的公平公正。

         你就像我们在抢票的时候,如果我们的优先级范围过大,那么就会直接决定谁会抢到票谁抢不到票,这是很不公平的,所以我们规定一个范围,让它在可控范围呢变化。

        一直有优先级高的人插你的队,此时你就长时间抢不到票,你就相当于一个进程,长时间得不到资源,就会导致进程饥饿问题。

4.2查看进程优先级的命令

        

top
进⼊top后按“r”‒>输⼊进程PID‒>输⼊nice值注意:
其他调整优先级的命令:nice,renice
系统函数:
#include <sys/time.h>
#include <sys/resource.h>
int getpriority(int which, int who);
int setpriority(int which, int who, int prio);
        了解一下就行,这个基本不用。
        

4.3 补充概念-竞争、独⽴、并⾏、并发

        

        第一句话是很简单的,竞争就是资源有限,但是我们的进程是大量的,所以要竞争资源吗。

        第二个独立性是很重要的,就是多个进程运行互相不干扰,就像你打开了多个应用,其中一个应用死机了会影响其他应用吗,肯定是不会的,我们怎么理解进程和进程的运行是互相隔离的呢?

        

        第三个也是很好理解的,就是并行吗,同时多个软件同时运行吗,很简单理解。

        第四个举个例子就是,你是一个渣男,有很多女朋友,你每个时间段陪着不同的女朋友干不同的事情,然后你的女朋友们都认为你是他们独有的其实是不断切换的,怎么理解呢,就是多个进程在运行的时候,cpu会给每个进程都运行不同的时间,让进程都得以推进,而不是把一个进程安排好再关心其他的进程,这就叫做并发。

        物理层面上理解一下就是,我们只有一个cpu,但是在逻辑上,你可以认为有多个cpu,只不过这些cpu的效率都变得很低,总效率加起来才等于我们的这一个cpu。

        

        五.进程切换

        CPU上下⽂切换:其实际含义是任务切换, 或者CPU寄存器切换。当多任务内核决定运⾏另外的任务 时, 它保存正在运⾏任务的当前状态, 也就是CPU寄存器中的全部内容。这些内容被保存在任务⾃⼰的堆栈中, ⼊栈⼯作完成后就把下⼀个将要运⾏的任务的当前状况从该任务的栈中重新装⼊CPU寄存器, 并开始下⼀个任务的运⾏, 这⼀过程就是context switch。

        

        举个我们熟悉的例子就是。

        我们函数的返回值问题:

        函数返回值返回的并不是它本身,而是拷贝,这个拷贝生成的临时对象就是在寄存器中的。

        时间片:当代计算机都是分时操作系统,每个进程都有它合适的时间片(其实就是一个计数器)。时间片到达,进程就被操作系统从CPU中剥离下来。

        进程切换简单来说就是你的一个进程cpu调度一会儿之后,你出去的时候要把你的上下文数据全部带走,然后再让下一个进程进来调度。

        

六.Linux2.6内核进程O(1)调度队列

        

        看一下这张图,先看我们框住的部分,我们的这个queue这个数组为什么是140个大小呢?

        我们看100往后的,正好是40个优先级,而且我们的进程的优先级范围我们也讲了就是在这个范围中的,所以这40个位置正好对应了40个不同优先级的进程,然后通过链表把他们链接起来。

        

        就是如图所示的。

        我们的这个int bitmap[5],为什么是5呢?

        因为我们的int占四个字节是32个比特位,32*5=160,是我们的位图。

        我们正常查看此优先级位置是否存在进程,还需要进行数组的遍历,效率是有点低的,并且还占用很多内存,因为需要一个专门的数组负责这个吗。

        但是如果使用我们的位图的话,我们的这个过程就会变得很简单,我们来看一下,我们的这个bitmap数组大小是5个空间,就是160个比特位,我们160/40=4,就是每四个比特位就表示我们的一个位置的优先级是否存在进程,我们的0000就是表示没有进程,我们如果不是0000就表示存在进程,这个做法可以使我们的查找效率变高,也可以使我们占用更少的空间。

        

        为什么是两套这个呢?

        

        他也是通过一个结构体封装了这三个变量。

        

        通过这个数组来实现的。

        

        这两个指针一个active指向的是我们的活跃进程,一个是指向过期进程。

        cpu调度的时候就是直接从active指针找到我们对应的queue[]的内容的。

        新增进程或者时间片到了的进程,被从cpu剥离下来,被剥离下来的进程只能重新入队列,入过期队列当中。

        

        我们其实存在两种这样子的队列,一个是活跃的,一个是过期的,我们活跃队列中的进程被cpu调度完之后,只能进入到过期队列不能再重新插入到我们的活跃队列当中。

        最后所有的活跃队列中的进程都会进入到过期队列当中,此时只需要交换一下active指针和过期指针的指向就行了,此时我们的active指向的进程能够继续被调度了,因为我们的cpu是通过我们的active指针进行调用的,周而复始,这叫O(1)算法。

        你像我们调整优先级的时候,我们如果直接改变优先级,此时它就需要直接在活跃队列当中调正我们进程所在的位置,这样是很麻烦的,所以我们加入了一个nice值,你此时在活跃进程当中还正常调用你的80的优先级,然后进入到过期队列当中,再通过我们的公式调整我们进程所在的位置,此时只需要调正一次就行了,因为我们的程序是并行运行的,,一个进程只调度一点时间,然后调度下一个,是多次调度来完成的,所以才需要这个麻烦的过程,你调整优先级的时候是在过期队列中才给你完成调整的,此时是不会出现饥饿问题的,因为我们也说了,进程的优先级是在过期队列当中完成的,即使你的过期队列当中的优先级是高于我们活跃队列的,此时还是需要我们先把活跃队列当中的进程运行完成之后才能去调度我们的过期队列当中的,因为我们活跃队列调度完成之后,我们此时的过期队列就要变成活跃队列了,因为我们的cpu不是串行运行的不是先把一个进程运行完之后才运行其他进程的,而是并行的,是每个进程调度一点时间,然后进入到过期队列等待下次调度,是并行的。

        

        看一下我们Linux底层的源代码,左边的就是我们的rq队列,我们圈住的就是我们的上面所说的struct q结构体。

        它的第二个参数就是一个宏替换,你直接看我们上面的结构体来理解就行了。

        

        

        

        下面我们来讲讲,0-99是给谁用的。

        Linux操作系统不仅在互联网中被使用,也在工业中使用。

        我们在互联网中使用的Linux操作系统它是分时操作系统,但是它还支持我们实时操作系统的功能。

        这个[0,99]就是来存放这个实时操作系统的一些进程的。

        举个例子就是我们的智能驾驶,如果你此时正在使用智驾,此时你的听歌进程在运行,但是此时你要刹车,需要创建一个刹车的进程,此时要是还是按照我们分时的FIFO公平调度的话,此时就会有危险了,所以此时就要使用基于优先级的实时运行了。

        我们讲完这里之后就能理解就绪状态了,就绪状态就是存在在过期队列当中的进程的状态,但是我们基本不区分运行和就绪状态。

        

七.命令⾏参数和环境变量

 7.1       命令行参数

        什么是命令行参数呢?

        下面我们来讲个例子帮助我们理解一下。

        

        我们都知道我们的main函数也是可以传参的,下面我们来运行一下这段代码。

        

        我们发现怎么打印这些信息啊,我们再来加些参数。

        

        这些参数都被拿到了,我们怎么理解呢?

        

        就是这样子,你可以把我们输入的命令看成一个字符串,然后这个字符串通过操作系统分为了我们这几个字符串,然后都给了我们的argv数组。我们的argc也跟着++。

        

        思考一下这三个问题。

        我们为什么要这么做呢?

        下面我们来写个代码理解一下。

        

        我们完成了一个这个代码,下面我们来演示一下。

        

        这个就说明你运行的时候没有带选项。

        我们加上选现看一下。

        

        看到没,此时我们就可以完成不同的功能了,回归到我们的Linux代码中,我们看一下。

        

        你像我们输入的这种命令,此时这个后面的选项就是通过我们的这种方式来拿到的。

        

        我们的ls指令本质就是程序,选项的本质你们可以看一下。

        那么谁来做呢?

        我们思考一个问题,就是我们的命令行在没有按下回车运行的时候,这个命令行是被谁拿到的呢?

        是我们的bash,为什么是他呢?

        因为它是我们所有开始运行进程的父进程。

        

        总结一下就是这个。

        

        这个主要就是想说我们的这个指针数组的最后一个元素一定是NULL结尾。

        

7.2 环境变量

        

        从这六个方面来讲解一下。

        先讲一下我们的历史经历,相信大家都遇到过下载某种软件的时候,都需要配置我们的环境变量,这个工程是很痛苦的,你像我们在装一些mysql和java什么的开发的时候都要配置环境变量,这是发生在我们身上的一些经历。

        2. 环境变量的定义

        是系统级别的一些全局变量,具备不同用途。

        3. 简单罗列

        PATH:

        

        看一下我们这个命令,我们在运行ls的时候直接输入ls就可以运行了,但是我们输入我们自己生成的a.out的时候,为什么要指定路径呢?

       

        操作系统在执行指令的时候会先去找,如果你没有指定路径,那么它就会在我们的PATH环境变量中去找我们的指令,看看是否存在, 存在就执行,不存在就报错。

        

  

        我们可以通过这个指令来帮助我们查看我们PATH环境变量下配置的路径了。

        你如果没有指定路径,它会在这些路径中找,看看是否存在。

        我们验证一下。

        

        我们把PATH路径置空,然后查看发现什么都没有,此时我们就无法直接执行命令了。

        

        看到没,我们怎么恢复呢?

        只需要重新登录一下就恢复了。

        

        我们也可以把我们的当前路径也加到我们的PATH环境变量下,就是通过我们export这个命令,PATH=就要写新路径,$PATH就是我们的old 路径,然后:+我们要添加的路径即可。

        此时我们再运行就不需要指定路径了。

        

        此时我并没有指定路径也能执行。

        我们通过env命令来查看一些环境变量。

        

        我们来解释一下,前两个没什么好说的,我们直接看第三个,这个就是我们为什么可以通过按下上下键来翻阅我们的历史命令,因为他被记录下来了,这个变量表示的就是最多被记录多少条命令了。

        我们可以通过cd -可以进入我们上次的路径这个命令的本质就是OLDPWD记录了我们的地址了,所以你才可以一直跳。

        这个SSH_TTY简单理解就是我们的Linux一切皆文件,显示屏也是文件,我们这个地址就是对应我们当前显示屏的地址,我们之前也讲过。

        最多的那个变量就是设置色彩的不用管,pwd可以获取当前地址就是依托这个PWD环境变量的,这个HOME记录的是我们的家目录,这就是我们cd ~能进入家目录的原因,这个LDGNAME记录的是我们的当前用户信息,我们通过whoami可以查出当前用户的原因,其他的环境变量先不用看。

        

        我们的main函数还有第三个参数,这个数组中存放的是我们的环境变量。

        

        写个代码验证一下。

        

        我们发现把我们的环境变量打印出来了。

        他这个数组也是以NULL结尾的。

        这个也能获取到环境变量,既可以向上图一样获取全部的环境变量,也可以输入环境变量名来获取单个的环境变量。

        

        通过这个方法也是可以的,这是c语言给我们提供的一个二级指针,它指向的就是那个env那个指针数组。

        我们的进程是如何获得环境变量的呢?

        不是我们的进程获得了环境变量,而是我们的父进程获得的环境变量,形成环境变量表。

        环境变量表是数据吗?

        是数据,是我们父进程的数据,子进程会继承父进程的代码和数据。

        bash又是从哪获得的环境变量的呢?

        系统的配置文件来的,环境变量表是内存级别的,你就算删除了,重新登陆也会恢复的。

        那这个系统文件又在哪里呢?

        在我们的家目录的这两个文件当中,在我们启动xshell登录成功的时候它就会自动执行配置的。

        为什么要有环境变量呢?

        我想写一个只有我自己能执行的程序。

        不同的环境变量都有不同的用途。

        

        这里就表示了,只有你是某个用户的时候,你才能执行相应的代码,就是通过我们的环境变量USER来完成的。

        

        返回负数的时候也能进入这个条件判断,只有0不能进入。

        

        

        我们还有一个getcwd方法,这个也是获取当前路径的,那我们到底用哪个啊,pwd环境变量来获取还是这个来获取啊,这里给大家说一下,你的bash的pwd从哪里来啊,就是底层通过调用这个getcwd这个函数不断更新的环境变量的。

        所以环境变量大量是从系统配置文件中来,也有少部分是启动之后,动态获取或者创建的。

        

        此时我们第一个操作就是自己导了一个环境变量,第二个unset就是删除环境变量的。

        我们如果不用export导入的话,你可以理解为只在你的bash或者你的xshell中使用不能在其他地方使用,类似于我们的局部变量,但是在linux中把它叫做本地变量,只在本bash中有效。

        环境变量会被子进程继承,而本地变量不会被继承,你可以在bash中定义一个本地变量,看看他是否会被子进程继承,子进程创建子进程,一直创建都会继承环境变量。

        相信大家还有一个问题。

                这里的MYENY是一个本地变量。

        我们也说了,我们的echo是命令啊,命令都是放在/usr/bin路径下的程序啊,把这个路径添加到我们的PATH中,我们才能直接使用啊,你这个是一个程序,那你的父进程不就应该是我的bash吗,刚才也说了,子进程不继承父进程的本地变量啊,为什么这里继承了呢?

        这个问题不好讲以我们现在的知识储备,你可以先这样理解,就是我们的本地变量相当于类中的private,外部函数当然无法直接访问了啊,但是内部函数可以啊。先这样理解吧。

        

        就是这个命令是内建命令,先了解一下这个名词就行。

        

八.程序地址空间

        

        

        我们使用这个代码来验证一下。

        

        我们发现确实符合我们的正文代码是低地址,栈是高地址,这都得到了验证。

        有一个问题就是,这是物理内存吗?

        不是物理内存而是进程地址空间也叫虚拟地址空间。

        

        看一下这个例子,我们的这个gval是我们的全局变量是可以被子进程继承的,我们发现了一个问题,这个全局变量在父子进程中的地址都是一样的,为什么会存在不同的值呢?

        

        我们能得到这样的结论,如果是物理地址不可能出现这种情况。

        下面简单来讲一下。

        

        看一下这个,我来讲解一下,我们的可执行程序在执行的时候首先需要从磁盘中加载到内存中,此时内存中就会创建我们的task_struct这个结构体,创建结构体的时候也会创建一块虚拟地址空间,虚拟地址的范围就是主要看你是多少位的,如果是32位的,就是从32个0到32个F的范围,虚拟地址和物理地址是通过一个页表来对应的,一个虚拟地址对应一个物理地址。

        

        看一下这个图,我们的父进程的g_val的虚拟地址我们认为它是图中所示的,它就会在页表中对应一个虚拟地址,当我们父进程创建子进程的过程,子进程会进程父进程fork之后的代码和数据。

        

        就是这个样子,因为子进程是继承父进程的,所以,子进程的虚拟地址和父进程也是一样的,页表也是一样的,此时都指向的是父进程的物理地址的g_val,但是我们子进程修改了这个值,因为我们的进程之间都是独立的,不能影响,你子进程修改了,如果影响了父进程可能会造成严重的后果,所以此时我们操作系统就开辟了一块空间,专门把我们的父进程的g_val的内容拷贝到这个新物理地址处,此时只是子进程页表的对应的物理地址改变了,虚拟地址并没有改变,由于我们的代码打印的全是虚拟地址我们是看不到物理地址的,所以我们才会看到一块虚拟地址,存在两个值了。

        

        只改变了物理地址的指向,没有改变虚拟地址的值。

        

        所以我们之前遗留的一个问题,就是为什么会存在两个不同的值呢?

        这里也会得到解决了,我们返回一个值,本质不就是对id进行写入吗?

        由于我们进程是互相独立的,所以此时就会开辟一块新的物理地址空间了,另外一个进程就会指向另外一块物理地址空间了,虽然虚拟地址一样,但是物理地址不一样,所以导致了两个不同的值。

        什么是虚拟地址空间呢?

        举个例子再来理解一下吧。

        从前有个大富翁,这个大富翁有4个儿子,这4个儿子之间互相不知道对方的存在,所以大富翁就给他们画饼说,我过世了这些钱全是你的,给四个儿子都说了这话,但是真到他过世了他也不可能给这四个儿子都给全部钱,这是不现实的,但是只要大富翁存在,这四个儿子也不可能直接张口要全部的钱,也不敢要,这里的四个儿子就是进程,大富翁就是操作系统,给他们画的大饼也就是全部的钱就是我们的虚拟地址空间,比如四个进程要是全问操作系统要全部的钱,操作系统也给不了啊,这就是画的一个饼。

        划分区域

        就是通过一个结构体通过两个变量一个记着起始地址一个记着结束地址,这样就完成了区域划分。

        所以我们的虚拟地址空间就是我们的一个内核数据结构来管理这个区域,通过大量的开始和结束变量来划分这个区域。

   我们在底层看一下这个神秘的虚拟地址空间吧。

   在我们的task_struct结构体中存在一个如下的变量。

     

        这个mm_struct这个结构体就是管理我们虚拟地址空间的,我们点进去看一下。

        就是这样子的,它就是通过不同的变量记录该区域的起始和结束的位置来划分这个虚拟地址的。

        

        这里就是你划分完这里的虚拟地址之后,在物理地址处也会给你开辟一块相当大小的空间,此时就可以通过页表把他们关联起来了。

                                

        这个4G的区域,我们下面的3G是我们用户可以直接使用的直接访问的,上面的1G是内核空间,我们用户是无法直接访问的,必须由系统调用。

        

        我们的进程等于我们的这些东西,我们也都知道,你进程的内核数据结构都是直接拷贝父进程的,你的页表,虚拟内存地址,还有我们的页表信息都是拷贝父进程的,所以虚拟内存的地址指向物理空间的地址也是对应一样的,但是我们的代码和数据虽然一样,但是只是具有可读行,你要是修改,就要发生写时拷贝了,此时数据就分离了。

        这是我们代码申请内存的一个过程。

        

        我们的页表当中是存在这个叫做一个权限的区域的,专门就是存放你对哪个区域有什么样的操作,举两个最有力的就是我们的已初始化数据区和我们的代码区,我们的代码区只有只读操作而我们的已初始化区有读和写的操作。

        下面我们来看一下下面这个代码。

        下面我们先来了解一下页表,再来继续讲。

        

        我们来分析一下这些地址,我们的函数在代码区我们的全局变量在已初始化数据区,所以它俩的地址才会离得那么近,我们的全局变量和静态变量能一直被访问就是因为它的地址空间一直存在,所以才可以被一直访问,直到结束。

        

        这个可以被修改吗?我们的字符串,这里的字符串不是string,string是我们实现的数据结构,它是存在在栈区的,答案是不可以的,因为我们的字符串是存在在我们的代码区的,代码区有一部分区域叫做常量区,当你试着修改这个的时候,此时就会找到你的虚拟地址,然后映射我们的物理地址,然后再查看你的权限,发现你没有w权限,所以就会杀死进程,我们的代码就崩溃了。

        

        既然你本身就不能修改,那为什么还要加上const呢?

        const的作用就是约束我们的编译器的,让编译器知道我们的这个是不能修改的,修改了就报错,此时编译阶段就会报错,而不是运行查页表的时候才会报错,更加安全。

        这个代码就是让我们更好理解这个图的。

                                

        就是上面部分的地址都是大于下面部分的地址的。

        

        为什么要有虚拟地址空间呢?

        简单举个例子就像,以前你的压岁钱都是你自己支配的,你想买什么就买什么,但是你妈发现你乱花钱,所以把你的压岁钱管理了起来了,你要买什么东西还需要先问你的妈妈要钱,然后才能买,就是防止你乱花钱的。

        理由1:因为有了虚拟地址,就必须转换为物理地址,此时我们都知道,每个进程都对应的有虚拟地址和自己的页表映射物理地址的关系,如果我们没有虚拟地址,此时你通过地址直接访问物理地址,物理地址存放的不止你一个进程的地址啊,还存放其他进程的地址,所以你可能访问到其他进程的地址,从而导致一些问题,有了虚拟地址之后,就不会存在这个问题了,因为只要你有虚拟地址,通过虚拟地址访问,只要你的虚拟地址不存在,此时你就访问不到虚拟地址,你存在的虚拟地址都存在与物理地址的映射,此时你无法访问到其他进程的地址,增加了安全性,我们刚才也说了,虚拟地址转换为物理地址需要通过页表,页表存在一个权限列,此时我们不想让修改的数据,就可以把它放在特定的区域,然后只给只读属性,此时你无法修改,变相保证了安全性。

        理由2:

        我们看一下这个过程,我们磁盘中的可执行文件肯定是存在代码和数据的,如果我们没有虚拟内存地址,理论上我们的可执行程序的代码是可以加载到物理内存的任意位置的,此时你一个可执行程序就是一个进程啊,你随便加载那能行吗,你的正文代码和已初始化数据什么的内存地址的存放,肯定是要有序的,此时你随便放,那不就无序了吗,但是如果你存在虚拟内存地址,此时你创建一个进程,物理内存就会为虚拟内存地址开辟相应的空间,就是我需要多大的空间你就给我开辟多大的空间,此时我不管你在物理内存是怎么存放的,你只需要创建完成之后在我的虚拟地址和物理地址的页表中进行映射一下就行,我的虚拟内存地址一定是有序的,你的物理空间申请的内存不一定是有序的啊,此时我们不就把无序的地址变得有序了吗。

        

        我们再来探究一下这个问题,我们上面也说了是先加载我们的内核数据结构的,那么加载完内核数据结构一定会立即加载我们的代码和数据吗,把它放到内存先占着空间吗?

        答案是肯定不会的,举个例子就是你有1000块钱,你两个小时之后要急用,但是此时你朋友也有事需要钱,它使用一个小时就能还你,此时你借给他了,他一个小时之后也还给你了,此时你的一千块钱还是一千块钱,只要在你用的时候给你就行了,中间无所谓啊,所以回归到我们的进程也就是,我们的进程的代码和数据并不会直接加载到内存中占用着内存,而是你进程需要的时候我才加载,因为你进程还要调度什么的,也不是一次就调度完的,进程是并行的,所以此时就是我们用着加载着,然后此时我们的内存空间就可以得到更好的利用了,这种叫做惰性加载,比如我们之前的写时拷贝也是一样的,就是我们的子进程和父进程一个变量的虚拟地址一样,但是我们只要不对它进行修改操作,此时它俩虚拟地址指向的都是同一块物理地址,只有修改的时候才会重新开辟一块物理地址。

        理由三:进程管理和内存管理进行解耦合。

        

        我们的右边叫做内存管理,左边是进程管理,这些都是我们操作系统完成的,什么意思呢?

        就是如果你直接在物理内存上开辟空间,此时我们的进程管理和内存管理的耦合度就会非常高,因为我要知道我的进程都在哪存着的啊,但是有了虚拟地址之后,我不管你的物理地址怎么存放,你只需要把我的信息通过页表关联到我的虚拟地址就行,我不管你物理地址怎么搞的,此时不就解耦合了吗,因为耦合度越低越好。

如何进行堆或者其他区域内存的划分呢?

        我们下面再来思考一个问题,我们上面也说了虚拟地址是如何划分空间的,就是通过一个mm_struct结构体,给每个区域一个start和end来表示自己的区域,但是我们的堆空间如果按照这种方式的话,你在堆空间申请一小块内存,因为你只有堆的开始和结束的地址,并没有中间某个区域的地址啊,此时无法统计这一小块的地址,此时要么堆空间就会把内存全部给你了,,要么你就无法管理你这一小块的地址,此时你就无法再次向堆申请内存了,但是我们都知道我们是需要多次向堆空间申请内存的啊,这是怎么做到的呢?

        我们直接来看源码。

        

        这是我们的虚拟内存结构体,我们只需要看我们选中的这个结构体就行了,下面我们来介绍一下它。

        

        我们进到了这个结构体中了,我们来看一下,此时在我们的虚拟地址空间结构体中我们存在了一个指向这个结构体的一个指针,就是管理这个东西的,我们进入到这个结构体后我们发现,他也是存在这个start和end的,就是你在向堆申请空间的时候,此时就会创建出这样的一个结构体,然后通过给它一个起始地址和结束地址,需要在我们的这个堆的地址范围内,然后我们通过mm_struct这个结构体指针来回指向我们是属于哪个进程的虚拟地址空间中的对象,然后再通过我们的vm_next通过我们的链表的形式把我们这些结构体管理起来,下面的rb_node是红黑树结构体,就是少量的话就用链表管理,多了就用红黑树管理。

        这个就是管理我们权限的,通过你对这块区域的操作,然后和我们拥有的权限匹配如果出现非法操作就杀掉进程。

        

        通过这个图帮你理解一下。

        我们通过这个图来帮助我们理解一下,这个是什么意思呢?

        就是我们不是通过我们每个区域的start和end的long类型的变量划分出区域了吗,此时就会创建出这几个区域的vm_area_struct结构体,然后你把你的起始地址和结束地址给这个结构体,创建出每个区域的vm_area_struct结构体对象来管理这块区域,此时就不用long类型的变量来管理了,比如原来地址start是00000000,end是01111111,此时这个表示代码区,此时我们就要创建一个vm_area_struct,把这个start给我们vm_area_struct中的start,end也是同理啊,此时你可以理解为这块区域由原来的long类型的变量管理的升华到由结构体来管理了,通过我们mm_struct中的vm_area_struct结构体指针来管理我们的这个虚拟地址划分区域的管理,那么此时我们就很好区分区域了啊,因为我们每块区域都存在自己的地址,只需要看地址在哪块区间就可以知道我们的结构体对象存在哪个区域当中啊,此时你要对这些代码段或者堆区的区域进行划分,再次创建这个对象,然后通过这个vm_area_struct结构体中的vm_area_struct结构体指针连起来,此时你就可以很好的管理这些区域了,我们以堆为例,你申请一次空间我创建一个对象,给它所需的start和end,然后通过我的vm_area_struct结构体指针指向它,和我们的链表一样,这样就把这个连接起来了。

        以上就是我们的全部内容了。

九.结束语

        感谢读到这里的每一位朋友!技术之路漫长,每一次代码的调试、每一个知识点的梳理,都因你的驻足而更有意义。如果文章对你有帮助,欢迎点赞收藏,也期待在评论区和你交流更多技术细节~本期的技术分享就到这里啦!感谢你的耐心观看。文中若有疏漏或更好的优化方案,欢迎随时指出,一起在技术的世界里共同进步!

<think>好的,用户问多线程、MPI和并行编程是不是同一个东西。我需要先理清这三个概念的关系和区别。 首先,用户可能对这些术语不太清楚,或者在学习中遇到了混淆的情况。他们可能是在学习并发编程或高性能计算,所以需要明确的概念区分。我需要用简单易懂的方式解释,避免技术术语过多。 多线程属于并行编程的一种,通常是在单个进程内利用多个线程来执行任务,共享内存,适用于多核处理器。而MPI是消息传递接口,用于分布式内存系统,比如集群计算,每个进程有自己的内存,通过消息传递通信。并行编程是一个更广泛的概念,包括了多线程、MPI、OpenMP等技术。 用户可能想知道这些技术之间的区别和应用场景。需要举例说明,比如多线程适合共享内存的情况,MPI适合跨机器的分布式计算。同时要指出并行编程作为总称,涵盖这些方法。 还要注意用户可能的背景,如果是初学者,可能需要更基础的例子,比如多线程用于加速数据处理,MPI用于科学计算。需要检查是否有其他相关技术需要提及,比如GPU并行计算,但根据问题,可能不需要展开。 最后,总结三者关系,强调它们都是并行编程的手段,但应用场景和实现方式不同。确保回答结构清晰,帮助用户建立正确的知识框架。</think>🔍 **一句话回答**: 它们都用于**提升计算速度**,但**设计层级和应用场景不同**,可以理解为“用不同工具解决不同规模的并行问题”。 --- ### **一、核心关系图** | 概念 | 定位 | 典型场景 | 内存模型 | |-------------|-----------------|--------------------------|------------| | **多线程** | 单机多核CPU的并行 | 数据处理、GUI响应、Web服务器 | 共享内存 | | **MPI** | 跨机器的分布式并行 | 超级计算机、大规模科学计算 | 分布式内存 | | **并行编程** | 涵盖所有并行技术的总称 | 从单机到超算的所有并行场景 | 多种模式 | --- ### **二、逐层解析** #### **1. 多线程(Multi-threading) → 单机内的“分工协作”** - **核心原理**: - 一个进程内创建多个线程,共享内存 - 依赖**锁机制**(如synchronized)避免数据竞争 - **代码示例**(Java): ```java // 创建两个线程同时打印数 new Thread(() -> { for (int i=0; i<10; i++) System.out.println("线程A:" + i); }).start(); new Thread(() -> { for (int i=0; i<10; i++) System.out.println("线程B:" + i); }).start(); ``` - **优点**:开发简单,适合**IO密集型任务**(如网络请求) - **缺点**:线程数受CPU核心限制,跨机器无法扩展 #### **2. MPI(Message Passing Interface) → 跨机器的“团队作战”** - **核心原理**: - 不同机器上的进程通过**消息传递**协作(发send/收recv) - 每个进程有独立内存,适合超大规模计算 - **代码示例**(C + MPI): ```c #include <mpi.h> int main() { MPI_Init(NULL, NULL); int rank; MPI_Comm_rank(MPI_COMM_WORLD, &rank); if (rank == 0) { int data = 100; MPI_Send(&data, 1, MPI_INT, 1, 0, MPI_COMM_WORLD); } else { int recv_data; MPI_Recv(&recv_data, 1, MPI_INT, 0, 0, MPI_COMM_WORLD, MPI_STATUS_IGNORE); printf("进程1收到数据:%d\n", recv_data); } MPI_Finalize(); } ``` - **优点**:可扩展性强,支持**数十万核**并行 - **缺点**:编程复杂,需手动管理通信 #### **3. 并行编程(Parallel Programming) → 广义的“并行方法论”** - **涵盖技术**: - 多线程、MPI、OpenMP、CUDA(GPU并行)、Spark(分布式计算)等 - **核心目标**: - 通过任务分解(如MapReduce)、数据分割(如矩阵分块)提升效率 - **典型应用**: - 天气预报(MPI)、深度学习训练(CUDA)、实时推荐系统(Spark) --- ### **三、对比表格** | | 多线程 | MPI | 并行编程 | |----------------|--------------------|----------------------|--------------------| | **适用规模** | 单机多核 | 多机集群 | 所有规模 | | **通信方式** | 共享内存 | 消息传递(网络) | 多种模式 | | **开发难度** | 简单 | 复杂 | 取决于具体技术 | | **典型框架** | Java Threads, OpenMP| OpenMPI, MPICH | MPI/OpenMP/CUDA/Spark | | **硬件要求** | 多核CPU | 高速网络连接的集群 | 无特殊限制 | --- ### **四、如何选择?** 1. **单机快速开发** → 多线程(Java/C#)或OpenMP(C/C++) 2. **科学计算/超算** → MPI + 高性能集群 3. **GPU加速** → CUDA(NVIDIA显卡)或OpenCL(跨平台) 4. **大数据处理** → Spark/Hadoop(分布式并行框架) --- ### **五、避坑指南** 1. **多线程陷阱**: - 死锁(两个线程互相等锁) - 竞态条件(未同步导致数据错误) 2. **MPI陷阱**: - 消息不匹配(发送和接收的数据类型/数量不一致) - 通信死锁(所有进程都在等待接收) 3. **通用原则**: - 优先用现成框架(如Spark),避免重复造轮子 - 测试时从小规模逐步扩展 --- **动手建议**: 1. 用Java写一个多线程累加器,观察不加锁时的数据错误 2. 在Linux集群上运行MPI示例,体验跨进程通信 3. 用Python的`multiprocessing`模块对比多进程与多线程的速度差异
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值