[Linux] 进程概念

目录

1.冯诺依曼硬件体系结构

2.操作系统(OS)

3.系统接口

4.进程的概念

5.进程状态

6.四个其他概念

7.环境变量

8.进程地址空间


1.冯诺依曼硬件体系结构

在冯诺依曼体系结构中,计算机是由输入、输出、存储设备和中央处理器cpu组成的。图中体结构的存储器专指内存,内存的一个特点就是带电存储,掉电易失。 而磁盘等具有永久存储能力的设备输入外设。输入设备和输出设备都是外设,磁盘既是输入设备也是输出设备。 外设是相对于内存和cpu来说的,体结构中的运算器和控制器都是和并在cpu中的。

之前我们就提到过,这些硬件中,cpu的速度是最快的,内存次之,最慢的就是外设 ,他们的速度快慢是相对而言的,同时这三者的速度差距都是数量级的差距。

cpu虽然速度很快,但是他却只能被动接受别人的指令和数据,然后执行别人给的指令和计算其数据。所以cpu首先必须能够认识别人的指令,比如我们的二进制可执行文件,cpu有自己的指令集,是以硬件的方式存在cpu中的。所以编译器的编译工作将我们写的代码翻译成二进制可执行程序的本质就是将代码翻译成cpu内部的指令集中的指令,从而让cpu执行代码的操作。 

在上面的图中我们可以看到,cpu在数据层面只与内存交互,从内存中读取数据,同时将运算的结果写入到内存中,这是因为外设的速度相对于cpu而言实在是太慢太慢了,内存虽然也比c慢,但是比外设的速度还是要快得多的,只与内存打交道是为了提高整机的效率,类似于短板效应。

cpu要从内存中拿数据,那内存中的数据又是从哪来的呢?我们知道内存是带电存储的,掉电之后内存数据就失效了,再次上电之后就是一个重新将数据载入内存的过程。而数据是存储在磁盘上的,一般在我们的cpu要使用某些数据之前,会预先将磁盘中的一些数据载入到内存中,这其中最重要的就是我们的操作系统,在开机的时候,一颗cpu会去运行BIOS的程序,之后启动硬盘,将操作系统载入到内存,然后由操作系统接管整台计算机的控制权。当操作系统启动之后,操作系统会帮我们进行预先将数据载入内存、定时刷新、定时写入等操作,将计算机的软硬件管理起来。

于是在数据层面上我们有两个结论:

1.cpu不和外设交互,值和内存直接交互。

2.所有的外设有数据需要在如,只能载入到内存中,而cpu要写数据,也只能写到内存中,然后再由操作系统刷新内存写到外设中

这就是为什么我们的程序运行首先要将程序加载到内存中。因为cpu要读取我们的代码指令和读取我们的数据,只能通过内存来读取。

cpu在控制层面和外设是由交互的,比如上面的控制器就是用来响应外设的请求的。

2.操作系统(OS)

操作系统是一个进行软硬件资源管理的软件。它通过合理的管理软硬件资源(手段),为用户提供良好(稳定安全高效)的执行环境(目的)。

操作系统的四大软件管理模板:进程管理、文件系统、内存管理、驱动管理、

操作系统作为软硬件资源的管理者,它是如何进行硬件管理的呢?首先我们给出一个结论,就是操作系统不与硬件直接交互。既然要进行管理,那么他做出的一些操作和决策就必须有一定的依据,而操作系统做出决策的依据就是数据。 只要拿到一个硬件的所有数据(包括属性状态类型等等的所有数据),操作系统就能通过对这些硬件的数据进行分析从而做出相应的决策。但是问题又来了,操作系统都不和硬件交互,他是怎么拿到硬件的数据的以及将决策执行到硬件中的呢?在操作系统和硬件之间还有一层结构就是驱动程序。每个硬件都有对应的驱动软件,而驱动则能够拿到硬件的所有数据将其交给操作系统,同时能够一直获取硬件的数据,保持数据的更新,对操作系统的管理提供依据。同时,操作系统将决策交给驱动软件去执行,驱动软件才直接操作硬件的程序。

当硬件或者硬件的数据很多的时候,操作系统如何更好地进行管理呢?数据的数量可能很多,但是数据中的信息种类是一样的。比如有很多学生,但是学校对学生做管理的时候需要的数据都是同样的信息种类比如姓名、年龄、电话、成绩等等,而如果我们用代码进行管理的话,则是用结构体对学生进行数据的采集管理。那么既然Linux操作系统是用C语言写的,对于硬件的数据也是使用结构体来保存的,这就是操作系统对硬件对象的一个描述的过程。虽然每一个硬件的数据我们都能用一个结构体来保存了,但是这些结构体是杂乱无章的分布在操作系统持有的内存中的吗?杂乱无章的存储是不利于操作系统进行管理的,操作系统是使用合适的数据结构将所有的硬件的数据结构体链接起来的 ,比如我们的链表。 

这里的结构体具体是什么也取决于操作系统的实现。

总结下来就是两个结论:

1.管理的本质是对数据进行管理

2.管理的方法是先描述,再组织

这个逻辑是操作系统管理对象的基本逻辑,对软件软件的管理也是类似的,比如我们的驱动程序。是通过一个个的进程进行管理的。 

描述是语言的问题,组织是数据结构的问题,对对象的管理就变成了对特定数据结构的管理。

3.系统接口

在讲命令行解释器的时候我们就知道,我们是无法直接对操作系统进行操作的。操作系统要对计算机进行管理,事关重大,所以操作系统需要将自己保护起来,不会让任何人能够直接操作。操作系统不会相信任何人,但是操作系统又需要为用户提供各种服务,比如查看磁盘的使用情况、查看当前目录等。所以操作系统必须提供一些接口(函数)来对上层提供服务,简称为系统调用。系统调用的接口是由操作系统提供的,用户将请求以及数据发送给操作系统之后,操作系统会判断你的请求是否合法,然后由操作系统去完成你对应的请求,所以操作系统提供的是接口式服务,就好比银行的业务,银行是通过窗口提供服务的,而不会说任由用户自己进到银行系统里进行操作,用户只负责提供数据以及调用系统接口,以及接收结果,其他的一律由操作系统自身完成。 

