一、冯诺依曼体系结构
冯诺依曼体系结构其实是一个老生常谈的内容,我们常见的计算机、服务器等大部分都是遵循冯诺依曼体系结构的。
我们都知道我们的计算机是由很多硬件组成的,比如鼠标键盘、显示器、网卡、CPU、内存、磁盘等等。我们给这些硬件设备分一下类就是:
- 输入设备:鼠标、键盘、话筒、摄像头、扫描仪、磁盘、网卡……
- 输出设备:显示器、音响、网卡、磁盘、显卡……
- 存储器:内存
- 中央处理器(即CPU):含有运算器和控制器等……
想要了解什么是冯诺依曼体系结构,我们首先要认识什么是体系结构,体系结构其实就相当于是计算机硬件的骨架,这个骨架决定了我们操作系统如何组织我们的硬件设备,将硬件设备组织在一起然后服务于软件,最后通过软硬件的配合就可以完成计算机的工作。
那么什么是冯诺依曼体系结构呢?我们来看一下冯诺依曼体系结构图:
关于冯诺依曼体系结构,我们需要理解以下几点:
冯诺依曼体系结构中的存储器,指的就是我们平时所说的内存,那么为什么需要有内存呢?
我们都知道CPU的运算速度是非常快的,在计算机中,CPU的运算速度>寄存器的速度>缓存速度>内存>>外设(比如说磁盘)>>光盘;我们设想一下假如冯诺依曼体系结构中没有内存,那么CPU会直接与输入设备、输出设备进行数据交换,那么就会导致一个问题:CPU的速度远远大于输入设备和输出设备,从而使得CPU很快地完成了数据接收与运算,但需要等待输入设备和输出设备,因为它们两个的速度太慢了。这样的问题就会导致即使CPU的速度特别快,计算机整体的速度效率也会因为CPU等待输入输出设备而大大降低。因此,我们取了速度处于二者之间的硬件——内存。输入设备的数据传入内存当中,内存再将数据传给CPU,CPU处理完毕后再返回给内存,最后由内存将数据返回给输出设备。
在不考虑缓存的情况下,冯诺依曼体系结构中的CPU只能够对内存进行读写,不能访问外设
(理由和第一点是一样的)外设(输入输出设备)要输入或者输出数据,也只能写入内存或者从内存中读取
即所有的设备都只能够直接和内存打交道,不能直接与CPU打交道
我们举个简单的例子来理解一下冯诺依曼体系结构:
在日常生活中我们经常使用微信聊天,其实在微信聊天的过程中就是冯诺依曼体系结构的很好体现。我们能看到下面的示例图:当我们在键盘上输入"你好"点击发送时,输入设备会将这条信息传输给内存,内存再将数据给CPU进行处理,CPU处理完毕以后返回给内存,内存再将数据传输给输出设备即网卡,通过网络你的信息就能够传送到你好友的网卡上,网卡此时作为输入设备再将数据传输给内存,内存将数据传输给CPU,CPU处理完毕以后再返回给内存,内存最后将数据传输给输出设备即显示器,这样你的好友就能看到你发的消息了。
再比如:我们以前在写C/C++的时候,我们自己写好的程序,编译好之后,如果要运行必须先加载到内存当中,那这又是为什么呢?
因为我们如果需要运行我们的程序必须靠CPU去执行处理我们的语句吧?但是在冯诺依曼体系结构中CPU只能与内存进行数据交换,而我们编译好的程序是可执行程序,它是一个文件(在windows下是.exe可执行文件),这个文件是在磁盘上的,此时磁盘作为输入设备是无法将数据直接给到CPU的,所以必须先加载到内存才能够运行。
另外,我们还需要介绍一下中央处理器(CPU)在冯诺依曼体系结构中的作用。CPU包含运算器和控制器。运算器能够进行算术计算(加减乘除等运算)和逻辑运算(逻辑与逻辑或等运算),控制器则需要控制外设、内存、运算器,协调它们之间的数据交互。比如说判断数据是否从输入设备写入到内存了,数据是否传输到输出设备了。我们需要CPU中的控制器来与外设进行控制交互。
二、操作系统
我们上面讲的冯诺依曼体系结构是属于硬件层面的内容,下面我们来谈谈软件层面的内容——操作系统(Operator System)。
1.操作系统的概念
操作系统是计算机中一个基本的程序集合。实质上操作系统就是一款软件,它是一款负责管理的软件。它对下需要管理好软硬件资源,对上需要为用户提供良好的、稳定的、安全的服务。
2.管理的本质
我们想要弄清楚操作系统是如何进行管理的,首先我们需要弄清楚管理的本质是什么?
在现实生活中我们的管理不一定是直接对着被管理者面对面的管理,就好比说在学校里管理者假设是你的校长,你作为学生就是被管理者,你会发现你在学校里见校长的次数少之又少,但是呢校长依然能够将所有的学生管理得很好,这是为什么呢?
原因就是管理其实是通过管理数据从而实现的对特定对象的管理。你在学校入学的时候,你作为学生肯定需要录入你自己的个人信息,然后校长手上就会有一份包含你个人信息的档案,这就是你的数据,校长只需要对这一份又一份的学生数据进行管理,即可实现对学生的管理。
我们如果需要实现一个学生管理系统(假设用C语言),我们首先要做的事情一定不是各种管理功能的实现,而是定义一个描述学生身份信息的结构,比如描述学生的姓名、性别、专业、班级、绩点等等……当我们来了一个新学生就创建一个新的这种描述,有多少个学生就有多少个描述,每个学生的描述是独立的整体(相当于你的学生档案),接着我们要将学生管理系统的功能进行实现呢则需要将这些一个个独立的描述结构组织起来,从而实现相应的功能。这个组织起来的过程就需要运用到我们的数据结构,比如说将所有学生用链表连接起来,一个链表节点就代表一个学生的档案,这样就实现了组织。最后只需要将需要管理的功能实现出来,这样一个管理系统就完成了。
因此我们得出结论:管理的核心本质是 ”先描述再组织“!
3.操作系统是如何进行管理的
操作系统的管理和上面说的管理本质是一样的,操作系统的管理也是 先组织再管理。
为了更好地理解,我们将操作系统的管理类比成银行运作系统的管理:
银行的行长可以对银行职员进行管理,同时也能够对银行的硬件设施进行管理,比如说银行的电脑、银行的桌椅;那行长是怎么进行管理的呢?其实也是 先描述再组织 。对于员工,行长在员工入职前会收集员工个人的信息,这就是描述的过程,对于硬件设施,行长也会有每种硬件设施的信息,比如电脑的型号电脑的数量,这也是描述的过程;完成了描述以后,行长会组织银行职员给他们分配各自的工作任务,会安排银行职员如何去使用银行的硬件设施,这就是组织的过程。这样行长就实现了对银行的管理,银行就可以正常运作对外给客户提供服务了。
操作系统也是类似的,我们操作系统需要先保存下驱动程序和底层硬件的属性信息,这就是描述的过程,操作系统再组织驱动程序利用哪些底层硬件去实现程序的功能,这就是组织的过程。这样操作系统就实现了它的管理,一台计算机就能够正常运作对外给用户提供服务了。
总之,操作系统的管理核心本质也是”先描述,再组织“。
系统调用和库函数
我们都知道银行是不会将自己整体暴露给外界的,意思就是说银行系统只会对外提供一个个服务的窗口,客户到银行办理业务是通过窗口实现的,银行怎么存钱怎么取钱,把钱存到什么地方,在什么地方取钱客户是不知道的,因为银行需要确保一个安全性,所以要将自己的整体封闭起来。
操作系统也是同样的,为了安全性,防止用户无意或者有意修改了操作系统的某些数据,从而导致操作系统不能正常运行。所以操作系统也会将自己封闭起来,那么用户在使用的时候呢操作系统会对外提供一些接口,用户只能够通过这些接口来让操作系统实现相应的功能服务。
操作系统对外表现为一个整体,只会暴露出自己的部分接口,供给上层开发使用,调用这部分由操作系统提供的接口,就叫做系统调用
系统调用在使用上,功能比较基础,对用户的要求相对也比较高,所以有些开发者已经对部分的系统调用进行适度的封装,从而形成了库,有了库就更有利于用户更好地使用和开发。
4.操作系统上下层关系图
我们上面提到操作系统既要管理软件,又要管理硬件,在计算机中操作系统可以说是起着 承上启下 的作用,下面是操作系统与上下层关系的详细图解:
关于操作系统上下层关系图的几点解释:
- 底层硬件之间实际上是服从冯诺依曼体系结构的。
- 操作系统可以组织管理驱动程序,驱动程序能够驱动对应的底层硬件工作,操作系统也就实现了对软硬件的管理。
- 操作系统是通过系统调用的方式,对外提供接口服务的。
到这里我们拥有了操作系统的一些超级基础知识以后,我们可以回头再看看我们以前经常使用的printf这种库函数。printf能够将数据打印到显示器上,我们作为用户层面使用printf进行开发操作,它是怎么能够操作底层硬件将数据打印出来的呢?从上面的图示我们可以看出,答案是通过操作系统。更准确地说是:操作系统将“打印到显示器”这一功能的使用做成一个接口供外界使用,printf这个库函数则是将这个接口进一步进行封装,封装成用户使用起来简单的库函数,所以我们才能够直接一个printf语句就完成了打印操作。
三、进程
1.进程的概念
在操作系统的书籍里我们经常能看到进程的定义是:进程是一个运行起来的程序。
但是进程与程序有什么区别呢?
可能有人会觉得这不就是运行了和没运行的区别嘛!
真的是这样嘛?
我们看到:我们的程序是在磁盘上的可执行文件,当我们运行可执行文件时必须先将程序从磁盘加载到内存当中。但程序加载到内存以后就叫做进程了吗?答案是:不是的
我们的操作系统需要管理的进程可能不止一个,既然有多个进程,那么操作系统再进行进程管理是也是服从 先描述再组织 的原则。当一个程序加载到内存时,操作系统可不仅仅只是将程序的代码和数据加载到内存,操作系统还要为了管理该进程,创建对应的数据结构,这个数据结构包含了进程的所有属性数据,能够描述该进程。Linux是用C语言写的,在Linux中描述进程的数据结构是struct结构体,名字叫 task_struct 。所以所谓的进程,是将描述进程属性数据的结构和进程的代码数据结合在一起,才叫做进程。
进程 = 可执行程序 + 该进程对应的内核数据结构
2.PCB(Process Control Block)
我们操作系统中用来描述进程的属性数据的结构就叫做 PCB(Process Control Block) 。如上面所说的在Linux系统下的PCB就是 task_struct 。PCB即进程控制块,进程的信息被放在进程控制块中,可以理解为进程控制块是进程属性的集合,用来描述每一个进程。
3.如何查看进程
第一种方法(简单查看方法)
我们自己写的程序运行起来以后就是一个进程,那么该进程在操作系统中怎么查看呢?在Linux下我们写一个简单的C语言程序,让它运行起来我们查看一下这个进程:
我们写一个简单的死循环程序,将一直在屏幕上打印输出’I am a process"。
我们让该程序运行起来
这时这个进程就欢快地跑起来了。我们分割出另一个界面,输入指令:
ps axj | grep 'mytest' | grep -v grep
这个就是我们正在运行的进程!!
第二种方法(可查看进程详细信息)
我们在根目录下有个名字叫 proc 这么一个目录,它是一个内存文件系统,这个目录里面放的是当前系统实时的进程信息!
我们可以用指令查看一下proc目录里面的东西是什么,我们发现里面都是我们不认识的一大堆文件和目录,其中呀这些蓝色的数字叫做PID,关于什么是PID我们下面会介绍。
我们查看当前正在运行的进程,可以通过在proc目录下用PID来查询,输入指令:
ps ajx | head -1 && ps ajx | grep 'mytest' | grep -v grep
我们发现当前正在运行的程序的PID是5370,我们通过ls指令利用PID来查看进程
输入指令:
ls /proc/5370
我们看到当前进程确实是存在的,在这个目录下放着的就是这个进程的所有属性信息,是以文件的形式展示的。其它的是什么我们先不看,先来看看图中框起来的两个。
输入指令:
ls /proc/5370 -al
我们看到exe对应的是当前这个进程对应的可执行程序的磁盘文件以及该文件所在的路径;cwd对应的是进程当前的工作路径。
当前路径
我们还记得我们在学习C语言文件操作时提到的创建文件如果不带路径默认创建在当前路径下,这个当前路径指的不是当前程序源代码所在的路径,而是当前进程所在的路径,我们可以验证一下:
我们先在源代码中写一条创建文件的代码,文件没有带路径,所以是默认当前路径下,如果当前路径存在该文件就写入,不存在就创建之后再写入。
我们再将可执行程序mytest移动到Test目录下,将可执行程序与源代码分开,我们运行可执行程序,查看一下新创建的文件是在哪个目录下:
我们发现了新创建的文件并不是在源代码所在的目录下,也就是说当前路径指的并不是源代码所在的路径,而是进程所在的路径。
4.PID和PPID
上面我们也提到了PID,那么什么是PID呢?什么又是PPID呢?
PID:全称Process Identity,是进程标识符,每一个进程都有自己的PID,用来描述进程的唯一标识符,类似于我们每个人都有一个独一无二的身份证号码。
PPID:全称Parent Process Identity,顾名思义PPID就是进程的父进程的标识符,也就是父进程的PID。
如何查看PID和PPID
第一种方法
第一种查看PID和PPID的方法其实上面已经提到过了,当一个进程运行起来时,输入指令:
ps ajx | head -1 && ps ajx | grep 'mytest' | grep -v grep
这种方法可以很直接地查看到PID和PPID
第二种方法
我们可以通过系统调用来获取PID和PPID,这里需要介绍两个函数:getpid()、getppid()
我们通过man手册查看一下这两个函数,输入指令:
man 2 getpid
此时我们可以看到这两个函数需要包含的头文件以及返回值,这两个函数的作用是获得进程的PID和PPID,返回值pid_t是一个无符号整数。
接下来我们在代码中用这两个函数来获取一下PID值和PPID值:
代码运行起来后,我们看到该进程的PID和PPID都打印出来了:
我们还可以用第一种方法来验证一下,发现PID确实是16059,PPID也确实是5300.
5.创建进程的方法
第一种方法
第一种方法是我们目前为止最常用的:利用Linux命令行指令来创建进程,即程序编译好以后,输入指令:
./可执行程序文件名
这种方法比较简单,我们就不细说了。
第二种方法(fork)
第二种方法是通过系统调用来创建进程,这里我们需要学习一个函数fork()
我们先通过man手册来查看一下这个函数,输入指令:
man 2 fork
我们可以看到fork函数需要包的头文件以及返回值和形参,fork是一个用来创建子进程的函数,返回值pid_t是一个无符号整数。
fork还有一个很有意思的点,我们再来详细地看看fork的返回值:
它说如果创建子进程成功了,那么父进程返回的是子进程的PID,而子进程返回的是0。
这是什么意思呢?意思是调用fork会有两个返回值嘛?可是我们从来没有学过哪个函数会有两个返回值的呀。那到底是什么意思呢?
其实fork一调用起来,如果子进程创建成功,就会有两个进程同时跑起来,所以上面说的父进程返回的是子进程的PID,子进程返回的是0,我们可以用代码来验证一下:
我们代码中只有一句printf输出语句,按理说程序运行以后屏幕上应该会打印出一句:“I am a process,id:XXX”,但事实上我们打印出来的结果是:打印出了两句,上面的一句是父进程打印的,下面的一句是子进程打印的。说明fork函数调用子进程成功了。
我们可以再看一个更具有代表性的例子:
我们知道在C语言中的if分支语句只会进入一个分支,但这里却同时进入了两个分支,同时打印两个死循环:
所以我们可以得出结论:
- fork之后,父进程和子进程会共享代码,一般都会执行fork函数后续的代码。
- fork函数创建子进程之后,父进程和子进程的返回值不同,可以通过不同的返回值来判断,让父子进程执行不同的代码块
对于fork函数,我们有几个问题需要解释一下:
①为什么fork()给父进程返回子进程的pid,给子进程返回0呢?这样做有什么意义?
答:我们知道一个父进程都有可能会有多个子进程,而每一个子进程呢有且仅有一个父进程,所以父进程和子进程可能存在一对多的关系,父进程必须要有能够标识子进程的方案,因此fork()给父进程返回子进程的pid,父进程可以根据这个pid查找到对应的子进程。而给子进程返回0,其实是因为子进程找父进程的成本比较低(比如可以用 getppid() 函数获取父进程的pid),子进程只需要知道自己被创建成功就可以了。
②fork()函数之后,操作系统做了什么?
答:fork()函数是用来创建子进程的,当创建子进程成功以后,操作系统中就多了一个进程。我们都知道操作系统对进程的管理是先描述再组织,所以每一个进程都会有对应的task_struct+进程代码和数据,那么子进程被创建成功以后,操作系统多了一个进程以后,也会为这个新增的进程创建其对应的task_struct+进程代码和数据。
③子进程的内部数据从哪里来呢?
答:我们知道子进程被创建成功以后,操作系统为其创建对应的task_struct+进程代码和数据,就相当于操作系统创建多了一个子进程的task_struct对象,其内部的数据绝大部分都是从父进程拷贝下来的,有些属性数据比如pid和ppid除外。
④子进程执行的代码从哪里来呢?
答:fork()创建的子进程,只有父进程有代码,子进程在执行代码计算数据时,只能够和父进程执行同样的代码,也就是说fork()函数之后,父子进程代码共享。(如果有需要的话,我们也可以利用父子进程的返回值不同,让父子进程执行不同的代码块)
⑤子进程被创建好以后,子进程是怎么被运行起来的呢?
答:我们知道进程是通过PCB描述起来的,每个进程都有对应的PCB,每一个CPU都会存在一个运行队列,所谓的运行队列我们叫做runqueue,里面放的全都是PCB,每一个PCB里面都有该进程的属性信息,并且每一个PCB都有指向该进程自己的代码和数据。CPU中的调度器会去runqueue中根据优先级选择合适的进程被CPU运行。同样的,子进程被创建出来以后,将被放入到CPU的运行队列当中,从而让CPU去调度。
⑥为什么fork()会返回两次?
答:因为fork()是一个函数,既然是函数那就会有函数体会有返回值,从上面的所有问题我们已经知道fork会创建子进程,子进程与父进程共享同一份代码。那么fork这个函数当它走完它的所有函数体以后,准备return的时候,这个函数的核心功能已经完成了,也就是说子进程已经被创建成功了,而且子进程也已经被放入运行队列等待CPU的调度了。从这个时候开始,往后的所有代码都是父子进程共享的了,return同样是语句,因此return是父子进程共享的语句,所以return会被父进程执行一次被子进程执行一次,也就是返回了两次。
四、进程状态
进程的状态体现了一个进程的生命状态,在进程执行的不同时间里进程可能会处于几种不同的状态。
今天我们首先介绍一下操作系统的运行态、终止态、阻塞态和挂起态。
1.运行态
想要理解什么是运行态,我们只需要弄清楚一个问题:
运行态指的是进程正在CPU上运行时的状态,还是进程在运行队列等待调度时的状态呢?
答:运行态指的是只要是在运行队列里排队等待调度的进程,都叫处于进程态。运行态不代表进程正在运行,代表进程已经准备好了在运行队列里随时等待调度。
2.终止态
同样的,想要理解什么是终止态,我们只需要弄清楚一个问题:
终止态指的是这个进程已经被释放了,还是指这个进程依然存在,只不过永远不会运行了,随时等待被释放?
答:答案是后者,原因是进程运行完了并不一定马上就能够释放,有可能存在当前操作系统可调度资源有限,简单来说就是操作系统很忙,那么同时存在很多个运行完的进程,就需要排队等待释放。
3.阻塞态
关于阻塞态,其实是会比上面两种状态比较难理解的。想要理解阻塞态,我们首先要明确一点的就是:
一个进程在使用资源的时候,可不仅仅只是在申请CPU资源,进程还可能在申请其他更多的资源,比如说磁盘资源、网卡资源、显卡资源、显示器资源等等……
上面的其实不难理解,我们举个例子来看一下:当你在电脑上下载一个软件时,你肯定需要申请CPU的资源,但你同时也需要申请网卡资源因为你需要网络下载,你同事也需要申请磁盘资源因为你需要将软件下载到你的电脑上……所以一个进程在使用资源的时候不仅仅只是申请CPU资源那么简单。
那么当我们申请CPU资源暂时无法得到满足时,我们需要在CPU的运行队列中排队。当我们申请其他慢设备的资源时,也是需要排队等待的。
我们知道操作系统的管理核心是“先描述再组织”,那么CPU描述的方法是利用PCB,Linux中叫task_struct描述进程的属性信息,利用运行队列将进程管理起来。同样的,计算机的其他硬件也是这样管理进程的,比如磁盘、网卡、内存,他们也会有自己的描述进程的task_struct,也会有自己的等待队列来管理进程(上面也说到了,进程在向这些慢设备申请资源时也是要排队的)。
由于CPU的速度是很快的,而慢设备比如磁盘、网卡的速度是比较慢的,所以就会存在这样的情况:当进程访问某些资源时,比如说CPU即将准备运行的进程需要对磁盘数据进行读取,但此时磁盘资源暂时还没有准备好,或者是磁盘正在给其它进程提供服务,那CPU将要运行的进程就会没办法读取磁盘数据只能够等待磁盘资源。但是CPU的这个进程没理由在CPU的运行队列里等着吧,俗话说不要占着茅坑不拉屎,所以:
- CPU当前的进程要从CPU的运行队列中暂时移除;
- 将暂时移除的进程放入到对应设备的等待队列当中,等待对应设备的资源准备就绪。
当我们的慢设备资源准备就绪时,即相当于告诉CPU现在我可以被你读取了,那么这个进程将再次被放回CPU的运行队列中等待被运行处理。
当我们的进程在等待外部资源时,也就是上面所说的CPU即将运行该进程,却发现磁盘的资源没有准备就绪,因此只能将该进程从CPU的运行队列中移除,放入磁盘的等待队列中等待。这一个等待的过程如果从我们使用计算机用户的角度来看,就是平时我们所说的卡住了,实质上这就是进程阻塞状态。
更详细一点说就是,当进程等待某种资源(非CPU),资源没有准备就绪的时候,进程需要到该资源的等待队列中进程排队,此时进程的代码不能够运行,进程所处的状态就叫做阻塞态。
阻塞态是一种临时状态,在外界看来就是软件卡住了,所以如果我们电脑的CPU速度够快,同时磁盘、内存等各种硬件也很快,那电脑出现卡顿情况会少一点。
4.挂起态
最后说的挂起态,应该会比阻塞态还要稍微难理解一点。首先我们知道进程挂起态不是进程运行态,进程不是正在运行的,那么也就说明处于挂起态的进程是不会去申请CPU资源的。我们举个具体的例子来理解一下进程挂起:
我们都知道在冯诺依曼体系结构中,磁盘上的可执行程序要想运行起来首先要将代码和数据加载到内存,再创建该进程的PCB,在内存中等待CPU的调度。那么如果一时间在内存中的进程太多了,内存不足了怎么办?此时操作系统就要管理内存空间了,需要对内存的代码和数据辗转腾挪。操作系统会将短期内不会被调度(也就是那些在等待慢设备资源的处于阻塞状态的,并且短期之内资源不会准备就绪的)进程的代码和数据置换到磁盘上,因为这些进程短期内不会等待到资源准备就绪,而它们的代码和数据却依旧在内存中,这就是白白地浪费空间呀。
所以操作系统会在磁盘上找到一个专门的区域(磁盘中的swap分区),将这部分进程的代码和数据置换到这个区域里,再将内存原来的代码和数据释放掉,这样就能腾出空间来让其他进程使用。
所以操作系统可以通过这种方式,可以让内存只短暂地留存进程的PCB,剩下的代码和数据全部置换到磁盘上,此时这样的进程就叫做进程挂起。详细点说就是:因为资源空间不足而被操作系统将进程的代码和数据临时地置换到磁盘当中,此时进程的状态就叫进程挂起。
所以我们在日常使用计算机的过程中会发现这样一个现象:当内存不足的时候,磁盘也在被高频访问。
5.Linux进程状态
上面讲到的都是操作系统层面的进程状态,下面我们要谈一下Linux系统里的进程状态:
Linux系统下有7种状态,分别是:R(running)、S(sleeping)、D(disk sleep)、T(stopped)、T(tracing stop)、Z(zombie)、X(dead)
R状态(running)
在Linux系统下的R状态其实就是我们上面说的运行状态,我们可以在Linux中写一个代码来看一下R状态:
我们写一个简单的输出代码,按照上面所说我们只需要将代码运行起来就能够看到R状态了。
输入指令:
ps ajx | head -1 && ps ajx | grep process | grep -v grep
查看进程的状态:
我们发现当我们的程序运行起来了,屏幕也一直在输出显示了,为什么查看进程状态看到的竟然不是R状态而是我们不认识的S+状态呢?
原因在于:printf函数打印的时候,打印到我们的显示器,显示器是属于慢设备,它的速度相比于CPU是非常慢的,其实这个进程看起来是在进行printf打印,但大部分时间都是在等待,它在等待我们的显示器资源准备就绪。其实一开始进程状态确实是R状态,但是很快就变成了S+状态,因为大部分时间进程都在等待显示器的资源。
所以我们修改一下代码,不做printf打印了,直接死循环让程序运行起来:
再输入指令查看一下进程的状态:
这个时候的状态就变成了R状态。我们的代码当前正在做死循环,死循环并没有访问其他外设资源,没有读写文件没有读取磁盘没有打印到显示器,所以这个进程只等待CPU,不会被阻塞,也就是这个进程一直都在CPU的运行队列中,该进程的状态也就一直都是R状态了。
S状态(sleeping)
上面例子我们也看到了,其实S状态对应的就是阻塞状态,进程正在等待某种(非CPU)资源,S也叫休眠状态。一般指的是浅度睡眠,也叫做可中断睡眠(意思就是当进程处于S状态,该进程可以随时被唤醒,不仅是操作系统可以随时唤醒,用户也可以随时唤醒,甚至直接把进程杀死也可以,区别于D状态)。
当操作系统将正在等待外设资源的进程从等待队列放回CPU的运行队列时,对应的就是S状态转变为R状态
D状态(disk sleep)
D状态其实也是一种阻塞状态,和S状态非常类似。一般而言,在Linux中,如果我们等待的是磁盘资源,我们进程阻塞所处的状态就是D状态。
关于D状态我们举个具体的例子来理解一下,如果没有D状态,就可能会存在以下的问题:
在内存中有一个进程,它需要到磁盘中写入数据,磁盘接收到写入的请求以后,就开始写入数据了,此时该进程在内存中就处于等待磁盘资源的状态,也就是变成了S状态。但如果计算机服务压力过大,进程太多内存满了,此时操作系统会终止掉用户的进程。如果正在等待磁盘写入数据的进程被操作系统终止了,那么如果磁盘写入数据成功了就还好,但凡是写入失败了,磁盘需要将写入失败的信息反馈给进程,但那个进程已经被终止了,磁盘找不到那个进程了,这就会导致数据出问题,后果可能会很严重。
因此Linux中引入了D状态,也叫作深度睡眠,或者叫作不可被中断睡眠,此时就连操作系统都没有权力中断该进程,只能等D状态自己唤醒,变成非D状态才能正常运行完。这就是D状态和S状态的区别。
X状态(dead)
X状态叫作死亡状态,其实就是上面所说的终止态
Z状态(zombie)
Z状态叫作僵尸状态,Z状态是一种已经死亡的状态,但并不是直接进入X状态(死亡状态,资源可以立马被回收了),操作系统不会将它的资源立马释放,而是进入Z状态。
进程被创建出来的原因一定是因为有任务要被这个进程执行。那么当进程运行完以后,操作系统或者父进程怎么知道这个运行完的进程是否完成了它的任务呢?
所以一般需要将进程的执行结果告知父进程或者操作系统。Z进程就是为了维护进程退出时的信息,这个退出信息通过task_struct来存储,可以让父进程或者操作系统读取。
那么我们怎么能在Linux系统下看到僵尸进程呢?
如果我们创建了子进程,子进程退出了,但是父进程没有退出,并且父进程也没有回收子进程,此时子进程所处的状态就是僵尸状态:
我们将程序运行起来,再利用命令行去查看当前进程的状态,可以发现子进程在退出以后,父进程没有退出并且也没有对子进程进行回收,这时子进程就是僵尸进程。
如果没有回收子进程,那么子进程将会一直处于僵尸进程,该状态会一直被维护,该进程的相关资源(task_struct)不会被释放,就会一直占用资源,这将导致内存泄漏。
孤儿进程
上面提到的僵尸进程是子进程先退出,父进程没有退出并且没有对子进程进行回收就会导致僵尸进程。孤儿进程则是子进程不退出,父进程先退出,这种进程状态就叫作孤儿进程。
我们可以用代码来模拟看一下孤儿进程:
我们让代码运行起来,查看一下当前进程的状态情况:
我们看到的结果是,当父进程退出以后,并没有维持僵尸进程状态,而是直接就退出了,而原先子进程的父进程变为了1号进程。
这个结果就有点奇怪了,按理来说父进程也会有它自己的父进程,它退出的时候也应该和子进程退出一样,处于僵尸进程的呀,为什么会直接退出了呢?
原因是父进程的父进程其实是bash,bash是操作系统的进程,这个进程已经实现了对子进程的回收,也就是说父进程即使作为别人的子进程,它在退出的时候被它的父进程回收了,所以不会处于僵尸状态而是直接退出。
那么原先的子进程在父进程提前退出以后,子进程的父进程变为了1号进程,这个1号进程其实就是操作系统,这里我们可以理解为子进程被操作系统领养了。
我们又发现一个问题,上面我们模拟孤儿进程的代码还在运行,我们平时只需要按下“ctrl+C”就可以结束进程,但当我们想要结束的时候,我们发现进程依旧在运行。
其实是因为现在进程的状态是S状态,以前我们看到的可以被“ctrl+C”终止的进程状态是S+状态,S状态表示的是后台进程,而带上加号以后的S+状态表示的是前台进程,后台进程其实还是在运行,只不过它会影响我们的命令行输入,此时我们只能用指令:
kill -9 要终止进程的pid
这时我们就将该进程终止掉了。
T状态
T状态就是所谓的暂停状态,我们其实很经常见暂停状态,只是我们很少留意。举个生活中的小例子就能理解了:你在看视频的时候暂停播放,你在下载的时候暂停下载,这些时候其实都是进程暂停状态,也就是我们所说的T状态。
我们同样可以写代码来模拟看一看T状态:
我们通过最简单的代码来看看
我们看到当前进程已经运行起来了,其实的状态是S状态:
那我们怎么让它暂停下来呢?我们来看看kill的选项,输入指令:
kill -l
我们看到kill的选项里19号选项是暂停,18号选项是继续运行,我们来尝试一下。
我们看到此时进程已经被暂停了。
此时进程的状态我们查看一下,确实是T状态:
我们再让进程继续运行起来试试看:
我们看到进程又重新运行起来了。
五、进程的优先级
1.什么是进程优先级
优先级就是进程获取资源的先后顺序。优先级和权限有什么区别呢?权限讨论的是能与不能的问题,拥有权限是可以访问可以获取资源,而优先级是都可以访问都可以获取资源,只不过是先后顺序的问题。
那么为什么需要有优先级呢?
优先级存在的核心原因是资源不足,操作系统中永远都是进程占大多数,而资源是少数。其实现实生活中我们也是这样的,想象一下如果我们没有排队来确认优先级,那只有用蛮力竞争,这会导致资源倾斜过于严重从而导致问题的发生。
2.怎么查看进程优先级
我们先写一个非常简单的程序,让程序一直在运行,然后查看一下进程的优先级。
我们让程序运行起来,输入指令:
ps -la
我们看到当前进程的优先级也就是PRI和NI对应的那一列,优先级是80。
Linux下的优先级由两部分组成,分别是PRI(priority)和NI(nice),PRI就是进程的优先级,NI是优先级的修正数据,准确来说Linux下的优先级就是PRI和NI的和。在Linux下的优先级默认是80。Linux下优先级的数字越小代表进程的优先级越高,数字越大代表优先级越低。
所以如果我们想要修改进程的优先级,只需要更改进程的NI值即可。但是一个进程的优先级不能轻易的被修改,这是对系统的保护。超级用户可以修改优先级,但也不是无节制地修改优先级,Linux下最小的NI值为-20,最大为19。
Linux下优先级的计算:pri=pri_old+nice(每一次设置优先级,这个pri_old都会变为默认的80)
3.如何让优先级高的进程先运行
我们也知道操作系统中有不同优先级地进程能够同时存在,并且相同优先级地进程是可能存在多个的。那么如果此时运行队列来了一个优先级更高的进程,而队列又只能够是先进先出,不能让优先级高的进程“插队”到靠前的位置,那操作系统是怎么保证优先级的呢?
其实运行队列并不只有一条,实际上操作系统有多少个优先级,就会有多少条运行队列。具体是怎么样的呢?我们举个例子来看一下:
假设当前操作系统只有0、1、2、3、4五种优先级,那么操作系统会创建维护一个指针数组task_struct *queue[5],这个数组存放的指针都指向一条条运行队列,每一条运行队列都代表一个优先级。操作系统根据不同的优先级,将特定的进程放入到不同的队列中。然后按照优先级从高到低调度进程,这也就保证了高优先级的进程先被调度,低优先级的进程后被调度。
六、进程的其它基本概念认识
1.进程的性质和并行并发
- 竞争性:系统当中进程的数目是非常多的,而CPU的资源是少量的,所以进程之间会存在竞争属性,也就是进程之间共同竞争CPU资源。竞争的手段是通过不断地入队列,来完成优先级的确认。
- 独立性:进程运行的时候是具有独立性的,也就是进程与进程之间是相互独立的,多进程运行的时候,每一个进程都是独享各种资源的,多进程运行期间互不干扰。或者说当多个进程运行时,不会因为一个进程挂掉或者异常而导致其它进程出现问题。
- 并行:多个进程在多个CPU下分别并且同时运行,这就是并行。
- 并发:多个进程在一个CPU下采用进程切换的方式,在一段时间内,让多个进程都得以推进,这就是并发。
我们可能会有这样一个疑问:为什么我们的电脑是单CPU的,但是我们的电脑中也可以有各种进程在运行啊,比如你现在在用网页浏览着CSDN看着我的博客,你的QQ、微信也在登录着,你既能看到我的博客,又能接收到QQ、微信的消息,这种现象是并行还是并发呢?
首先我们要明确:多个进程都在运行不等于多个进程都在同时运行!
那我们单CPU是怎么做到上面所说的那些情况的呢?我们一个CPU在运行进程的时候,会维护一个运行队列,运行队列上全都是需要被运行的进程。当CPU正在运行一个进程时,并不是说这个进程一旦占用了CPU资源,就会一直执行到进程结束,才会释放CPU资源。我们这里需要提出一个“时间片”的概念。我们遇到的大部分操作系统都是分时运行的,所谓的分时运行,就是操作系统会给每一个进程,在一次调度周期中,赋予进程一个时间片的概念,每一个进程都有自己的时间片,在这个时间片内进程可以执行自己的代码运行程序,但一旦时间到了不管有没有执行完毕,操作系统都会将这个进程从CPU上剥离下来,继续运行下一个进程,下一个进程也是同理。被剥离的进程如果没有执行完毕,会继续加入运行队列。
简而言之,在一个时间段内,多个进程都会通过切换交叉的方式,让代码在一段时间内得以推进,而不是一次性执行结束(除非在时间片内一次就能执行完毕),这种现象我们叫做并发。
2.抢占式内核
如果当前的运行队列来了一个优先级更高的进程,操作系统还是简单地根据队列来进行先后调度嘛?答案是不是的!当代CPU一般都是抢占式内核,也就是说如果正在运行的是低优先级进程,突然来了一个优先级更高的进程,我们的调度器会直接把进程从CPU上剥离,放上优先级更高的进程,这就叫进程抢占。
3.上下文数据
我们现在知道了一个进程在CPU中一般不是直接运行结束了才退出CPU,而是可能在固定的时间片内运行。时间到了被CPU剥离下来,或者是遇到了优先级更高的进程被CPU剥离下来,所以就会存在这样一个问题:进程突然被CPU剥离,之前运行留下的还未使用的数据怎么办?
我们的CPU内存在各种各样的寄存器,寄存器是CPU内的用于临时存储数据的存储器,不过它保存的临时数据是非常少的,因为寄存器的容量很小,但其保存的数据非常重要!我们可以通过下面的例子来简单认识一下寄存器:
当我们从磁盘将可执行程序加载到内存中时,内存中保存的可执行程序的代码有一条是对变量进行加一操作,这时首先内存要将该变量和指令加载到寄存器中,然后在CPU内完成加法运算,再将运算完的结果保存到寄存器中。最后再将结果写回内存。
那么当进程在被执行的过程中,一定会存在临时数据暂存在CPU的寄存器中。如果此时该进程由于时间片到了或者是来了一个优先级更高的进程时被CPU剥离,那它存在寄存器中的临时数据怎么办呢?如果还是存在寄存器里,岂不是占用了资源,后面来的进程也就无法使用这一个寄存器资源了。所以进程在被CPU剥离时必须将寄存器中暂存的数据带走,当进程恢复的时候,需要将曾经带走的数据恢复到寄存器中,我们把进程在运行中产生的各种寄存器数据叫做硬件的上下文数据。
那么问题又来了,进程带走的上下文数据保存在哪里呢?
答案是保存在进程控制块中,也就是PCB(在Linux下是task_struct)。
七、环境变量
1.什么是环境变量
我们首先要明确一点概念:我们自己在Linux下写的代码生成的程序是可执行程序,Linux下的指令诸如“ls、ll、pwd……”这些其实也是可执行程序。
不知道大家有没有这样的疑问:当我们在Linux下写的代码生成可执行程序以后,我们运行起来是要带路径的:
而Linux系统自带的指令,既然也是可执行程序,为什么我们在运行这些可执行程序的时候就可以直接输入指令名称而不需要带路径呢?
我们也可以尝试一下不带路径运行我们自己写的可执行程序:
系统提示说命令没有被找到!!!
那么为什么系统的命令能够被找到,但我们自己的程序却找不到呢?
这就是环境变量的存在所导致的。
原因是系统中是存在这些指令的可执行程序的相关环境变量,这些环境变量保存了程序的搜索路径的,因此可以直接输入指令运行而不需要带上路径。
环境变量一般是指在操作系统中用来指定操作系统运行环境的一些参数。
2.常见的几种环境变量
常见的几种环境变量:
- PATH:系统中可执行程序的搜索路径
- HOME:指定用户的主工作目录(即用户登录到Linux系统中时,默认的目录)
- SHELL:当前shell,它的值通常是/bin/bash
我们可以查看一下PATH环境变量的内容,输入指令:
echo $PATH
PATH中包含多种路径,路径与路径之间用冒号分隔开。当我们在命令行输入指令执行的时候,操作系统呢就会到PATH中的路径一个一个去搜索,搜索每一条路径下是否拥有这个指令,如果搜索到了就执行,然后停止搜索。因此PATH是可执行程序的搜索路径。
现在我们能回答为什么指令可以直接执行不需要带路径,而我们自己写的程序需要带路径才能执行,原因是我们自己写的程序路径不在PATH中,如果你也想自己的程序像指令一样不用带路径执行,可以把你的程序路径加入到PATH中,这里就不细说了。
3.环境变量的代码获取方式
我们在写C/C++代码时,必不可少的函数就是main函数,那么请问main函数可以带参数嘛?可以带的话最多可以带多少个呢?
答案是可以带的,并且最多可以带三个!
命令行参数
我们首先来看main函数的前两个参数:
第一个参数是一个整型变量,第二个参数是一个指针数组,第一个参数的含义是第二个参数指针数组的元素个数,那第二个参数的指针数组里存放的是什么呢?让我们打印出来看一下:
我们打印出来发现,第二个参数的指针数组里存放的是命令行的输入值,我们命令行输入什么(以空格分隔开),就会存入什么放进指针数组中。其实这就是我们Linux中的指令的原理,我们在Linux下使用指令时,输入的指令就是以这种方式传入到main函数中的,其中的-a、-b、-c这些就是我们平时输入指令的选项。
argv数组最后一个元素一定是以NULL结尾!
那么命令行参数存在的意义是什么呢?C语言为什么要这样设置呢?
原因是有了命令行参数,就可以同一个程序通过传递不同的命令行参数,让同一个程序有不同的执行逻辑,从而得到不同的执行结果!
在我们Linux系统下一个指令会根据不同的选项,可以有不同的表现。这就是Linux指令中那么多选项的由来以及它们起作用的方式!
第一种方法(main函数获取)
命令行参数介绍完了,接下来我们要回归正题:怎么用代码获取环境变量呢?
我们main函数除了前面提到的两个参数以外,还有第三个参数:
这个参数同样是指针数组,里面存放的就是我们的环境变量,我们可以打印出来看一下:
与argv一样,env最后一个元素也是NULL!
我们可以看到我们的环境变量被打印出来了,也就是说一个进程是会被传入环境变量的!
第二种方法(第三方变量)
C语言为我们提供了一个变量叫做environ,我们通过man手册来查看一下,输入指令:
man environ
这个变量是C语言提供的全局变量,我们只需要直接调用就可以打印出环境变量,下面我们用代码来演示一下:
运行代码后我们看到环境变量也确实被打印出来了:
第三种方法(getenv)
上面介绍的两种方法虽然很简单粗暴,但每次打印出来的环境变量都将所有的打印出来了,如果我们只需要特定的那一个环境变量的信息,我们就需要用到getenv这个C语言提供的接口函数。
同样的,我们通过man手册先查看一下这个函数,输入指令:
man getenv
getenv函数可以传入一个字符串,直接获取该字符串的环境变量信息。我们通过代码演示打印一下:
我们运行代码看看打印出来的结果:
确实是只打印出了我们指定的环境变量的具体信息。
4.本地变量和环境变量
我们先写一个获取环境变量的代码,我们自定义的环境变量名为“JJP”:
当我们没有定义JJP这个环境变量时,运行程序看看结果:
我们看到如果没有定义这个环境变量,在操作系统中是找不到的,因此打印出来的是null
我们在命令行里定义一下JJP,再输入指令查看一下JJP:
set | grep JJP
那我们再用刚刚写的程序获取一下JJP,看一下会有什么样的结果:
我们发现结果依然是null,也就是说操作系统依然没有查找到JJP这个环境变量。
其实这个原因是上面的方法并不是定义环境变量,而是定义了本地变量。
如果我们想要定义环境变量,需要用export导出环境变量,输入指令:
export JJP=1124
再输入指令查看一下环境变量:
env | grep JJP
我们看到环境变量打印出来了,最后我们用程序运行来看一看:
结果表示环境变量定义成功了。
那么环境变量和本地变量有什么区别呢?
首先我们要知道所有在命令行启动的进程,它们的父进程都是bash。环境变量是具有全局属性的,怎么理解呢?其实就是环境变量是会被子进程继承下去的!而所谓的本地变量本质上就是bash内部定义的变量,不会被子进程继承下去,所以也就不具有全局属性。
八、进程地址空间
1.进程地址空间布局图
我们在学习C语言的时候,或者是学习C++的时候,一定都见过这一张空间分布图,这张图的意思是说我们写的代码,不同的部分分别处于不同的空间。但这一个进程地址空间并不是内存,不可以简单地认为这张图就是内存空间分布图。下面我们来看一下这张图:
但有一个问题是:我们怎么知道进程地址空间分布就是像这张图一样的呢?我们有什么办法能验证一下呢?下面我们写一个代码来验证一下:
- 正文代码我们可以用main函数的地址来代表。
- 我们定义一个全局变量g_val并且对其进行初始化,它的地址代表初始化数据的地址。
- 我们定义一个全局变量un_g_val但不对其进行初始化,它的地址代表未初始化数据的地址。
- 我们动态申请一个字符数组tmp,由于动态申请内存是向堆申请的,所以tmp里申请的内容是位于堆上的,tmp里的内容的地址代表的是堆上的地址。
- 由于tmp这个字符数组本质上也是一个定义在栈上的数组,只不过它的内容是向堆申请的,所以我们取tmp的地址代表的是栈上的地址。
- argv数组代表的是命令行参数的地址。
- env数组代表的是环境变量的地址。
我们运行程序看一下打印出来的结果:
结果表明最低地址确实是正文代码的地址,最高地址是命令行参数和环境变量的地址。从低地址向高地址增长。
下面我们可以再验证一下堆和栈的增长方向,我们要修改一下我们的代码:
我们运行看一下增长情况:
我们发现堆区是向地址增大的方向增长,栈区是向地址减小方向增长。
(因此我们在C/C++函数中定义的变量,通常在栈上保存,那么先定义的一定是地址比较高的)
2.虚拟地址
什么是虚拟地址呢?
我们举个例子来看一下:前面我们已经说了,父子进程会共享同一份代码,所以我们下面的代码里定义了全局变量g_val是在父子进程中都能被访问的,并且父子进程访问到的应该是同一个变量,因为访问的地址是相同的。那么如果我们在子进程中修改了g_val的值,父进程再去访问这个变量,结果会发生改变嘛?
我们程序运行起来后看结果表明:当父子进程没有人修改全局变量的数据时,父子进程是共享该数据的。当子进程修改了全局变量的数据时,父子进程读取的依然是同一个变量(因为图中父子进程访问的地址是一样的),但是父子进程读取到的内容确实不一样的!于是我们可以大胆猜想:
我们在C/C++中使用的地址,绝对不是物理地址!
因为如果是物理地址的话,访问同一块物理空间怎么会出现不同的值呢?
其实上面访问的确实不是物理地址,而是虚拟地址,或者叫线性地址/逻辑地址。虚拟地址是为了对操作系统以及各种硬件进行保护。如果可以直接访问物理地址,我们一旦误操作,比如越界访问,内存泄漏就会造成很大的甚至是不可挽救的问题。
3.什么是进程地址空间
我们每一个进程在启动的时候,都会让操作系统给它创建一个地址空间,这个地址空间就是进程地址空间。既然每个进程都会有一个自己的进程地址空间,那么操作系统要不要管理这些地址空间呢?
答案是:要的!管理的方法也是先描述再组织,所谓的进程地址空间其实是内核的一个数据结构,在Linux当中这个结构叫作sturct mm_struct.
简单来看其实在操作系统中每一个进程都会有自己的task_struct,同时也都会有自己的进程地址空间,task_struct并不是直接指向物理内存,而是指向进程地址空间。也就是说物理内存与进程之间相隔了一个进程地址空间。至于什么是进程地址空间,我们接着往下看:
进程地址空间存在的最大意义就是:让每一个进程都认为自己是独占操作系统中的所有资源的!
所谓的地址空间,其实就是操作系统通过软件的方式,给进程提供一个软件视角,让进程认为自己会独占系统的所有资源。
我们的进程地址空间内部被划分成了很多个区域,每一个区域都有对应的地址,这个地址就是虚拟地址。而我们的物理内存内部也同样会被划分成很多个区域,每一个区域也会有对应的地址,这个地址我们叫物理地址。
在每一个进程被创建的时候,操作系统都会自动生成一个页表结构,这个页表就建立起了进程地址空间和物理内存之间的映射关系。页表的一边放着的是虚拟地址,另一边放着的是物理地址。我们进程访问的是虚拟地址,在虚拟地址上做操作,页表通过这个虚拟地址映射到物理地址,通过这个物理地址才能访问到物理内存。所以页表就是进程地址空间与物理内存之间的中间桥梁!
现在我们可以解释上面的问题:
为什么父子进程访问的变量地址相同,但子进程修改了变量值以后父子进程访问到的数据不同呢?
在修改变量内容之前,父子进程指向的同一块虚拟地址,这一个虚拟地址会通过页表映射到物理内存上的一块空间,这一块空间是父子进程共同访问的,因此这时父子进程访问的变量地址和变量内容都是相同的。
但当子进程对变量的内容进行修改以后,父子进程访问到的内容就不一样了。原因是虽然父子进程访问的变量地址还是相同的,但那只是虚拟地址相同,物理内存上会为子进程开辟一份新的空间,并将原来的空间拷贝过来,再让父进程指向原来的物理空间,子进程则指向新的空间。这样子进程对变量内容的修改是在新空间内修改,父进程指向的旧空间并不会被修改。这就是为什么父子进程访问的变量地址相同(虚拟地址相同,物理地址不同了),但访问的变量内容却不同的原因。
上面所说的操作系统为修改数据的一方开辟新的空间,并且把原始数据拷贝到新空间当中,这种行为叫做写时拷贝
其实这种设定是非常有必要的,设想一下假如父进程正在用g_val做if else的条件判断,如果子进程随便就更改掉了父进程的变量值,那么父进程的逻辑判断就会出现错误,这样程序就很容易混乱了。这其实就是我们说的进程具有独立性,操作系统的这种做法是在维护进程的独立性!
现在我们也能回答之前fork函数的一个问题了:
为什么fork函数有两个返回值,同一个变量怎么接受两个不同的值呢?
答:fork函数在执行到return语句的时候,函数体的功能已经完成了,也就是说此时子进程已经创建好了,那么父子进程将共享剩下的代码(包括return语句),但是父子进程返回的值是不同的,所以父子进程中有一个进程要发生写时拷贝,因此父子进程虚拟地址是一样的,但是对应的物理地址是不一样的,父子进程对应了不同的物理内存空间,两个空间是独立的,所以返回了两个不同的值就很好理解了!