认识冯诺依曼系统
操作系统概念与定位
深入理解进程概念,了解PCB
学习进程状态,学会创建进程,掌握僵尸进程和孤儿进程,及其形成原因和危害
我们常见的计算机,如笔记本。我们不常见的计算机,如服务器,大部分都遵守冯诺依曼体系。
截至目前,我们所认识的计算机,都是有一个个的独立的硬件组件组成
运算器: 运算单元是计算机的核心,负责执行各种算术和逻辑运算,如加法、减法、乘法、除法以及比较操作。ALU根据控制单元发出的指令执行相应的计算任务。
控制器: 控制单元负责协调计算机内部各个部分的工作。它从内存中读取指令,解释这些指令,并生成控制信号,指导运算单元、输入/输出设备和存储单元执行相应的操作。控制单元是计算机的指挥官,确保所有的操作按照正确的顺序和时序进行。
存储器: 存储单元用于存储数据和程序。计算机的内存分为主存储器(RAM)和辅助存储器(如硬盘、固态硬盘),主要区别在于速度和易失性。主存储器用于存放当前运行的程序和数据,而辅助存储器用于长期存储数据和程序。
输入设备: 输入单元负责将外部的数据或指令引入计算机系统。输入设备可以包括话筒、摄像头、键盘、鼠标、磁盘、网卡等。输入单元将外部信息转换为计算机能够理解的形式,并传递给存储单元或控制单元进行进一步处理。
输出设备: 输出单元将计算机处理后的结果呈现给用户或其他设备。输出设备可以包括声卡、显卡、网卡、磁盘、显示器、打印机等。输出单元将计算机内部的信息转换为人类可读或其他设备可处理的形式。
这些独立的设备如果想要使用起来就必须要在物理上存在某种线路连接起来,但是我们拆开我们的计算机发现没看到这门多条线。每个独立的设备通过总线连接在一起,但是这些总线都被集成到主板上。主板是计算机内部的核心组件之一,它起到连接和支持其他硬件设备的作用。主板上集成了各种总线,通过这些总线,独立的设备如处理器、内存、输入输出设备等能够相互通信和协同工作。通过独立设备连接的手段,实现多个独立设备之间的数据流动的目的。
对冯诺依曼的理解,不能停留在概念上,要深入到对软件数据流理解上,我们先考虑设备之间的数据信号流动。
设备之间的数据信号流动本质是设备之间进行数据的来回拷贝,拷贝的整体速度,是决定计算机效率的重要指标。一个商品能被大众所接收,首先要稳定性效率都不错,其次是价格比较低。现在我们在来深入了解一下冯诺依曼以及背后的价值。
why1:为什么在体系结构中要存在存储器(内存)?
我们先来看一下计算机存储金字塔。
规律:距离CPU越近的存储单元,效率越高,造价越贵,单体容量小,反之,距离CPU越远的存储单元,效率越低,造价越便宜,单体容量大。
按照下面的结构可行吗?为什么要有存储器呢???
输入设备和输出设备这些都叫做设备,输入输出设备的速度通常较慢。例如,硬盘驱动器的读写速度相对较低,而键盘和鼠标等输入设备也有一定的延迟,而且这些设备都是距离CPU很远的存储单元,所以CPU直接访问这些设备都是非常耗时间的,而CPU的计算速度都非常快,所以这样就形成了快的CPU与慢的输入输出设备组合,这就好比我们的木桶原理,最短的木板取决于木桶存放水的储量,计算机也是如此,此时计算机的效率就取决于输入输出设备,完全以外设为标准,因此计算机会非常的慢。
此时CPU就一直在问输入设备,我已经等好久了,输入设备你输完了没有,待数据输完之后,CPU立马就计算出来,然后就给输出设备,然后就一直问输出设备,我已经给输出设备了,输出设备怎么还没写完。此时CPU就一直处于闲置等待的状态。
鉴于此问题,我们就需要插入一个存储器(内存)
前提:存储器的速度比外设快,比CPU慢。
所以以后输入设备的信息就给到内存,又内存再交给CPU,而CPU也不用把信息直接交给输出设备,而是由内存交给输出设置。就比如在现实生活中,一个工程由一个急性子(CPU)和一个慢性子(外设)的人共同完成,此时工程的进度肯定由慢性子(外设)慢性子决定。但是后面我们加入一个速度不快不慢(内存)的人,让这个人去帮一下慢性子(外设),帮慢性子(外设)的人解决一部分问题,由此这个工程的完成效率就能提升。
引入内存之后就可以保证计算机的整体速度不要以外设为标准,而变成以内存为标准设定计算机的整体速度。但是这里不对呀?上面的外设还是存在的,此时不应该还是取决于外设吗?木桶原理的那个最短木板还在呀!本来CPU可以直接拷贝外设的数据,现在要将数据通过外设拷贝内存上,然后再拷贝到CPU上才能进行计算,那我们这不是选择了一条远路,而且这条路还绕。那这样效率不是更低吗?
其实不然,内存把输入设备要输入的多批数据,在CPU还没有访问的时候再做其他计算的时候,提前外设要输入的多批数据拷贝到内存(预先加载),当CPU忙完计算时,CPU就直接将内存的数据拷贝过来,这也速度就提升了很多。同时CPU计算完后,只需要将数据写入内存,然后外设再慢慢读取内存(缓存)的信息输出结果即可,这样CPU只用和内存打交道,内存此时看像一个巨大的缓存,介于设备和CPU之间,整体上宏观上提升了速度和效率。
内存:预存任务、缓存结果,计算机的效率最终变成了以内存效率为主。因为内存的引入,不仅计算机的效率有了很大的提升,同时价格比较便宜。冯诺依曼结构巧妙的把效率问题转化为软件问题。
程序在运行的时候,必须把程序先加载到内存中,为什么?
首先我们的程序是一个文件,文件是存放在电脑磁盘中的,磁盘是外设,而程序中存放了很多指令和数据,这些最终都是要CPU去执行的,而这些指令和数据需要通过外设拷贝到内存,然后再由内存交给CPU才能执行。在数据层面,CPU只和内存打交道,外设只和内存打交道,内存起一个核心作用,而这是由于冯诺依曼体系结构规定的。
解释,从你登录上qq开始和小美聊天开始,发送"我喜欢你"这个字符串,整个数据的流动过程。
你和小美各自使用一台电脑,那么就各自有一套冯诺依曼体系结构,当你在键盘输入信息时,这个信息会被加载到你电脑的内存中,然后再由CPU对你送的信息进行打包加密,CPU执行完后返回给到内存,内存交给网卡,再通过网络,传输到小美的网卡上,此时网卡作为输入设备,将你刚刚发送的信息给到内存,内存传给CPU后,CPU对信息进行解包解密,CPU执行完后返回字符串信息给到内存,内存交给显示器,因此小米才能收到你的表白信息。
如果是在qq上发送文件呢?
文件存放在磁盘上,此时就从磁盘上读取到文件给到内存,发送文件和发送消息数据流动差不多,唯一的区别就是两种发送的容量大小不同,文件最终会被输出到小美电脑聊天窗口,当小美点击下载的时候,此时文件就被下载到磁盘当中,第二天小美将电脑重启,依然能够看到这个文件。
前言:在我们的计算机里,第一个加载的软件不是qq,不是微信。实际上我们电脑加载的第一款软件是操作系统,当我们电脑想要开机,加载的那一段时间,就是系统在加载操作系统时间。
任何计算机系统都包含一个基本的程序集合,称为操作系统(OS)。笼统的理解,操作系统包括:
在整个计算机软硬件架构中,操作系统的定位是:操作系统是一个进行软硬件资源管理的软件。
首先我们来看一下计算机的层状结构。
今天我们先来看硬件层面,底层硬件是通过冯诺依曼体系结构连接起来的。操作系统(OS)通过相关的驱动程序来控制底层硬件。操作系统是一种软件,它提供了一个抽象层,使应用程序能够与硬件进行交互,而无需直接处理底层硬件细节。
操作系统(Operating System,OS):
驱动程序(Device Drivers):
底层硬件:
在计算机系统中,操作系统、驱动程序和底层硬件形成一个层次结构,操作系统位于最上层,通过驱动程序与底层硬件进行通信。这种分层结构的设计使得操作系统和应用程序能够更容易地适应不同的硬件平台,同时提供了一个统一的接口,简化了软件开发和硬件管理的复杂性。
操作系统是一个进行软硬件资源管理的软件。何为管理,如何管理???
现实生活中每个人做事情,都在做决策和做执行,比如某天中午你起来饿了决定吃面条还是麻辣烫,这就是做决策,然后你决定去吃面条,这就是做执行。什么叫做领导呢?在管理体系中,领导充当做决策的人, 所以管理者(操作系统)就是做决策的人,而被管理者(硬件部分)就是做执行的人。
学校管理的例子
怎么理解先描述,后管理呢?我们以面向对象语言为例
我们也可以回顾一下我们之前写的C语言版通讯录
我们所有编程语言除了C语言,其他语言基本上都是面向对象的,所有的语言都要提供类似于C++的STL容器,java的集合类这样的东西,因为编程语言本质就是对数据进行管理。
未来我们如果想要写一个教务管理系统,首先就要先描述,通过struct或者class描述学生对象的属性(学号、姓名...),然后再组织,将学生对象的属性放到某些容器(数据结构)中,从而可以把对数据的管理场景转化为对特定数据结构的增删查改。
将教务系统管理这个具体的问题,进行计算机级别的 建模过程!转化为计算机能够认识的问题。
总结 计算机管理硬件
现在我们再向上谈论一下,为什么要有操作系统?
操作系统充当计算机硬件和应用程序之间的中介,提供了一个有效且方便的计算环境。没有操作系统,计算机将是一个集合了硬件组件但缺乏组织和协调的庞大机器。操作系统的存在使得计算机能够更容易地被人类使用,提高了计算机的效率和可用性。
操作系统是一款管理软硬件资源的软件,那么问题也可以转化为什么要有操作系统的管理?
操作系统的管理是为了有效地协调和控制计算机系统中的各个组件(对下的手段),以提供一个稳定、高效、安全的计算环境(对上的目的)。
怎么体现稳定,安全的环境呢?
如果用户能够直接进入操作系统,那么用户就可以直接访问内部的数据,用户可以直接修改某些数据,这样很不安全,就相当于去银行取钱,取钱存钱都是自己去银行的金库拿,那么银行肯定会很担心,万一有人多拿了怎么办?因此银行就设置了一个窗口,根据用户的诉求,窗口的工作人员负责用户的取钱存钱的功能,而我们的计算机也提供了这样一个窗口:系统调用接口,应用程序通过系统调用提出诉求,由操作系统去完成应用程序请求操作系统核心功能和服务的机制。
- 在开发角度,操作系统对外会表现为一个整体,但是会暴露自己的部分接口(函数),供上层开发使用,这部分由操作系统提供的接口(函数),叫做系统调用函数。
- 系统调用在使用上,功能比较基础,对用户的要求相对也比较高,所以,有心的开发者可以对部分系统 调用进行适度封装,从而形成库,有了库,就很有利于更上层用户或者开发者进行二次开发。
凡是函数那就会由输入和输出,比如在银行,你取钱的时候将身份证交给窗口内部的工作人员(输入),由他帮你核实信息,待工作人员核实完毕,按照你的取钱要求,将钱和身份证给到你(输出)。操作系统根据输入输出就能够给上提供数据上面和功能上面的支持。任何人都不能直接访问操作系统中的数据,只能通过操作系统提供的系统调用函数去获取数据和达到自己的请求。
用户能不能跨越操作系统,直接访问底层硬件?
一旦我们确立了操作系统为管理者,就不能由任何跨越操作系统的动作,必须要贯穿操作系统。这是为了确保系统的稳定性、安全性和可控性。
printf输出信息是如何打印到硬件显示器上
当你在程序中使用 printf
函数时,它会将格式化的数据发送到输出流(stdout)。在大多数标准的桌面操作系统中,stdout 默认指向屏幕终端。这些操作系统会提供一些底层的系统调用和驱动程序来管理这个输出的显示。
对于硬件显示器,例如在嵌入式系统中,printf
函数的输出通常需要一个底层的驱动程序或操作系统支持,以便将数据发送到硬件显示设备。这个驱动程序可能会直接操作显示器的硬件寄存器或者与显示控制器进行通信,将 printf
输出的数据传输到显示器的缓冲区中,然后显示在屏幕上。
用户如何可以调用操作系统提供的系统调用接口?
当然可以,但是我们调用比较繁琐,就相当于一个大爷去银行取钱,银行要求大爷填一些信息单,但是大爷不识字,为了解决这个问题,此时银行就提供了一个角色:大堂经理,大爷只负责告诉大堂经理信息,由大唐经理帮大爷解决这些问题。大堂经理就相当于计算机中的lib库(银行流程都清楚),负责提供服务,系统调用一旦由我们的操作系统向上提供,我们想使用这个系统调用就必须要对操作系统有一定程度上的理解,所以就有人对系统调用进行了封装,形成对于的lib->库,此时就减少了用户的使用成本,普通用户通常会通过编程语言或库函数提供的高级接口来使用操作系统的系统调用。
使用printf函数不需要关注底层是Windows系统还Linux系统的原因。
编程语言的标准库通常是具有跨平台性的,它提供了一组标准的、可移植的接口和功能。例如,C语言的标准库(如
、
等)在不同平台上具有相似的实现,使得基于这些库的代码更容易移植。同时,很多语言还有丰富的第三方库,这些库的设计目标之一就是提供跨平台的支持,使得开发者能够更方便地在不同系统上使用相同的代码,这样我们就屏蔽了系统调用接口的差异性。
那在还没有学习进程之前,就问大家,操作系统是怎么管理进行进程管理的呢?很简单,先把进程描述起来形成一个struct或者class,每一个进程就是一个struct或者class对象,再把进程使用链表或者其他数据结构组织起来,进而把对进程的管理自然转化为对链表的增删查改!
struct
(结构体)或class
(类)。为什么程序加载到内存,变成进程之后,我们要给每一个进程形成一个PCB对象呢?
因为操作系统不认识这些以.exe的可执行文件,操作系统需要管理(先描述,再组织),PCB(Process Control Block,进程控制块)用于描述和维护进程的各种信息。当程序加载到内存并成为一个进程时,操作系统需要管理和跟踪这些进程。PCB 是操作系统用来管理进程的数据结构,它包含了关于进程的各种重要信息,如进程状态、程序计数器(PC)、内存分配情况、寄存器的值、进程优先级、进程ID等等。
进程的新定义 = 内核PCB对象 (内核数据结构)+ .exe可执行程序
未来,所有对进程的控制和操作,都只和进程的PCB有关,和进程的可执行程序没有关系!!!
PCB相当于节点(Node),可以放到任意数据结构中。
task_struct-PCB的一种
task_ struct内容分类 - 操作系统内部的数据 - 系统调用
程序计数器:
可以在内核源代码里找到它。所有运行在系统里的进程都以task_struct链表的形式存在内核里。
ps axj
是一个用于在Unix/Linux系统上显示当前运行进程信息的命令。下面是对该命令各部分的解释:
ps
:显示进程信息的命令。a
:选择在终端上运行的所有进程,但不包括其他用户的进程。x
:选择没有控制终端的进程,包括后台进程和其他用户启动的进程。j
:以BSD风格的进程列表格式显示额外的信息,包括进程组ID、会话ID和控制终端。运行 ps axj
会以表格形式输出各种列,提供有关正在运行的进程的信息。具体的列可能包括:
然后我们来写一个代码,这个代码是一个死循环的输出"I am a process!",然后运行我们的程序。
一旦我们将程序运行起来,此时可执行程序立马就变成了进程。怎么证明呢?
我们发现第一个是我们的代码进程,那第二个是什么呢?第二个也是一个进程,grep在过滤的时候,它自己也是一个程序,当它过滤的时候,它的过滤关键字是包含myprocess的,一瞬间grep也变成了进程,因为包含了myprocess,所以也被留下来了。结论:几乎所有的独立的指令,就是程序,它运行起来,也要变成进程。如果我们不想要的话,可以后面加上:grep -v grep
grep -v grep
是在Unix/Linux环境中经常用于过滤命令输出的一种方式,用于排除包含字符串 "grep" 的行。让我们分解一下这个命令的各部分:
grep
:这是一个用于在文本数据集中搜索匹配正则表达式的命令行实用程序。
-v
:这个选项反转匹配,也就是说,它选择所有不匹配指定模式的行。
grep
:第二个 "grep" 是要从输出中排除的模式。在这种情况下,你排除包含单词 "grep" 的行。
因此,当你使用 grep -v grep
时,实际上是在说“显示所有不包含单词 'grep' 的行”。这通常用于在查看进程或服务输出时,排除实际的 grep
命令本身。
我们可以通过循环测试间隔1秒来查看当前进程,进程是有生命的!!!
task_ struct内容分类 - 操作系统内部的数据 - 系统调用
因此我们来修改一下我们的程序
一般在我们的Linux中,普通进程都会有他的父进程!!!怎么获取呢?调用getppid()。
此时我们就可以看到相关父进程的pid。
我们再多次运行我们的程序,我们发现进程的pid一直在变化,而父进程的pid始终没有变化。
当一个程序多次运行时,它的进程PID会不断变化,因为每次运行都会创建一个新的进程。每个进程都有一个唯一的PID,它是操作系统为了标识和管理进程而分配的数字标识符。然而,父进程的PID通常不会改变,因为它是启动新进程的原始进程的标识符。子进程会继承父进程的一些属性,包括父进程的PID。所以,无论我们运行多少次程序,它们的父进程的PID通常会保持不变,因为它们都是由同一个父进程启动的。
如果你在多次运行中看到父进程的 PID 一直没有变化,这表明每次运行的新进程都是由同一个父进程启动的,我们可以看到父进程是bash,是我们的命令行接收器,在命令行执行的指令或者程序都是bash的子进程。这里的bash就是曾经提到的王婆,而子进程就是实习生。
LInux会把进程相关的数据以文件的形式显示到系统文件中。
以数字进行命名的我们称之为目录,这个数字就是我们进程的PID。如果我们运行我们的程序,那我们是否可以在这个系统/proc目录下查看到我们的此时进程的一个目录。
同时我们还可以看到这个进程的具体信息:ls /proc/19132 -l
我们着重看一下上面标记的两个。
"exe" 是 "可执行文件"(Executable)的缩写。它是一种计算机文件格式,通常用于存储程序代码,在LInux下可以通过在磁盘中的绝对路径进行运行,上面exe指向的就是可执行程序在磁盘中的绝对路径。
删除一个可执行程序文件不会影响已经在运行的进程。当你运行一个可执行程序时,操作系统会将该磁盘中存在的这个程序的拷贝加载到内存中,然后执行。一旦程序开始执行,它与原始的可执行文件就没有直接的关系了。
删除可执行文件后,文件系统上的相应实体被删除,但已经加载到内存中的程序仍在执行。这是因为在执行过程中,程序已经被加载到系统内存中,而不再依赖于原始文件。程序会一直执行,直到其运行结束或被手动终止。
如果你删除了一个正在运行的可执行文件,该文件的文件名和路径信息将不再可见,但操作系统仍会保留该程序在内存中的拷贝。只有当该程序运行结束并释放了系统资源,或者被手动终止时,相关的进程才会被终止。
"cwd" 是 "Current Working Directory" 的缩写,表示当前工作目录。在操作系统中,当前工作目录是指在命令行或程序中正在进行操作的目录路径。
当你在命令行中运行命令或执行程序时,操作系统会将这些操作基于当前工作目录来进行,比如创建一个文件。
终端或命令行提示通常显示当前工作目录的路径。当我们执行上面的可执行程序后,文件就在当前工作目录显示了,同时还可以通过绝对路径寻找到这个文件。
如果我们想改变不当前工作目录呢?系统调用接口为我们提供了一个函数:chdir
然后我们来修改一下我们的代码
然后执行我们的代码,我们发现当前工作目录发生改变了,同时创立的文件绝对路径也发生变化
然后我们来初步使用一下fork函数。
运行一下,看一下会输出什么?
我们发现before输出了一遍,而after输出了两遍,我们可以得到一旦fork函数执行后,存在两个执行分支的,所以after会执行两次,我们发现第一次after输出的pid和ppid和before相同,说明它俩是同一个进程,第二次after输出的ppid和第一次输出的pid是一样的,说明第二次after是第一次after的子进程,它们之间存在父子关系。
上面的31564是命令行的进程,也就是我们的bash。
fork执行后有两个进程,父和子都会运行。那我们怎么知道哪一个是父进程,哪一个是子进程呢?
按照这个返回值的意义,那是不是有两个返回值,我们来验证一下
这里就要问一下,这里访问的是同一个变量,为什么能返回两个值呢?这个我们暂时当成这个函数的特性。我们待会讲。这里要提一个问题,我们为什么要创建子进程?
通过创建子进程,可以在一个程序中并行执行多个任务。每个子进程都有自己的独立内存空间和执行上下文,因此它们可以同时执行不同的代码块,与自己的父进程互补干扰,这样我们的子进程和父进程就能执行不同的任务。
那怎么分别执行呢?通过返回值使用if进行分流即可,让父子执行不同的代码块
于是这样就执行了我们子进程和父进程的代码。
我们知道代码无法执行两个死循环,但是今天我们可以通过父子进程去实现,fork 之后通常要用 if 进行分流。
我们可以看到父子进程中两个死循环同时执行了,可以证明fork后此时有两个执行流。那我们怎么理解上面的父子进程代码共享呢?
进程 = 内核数据结构 + 可执行程序和数据。当父进程创建一个进程的时候,系统中就多了一个进程,系统调用使用 fork
创建子进程时,子进程将会复制父进程的可执行程序的代码。这种复制是通过写时复制(Copy-on-Write,COW)实现的,同时PCB相关的属性子进程也会拷贝父进程的task_struct属性。
上面的这个代码都是又父进程提供的,只不过通过fork函数的返回值来让父子执行不同的代码块。子进程被创建,是以父进程为模板的!!!现在再来讲一下fork函数。
关于第三点,我们可以验证一下,父子进程共享代码,因此父进程挂掉了,子进程还可以运行,说明父子进程之间运行具有独立性,且在运行期间不能相互影响。当我们对应的父进程或者子进程尝试对某一个变量做写入的时候,因为子进程拷贝了父进程的代码,且如果这个数据是父子进程共享的,那么就会在操作系统发生写时拷贝,此时父子进程使用两个不同的地址空间。此时就可以用同一个变量名,表示不同的内存,而fork的返回值,实际上就是写入,本质也就是写入到id变量中,这也就是上面提到的数据各自开辟空间,私有一份(采用写时拷贝)。
注:写时拷贝通常应用于数据的共享,而不是可执行程序的共享。
我们现在来写一个代码从创建到退出的过程。
我们上面的代码时创建10个进程,让每个进程执行一下worker函数,当每个进程执行完worker函数后,自行退出程序。
我们首先来了解一下进程排队,进程 = 内核数据结构 + 可执行程序代码和数据,但是进程不是一直运行的,我们可以验证一下。
上面的进程就没有一直运行,可能在等待某种软硬件资源,我们上面的程序就在等待键盘资源的输入,如果键盘资源没有准备好,这个进程就不会被调度,不会往后运行,所以上面的进程才会卡住。所以我们的程序加载到内存后,并不会一直运行。
即使进程虽然放在了CPU上,但也不是一直运行的。这里举一个例子我们就可以知道,我们现在的电脑是一个CPU的话,当我们写一个死循环的代码时,当CPU调度这个死循环进程的时候,此时CPU应该就被占满了,其他进程应该就不会被CPU执行,可事实上,我们发现CPU调度这个死循环进程的时候,电脑可能稍微会卡顿一下,但是其他应用软件依然能被执行。我们的进程一旦被CPU调用的时候,并不一定要等这个进程执行完才能调度下一个进程。
操作系统使用一种叫做时间片轮转的调度算法。每个进程被分配一个小的时间片,称为时间片量,来在CPU上运行。当时间片用尽时,操作系统会挂起当前运行的进程,将CPU分配给下一个等待执行的进程。这种切换会在短时间内发生很多次,使得每个进程都有机会执行。
我们再来回归本题:进程为什么要排队?
进程排队一定是在等待某种资源(CPU或者软硬件资源),进程 = 内核数据结构 + 可执行程序代码和数据,进程排队实际上是内核数据结构,也即是task_struct在排队,因为它是描述进程结构体的一个对象。比如在未来我们找工作的时候,我们将我们的简历投递给公司,本身是将我们的属性数据给到hr,当我们的hr看第一份简历的时候,其余九个人的简历就在排队,等待hr这个资源,说是简历在排队,实际上就是我们的属性数据在排队。只要是排队,一定是进程的task_struct进行排队。
一个进程的PCB已经链入到链表里,排队的时候又要链入到队列里,这是不是就有点绕?
一个task_struct可以被链入多种数据结构。其实上面这个很好解释,我们在数据结构里面就学习了链表的相关操作,而在队列的学习过程,我们的队列就是使用链表去实现"先进先出"的这个特性,所将一个进程的PCB首先链接到PCB链表,然后再将其链接到队列,可能看起来有些绕,但这是为了灵活性和效率考虑的一种设计。PCB链表用于维护系统中所有进程的信息,而队列则用于管理进程的调度和执行顺序。将PCB链接到链表是为了方便系统整体的管理,而将其链接到队列则是为了按照一定的策略进行调度。
在Linux内核中,每一个进程task_struct不是被链入到单链表中,而是我们的双链表中。
这里双链表和数据结构的双链表有一些区别,我们来画一下。
通过上面的双链表结构我们可以访问到struct listnode n,但是我们要使用的是task_struct t,我们需要获得整个PCB的属性信息呀,那这样怎么处理呀!我们下面举一个例子
所以求想知道整个PCB的属性信息,我们只需要找到task_struct t的地址即可。首先我们可以知道struct listnode n是task_struct t里面的一个变量,我们可以知道struct listnode n的地址,再求出struct listnode n的偏移量,就可以求出task_struct t的地址。
即可推出公式:&t = (int)&n - (int)&((task_struct*)0)->n;
一个task_struct可以被链入多种数据结构。我们现在再看这句话就好懂很多了。
一个进程的PCB已经链入到链表里,排队的时候又要链入到队列里,这是不是就有点绕?
再看这个问题就很简单了,虽然它是链表,但是我们可以改造一下它的插入和删除的特点,这样就是我们的队列,而队列的底层实现也是链表,所以task_struct可以被链入多种数据结构,我们可以将链入的众多链表中的一个当成我们的队列使用即可。此时PCB插入到链表中,就链接task_struct中的struct listnode双链表即可,排队的时候根据实现队列新的struct listnodel双链表链入即可。整个过程不用对task_struct进行任何修改。如果未来我们要删除一个进程,只需要在这个双链表中把当前进程这个节点 删除即可。
我们先来了解"状态"这个名词
那何为运行状态呢?
一个进程只要在CPU的运行队列上,那么该进程就处于运行状态。一个进程处于运行状态并不是当前进程正在CPU上跑,当让,一个进程正在CPU上跑的时候这个进程一定是属于运行状态的。大部分操作系统中,只要进程被链入运行队列上排队,我们就可以称这个进程处于运行状态。
R:进程已经准备好随时被调度了。(此时就绪状态 等同于 运行状态,两个同为一个概念)
现在我们再来理解一下阻塞状态,要理解它,必须要从硬件方面谈起。首先第一个问题:硬件是如何管理的。先管理,再描述。
那我们就先管理呗!
然后再描述呗!!!
所以对硬件的管理就转化为对特定数据结构的管理。
当未来一个程序,内部有scanf函数,处于运行队列并且已经已经在CPU上跑了,当CPU执行到程序scanf函数,此时这个进程就不能再往后执行,因为当前scanf还没有收到用户输入,此时操作系统就将该进程从运行队列,状态由R变成非R(阻塞),然后将这个进程链入到键盘队列,所以这里的描述还要加一个设备队列。未来有其他程序需要网卡,就链入网卡队列。
设备也有队列哟!这不奇怪,CPU就有一个运行队列,CPU也是设备!!!CPU调度的时候不会调度scanf函数的那个进程,CPU只会调度运行状态的进程。所以当我们执行scanf函数的时候,终端就卡在那里,即没有被运行。那什么时候唤醒这个进程呢?硬件的就续状态只有操作系统最清楚!因为操作系统是硬件的管理者!当操作系统检测到键盘已经就绪,就会找键盘里面的队列,把该进程的阻塞状态改为运行状态,然后再将队列里面的第一个进程链入到CPU运行队列上,后面就静等CPU的调度,当CPU运行到该进程时,然后再执行scanf,此时键盘资源已经准备就绪(操作系统已经将scanf数据搬到内存了->就绪),所以就能直接获取到用户的输入,继续执行后面的代码。
当我们的进程在进行等待软硬件资源的时候,资源如果没有就绪,我们的进程task_struct只能将自己设置为阻塞状态,同时将自己的PCB连入等待软硬件资源提供的等待队列。状态的变迁, 引起的是PCB会被OS变迁到不同的队列中。
谈完阻塞状态,我们再来谈谈挂起状态。
我们先不说挂起状态是什么,但是挂起状态都有一个前提:计算机内存资源已经比较吃紧了,当我们的一个进程被连入等待软硬件资源提供的等待队列,变成阻塞状态,此时这个进程是不会执行的,此时计算机的内存已经很吃紧了,而这个进程代码和数据还占用内存,所以操作系统认为当前进程不会被调度,代码也不会被运行,操作系统此时会把代码和数据交换到外设磁盘中,当要被调度的时候,再换回内存,这个就是阻塞挂机,也就是阻塞的状态下,操作系统已经吃紧了。
在我们唤入(把数据拷贝到外设)唤出(数据从外设唤入到内存)【外设访问速度较慢:本质是拿计算机的效率换系统内存本身的可用性】的时候,我们的PCB不会被唤入唤出,如果我们唤入唤出的话,这个进程就不在当前内存执行队列中,我们就无法管理了,只有通过PCB我们才知道当前进程被唤入唤出,知道它是否再内存还是外设磁盘中。
创建进程是先创建我们的内核数据结构(PCB),然后再加载代码和数据,还是先加载代码和数据,再创建我们的内核数据结构(PCB)呢?
这里我们举一个例子,我们手机上的王者荣耀在手机磁盘上有20多个G,而我们的手机内存通常都是比磁盘空间小,就拿我的手机举例,手机运行内存只有8个G,我们如果想要运行王者荣耀的时候,那我们手机的运行内存直接就占满了,那么有的人说可以批量加载,确实可以,但是如果我们没有先创建我们的内核数据结构(PCB),我们怎么知道王者荣耀小程序被加载了多少,还有多少需要加载,当前进程应不应该调度呢?在这里我们可以用上面的挂起状态解释,当我们的进程创建了内核数据结构(PCB),没有代码和数据也不影响,因为我们这个进程没有代码和数据的时候,这个进程在未来操作系统不紧张的时候还可以被唤入,此时再调度就行。所以先创建我们的内核数据结构(PCB),然后再加载代码和数据。只有创建一个进程的内核数据结构(PCB),操作系统内部就知道这个进程已经有了。此时有PCB我们就知道王者荣耀小程序被加载了多少,还有多少需要加载,当前进程应不应该调度,然后在慢慢加载后面的代码。
看看Linux内核源代码怎么说
为了弄明白正在运行的进程是什么意思,我们需要知道进程的不同状态。一个进程可以有几个状态(在 Linux内核里,进程有时候也叫做任务)。 下面的状态在kernel源代码里定义:
/*
* 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 */
};
我么先来看一下R运行状态和S睡眠状态,我们先来写一段代码。
make编译之后执行我们的可执行程序
然后我们加入获取pid的代码,然后再次make编译运行,立马输入指令:ps axj | head -1 && ps axj | grep myprocess
我们可以看到状态列:STAT,但是此时我们发现此时进程的状态是S状态,S状态不是我们的睡眠状态嘛,但是我们的程序此刻正在运行,不应该是R运行状态吗???
这是因为我们的代码有一句sleep休眠1秒,注定了当前进程有90.99999999%的可能性大部分处于休眠状态的,只有很少的情况下才会被调度一下,输出"hello myprocess!\n",所以要查到运行状态是很难的,因为只有几毫秒甚至几微米这个进程才会被调度一次,大部分这个进程都是没有被调度的,所以处于S睡眠状态很正常。
我们把我们的sleep语句注释掉,这下应该是R运行状态吧,我们来看看结果。
我们发现此时还是S睡眠状态,这是为什么呢?
这是因为我们的代码有一句printf打印输出的语句,它作用是向显示器打印,而且我们这个代码是在远端服务器上运行的,远端服务器上运行的结果展现在我们电脑的显示器上,printf打印输出它是会访问外设的,此时外设设备不一定已经准备就绪了,第一次输出打印成功,第二次我们就能保证数据立马从缓冲区刷新出来打印了吗?CPU的速度比外设快很多,当时我们打印输出的缓存区的时候,显示器这个外设不一定能立马刷新数据到显示器,外设相比CPU太慢了,所以printf打印输出的语句里面伴生着很多引起我们当前进程处于某种等待这个设备就绪的过程,所以这个进程还是有大量时间在等待,所以还是S睡眠状态。
那我们把我们的printf语句注释掉,这下应该是R运行状态吧,我们来看看结果。
此时终于是我们的R运行状态。这里我们发现grep --color = auto myprocess也是R运行状态,为什么?首先grep也是一个进程,当一个进程被调度的时候,它才是运行状态,也只有grep进程被调度的时候,它才能帮我们过滤process进程信息,只不过这里grep也过滤出了自己。这就相当于我们每天吃饭,看电影和打游戏,前提是自己得运行起来,你要是睡觉肯定就完成不了,所以grep要过滤得前提必须自己是运行状态。
我们先将我们的程序恢复如初,然后我们再来看一下这个'+'是什么意思?
当我们执行程序的时候,期间无论输入什么指令,这些指令都不会执行,这就是我们的前台程序,当为前台程序的时候,会在状态后面加上'+'。
那我们怎么让加号'+'变没呢?我们需要在执行我们的可执行程序后面加上&,这样我们的程序就变成了后台程序,期间无论输入什么指令,这些指令都会执行。
这种后台进程我们无法通过ctrl+c终止,只能通过kill -9 18361杀掉程序。我们再来看一下我们的S休眠状态,它到底是什么呢?我们来写一段代码。
然后我们编译运行我们的程序,当我们不从键盘上输入的时候,我们来查看此时的进程状态。
此时我们的进程正在干嘛呢?此时它正在等待键盘的输入,此时它正在等待我们的外设,当前进程并没有被调度,因为我们的外设资源没有就绪,我们的用户没有按下键盘,所以此时process这个进程处于阻塞状态,所以Linux下的S状态就等同于操作系统学科中的阻塞状态,但是阻塞状态就不只有S状态这一种。我们可以通过ctrl +c终止上面的进程,所以在Linux下S状态也可称为可中断睡眠 - 浅度睡眠。
那我们有其他睡眠形式嘛?有,比如我们的D磁盘休眠状态,它是不可中断睡眠 - 深度睡眠,不过这种形式无法用代码演示,这里我们只能举一个例子:
未来当一个进程运行的时候需要向磁盘写入文件(文件比较大),于是进程就隔着老远向磁盘喊:磁盘,你出来,我们有事和你说,磁盘就探出个脑袋说:进程你找我什么事啊!进程说:我这里有100MB的数据哦,很重要,你帮我去写入吧!写入到你的磁盘里,我在内存种等你,你完成的时候,不管是写入成功还是写入失败你都告诉我一声啊!因为我要给用户交代,我要告诉用户结果。于是进程就把数据交给磁盘,让磁盘写入数据,于是磁盘就开始找合适位置开始写了,因为磁盘是外设,所以进程就只能等了,进程就等的很无聊,在内存种搬个小板凳,翘着二郎腿和磕着小瓜子在那等外设磁盘的结果。当时操作系统看到这个进程为你干啥呢?进程说:我在休眠,我在等待磁盘的返会结果。操作系统生气的说:等什么等,你没看到我们忙成什么样子啦,整个内存的资源已经严重吃紧了,我已经唤出了很多进程的代码和数据,你还在这里等!!!你在这里什么都没干,还翘着二郎腿和磕着小瓜子,还占着内存,于是操作系统一怒之下把这个进程回收了。此时磁盘写着数据,突然发现磁盘空间不够了,于是磁盘弹出脑袋说:进程老哥,我空间不够了,写失败了,进程老哥,你人呢?怎么不见呢.?此时磁盘找不到就只能将这个结果给丢弃了。用户一直在那等,发现那个进程已经退出了,就认为文件已经写入到磁盘中了,于是用户就去磁盘中寻找,一看头一炸,发现并没有,更严重的是这个100MB的文件是在某宝上面购买的,而且文件就只有5秒的使用权限,现在5秒时间过了,我文件还没有得到,用户直接崩溃了。用户此时就把操作系统,进程,磁盘拉出来军训了,到底是谁的问题。操作系统在被逼急了得时候,是会杀掉进程得哦!此时操作系统通过唤入已经不能解决这个问题。当我们国庆节访问高铁12123网站买票的时候,此时网站很大几率会被挂掉,因为资源已经非常吃紧了,操作系统已经唤出很多进程的数据了 ,此时操作系统发现资源还是非常吃紧了,操作系统自己马上就挂掉了,于是就把进程直接删掉了。所以我们也就不能访问到网站。此时D磁盘休眠状态就应运而生了,它就相当于一块免死金牌,当操作系统发现进程具有这块免死金牌,就不会杀掉这个进程,此时磁盘返回下载失败的结果也能返回给进程,然后进程就由D状态变为R状态,然后告诉用户说失败了。D磁盘休眠状态也是教材上的阻塞状态,因为它也在等外设资源就绪。
一步一个jio印,我们在来学习一下T停止状态(stopped)。我们先看现象,后看结论。
首先我们先修改我们的myprocess.c代码
运行一下没有问题
然后我们学习一下kill -l
kill -l
是在Linux操作系统中用来列出所有可用的信号的命令。信号是一种进程间通信的方式,用于通知进程执行某种动作或事件。kill
命令除了用于终止进程外,还可以向进程发送不同的信号,这些信号有各种不同的作用。以下是一些常见的信号及其含义:
SIGINT (2): 中断。通常通过按下 Ctrl+C 产生,用于终止当前运行的程序。
SIGKILL (9): 杀死。立即终止进程,无法被捕获或忽略。
SIGCONT (18): 继续。继续执行停止的进程。
SIGSTOP (19): 停止。停止进程的执行,但不终止它。
所以我们可以通过:SIGSTOP (19),停止。停止进程的执行,但不终止它。
此时这个进程就变成了T停止状态,并且后面的'+'没有了。T停止状态:让进程处于暂停状态。此时我们使用ctrl+c无法终止程序,因为一旦把一个进程暂停了,这个进程立马就由前台进程转为后台进程,只能通过kill -9 16659杀掉进程。
再来看一下t(tracing stop)状态,我们先来修改一下我们的process.c和makefile
然后开始进入我们的gdb调试界面。
此时我们就发现进程处于t(tracing stop)状态,它在等gdb进程发生下一个指令。当我们输入r的时候,这个调式进程就运行了,进程在断点处停下来的本质是以t(tracing stop)状态等待用户的下一条指令,由于此时gdb进程没有得到用户的输入,自己也就处于阻塞状态了。这里暂停状态程序什么都没有做,那么可以理解成暂停状态也属于教材上的阻塞状态!!!
当一个进程在退出之后,它所对应的资源当中,代码和数据可以被直接释放,因为此时代码和数据肯定不会执行了,因为进程已经不执行了,但是当前进程的PCB,描述改进程的状态信息不应该立马被释放,应该缓一缓,此时让系统或者其他进程知道这个进程的退出数据,只有拿完之后这个进程才能算作X死亡状态(dead),此时PCB才能被释放,我们把一个进程已经执行完毕,但是当前并没有会去获取它进程的退出的相关数据时,此时就是Z僵尸状态(zomble)。可以理解人不在了但是精神还在影响着我们!!!现在我来看一下这种状态。
Z(zombie)-僵尸进程
- 僵死状态(Zombies)是一个比较特殊的状态。当进程退出并且父进程(使用wait()系统调用,后面讲) 没有读取到子进程退出的返回代码时就会产生僵死(尸)进程
- 僵死进程会以终止状态保持在进程表中,并且会一直在等待父进程读取退出状态代码。
- 所以,只要子进程退出,父进程还在运行,但父进程没有读取子进程状态,子进程进入Z状态
此时父进程是没有读取子进程相关退出信息的。
此时我们就可以看到僵尸状态,同时子程序此时后面还有一个单词:defunct
那我们怎么可以观察到死亡状态呢?我们先来学习一下wait()
。
wait()
获取子进程的终止状态: 当一个子进程终止时,它的终止状态会被保存,父进程通过 wait()
调用可以获取到这个子进程的终止状态信息。这包括子进程的退出码(Exit Code)等信息。
我们首先来看一下这个代码的含义:让子进程执行5秒,父进程执行10秒,前5秒父子进程共同运行,后5秒只有父进程运行,中间5秒子进程处于僵尸状态,10秒之后父进程就结束了,我们让父进程等待 wait()
获取一下子进程的终止状态信息,然后我们就可以看到子进程已经会销毁了。
前五秒的结果:
后五秒结果:
父进程获取子进程死亡信息,子进程被销毁
随后父进程也被退出了
上面子进程确实已经死亡了,已经时死亡状态了,只不过子进程死亡的时间很短,我们没有检测到。这里我们提一个问题:为什么要有Z僵尸状态?创建进程是希望这个进程给用户完成工作的,子进程就必须必须产生结果返回给用户,进程退出时这些结果都放在PCB里,必须要等到父进程拿到PCB里面的结果,僵尸状态的子进程才能被释放。什么时Z僵尸状态?子进程已经退出,但是当前的进程的状态需要自己维护的特性,供父进程读取,此时就必须处于Z僵尸状态。如果我作为父进程不读取呢?Z僵尸状态就会一直存在,也就意味着PCB一直存在,此时就要占用内存,而内存不释放就会发生内存泄漏的问题。这里的父进程为什么查看不到Z僵尸状态呢?因为bash自动读取了父进程的PCB信息,读取完是一瞬间的事,也就看不到父进程的Z僵尸状态。
僵尸进程危害
- 进程的退出状态必须被维持下去,因为他要告诉关心它的进程(父进程),你交给我的任务,我办的怎 么样了。可父进程如果一直不读取,那子进程就一直处于Z状态?是的!
- 维护退出状态本身就是要用数据维护,也属于进程基本信息,所以保存在task_struct(PCB)中,换句话 说,Z状态一直不退出,PCB一直都要维护?是的!
- 那一个父进程创建了很多子进程,就是不回收,是不是就会造成内存资源的浪费?是的!因为数据结构 对象本身就要占用内存,想想C中定义一个结构体变量(对象),是要在内存的某个位置进行开辟空 间!
- 内存泄漏?是的!
- 如何避免?后面讲
这就是Linux下的进程状态转化图
至此,值得关注的进程状态全部讲解完成,下面来认识另一种进程
先不解是什么孤儿进程,我们先来改一下我们的代码。
我们让子进程执行500秒,让父进程执行5秒。
前5秒结果:
后面时间结果:
毫无疑问程序肯定是父进程先退出,此时父进程退出后,bash获取到父进程的PCB后,父进程就销毁了。父进程先退出,此时子进程就称之为“孤儿进程”。
问题来了,那么父进程如果提前退出,那么子进程后退出,进入Z之后,那该如何处理呢?这里我们发现父进程退出之后,此时父进程的id就变为1了,1号pid是谁呢?我们来看一看。
父进程如果提前退出,那么子进程后退出,进入Z之后,此时子进程被1号init进程领养,当然要有init进程回收喽。同时我们还可以发现孤儿进程还是一个后台进程。