Linux的系统接口本质上就是C式的接口,因为Linux就是用C语言写的。 但是在我们的学习过程中,使用过操作系统的shell命令行操作、语言的库提供的接口,好像还没有使用过系统接口,其实我们很多很多操作都间接调用了系统接口,只是不是由我们自己直接调用的,而是有我们使用的操作或接口的实现中调用了系统接口。 就好比我们使用的C语言的printf函数,他将数据打印到了显示器上,肯定是访问了硬件的,但是我们再使用printf函数的时候则很简单,直接将数据以及各式传参给printf就行了,这其实是printf函数的底层实现中调用了外设的系统接口,只是我们没有去关心printf的底层实现。 而我们的shell指令中最常见的就是访问磁盘的数据,比如 ls ,显示当前目录下的文件,本质上也是一个访问磁盘的操作,并且将结果打印到了显示器上,他的实现中也是调用的相应的系统接口的。 只要访问硬件,就一定会使用系统调用,同时系统调用并不是我们自己直接去访问硬件,而是由操作系统去完成,而操作系统本质上又是交给驱动程序去完成访问硬件的操作的。

所以我们可以了解到在我们的操作系统与用户之间其实还有两层结构,一个就是系统调用,由于系统调用的学习成本是十分高的,所以有一些软件在操作系统和用户之间来改善我们的使用体验,这就是shell外壳,库和界面等。外壳程序是为了满足用户的指令要求,而库则是为了满足用户的开发需求。

我们常用的系统调用接口由 fork:创建子进程

                                           waitpid:进程等待    

等等......

4.进程的概念

我们的源文件编译链接之后形成的文件是二进制可执行文件,而这个文件是存在在磁盘中的,要想让cpu能够执行代码中的指令,我们就需要将这个二进制文件加载到内存中。那么我们最简单的思路就是,程序加载到内存,内存中的程序就是进程,cpu执行进程的指令以及读取数据

进程我们就可以理解为一个运行起来的程序,对于运行起来的定义就是加载到内存中。

进程和可执行程序相比,进程具有动态属性。

但是我们说过,操作系统是一款管理软硬件的软件,当我们正在运行的程序数量很少时,上面的逻辑可能还能够管理好,但是实际情况是,我们的计算机,同一时间可能有无数个程序在运行,而不是简单的寥寥几个,当这些程序都加载到内存中时,操作系统要怎么样才能更好的管理呢?  

我们首先要将操作系统的管理的方法运用起来,先描述再组织,首先是用C语言描述进程,每一个进程所在内存的位置这是必须知道的,但是知道这个还远远不够,操作系统管理的成本太大,操作系统要管理一个具体的进程,只知道他的位置是远远不够的,就好比了解一个人,只知道他的家庭地址是远远不够的,对于进程,我们应该要描述得更加详细才能进行高效的管理。  为了描述进程,操作系统学科就有一个PCB(进程控制块 Process Control Block)的概念。PCB是一个笼统抽象的概念,而放在Linux系统里,描述进程的PCB其实就是一个结构体 struct task_struct{ ......};要管理好一个进程,在结构体中就需要保存进程的所有属性比如调度优先级,进程编号,pid、代码和数据的地址和其他的所有属性。这些属性大多在我们磁盘中的可执行文件中是没有的,程序文件中只有代码和数据,进程属性是程序加载到内存时操作系统给的。操作系统在创建一个进程的时候会给每一个进程创建一个进程控制块PCB,里面有该进程的所有属性以及代码和数据的地址,这就是进程的描述的过程。

描述进程之后,操作系统就能对具体的某一个进程进行管理,但是只有描述的话,对于数量如此之多的进程的管理还是很不方便,还需要一个组织的过程,将所有进程的PCB建立一定的关系,这就需要我们之前学到的学科,数据结构。 我们就拿链表来举例,假如将所有PCB以链表的形式链接在一起,如此一来,操作系统只需要直到链表的头,就能访问到所有的进程的PCB,cpu要调度某一个进程时,操作系统通过遍历这一个链表找到对应的PCB以及代码和数据,将其交给cpu去执行。 而当要杀掉某个进程时,也只需要找到对应的节点,将其PCB和代码和数据快释放掉,将链表重新链接就好了。 

这样一来,所谓的对进程的管理,就变成了对进程对应的PCB进行管理,也就变成了链表的增删查改等操作,当然实际肯定不是这么简单,这只是一个粗略的描述与组织的结果。

进程我们了解了,那么怎么查看进程呢?

ps ajx 查看所有进程

我们可以看到 ps ajx 将所有的进程罗列了出来,并且在第一行还有一些描述进程的部分属性。

如果我们只想要看到我们自己的进程,可以用grep进行过滤。

首先将我们的程序以及makefile文件写出来

简单写一个死循环,以便我们在查看进程的时候它还没有结束。

但是我们在前台运行一个死循环的程序之后,我们好像就不能使用其他的shell命令了,我们可以这样操作

筛选出来有两个进程,其中一个是 grep 进程,当我们在筛选进程的那一刻,grep 程序肯定是在运行着的。但是就这样好像看不出来什么东西,我们不知道他的每一个字段代表的意思是什么。这时候我们在将 ps ajx 打印出来的第一行提取出来打印就行

在这些信息中,我们能够知道的就是最后一个 ,  COMMAND ,也就是运行该进程的指令。

在上图中,PID 表示的是进程的编号,是进程的唯一标识。PID我们在很多操作中都需要用到,比如我们的系统信号。

PPID则是父进程的pid ,PGID是组id

我们的系统中可以使用一些信号对进程进行操作,在2号手册中我们可以看待kill系统接口的功能

我们可以直接在命令行使用 kill 命令来发送信号给进程。 

首先好知道有哪些信号,可以使用 kill -l查看信号的列表 ,目前我们常用的三个信号       

而我们使用 kill 操作给进程发信号时,需要指定进程的 pid ,pid 唯一标识一个进程

kill -信号编号  进程pid

同时我们也有一些系统接口能够查询进程的pid ,比如我们的getpid,能够获取到当前进程的pid,以及我们的getppid,能够获取当前进程的父进程的pid。在我们的程序内部就可以使用这两系统调用来获取相关信息

返回值都是 pid_t 类型,也就是我们的 unsigned int 类型。

这两个函数没有参数,哪个进程调用就返回哪个进程的pid或者ppid

还有一种查看进程的方式,在我们的系统中有一个内存级的目录, /proc  ,我们使用 ls 查看这个目录就能查看当前运行的所有进程,

在这个目录下有很多的目录,这些蓝色现实的就是目录或者说文件夹,而他们的名字就是他们的进程 pid ,每一个进程都是一个文件夹来维护。 这就说明了,进程在内存中也是以文件的形式存在的,Linux一切皆文件。 我们打开当前的进程的文件夹,可以发现有一个文件 exe ,这个文件就是我们的可执行程序的拷贝

同时还指明了程序所在的目录。在上面cwd还指明了执行程序时的路径。而其它的文件大多是关于进程的属性等。

如果在程序跑起来之后,我们将可执行程序在系统中删掉,这时候会发生什么呢?

这时候我们发现程序还是继续在跑,因为在程序加载到内存的那一刻,进程就与可执行程序的文件没有关系了,需要的指令和数据已经拷贝到了内存中。但是在进程的目录下我们可以看到 exe 的位置一直在闪烁红色,标示 可执行程序已经被删除。  

与此同时,我们可以对比我们上面的两次运行程序的图,其中的 pid 每一次程序启动都会发生变化,这是很正常的,因为每一次重新启动都是另外的一个进程了 。但是我们发现,他的父进程竟然一直都没有变化,两次都是 19716 ,这时候我们在系统中去搜索一下这个进程目录

我们发现这个进程就是我们的命令行解释器 bash 。我们在命令行上启动的所有进程都是以 bash 的子进程的形式存在的,他们的父进程就是命令行解释器。每次当我们登陆云服务器的时候,操作系就指派了一个shell进程,在命令行上启动的程序,没有特殊情况的话,一般父进程都是shell ,通过指派子进程的方式去运行我们的程序,这样的话,就算子进程出现了问题,也不会影响父进程,父进程还能够回收错误信息来反馈给用户。

创建子进程 fork

fork也是我们的一个系统接口,用于创建子进程。我们可以查看手册

pid_t 实际上就是 int 的typedef

如果创建子进程成功的话,fork有两个返回值,子进程的pid返回给父进程, 0 返回给子进程。如果创建子进程失败的话,则返回-1给父进程。一个函数竟然有两个返回值,这是我们所不能理解的,我们在学习完进程空间就能想明白为什么能有两个返回值了。 

fork是一个函数,用来创建子进程,也就是说,在fork函数之前,只有一个进程,而在函数执行后,则有了两个进程执行后面的代码,这两个进程的代码是共享的。

注意,这里的两个进程执行的先后顺序是随机的,无法保证哪个进程一定先执行。

但是如果只是这样使用fork就太浪费了,父子进程执行相同的功能有些多余,我们更多是让父子进程执行不同的功能,怎么做到呢?就是利用fork的返回值,父进程和子进程的返回值不一样,我们可以加上一条判断来分别让子进程和父进程各自执行一部分代码

这就叫做多进程。我们想追觉得都很奇怪,为什么上面的选择语句 if 和else if 都进去执行了,为什么同一个id有不同的值。我们现在可以简单理解为就是fork之后的代码被父子进程共享,相当于由一个执行流变成了两个执行流,但是他们的数据是各自私有一份(不是所有数据都有两份)通过返回值不同,让父子进程分别执行后续共享代码中的一部分,这就是并发式变成。

5.进程状态

正在运行中的进程是什么意思?我们可能有一点不理解,进程不都是运行中的程序吗,怎么进程还分正在运行和其他的进程,要理解这个概念,我们就需要知道进程的不同状态,我们常见的进程状态有运行、等待、死亡、新建、挂起、阻塞、停止。进程的状态就是进程的属性,那么他就是从操作系统为了描述进程而产生的概念,这么多进程状态的本质就是为了满足不同的运行场景,这一点很容易理解,操作系统会根据进程的状态来对进程进行操作,将进程pcb放到合适的位置。

首先我们需要了解三个概念:运行、阻塞、挂起

运行:我们系统中有十分多的进程需要运行,但是cpu的数量是有限且很少的,就拿只有一个cpu来举例,cpu的资源是有限的,但是cpu又要运行这么多的进程,同时,我们要理解,cpu在同一个时刻是只能运行一个进程的,因为他里面只有一套寄存器等硬件,那么为了确保进程之间的公平性或者说让进程能够公平地竞争cpu等资源,我们就需要对需要cpu执行的进程进行排队,这就需要一个组织的方式。于是操作系统就会给cpu创建一个运行队列,进程队列中按照时间以及优先级的综合顺序进行排队,轮到谁就执行谁

一个cpu配备一个运行队列,在cpu的硬件描述结构体中肯定会有一个指针能够指向这个运行队列,当一个进程需要被cpu执行时,操作系统会将他的进程控制块pcb入队列等待资源。注意,入队列的不是进程的代码和数据,而是pcb。 因为cpu的速度十分快,一般执行一个进程的代码执行完只需要一瞬间,所以并不是说只有正在被cpu执行的进程才被称为运行状态,如果这样的话运行状态只有一瞬间没有什么意义也不好管理,而是只要pcb在cpu的运行队列中排队的进程的状态就是运行状态。

操作系统描述进程状态的可能就是在pcb中创建的一个整数或者一个字符,不同的变量代表不同的状态,并没有我们想象的高大上。

阻塞:虽然cpu执行速度很快,但是外设的速度是相对来说很慢的。但是进程或多或少都需要访问硬件,同时,与cpu一样,硬件的数量也是有限且数量很少的,但是要使用硬件的进程有这么多,所以操作系统也会为每一个硬件创建一个等待队列,在硬件描述的结构体中也会有一个指针指向等待队列,硬件同一时间也只能被一个进程使用,所以进程也可能需要等待硬件资源,也就是进程的pcb也可能会在硬件的等待队列中排队。  在这里我们要思考一个问题,就是当进程被执行到访问硬件的指令时,如果这时候硬件没有被人使用还好说,cpu就等待硬件就绪然后执行相应的指令。但是如果此时硬件正在被使用,也就是说这个进程要使用硬件需要在硬件的等待队列中去排队,这就有一个问题?cpu是会和进程一起等待硬件吗?当然不会,如果这样做,那么cpu的速度快这一优势就完全没有用了,整机的效率就会取决于硬件的速度,因小失大了。所以这时候操作系统会将这个进程的pcb连接到对应硬件的等待队列中,这时候这个进程的状态就不能是运行状态了,等待硬件的状态被称为阻塞状态,然后cpu会继续执行运行队列中的第一个进程。cpu执行的永远都是运行中的进程,如果需要等待外设资源,pcb就会被放到该硬件的等待队列中,等待外设的状态就是阻塞状态,意思是这个进程目前不能直接被调度,而是在等待某种资源。

而当硬件资源轮到该进程使用时,操作系统会将其状态设为运行状态并将其pcb连接到磁盘cpu的运行队列中,等待cpu执行对应的指令。在该进程等待cpu的过程中,对应的外设资源不会被其他进程占用,而是准备就绪后等待该进程使用。

到这里我们就能知道,进程状态的改变其实就是把进程的pcb放到不同的队列中去,将表示状态状态的变量进行修改就完事了。

挂起: 我们上面讲的普通的阻塞状态就是单纯的将pcb连接到硬件的等待队列,而该进程的数据和代码我们是没有动的,但是很多时候,我们的内存中是同时存在数量十分多的要访问外设的进程的,这么多的进程都在阻塞状态,暂时不会被cpu调度,同时可能等待外设的时间也很长。这时候,万一内存不够了怎么办?操作系统会尽量管理好我们的软硬件资源,当一些进程的代码和数据短期内不会被使用的话,内存空间不够的时候,操作系统会将这些进程的代码和数据暂时保存到磁盘上(pcb还是会保存在内存中,不会动),这样就能节省内存给新的进程使用,这就是挂起的概念。挂起也是阻塞的一种,在目前讲的概念上来说,阻塞不一定会挂起,但是挂起是一定阻塞的。当我们的外设资源轮到进程使用是,操作系统也会挂起其他的正在等待资源的进程,然后将这个进程的代码和资源换回到内存中。这就叫内存数据的换入换出。

上面讲的是三个操作系统层面的概念,具体的操作系统可能会不一样。 我们的Linux的进程状态有以下几种

R:运行状态

我们可以写上一个死循环同时不访问外设来看一下linux的R状态

这里我们一直在执行count++这个命令,所以我们能观察到当前进程的状态是 R+ 状态,R就是Linux的运行状态,这里的 + 代表这个进程是一个前台进程。前台进程我们既可以用 ctrl c 来结束,也可以使用 信号 kill -9 来结束。

S(Sleep):浅度休眠状态

我们在上面的代码上加上一行访问外设的代码,比如在屏幕上打印一个数字,我们再查看进程状态。

 我们可能会感觉到很奇怪,为什么他不是R状态,cpu明明也会读取和执行循环中的指令。如果你多查看几次,你会发现基本该进程都是处于休眠状态,这是为什么呢?printf是打印到显示器上,而显示器作为外设,他的速度是远远比不上cpu的,所以cpu可能一瞬间就执行完指令了,大部分可能99%以上的时间都是在等待外设IO就绪,只有很少很少的时间在执行打印的指令,而我们去查询进程状态的时候,显示出来的只是我们去查询的那一个时刻进程所处的状态,虽然实际上他可能是不断在R和S切换,但是我们去查询的时候大概率是处于S状态。S状态是阻塞状态的一种形式。

T(stopped 暂停状态)

在上面的代码执行过程中,我们可以使用 kill -19 信号,暂停该进程,在使用指令查看进程状态我们就能看到T状态

这就是暂停状态,同时我们也可以发现, T 后面没有+号的,这说明暂停的时候操作系统自动将该进程变为后台进程了。 而前台进程和后台进程的区别就是,如果是前台进程,占着前台运行,我们就无法使用命令行指令,只能使用 ctrl c结束前台进程,或者使用其他ssh渠道来结束该进程。但是如果变为了后台进程,我们的命令行是可以正常使用的,但是我们却无法使用ctrl c来终止该进程,ctrl c只能结束前台进程。我们只能通过 kill -9来结束该进程。我们将该代码的打印频率改为两秒一次就可以验证。在上面的图中我们也可以发现,我们的命令行的读取标志也有了

同时T也是阻塞的一种

D(深度睡眠)

上面的S是浅度睡眠,浅度睡眠是可以 ctrl c 或者kill -9 结束的,但是我们的深度睡眠除了掉电,无法终止,为什么会有这种状态呢?这种状态是磁盘操作时的用的,我们可以思考一种场景:当进程需要向磁盘的特定位置写入一些重要数据时,在将数据交给磁盘之后,我们知道磁盘写入数据是需要时间的,这时候该进程就处于等待磁盘写入结果反馈的阻塞状态,如果这时候,我们的内存空间不够了,当操作系统执行挂起策略之后内存还是不够是,操作系统这时候会自主杀掉一些处于阻塞状态的进程,因为操作系统既要保全自己,还要尽量的给新的进程腾空间,所以他就会将阻塞状态也就是暂时没有被使用的进程杀掉,后果如何对于操作系统而言无所谓。这时候当该进程被杀掉后,磁盘写入数据时发现特定的位置的空间不够写入,这时候磁盘会将这个结果反馈给该进程,但这时候进程已经被杀掉了,所以磁盘的反馈也就没有意义了,该进程也无法告诉磁盘接下来该如何做,当迟迟收不到该进程的回应,磁盘不会一直干等着,而是将数据清除,然后为下一个进程的使用做准备。这时候就出现了很严重的后果,也就是数据丢失了。那么这个时候是谁的问题呢,操作系统、磁盘还是进程?其实这三个都没有任何错误,都是在做着自己的本职工作,但是这种情况我们不希望见到,于是就有了一个 D 状态,在等待磁盘写入完成时设置为D状态,处于D状态的进程就相当于有了免死金牌,谁也杀不掉他包括操作系统,直到磁盘操作完成,他才会结束D状态,才能被杀掉。这个状态我们一般用不到,只有在高IO的情况下为了保证数据的写入时才会用到。D也是阻塞的一种

进程一份计算密集型的进程(计算操作多,也叫cpu密集型,占用cpu资源多)和IO密集型(与外设的交互多)。

t(tracing stop)追踪暂停状态

这个状态出现在我们调式过程中,当我们在某行代码停下来时,这个被调试的程序就处于t状态,该进程正在被GDB进程追踪(GDB此时处于S状态等待用户的下一步操作),等待用户查看该进程的上下文数据等信息。

t也是一种等待的状态,也是阻塞的一种

X(dead):死亡状态

死亡状态我们不好查看,因为程序死亡也就是程序所有资源被回收的一瞬间,这个状态我们平时也看不到。

Z(zombie):僵尸状态

为什么会存在僵尸状态呢?

一个进程被创建出来就是为了完成某些任务的,这些任务可能是操作系统给的也可能是用户给的,完成任务有两种情况,一种是需要知道任务的完成情况的,也就是要将结果返回,另一种就是不关注结果的。而Linux系统不管你关不关心结果,都会将结果保存下来,该进程的结果如何会由操作系统或者他的父进程回收。这样一来,为了保存进程运行的结果,进程退出的时候,不能立即释放所有的资源,可以释放代码和数据,但是pcb要保留下来,知道父进程或者操作系统读取结果并且将该资源回收,在进程退出之后,父进程或操作系统回收之前的这段时间,该进程只有pcb资源未被释放,这就处于僵尸状态,我们一般将这个未被释放的资源称为僵尸。

怎么观察到这种状态呢?我们可以使用fork创建一个子进程,然后退出子进程,同时保持父进程的运行,由于我们当前还不会回收僵尸的操作,所以这个僵尸在父进程结束之前一直存在

我们发现子进程推出之后,没有人来回收僵尸,所以一直处于僵尸状态。同时它的进程名也发生了变化,这说明这个进程当前是存在问题的,当然,我们这里的问题就是资源未回收。

如果不回收子进程退出后的僵尸。父进程一直在运行的话,就造成了内存泄露的问题。

孤儿进程:我们前面讲了父进程继续,子进程退出了却不回收,子进程是僵尸进程。而当父进程退出,子进程继续,这时候会出现什么情况呢?

我们发现父进程退出之后,这个进程的父进程就变成了1号进程(操作系统),可以说是被1号进程领养了。而这个被领养的进程就叫孤儿进程。为什么父进程退出之后没有僵尸呢?这是因为父进程的父进程是bash,bash将父进程的资源自动回收了,所以我们没有看待将是状态。 为什么要有领养的操作呢?如果不领养,那么子进程退出时,对应的僵尸就没有父进程回收了,所以只能由操作系统来回收,所以被操作系统领养。 子进程变成孤儿进程时,自动变为后台进程。

6.进程优先级

优先级的本质也是在pcb中的一个或几个整数数字。

优先级与权限是不同的概念。权限决定的是一件事能否被做,而优先级则是在能做这件事的基础上,决定做这件事的先后顺序。正如前面所说,硬件资源是有限的,要使用硬件的晋亨却有很多,所以就需要优先级来确定使用的顺序、

Linux中的优先级是由 PRI 和NI 决定的,我们可以使用 ps -l 来查看当前用户bash进程进程的优先级。

如果要查看我们自己的进程的优先级,可以使用 ps al 查看当前bash下所有的进程,然后使用grep筛选我们自己的进程

PRI是priority,是最终的优先级,NI是nice,最终的优先级是由老的优先级以及nice值共同决定的

最终优先级 = 老的优先级 + nice值

优先级数字越小,优先级就越高。Linux是支持在进程运行中调整优先级的,调整的策略就是更改nice值。 我们调整优先级也只能通过修改nice值来调整。 上面的公式中,每次调整优先级,计算的时候来的优先级的值都是 80 ,而不是我们当前的优先级,nice的值也是有范围的,[-20,19),也就是最终的优先级的范围在 [60,99]之间。将优先级的范围限定在一定范围内,为了防止某些进程恶意霸占系统资源。

修改nice值的方法:

sudo top  ->   r    > 输入要修改的进程的pid  ->  q(退出)

6.四个其他概念

1.竞争性。系统进程众多,而cpu等资源只有少量,所以进程之间是具有竞争性的,为了高效完成任务,更合理竞争相关资源,便有了优先级。

2.独立性。多进程运行,需要独享各种资源,多进程运行期间互不干扰。父子进程也是独立的。

3.并行。多个进程在多个cpu下分别同时进行,这称之为并行。

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

有的进程的执行时间很长,占用cpu时间很长,如果这个进程一次性占用cpu太长时间的话,影响了计算机的效率以及资源竞争的公平性。并不是说每个进程都要在cpu上一次执行完才从cpu上拿下来,当代计算机采用时间片轮转的方式,为每一个进程设置一个时间,比如一毫秒,如果进程在时间片耗尽之前执行完了,那就正常退出,如果在时间片耗尽没有执行完,也不会接着执行了,而是重新到运行队列尾端排队,先执行其他的进程。进程切换就能让只有一个cpu的情况下,在一段时间内推进多个进程。

进程切换:一个cpu内部存在大量寄存器,但是寄存器只有一套,这一套寄存器是被所有进程共享的,也就是说执行所有进程都是用这一套寄存器在工作。cpu永远在做三件事:取指令,分析指令,执行指令,cpu有一个pc寄存器(eip),这个寄存器中保存的是当前正在执行指令的下一条指令的地址,cpu在执行完这一条指令之后,会使用pc寄存器中的地址取指令,同时更新该寄存器,cpu的其他寄存器也都存储着进程相关的数据信息,所以当进程在cpu运行的时候,是会产生非常多的临时数据的,这些数据是属于当前进程的。(寄存器硬件是被所有进程共享,但是里面存储的数据是当前进程私有的)。进程在运行时会占用cpu,但是不会一直占用到进程结束,比如我们的死循环,如果一直占用到进程结束,那么执行一个死循环我们就执行不了其他的操作了,永远退出不了了。 进程在运行的时候,都有自己的时间片,当时间片耗尽的时候,进程就会出现没有执行完就被拿下去的情况,这时候如果不保存当前进程的数据,那么下一次执行这个进程的时候就有得从头开始,这就没有意义了,所以,进程在被cpu拿下去的时候,是需要对上下文进行保护的,也就是将当前的运行情况保存下来,上下文数据也就是cpu中寄存器中的数据,这些数据不是保存在pcb里,而失败存在操作系统的一个特定区域,局部和全局的段描述符表中,当这个进程下次被cpu执行的时候,会先回复上下文,从上次中断的地方开始执行。

进程概念学到这里,我们就大概能知道一个进程的执行过程是什么样了。可以简单用下面的一个流程图来表示。

7.环境变量

再使用命令行执行可执行程序的时候我们会有一个疑惑,我们的命令行的指令都是一些在操作系统中以及写好的可执行程序,而我们自己写的代码经过编译链接之后形成的也是可执行程序,他们在本质上是没有区别的,为什么执行我们自己的可执行程序时却需要加  ./ 呢? ./ 也就是表示我们当前路径,操作系统和cpu要执行我们的程序首要前提就是要能够找到我们的程序,而 ./ 则是通过绝对路径来表示我们的程序所在的目录和位置,那么为什么操作系统的指令却不需要我们自己指明路径呢?

我们可以使用 which 试着能不能将我们自己写的可执行程序的路径显示出来,

我们可以发现 which 的找不到我们的可执行程序,但是它返回的结果是在后面的这几个目录中找不到(上图中以冒号:为间隔符的几个目录),这说明了which只在上面的几个目录中搜索,而which指令我们知道是用来搜索我们的指令的存储目录的,这说明了什么?有没有一种可能,操作系统在识别我们命令行的指令时默认也是去这几个目录下去搜索?我们可以测试一下,将我们自己的可执行程序拷贝一份到上面的/usr/bin目录中去,由于这些特殊的目录普通用户可能没有权限操作,所以我们可能需要sudo提权。

我们可以看到,当我们将自己的可执行程序拷贝到 usr/bin 目录下之后,我们执行该可执行程序也不需要也可以直接使用文件名来执行了,而不需要加路径。 而当我们再次将该可执行程序从 usr/bin 目录删除之后,再次运行还是需要加目录才能运行。

这说明,我们执行可执行程序,如果不加路径的话,系统会去一些特殊的路径下去找,如果找到了该程序,就执行,如果找不到,就报错,那么这些目录被保存在哪里呢? 

系统中存在一个 PATH 环境变量,这是一个操作系统设置的内存级全局变量,每次我们登陆操作系统,系统都会重新配置我们的环境变量,PATH 变量是一串路径,我们也可以称它为系统命令的默认搜索路径,正如我们上面所演示的。我们可以使用 echo 打印到我们的显示器上,由于PATH是一个变量,如果我们想要查看PATH的变量的值,我们就需要使用 $ 引用变量,这样 echo 读到的就不是 PATH 这个字符串,而是PATH变量的内容

既然PATH是个变量,那么他就是可以修改的,我们可以使用 export 来修改全局变量,但是要注意的是,我们也要保存原来的那一串路径,如果直接  export PATH=当前路径  的话,就会导致原来的 PATH 存的内容就清理了,相当于是覆盖重写,所以我们要这样修改 export PATH= $PATH:当前路径 。冒号不能少,因为他是路径的分隔符,同时等号的两边不能有空格,否则空格被读取之后,相当于字符串结束了,后面的内容就失效了。

这样一来我们也可以直接不需要路径执行我们的可执行程序了。

那么这里又有一个问题,就是我们最初的PATH是怎么来的呢?操作系统是根据上面来配置这个PATH的呢?

我们可以在自己的家目录下找到 .bashrc 和  .bash_profile这两个隐藏配置文件

在 .bash_profile 这个文件中我们就可以明确看到PATH的初始值,这两个文件就是帮我们配置PATH环境变量的

到了这里我们就知道环境变量大概是用来做什么的了。

环境变量:操作系统中用来指令操作系统运行环境的一些参数,是操作系统为了满足不同应用场景而预先在系统内设置的一大批全局变量,这些变量在整个系统当中会一直被我们访问到。

常见的环境变量:

HOME(工作目录):

PATH(系统命令默认搜索路径):

HOSTNAME(主机名):

LOGNAME(登录用户名):

HISTSIZE(命令行历史命令记录条数,也就是我们平时用上下键翻的历史命令最多可以保存的个数):

PWD(当前所处路径):

USER(当前用户名):

我们也可知直接使用 env 显示当前所有的环境变量

如果我们想要在自己的程序中获取环境变量,可以使用C语言库里的getenv函数

这个函数的需要传的参数是环境变量的变量名,而变量名是一个字符串,所以我们要用双引号括起来或者直接传字符串的首字符的指针,结果是一样的。而他的返回值就是环境变量的值,也就是上面的 env 显示的所有环境变量中 = 后面的内容 ,如果找不到,就返回空指针。

当然这样写有点矬,我们可以将环境变量名的字符串用#define定义标识符,更加直观

环境变量的应用场景时十分多的,比如你使用的 pwd 命令,他就是将环境变量 PWD 的内容显示出来,或者说我们要对一个文件或者目录进行操作,使用rm mkdir等操作需要判断是否有权限,权限的判断可能就会用到 USER 变量,来判断是否当前操作的用户是什么身份从而确定是否有权限,用于身份验证是十分常见的。

当我们sudo提权来执行某些指令时,我们的身份即使root,在程序中我们可以得到验证

与环境变量相关的指令

echo :显示变量,可以显示本地变量也可以显示全局变量

env:显示所有环境变量

export:导入环境变量,可以在后面定义环境变量,如果跟的是一个已经存在的变量名,就会直接将其变为环境变量。

set:查看当前进程所有变量,包括环境变量和本地变量

unset:取消变量,可以取消本地变量也可以取消全局变量

我们直接在命令行定义的变量默认就是bash的本地变量,我们前面说了环境变量是全局变量,那么问题来了,全局变量和本地变量的区别是什么?

我们可以写一个代码来了解一下全局变量和本地变量的区别

我们发现,当我们将一个本地变量设置成环境变量之后,由这个本地进程bash 派发的子进程mytest.exe竟然也有了这个环境变量,这就是环境变量的全局属性。 

全局变量能够被子进程继承下去,而本地变量则是不会被继承,只在本地才有效。

这时候我们再来探讨一个十分久远的问题,就是我们的main函数。我们在刚开始学习C语言的时候,就知道main函数他其实是有三个参数的,我们却都不会显式地写在程序中,我们以前也不了解他的三个参数有什么作用,但是学到这里我们就能将其解释清楚了。

int main ( int argc , char* argv[ ] ,char* env[ ] )

首先我们看前两个参数,这两个就是我们的命令行参数,也就是我们命令行上输入地指令以及他的选项。argc是一个整数,他表示的是 argv 数组地元素个数 ,而argv则是一个指针数组,每一个元素都指向了一个字符串,那么这些字符串是什么呢?我们可以用一个程序试一下。

这一段代码就是用来打印我们的 argv 中指针指向的字符串的,我们可以简单来运行一下这个程序

当我们只是单纯运行这个程序时,argv 中只存储了一个字符串,就是我们执行程序的指令,也就是我们的可执行程序的路径。

我们平时使用的很多的指令比如 ls 等,一般我们都需要带选项来使用我们想要的功能,那么这个选项是怎么传递到程序中的呢?我们的指令也是C语言写的可执程序,而这些选项就是通过 argv 传递到main函数的

当我们也带上选项来执行我们的可执行程序时,我们就发现,我们的选项就存储在了main函数的argv参数中。我们的可执行程序和选项以空格分间隔符,被分割为一个一个的字串,然后通过将字串的地址传递给argv,从而在main函数中也能知道我们执行程序时的指令,知道了选项,我们就能在程序中用条件判断来执行不同的功能。这个指针数组可以理解为一张表,在这个数组的结尾会多开一个空间存储一个 NULL,我们也可以理解为是 \0 或者 0,他们的字面值都是一样的

main函数的第三个参数就是 char*env[ ] ,这个参数就是传递我们的环境变量的,环境变量我们也可以用上面的表的形式来看,他的最后一块空间也是NULL。

我们可以在程序内部查看一下环境变量

我们发现在我们的可执行程序也能拿到环境变量。这三个参数都是可执行程序在运行时操作系统自动给我们的程序传递的,不需要我们操心,如果我们要使用这些参数,我们就需要在main函数的参数列表中将参数写出来接收。  

我们在自己写的程序中,可以使用getenv来获取环境变量,也可以通过main函数的参数来获取环境变量,除此之外,我们还可以使用C语言库中的一个变量 environ来获取

这是一个库中的全局变量,同时因为是一个外部的变量,我们使用时需要 extern 进行声明,这个变量就指向了环境变量的标,声明之后就会将这个变量链接到我们的程序中,我们可以像使用 env[ ] 使用它,因为env[ ] 本质上也是一个二级指针。   

但是我们发现使用env[ ] 和 environ 这两个方法来获取环境变量的时候都有一个问题,他无法获取指定的某一个环境变量的内容,同时他获取到的结果,不是环境变量的值,而是一个环境变量的表达式,我们如果要使用还需要进行字符串解析将环境变量的内容解析出来才能够使用,所以我们在程序中最推荐的还是使用 getenv 接口来获取我们想要的环境变量。

8.进程地址空间

在C语言和C++的学习过程中,我们不止一次提高过C语言或者C++的地址空间,也就是我们认为的内存的区域划分,

我们以前喜欢把这个地址空间直观当成内存来看到,但是他真的是物理上的内存吗?我们可以写一个程序来验证一下。

fork之前只有一个进程,fork之后有了两个进程,子进程的pcb就是拷贝父进程的pcb,我们可以看到,在子进程将 a 的值修改了之后,父进程的a的值没有变 ,但是后续的打印,父子进程的 a 的地址竟然都是一样的,如果这里的地址是物理内存的话,是不可能出现这种情况的。这里的地址没有变,就已经说明了这些地址不是物理地址,也就说明了曾经我们学习的语言层面基本的地址(指针)也不是对应的物理地址,我们把这些地址称为虚拟地址。

虚拟地址是什么?我们首先要知道,把进程当作一个人来看待的话,进程是认为自己是独占系统资源(内存或者对象空间),当然是主观上认为,因为除了进程被cpu执行时,其他时间进程都是在休眠,只有在运行的时候才会醒来,所以它会认为自己独占系统资源,这也是设计时的理念。而进程申请资源的时候,如果一次性申请资源过多的话,操作系统也是可以拒绝的。

虚拟地址空间其实就相当于操作系统给进程画的大饼,操作系统告诉进程,你可以使用系统所有资源,但是最终能不能使用还是得操作系统说了算,而为一个进程画了饼之后,操作系统还需要画的饼进行管理,也就是要对上面的C/C++的地址空间进行管理,管理需要先描述再组织,对于地址空间的区域划分的描述,我们只需要知道,总空间有多大,也就是保存每一个区域的起始和结束位置,就能完成对区域的划分。

以32位cpu为例,他最多能够管理 2^32个地址,因为地址最大的意义就是要确保唯一性,每一个地址用来表示一个字节的起始位置,也就能够管理4gb的空间范围,那么描述一个地址,我们只需要 unnsigned int 的类型就能保存32位的地址,这就是我们在C语言学的地址的编址。

操作系统是怎么描述地址空间的? 区域划分用计算机语言描述,只需要两个边界,也就是每一个区域的开始和结束,将边界保存下来,就能表明各个区域的范围,在此基础上,调整边界的值,就是在调整区域的大小。地址空间的本质就是内核的一种数据结构(mm_struct),我们可以看一下某个版本的内核代码的mm_struct的一部分代码,我们可以在下面的图中看到 start_code,end_code,start_data,end_data以及栈区和堆区的开始还有命令行参数的开始结束(arg)以及环境变量的开始和结束(env),操作系统i据使用各种边界来描述各个区域的。

综上来说,当操作系统创建一个进程时,会给他创建一个进程管理块 PCB ,同时会创建一个mm_struct 对象,也就是创建一个进程地址空间,保存每一个空间的开始和结束地址,划分各个区域,这些地址都是虚拟地址。同时我们要确定一点就是,在进程被创建的时候,或者说在被编译问可执行程序的时候,除了堆区和栈区,其他的区域的大小都是已经被计算好了的,在程序运行的过程中,这些区域的大小一般都不会变,数据区在运行时可能会发生读写(全局和静态变量),但是他们的空间是在进程被创建的时候就已经开辟好了的,但是我们的堆区和栈区则是在运行过程中不断调整的,比如函数栈帧的创建和销毁,动态内存的申请和释放,都是在对这些区域大小姐进行扩大和缩小。PCB 中有能够指向地址空间的指针也就是  mm_struct 的指针。

但是,进程地址空间再怎么定义,最终数据还是要放到物理的内存上的,这时候操作系统是怎么管理虚拟地址与物理内存的关系呢?  首先,物理内存也是可以用地址来表示的,进程的代码和数据被加载到内存中之前,我们在程序中是相当于在进程地址空间中将除堆栈的其他区域空间都划分了出来,相当于都有了虚拟地址,同时也将这些数据存在了物理内存中,我们的物理内存存储的位置和虚拟内存中的位置是互相不受影响的,但是这样一来,他在物理内存和虚拟内存的地址不一样,我们的进程要怎么才能找到实际上是存在物理内存中的数据呢?这就要建立一个虚拟内存到物理内存的映射关系,也就是我们的页表。页是什么?内存在和磁盘等外设进行数据换入换出也就是IO的时候,是以4KB为基本单位的,表示一个页,所以我们可以把物理内存想象成一个数组,数组的每一个元素都是一页,数组的元素个数就是4GB/4KB ,操作系统访问业内的数据时,只需要知道页的起始地址和页内地偏移就能找到相应的空间了。 但是我们目前还没有学习过页和页表的结构,所以我们就简单理解,就是进程开辟的虚拟地址空间,实际上是存储在物理内存的,他们之间的转换就是通过页表来进行的,也可以理解为,进程的合法虚拟空间,通过页表的转换能够找到这块空间对应的物理内存空间。

所以,每个进程PCB中不仅要存储进程地址空间的mm_struct,还要存储对应的页表。创建进程地址空间和建立页表等工作都是操作系统做的,这一套机制就叫进程的虚拟地址空间。

每个进程都有自己的独立地址空间和独立的页表,进程都只能看到地址空间,看不到物理内存。堆栈的调整也不是严格按照字节来申请空间的,而是会与留出一部分空间,空间多大取决于编译器的实现,这就是为什么有时候出现数组越界我们也能访问到。

为什么会存在地址空间? 

如果直接让进程访问到物理内存,万一进程越界或者非法操作,那么我们的系统就危险了,所以直接访问物理内存这种方案是非常不安全的。而使用虚拟内存的话,如果进程越界访问或者非法访问的话,他只能拿着虚拟地址访问,在页表这里索引的时候就找不到页表项终止了或者说被操作系统拦截了,也就访问不到不属于进程的物理内存了,所以,所有进程因为页表的存在,只能访问到合法的物理内存,也就不存在越界和非法操作了,这也变相保护了物理内存。

同时,如此一来,我们也就能理解了为什么前面的程序中,打印出来的地址一样,而值却不一样了,因为地址一样是因为子进程创建的时候是复制的父进程的地址空间了页表映射关系,也就是最开始的时候,父子进程的 a 变量虚拟地址和物理地址都是一样的,而当子进程尝试修改父子进程所共享的数据时,也就是子进程要写入共享的空间时,为了保证进程的独立性,子进程会发生写时拷贝,也就是操作系统首先会将这块要被写入的空间拷贝一份到一块新的物理内存中,然后修改子进程的页表的映射关系,最后再修改子进程的 a

写时拷贝是发生在父子进程共享的数据被某一方写入时,谁写入谁就要发生写时拷贝映射新的物理内存。当然,对某个数据写时拷贝不会影响其他共享的数据,其他没有被写入的数据还是共享的。

所以说操作系统为了保证进程的独立性做了很多工作,通过地址空间,通过页表,让不同的进程,映射到不同的物理内存,同时通过写时拷贝,让不同进程的数据也独立。

地址空间的存在,可以更方便进程与进程之间的解耦,保证了进程独立性这样的特征

我们再思考一个问题,我们的可执行程序里面有没有地址(在没有加载到内存的时候)? 

物理地址当然是没有的,但是我们却发现可执行程序中是有地址的,比如我们的汇编中就能看到各种函数的调用等的地址,这个地址叫逻辑地址,而在Linux中,逻辑地址,线性地址和虚拟地址就是同一个概念。虚拟地址空间的规则,不是只有操作系统会遵守,我们的编译器也要遵守。编译器在编译我们的代码的时候,即使按照虚拟地址空间的方式对我们的代码和数据进行编址的,这些地址就是逻辑地址。当我们的可执行程序加载到内存的时候,我们的代码逻辑没有发生变化,数据也不会发生变化(相较于可执行程序文件),所以操作系统在为进程创建进程地址空间的时候,就是直接按照我们程序文件中的逻辑地址来划分范围,然后通过页表映射到存储的物理内存。

同时cpu读取指令时,读取到寄存器中的地址也是我们的可执行程序中的指令中的逻辑地址,我们代码内部的函数跳转的地址也都是逻辑地址,所以cpu执行时取到的地址都是逻辑地址也就是虚拟地址,实际上也是要通过页表的映射去访问物理内存的代码和数据的。,整个的执行过程中,cpu见到的也都是虚拟地址,而见不到物理地址

所以,进程我们可以理解为有两套地址,物理地址和逻辑地址,物理地址用于表示在物理内存中代码和数据的地址,逻辑地址用于程序内部互相跳转。这样能让进程以统一的视角,来看待进程对应的代码和数据等各个区域,方便编译器以统一的视角来进行编译代码,这样一来,编完即可直接使用。

  • 3
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值