【Linux进程理解】| 冯诺依曼体系结构 | 操作系统 | 进程理解 | 状态 | 优先级

【写在前面】

从此篇开始,就开始学习 Linux 系统部分 —— 进程,最近在准备挑战杯项目,这篇长文陆陆续续写了一个月才感觉完善了,在正式学习 Linux 进程之前,我们需要铺垫一些概念,如冯诺依曼体系结构 (解释可执行程序运行时,必须先加载到内存的原因)、操作系统的概念及定位、进程概念,我们会先铺垫理论,再验证理论。其次对于某些需要深入的概念我们只是先了解下。本文中的 fork 只会介绍基本使用,以及解答 fork 为啥会有 2 个返回值、为啥给子进程返回 0,而父进程返回子进程的 pid;而对于用于接收 fork 返回值的 ret 是怎么做到 ret == 0 && ret > 0、写时拷贝、代码是怎么做到共享的、数据是怎么做到各自私有的等问题会在《Linux进程控制》中进行展开。

一、冯 • 诺依曼体系结构

💦 体系结构

冯 • 诺依曼结构也称普林斯顿结构,是一种将程序指令存储器数据存储器合并在一起的存储器结构。数学家冯 • 诺依曼提出了计算机制造的三个基本原则,即采用二进制逻辑、程序存储执行以及计算机由五个部分组成 (运算器、控制器、存储器、输入设备、输出设备),这套理论被称为冯 • 诺依曼体系结构。我们常见的计算机,如笔记本。我们不常见的计算机,如服务器,大部分都遵守冯诺依曼体系。其中:

  • 输入设备:键盘、鼠标 … …。
  • 输出设备:显示器、音响 … …。
  • 存储器:如没有特殊说明一般是内存。
  • 运算器:集成于 CPU,用于实现数据加工处理等功能的部件。
  • 控制器:集成于 CPU,用于控制着整个 CPU 的工作。
  • 各个组件之间的互通是通过 “ 线 ” 连接实现的,这可不是那种电线杆上的线,因为计算机更精密,所以使用 “ 主板 ” 来把它们关联在一起。
    在这里插入图片描述

💦 数据流向

冯 • 诺依曼体系结构规定了硬件层面上的数据流向,所有的输入单元的数据必须先写到存储器中 (这里只是针对数据,不包含信号),然后 CPU 通过某种方式访问存储器,将数据读取到 CPU 内部,运算器进行运算,控制器进行控制,然后将结果写回到内存,最后将结果传输到输出设备中。

我们在 C/C++ 中学过,可执行程序运行时,必须加载到内存,为啥 ❓
在这里插入图片描述

在此之前先了解一下计算机的存储分级,其中寄存器离 CPU 最近,因为它本来就集成在 CPU 里;L1、L2、L3 是对应的三级缓存,它也集成于 CPU;主存通常指的是内存;本地存储(硬盘)和网络存储通常指的是外设。

如图所示,这样设计其实是因为造价的原因,对于绝大多数的消费者,你不可能说直接把内存整个 1 个 T 吧,当然,氪金玩家除外。

其中通过这个图,我们想解释的是为啥计算机非得把数据从外设(磁盘) ➡ 三级缓存(内存) ➡ CPU,而非从外设(磁盘) ➡ CPU。

原因是因为离 CPU 更近的,存储容量更小、速度更快、成本更高;离 CPU 更远的,则相反。假设 CPU 直接访问磁盘,那么它的效率可太低了。这里有一个不太严谨的运算速度的数据,CPU 是纳秒级别的;内存是微秒级别的;磁盘是毫秒级别的。当一个快的设备和一个慢的设备一起协同时,最终的运算效率肯定是以慢的设备为主,就如 “ 木桶原理 ” —— 要去衡量木桶能装多少水,并不是由最高的木片决定的,而是由最短的木片决定的。也就是说一般 CPU 去计算时,它的短板就在磁盘上,所以整个计算机体系的效率就一定会被磁盘拖累。所以我们必须在运行时把数据加载到内存中,然后 CPU 再计算,而在计算的期间可以同时让输入单元加载到内存,这样可以让加载的时间和计算的时间重合,以提升效率。
  
  

同理因为效率原因 CPU 也是不能直接访问输出单元的,这里以网卡为例,我刚发条 qq 消息给朋友,发现网络很卡,四五秒才发出去,而在这个过程,你不可能让 CPU 等你四五秒吧,那成本可太高了,所以通常 CPU 也是把数据写到内存里,合适的时候再把数据刷新到输出单元中。

所以本质上可以把内存看作 CPU 和所有外设之间的缓存,所有设备也都只能和内存打交道,也可以理解成这是内存的价值。

💨小结:所有数据 ➡ 外设 ➡ 内存 ➡ CPU ➡ 内存 ➡ 刷新到外设,其中我们现在所谈论的观点是在数据层面上 CPU 不直接和外设交互,外设只和内存交互,这也就是可执行程序运行时,必须加载到内存的原因,因为冯诺依曼体系结构规定了,而我们上面花了大量篇幅主要是阐述了冯诺依曼体系结构为什么这样规定,本质电脑在开机的时候就是将操作系统加载到内存。注意一定要区分清楚某些概念是属于 “ 商业化的概念 ” 还是 “ 技术的概念 ”。

💦 实例

对冯诺依曼的理解,不能只停留在概念上,要深入到对软件数据流理解上,请解释,你在qq 上发送了一句 “ 在吗 ” 给朋友,数据的流动过程 ?如果是在 qq 上发送文件呢 (注意这里的计算机都遵循冯 • 诺依曼体系结构,且这里不谈网络,不考虑细节,只谈数据流向) ?

☣ 消息:
在这里插入图片描述

☣ 文件:
在这里插入图片描述

本质上发消息和发文件是没有区别的。学习这里实例的意义是让我们在硬件层面上理解了它的数据流,你的软件无论是 QQ、WeChat 等都离不开这样的数据流。

二、操作系统 (Operate System)

💦 概念

操作系统是一个不易理解的领域,它被调侃为计算机学科中的哲学。操作系统是进行软硬件资源管理的软件,任何计算机系统都包含一个基本的程序集合,称为操作系统 (OS)。笼统的理解,操作系统包括:

  • 内核 (进程管理,内存管理,文件管理,驱动管理)。
  • 其他程序 (例如函数库,shell 程序等等)。

狭义上的操作系统只是内核,广义上的操作系统是内核+图形界面等,我们以后谈的也只是内核。

为什么要有操作系统 ❓

最明显的原因是如果没有操作系统,我们就没有办法操作计算机。换句话说,操作系统的出现可以减少用户使用计算机的成本。你总不能自己拿上电信号对应的电线自己玩吧,那样成本太高了。
对下管理好所有的软硬件,对上给用户提供一个稳定高效的运行环境。其中硬件指的是 CPU、网卡、显卡等;软件指的是进程管理、文件、驱动、卸载等。不管是对下还是对上,都是为了方便用户使用。

💦 计算机体系及操作系统定位

在这里插入图片描述

其中用户可以操作 C/C++ 库、Shell、命令、图形界面,然后底层可以通过操作系统接口完成操作系统工作,比如用户调用 C 库使用 printf 在显示器上输出,printf 又去调用系统接口最后再输出于显示器。当然后面我们会直接接触到一些系统接口;操作系统目前主流的功能有四大类 —— 1、进程管理。2、内存管理。3、文件管理。4、驱动管理。后面我们重点学习进程管理和文件管理,其次内存管理学习地址空间和映射关系就行了。

其次操作系统是不信任任何用户的,所以用户不可能通过某种方式去访问操作系统,甚至对系统硬件或者软件的访问。而对系统软硬件的访问都必须经过操作系统。也就是说作为用户想要去访问硬件,只能通过操作系统所提供的接口去完成,但是操作系统提供的接口使用成本高,所以我们就有了基于系统调用的库等。就比如银行不信任任何人,你要去取钱 (硬件),你不能直接去仓库拿钱,你也不能私底下指挥工作人员 (驱动) 给你去仓库拿钱,银行规定你要拿钱,必须通过银行提供的 ATM 机 (操作系统提供的接口) 来取钱,而对于一些老人来说所提供的窗口 (系统接口) 使用成本也较高,所以便有了人工窗口 (库函数)。

也就是说我们使用 print、scanf 等库函数时,都使用了系统接口,称之为系统调用。

系统调用和库函数概念 ❓

它们本质都是一种接口,库函数是语言或者是第三方库给我们提供的接口,系统调用是 OS 提供的接口。库函数可能是 C/C++,但是操作系统是 C,因为它是用 C 写的。

在开发角度,操作系统对外会表现为一个整体,它不相信任何用户,但是会暴露自己的部分接口,供上层开发使用,这部分由操作系统提供的接口,叫做系统调用
系统调用在使用上,功能比较基础,对用户的要求相对也比较高,所以,有心的开发者就对部分系统调用进行适度封装,从而形成库,有了库,就很有利于更上层用户或者开发者进行二次开发。类似于银行取钱时,一般都会人工窗口 (库),王大爷不会取钱,就去人口窗口 (调用库)。其实对于库函数的使用要么使用了 SystemCall(系统调用),如 printf 函数;要么没使用 SystemCall,如 sqrt 函数或者简单的 1 + 1 或循环,C 语言中还有很多数学函数,像这些函数并没有访问操作系统,因为这些函数的实现是在用户层实现的。
我们学习的 C/C++ 的范畴实际上在系统提供的接口之上,当然 Java 等语言还要在往上点。所以我们经常说的可 “ 跨平台性 ” 的根本原因就是因为 C语言的库对用户提供的接口是一样的,但系统调用的接口可能不一样,Windows下就用 W 的,Linux 下就用 L 的

现阶段所写的 C/C++ 代码价值并不大,因为大部分使用到的硬件资源是 CPU 和内存,所以更多的是完成存储器和内存之间的计算工作。事实上学了 C/C++ 什么都做不了,根本原因是只使用了 CPU 和内存,实际上语言要发挥更大的价值,需要你能访问其它的设备。

可以看到计算机体系是一个层状结构,任何访问硬件或者系统软件的行为,都必须通过 OS 接口,贯穿OS进行访问操作。

💦 管理

90% 的人操作系统学不会的根本原因是不理解 “ 管理 ”。

在学校里大概有这三种角色:

学生 (被管理者) —— 软硬件
辅导员 (执行者) —— 驱动
校长 (管理者) —— 操作系统
现实中我们做的事情无非是 a) 做决策。 b) 做执行。总之你是不是一个真正的管理者取决于你做决策的占比多还是少,所以辅导员严格来说不是真正的管理者。在现实生活中一般都有一个现象,管理者和被管理者并不见面,校长不会因为你挂科就过来找你谈心。

管理者和被管理者并不直接打交道,那么如何进行管理 ❓

是的,在现实生活中,可能就入学的时候和毕业的时候见过校长两面,很明显学生和校长并不直接见面,但还是把学生安排的明明白白的,比如拿奖学金与否、挂科与否。事实上我要管理你并不一定要和你见面,原因是你的个人信息在学校的系统里面,也就是说本质管理者是通过 数据 来进行管理的。比如说评选奖学金,校长在教学管理系统中筛选好某系某级综合成绩排名前 3 的学生来发奖学金,这时校长把 3 位同学对应的辅导员叫过来,并要求开一个表彰大会来奖励 3 位同学,然后辅导员就开始着手执行工作。

管理者和被管理者并不直接打交道,那么数据从哪来的 ❓

其实是执行者在你入学时把你的个人信息档案录入系统。

既然是管理数据,就一定要把学生信息抽取出来,要多少信息取决于被管理对象,抽取信息的过程,我们称之为描述学生,Linux 是用 C 语言写的,而学生信息就可以用一个 struct 来描述,因为学校里肯定不止一名学生,所以每一名同学创建一个结构体变量,然后利用指针把所有的同学关联起来,构成一个双向循环链表。此时校长要对旷课超出一定次数的张三进行开除学籍的处分,那么校长先通知辅导员,叫张三不要来了,然后从系统中遍历到张三,再把张三的个人信息给删除掉。所以本质学生管理的工作,就是对链表的增删查改。

说了这么多就是想说操作系统并不和硬件打交道,而是通过驱动程序进行操作硬件。操作系统里会形成对所有硬件相关的数据结构,并连接起来,所以对硬件的管理最后变成了对数据结构的管理。

管理的本质是:a) 对信息或数据进行管理 b) 对被管理对象先描述,然后通过某种数据结构组织起来,简化为先描述,后组织。后面我们都会围绕着这些观点学习。

三、进程 (process)

💦 概念

课本概念:进程就是一个运行起来的程序。
内核观点:进程就是担当分配系统资源 (CPU 时间、内存) 的实体。
当然对于进程的理解不能这么浅显,我们接着来了解一下 PCB。

💦 描述进程 - PCB

在这里插入图片描述

OS 能否一次性运行多个程序 ❓

当然可以,运行的程序很多很多,OS 当然要管理起来这些运行起来的程序。
在这里插入图片描述

正如校长和学生的例子,OS 如何管理运行起来的程序 ❓

先描述,在组织 !!!

操作系统要管理进程不仅仅是把磁盘加载到内存里 (这只是第一步),其次还会在 OS 中创建一个描述该进程的结构体,这个结构体在操作系统学科或 Linux kernel 中叫做 PCB(process control block 进程控制块),说人话就是在 Linux 下这个进程控制块是用 struct 来描述的 task_struct (因为 Linux kernel 是用 C 语言写的),这个 PCB 中几乎包含了进程相关的所有属性信息。其中被加载到内存中的程序就是学生,PCB 就是描述学生的属性信息。今天 OS 跑了一个进程,将来这些 PCB 是一定能够帮我们找到对应代码和数据的,就像学校系统中是一定包含你的个人信息的。

其次进程多了之后,操作系统为了更好的管理,需要使用 “ 双向循环链表 ” 将所有的 PCB 进行关联起来。

所以本质我们在 Linux 中 ./a.out 时主要做两个工作,其一先加载到内存,其二 OS 立马为该进程创建进程控制块来描述该进程。OS 要进行管理,只要把每一个进程的 PCB 管理好就行了,对我们来讲,要调整一个进程的优先级、设置一个进程的状态等都是对 PCB 进行操作。

💨小结:

描述:每个进程对应的 PCB 几乎包含了进程相关的所有属性信息。

组织:OS 使用了双向链表进行将每个进程对应的 PCB 组织起来。

所以 OS 对进程的管理转化为对进程信息的管理,对信息的管理就是 “ 先描述,后组织 ”,所以对进程的来之不易转化为对双链表的增删查改。

所以站在程序员以更深入的角度来看待进程就是等于:你的程序 + 内核申请的数据结构(PCB)。

💦 PCB (task_ struct) 内容分类

标示符 PID:描述本进程的唯一标示符,用来区别其他进程。

ps ajx,查看系统当前所有进程。

在这里插入图片描述

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

稍后我们会见到 Linux 进程的具体状态,细节下面再说。

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

比如去食堂干饭,需要排队,而排队就是在确定优先级,这口饭你是能吃上的,只不过因为排队导致你是先吃上,还是后吃上,所以优先级决定进程先得到资源还是后得到资源。在排队打饭时有人会插队,本质就是更改自己的优先级,你插队了,就一定导致其它人的优先级降低,对其它人就不公平,所以一般不让插队。其中 CPU、网卡等类似食堂的饭,进程类似干饭的人。

💨为啥需要排队 ❓

也就是说为啥要有优先级呢 ?假设世界上有无限的资源,那么就不会存在优先级了。而这里因为窗口太少了,所以优先级是在有限资源(CPU、网卡等) 的前提下,确立谁先访问资源,谁后访问的问题。所以优先级存在的本质是资源有限。 到目前为止,除了进行文件访问、输入输出等操作,大部分所写的代码竞争的都是 CPU 资源,比如说遍历数组、二叉树等,最终都会变成进程,然后竞争 CPU 资源,而我们后面需要竞争网络资源。

💨优先级 and 权限有什么区别 ❓

优先级一定能得到某种资源,只不过是时间长短问题;而权限是决定你能还是不能得到某种资源。

程序计数器 eip:程序中即将被执行的下一条指令的地址。

CPU 运行的代码,都是进程的代码,CPU 如何知道,应该取进程中的哪条指令 ❓

我们都知道语言中一般有三种流程语句 a) 顺序语句。 b) 判断语句。c) 循环语句。一般程序中默认是从上至下执行代码的。

在这里插入图片描述

在 CPU 内有一个寄存器,我们通常称之为 eip,也称为 pc 指针,它的工作是保存当前正在执行指令的下一条指令的地址。当进程没有结束,却不想运行时,我们可以将当前 eip 里的内容保存到 PCB 里 (其实不太准确,这里只是先为了好理解,后面知识储备够了,再回头校准),目的是为了恢复,具体细节后面会谈。

你说 eip 是指向当前正在执行的下一条指令的地址,那么第一次 eip 在干啥 ❓

这里是属于硬件上下文的概念,下面在谈进程切换时再学习。

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

CPU 不能直接去访问代码和数据,需要通过 PCB 去访问。内存指针可以理解为它是代码和进程相关数据结构的指针,通过这些内存指针可以帮助我们在 PCB 找到该进程所对应的代码和数据。
在这里插入图片描述

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

其中寄存器信息可以通过 VS 集成开发环境下查看:代码 ➡ 调试 ➡ 转到反汇编 ➡ 打开寄存器窗口。


在这里插入图片描述


我们常说的什么多核处理器,如四核八线程,注意它不是指 CPU 里的控制器,而是 CPU 里的运算器变多了,所以它计算的更快。后面我们可能会听过一个概念叫超线程,它其实是 CPU 开启了 并发指令流 的一种技术,所以它就允许有多种执行流在 CPU 上同时跑。
在这里插入图片描述


进程快速切换 && 运行队列 ❓

比如你是一名大二的学生, 已经上了二十几节课了,但因为身体原因,需要休一年的学,于是你就走了,而当你一年后回来时,你发现你能挂的科都已经挂完了,甚至你已经被退学了,原因是学校的资源都给你分配着呢,但因为你的一走了之,且没有跟导员打招呼而休学。所以正确方式是在你休学前,你应该跟导员打招呼,待导员向上级申明并把你当前的学籍信息 (你大几、挂了几科、累计学分、先把当前正在学习的课程停了) 保存后,才能离开,一年后,你回来了,但是你在上课时并没有你的位置,老师点名册上也没有你的名字,根本原因是你没有恢复学籍,你应该跟导员说恢复学籍,然后把你安排到对应的班级,此时你就接着上次保存学籍的学习状态继续学习。
  
  也就是说当一个进程运行时,因为某些原因需要被暂时停止执行,让出 CPU,此时当前 CPU 里有很多当前进程的临时数据,所以需要在 PCB 里先保存当前进程的上下文数据,而保存的目的是为了下一次运行前先恢复。所以对于多个进程,一个运算器的情况下,为了实现并发,进程对应的时间片到了,就把进程从 CPU 上剥离下来,在这之前会把上下文数据保存至 PCB,然后再换下一个进程,在这之前如果这个进程内有曾经保存的临时数据,那么它会先恢复数据,CPU 再运行上次运行的结果,这个过程就叫做 上下文保存恢复 以及 进程快速切换。
  
  系统里当前有 4 个进程是处于运行状态的,此时会形成运行队列 (runqueue),它也是一种数据结构,你可以理解为通过运行队列也能将所有在运行的 PCB 连接起来,凡是在运行队列中的进程的状态都是 R,也就是说每一个 PCB 结构在操作系统中有可能是链表,也有可能是队列,这个 PCB 里面会通过某种方式包含了大量的指针结构。注意以上所有的结构都是在内核中由操作系统自动完成的,这其中细节很多,后面每个阶段我们都会对细节进行完善,其次还包括阻塞队列、等待队列会再详谈。

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

白话就是哪些 I/O 设备是允许进程访问的。

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

白话就是你的一个进程,在调度时所使用的时间、在切换时切换的次数等。

记帐信息的意义 ❓

现实中也存在 “ 记帐信息 ”,也有一定的意义,比如每个人的年龄,每过一年,第人都会增长一岁,那么不同人累计下来的 “ 记帐信息 ” 值不同时,会有不同的状态,如六个月,你不会走路;六年,学习;二十四年,工作;八十年,有人主动让座。所以对系统来讲可以通过 “ 记帐信息 ” 来指导进程,比如有 2 个优先级相同的进程,一个累计调度了 10 秒钟,另一个累计调度了 5 秒钟,下一次则优先调度 5 秒钟的进程,因为调度器应该公平或较为公平的让所有进程享受 CPU 资源。

调度 ???

调度就是在从多的进程中,选择一个去执行,好比高铁站,你能准时准点的坐上高铁,根本原因是高铁站内部有自己的调度规则。

其他信息。

💦 查看进程

通过系统调用获取进程标示符 ❓

进程 id:PID
父进程 id:PPID
我们可以使用 man 2 getpid/getppid 命令来查看系统调用接口:
在这里插入图片描述

代码一跑起来就查看当前进程的 pid and ppid:

在这里插入图片描述
在这里插入图片描述

在这里插入图片描述

父进程和子进程之间的关系就如同村长家的儿子指明道姓要找王婆找如花媳妇,可是如花已经跟李四跑了,王婆一看生意没法做,风险太大,此时王婆就面临着两难,其一,张三是村长的儿子;其二,如花已经跟李四跑了。所以王婆就在婚介所招聘有能力说这桩媒的媒婆实习生,王婆不自己去,而让实习生去。如果事说成了,王婆脸上也有光,如果事没说成,那么对王婆也没影响。同样的 bash 在执行命令时,往往不是由 bash 在进行解释和执行,而是由 bash 创建子进程,让子进程执行。所以一般情况我们执行的每一个命令行进程都是命令行解释器的子进程。其细节,后面再谈。

其它方式查看进程 ❓

可以使用 top 命令来查看进程,类似于 Windows 下的任务管理器,一般用的少。

在这里插入图片描述

可以使用 ls /proc 命令来查看,proc 在 Linux 的根目录下。

在这里插入图片描述

三、创建子进程fork

上面我们写了一个死循环代码,然后 “ ./ ” 运行,一般我们称之为命令式创建进程,实际上我们也可以用代码来创建子进程。

fork 也是系统调用接口,对于 fork 我们还会在 “ 进程控制 ” 章节中再深入,在此文中我们会通过 a) 程序员角度。 b) 内核角度。来学习 fork。

💦 认识 fork

man 2 fork 来查找 fork 的相关手册:
在这里插入图片描述

💦 使用 fork 创建进程

这里 fork 后,后面的代码一定是被上面的父子进程共享的,换言之,这个循环每次循环都会被父子进程执行一次:

在这里插入图片描述

可以看到 fork 之前,当前进程的 pid 是 18188,fork 之后,也可以看到 18188 的父进程 bash 是12351。然后fork 之后的那个进程是 18189,它的父进程就是 18188。换言之,父进程 fork 创建了子进程,谁调用 fork,谁就是父进程 :
在这里插入图片描述
ps ajx 查看当前进程:
在这里插入图片描述

💦 程序员角度理解 fork

通过上面的代码知道了 fork 可以创建子进程,也就意味着 fork 之后,这个子进程才能被创建成功,父进程和子进程都要执行一样的代码,但是 fork 之后,父进程和子进程谁先执行,不是由 fork 决定的,而是由系统的调度优先级决定的。
也就是说父子进程共享用户代码 —— 只读的;而用户数据各自私有一份 —— 比如使用任务管理器,结束 Visual Studio2017 进程,并不会影响 Xshell,一个进程出现了问题,并不会影响其它进程,所以操作系统中,所有进程是具有独立性,这是操作系统表现出来的特性。而将各自进程的用户数据私有一份,进程和进程之间就可以达到不互相干扰的特性。
注意这里私有数据的过程并不是一创建进程就给你的,而是采用写时拷贝 的技术,曾经在 C++ 里的 深浅拷贝 谈过,这里后面还要再详谈,因为我们虽然在语言上学过了,但是在系统上还没学过。

💦 内核角度理解 fork

fork 之后,站在操作系统的角度就是多了一个进程,以我们目前有限的知识,我们知道 进程 = 程序代码 + 内核数据结构(task_struct),其中操作系统需要先为子进程创建内核数据结构,在系统角度创建子进程,通常以父进程为模板,子进程中默认使用的是父进程中的代码和数据 (写时拷贝)。

💦 fork 的常规用法

如上代码,fork 之后与父进程执行一样的代码,有什么意义 ❓

我直接让父进程做不就完了嘛,所以大部分情况下这样的父子进程,是想让父和子执行不同的代码。所以不是这样用 fork 的,而是通过 fork 的返回值来进行代码的分支功能。

fork 返回值 ❓

在这里插入图片描述

你没有看错,当 fork 成功时,它会返回两个值,在父进程中返回子进程的 pid,在子进程中返回 0。当 fork 失败时,它会在父进程中返回 -1,且没有子进程的创建,并设置 errno。虽然文档中提示它会返回 -1,但在内核中 pid_t 其实是无符号整型的。

在之前的学习中我们都知道 if … 、else if …,是不可能同时进入的,更过分的是那有没有可能它们在进入时同时跑 2 份死循环呢 ❓

放在以前根本不可能,因为它是单进程,而现在我们使用 fork 创建父子进程 (多进程),所以对于 if … 、else if …,它都会被进入,且 2 个死循环都会跑。对我们来讲这里的父进程就是自己,然后你自己 fork 创建了子进程,所以从 fork 之后,就有 2 个执行流,这里让子进程执行 if,父进程执行 else if。

在这里插入图片描述

一个变量 ret 是怎么做到既等于 0,又大于 0 的 ❓

按以前的知识,就现在看到的场景,用于接收 fork 返回值的 ret 是怎么可以既等于 0,又大于 0 的,在我们 C/C++ 上是绝对不可能的。这个的理解是需要我们进程控制中的 进程地址空间 的知识来铺垫才能理解的,所以本章中不会解释。

fork 为啥会有 2 个返回值 ❓

我们在调用一个函数时,这个函数已经准备 return 了,那么就认为这个函数的功能完成了,return 并不属于这个函数的功能,而是告诉调用方我完成了,这里 fork 在准备 return 时,fork 创建子进程的工作已经完成了,甚至子进程已经被放在调度队列里了。我们刚刚说过,fork 之后,父子进程是共享代码的,return 当然是代码,是和父子进程共享的代码,所以当我们父进程 return 时,这里的子进程也要 return,所以说这里的父子进程会使 fork 返回 2 个值。注意即使是父进程已经跑过的代码,对于那段代码,子进程也是共享的,只不过子进程不再执行罢了。

在这里插入图片描述

为啥给子进程返回 0,而父进程返回子进程的 pid ❓

在这里插入图片描述

在生活中,对于儿子,只能有 1 个父亲,而对于父亲,却可以有多个儿子,比如家里有 3 个儿子正在被训,其中老二犯了错,父亲不可能说 “ 儿子,过来,我抽你一顿 ”,而应该是说 “ 老二 过来,我抽你一顿 ”;而儿子却可以说 “ 爸爸,我来啦 ”。既定事实是儿子找父亲是特别简单的,而父亲找儿子,特别是有成百上千个儿子时就很不容易,所以可以看到父亲为了能更好的区别,会对每个儿子进行标识,并且记住它们。所以父进程返回子进程的 pid 的原因是因为父进程可能会创建多个子进程 (好比你出生后你爸就给你起了个名字),所以这为了保证父进程能拿到想拿到的子进程;而子进程返回 0 的原因是父进程对于子进程是唯一的 (好比你不可能给你爸起名字)。

父进程拿子进程干嘛 ???

那你爸拿你的名字干嘛,肯定是叫你办事呀,同样的父进程拿子进程有很多用途:比如说有 5 个子进程,我想把某个任务指派给某个子进程,这时就通过它的 pid 来指定;当然你要杀掉某个子进程,可以使用 pid 来杀掉想杀掉的子进程。

子进程的 pid 会存储在父进程的 PCB ???

不会,因为子进程的 pid 是给你看的,你可以拿着 pid 去搞事情。而实际在内核里它们父子是由对应的链表结构去维护的。

如何创建多个子进程 ???

在这里插入图片描述

循环初始声明仅在C99模式中允许,所以需要 -std=c99 编译:

在这里插入图片描述

运行后:

在这里插入图片描述

💦 进程状态

在这里插入图片描述

对于操作系统进程状态,大部分教材或者网上的一些资料,都是这种图。图肯定是没有问题的,只不过不好理解,比如超时就是时间片到了;什么是就绪,它是什么状态;这种状态是操作系统描述的状态,意思是说如上图所描述的状态,放在 windows 下是对的,放在 linux 下也是对的,放在任何一款操作系统下都是对的,它描述的更多的是一个宏观的操作系统,比较笼统,我们需要所见即所得的去具体了解一个操作系统。所以下面我们就需要学习具体 linux 操作系统的状态,等认识完 linux 操作系统的状态后再回过头来看上图 (其实是可以对应的)。

1、Linux 2.6内核源码

后期我们主要也是以 Linux 2.6 为主来学习,因为它匹配的书籍较多。

2、R (running)

进程是 R 状态,是否一定在 CPU 上运行 ❓

不一定。R状态并不意味着进程一定在运行中,它表明进程要么是在运行中要么在运行队列里

进程在运行队列中,就叫做 R 状态,也就是说进程想被 CPU 运行,前提条件是你必须处于 R 状态,R:我准备好了,你可以调度我。

为啥我在死循环跑,但状态却是 S ❓

在这里插入图片描述

因为代码大部分时间是在 sleep 的,且每次 1 秒钟,其次 printf 是往显示器上输出的,涉及到 I/O,效率比较低,一定会要求进程去等我们把数据刷新到显示器上。所以综合考量,我们这个程序可能只有万分之一的时间在运行(CPU运行的特别的快!!!),其它时间都在休眠,站在用户的角度它一直都是 R,但是对于操作系统来说可能只有一瞬间才是 R,它有可能在队列中等待调度。

如果我们就想看下 R 状态呢 ❓

循环里啥都不要做。

在这里插入图片描述

3、S (sleeping)

休眠状态(浅度休眠),大部分情况都是这种休眠,它可被换醒,我们可以 Ctrl + C 退出循环,而此时的进程就没了,也就是说它虽然是一种休眠状态,但是它随时可以接收外部的信号,处理外部的请求。
在这里插入图片描述

4、D (disk sleep)

休眠状态(深度休眠)
在这里插入图片描述

如上图,进程拿着一批数据对磁盘说:磁盘,你帮我把数据放在你对应的位置。磁盘说:好嘞,你先等着。然后进程就慢慢的往磁盘写数据,磁盘也慢慢地写到对应的位置。此时进程处于等待状态,它在等磁盘把数据写完,然后告诉进程写入成功 or 失败。此时操作系统过来说:你没发现现在内存严重不足了吗,我现在要释放一些闲置的内存资源,随后就把进程干掉了。磁盘写失败后,然后跟进程说:不好意思,我写失败了,然而进程已经挂了,此时我们的数据流向就不确定了。这种情况是存在的。

对于上面的场景,这个锅由谁来背 —— 操作系统/内存/磁盘 ❓

于是它们三方开始了争论:

操作系统说,你在那等,我又不知道你在等啥,系统内存不足了,我就尽我的职责,我的工程师就是这样写我的,杀掉闲置的内存。假如我这次不杀你,那你说下次我再遇到一些该杀死的闲置的内存,我怕我又被责怪,所以没杀,你就认为我不作为?操作系统说:我又识别不了哪些进程是重要或不重要的。

磁盘说,我就是一个跑腿的,你们让我干啥就干啥,又不是写入的结果不告诉你,而是你不在了。

进程说,我在那规矩的等着呢,是有人把我杀了,我自己也不想退出。

这里好像谁也没有错,但是确实出现了问题,难道说错的是用户,内存买小了吗?无论是操作系统、内存、磁盘都是为了给用户提供更好的服务。根本原因是操作系统能杀掉此进程,如果让操作系统不能杀掉此进程就可以了。我现在做的事情很重要,即便操作系统再牛,也杀不了我,你系统内存不够了,你想其它办法去,不要来搞我。所以我们针对这种类型的进程我们给出了 D 状态,所以操作系统从此就知道了以后 D 是个大哥,不能搞。

所以对于深度睡眠的进程不可以被杀死,即便是操作系统。通常在访问磁盘这样的 I/O 设备,进行数据拷贝的关键步骤上,是需要将进程设置为 D 的,好比 1 秒钟内,平台有 100 万的用户注册,如果数据丢失,那么带来的损失是巨大的。

对于深度睡眠的进程怎么结束 ❓

  • 只能等待 D 状态进程自动醒来。
  • 或者关机重启,但有可能会卡住。

总的来说:不管是浅度睡眠还是深度睡眠都是一种等待状态。

5、T (stopped)

对于一个正在运行的进程,怎么暂停 ❓

在这里插入图片描述

使用 kill -l 命令,查看信号,这里更多内容后面我们再学习:

在这里插入图片描述

使用 kill -19 13095 命令,给 13095 进程发送第 19 号信号(SIGSTOP)来暂停进程:
在这里插入图片描述

使用 kill -18 13095 命令,给 13095 进程发送第 18 号信号来恢复进程:

在这里插入图片描述

我们也可以认为 T 是一种等待状态,不过更多的应该认为程序因为某种原因,所以想让程序先暂停执行。

6、T (tracing stop)

当你使用 vs of gdb 调试代码,比如你打了一个断点,然后开始调试,此时在断点处停下来的状态就是 t,这里是小 t 为了和上面进行区分。这里先不细谈。

7、Z (zomble)

比如你早上去晨跑时,突然看到其他跑友躺地上已经无躺倒状态了,你虽然救不了人,也破不了案,但是作为一个热心市民,可以打电话给 110 和 120。随后警察来了,第一时间肯定不会把这个人抬走,清理现场,如果是这样的话凶手肯定会笑开花。第一时间肯定是先确定人是正常死亡还是非正常死亡,如果是非正常死亡,那么立马封锁现场,拉上警戒线,判断是自杀的还是他杀,医生对人的状态进行判断,如果是正常死亡,就判断是因为疾病,还是年纪大了,最终判断出人是是因为疾病离开的,警察和医生的任务已经完成后,不会就把人放这,直接撤了,而是把人抬走,恢复地方秩序,然后通知家属,需要做很多的工作,所以当一个人死亡时,并不是立马把这个人从世界上抹掉,而是分析这个人身上的退出信息,比如说体态特征、血压等信息来确定具体的退出原因。
同样进程退出,一般不是立马让 OS 回收资源,释放进程所有的资源,作为一个死亡的进程,OS 不会说你已经死了,就赶紧把你释放了,就像不会人一死亡,就赶紧把你拉到火葬场,而是要做很多繁杂的工作,同样 OS 也要做工作,比如要知道进程是因为什么原因退出的。创建进程的目的是为了完成某件任务,进程退出了,我得知道他把我任务完成的怎么样了,所以 OS 在进程退出时,要搜集进程退出的相关有效数据,并写进自己的 PCB 内,以供 OS 或父进程来进行读取。只有读取成功之后,该进程才算真正死亡,此时我们称该进程为 死亡状态 X,再由操作系统进行回收,关于回收会在进程控制中讲 wait 时提及。其中我们把一个进程退出,但还没有被读取的那个时间点,我们称该进程为 僵尸状态 Z 。
我作为父进程 fork 创建一个子进程,子进程死亡了,但父进程没通过接口让 OS 回收,此时子进程的状态就是 Z。

僵尸状态演示 ❓

在这里插入图片描述

这里我们可以写一个循环执行的监控脚本 while :; do ps ajx | head -1 && ps ajx | grep myprocess; sleep 1; echo “####################”; done 来观测:

在这里插入图片描述

当我们运行脚本时,发现只有脚本这个进程再运行,运行 myprocess 时,一瞬间就有 6 个进程运行,其中包含 1 个父进程,和 5 个子进程,它们都处于浅度休眠。当所有子进程都 exit 后,父进程也来到了 getchar,此时父进程再等待,而子进程还没有被回收,所以 5 个子进程都处于僵尸状态。

8、X (dead)

这里回车让父进程执行 getchar,所以父进程不再等待,操作系统就回收了所有进程 (1 个父进程和 5 个子进程),因为它是一瞬间的,所以我们看不到 X 状态。

在这里插入图片描述

💦、补充说明

1、 S and S+

一般在命令行上,如果是一个前台进程,那么它运行时的状态后会跟 +。前台进程一旦执行,bash 就无法进行命令行解释,ls、top 等命令都无法在当前命令行上执行,只有 Ctrl + C 可以进行终止。
在这里插入图片描述

如果想把一个进程放在后台可以 ./myprocess &,此时 bash 就可以进行命令行解释,ls、pwd 等命令就可以执行了,此外 CTRL + C 也无法对后台进程终止了,只能对该进程发送第 9 号信号来结束进程。

在这里插入图片描述

2、 OS 描述的状态 && 具体的 Linux 进程状态

在这里插入图片描述

其中新建没有对应的 Linux 进程状态;就绪可对应到 Linux 进程中的 R;运行也可对应到 Linux 进程中的中的 R;退出可对应到 Linux 进程中的 Z/X;阻塞可对应到 Linux 进程中的 S/D/T;

所以 Linux 状态的实现和操作系统的实现是有点差别的。操作系统的所描述的概念是所有操作系统都遵守这样的规则,而 Linux 就是一种具体的操作系统规则。

3、僵尸进程的危害

进程的退出状态必须被维持下去,因为它要告诉关心它的进程(父进程),你交给我的任务,我办的怎么样了。可父进程如果一直不读取,那子进程就一直处于 Z 状态。
维护退出状态本身就是要用数据维护,也属于进程基本信息,所以保存在 task_struct(PCB) 中,换句话说,Z 状态一直不退出,PCB 一直都要维护。
那一个父进程创建了很多子进程,就是不回收,就会造成内存资源的浪费,因为数据结构对象本身就要占用内存,想想 C 中定义一个结构体变量(对象),就是要在内存的某个位置进行开辟空间。
内存泄漏。
如何避免,后面再谈。

4、孤儿进程

父进程如果先子进程退出,那么子进程就是 孤儿进程,那么子进程退出,进入 Z 之后,又该怎么处理 ❓

在这里插入图片描述

可以看到 5 秒前有 2 个进程,5 秒后父进程死亡了,只有 1 个子进程 (父进程没有被僵尸的原因是因为父进程也有父进程 25593 -> bash,父进程退出后就被 bash 回收了)。这里 29330 就为孤儿进程,此时孤儿进程会被 1号进程领养,它是 systemd(操作系统),被领养后进程状态会由前台转换为后台,后台进程可以使用第 9 号信号来结束进程,此时操作系统就可以直接对它回收资源。

在这里插入图片描述

5、1 号进程

在这里插入图片描述

操作系统启动之前是有 0号进程的,只不过完全启动成功后,0号进程就被1号进程取代了,具体的取代方案,后面学习 进程替换时再谈。可以看到 pid 排名靠前的进程都是由 root 来启动的。注意在 Centos7.6 下,它的 1 号进程叫做systemd,而 Centos6.5 下,它的 1 号进程叫做initd。

四、Linux 系统中的优先级

💦 基本概念

优先级是得到某种资源的先后顺序;权限是你能否得到某种资源;

优先级存在的原因是因为资源有限;

💦 PRI and NI

ps -al查看当前进程 PRI 和 NI:

在这里插入图片描述

PRI 比较好理解,即进程的优先级,或者通俗点说就是程序被 CPU 执行的先后顺序,此值越小,进程的优先级别越高。

NI 就是我们所要说的 nice 值了,其表示进程可被执行的优先级的修正数值。

饥饿问题 ❓

Linux 中的优先级由pri和nice值共同确定。Linux 优先级的特点,对于普通进程,优先级的数值越小,优先级越高;优先级的数值越大,优先级越低。但是优先级不可能一味的高,也不可能一味的低,比如说优先级最高的是 30,最低的是 99,那么我们不可以把最高搞成 -300,最低搞成 999。为啥优先级能设置,但不能很夸张的设置,是因为即使再怎么优先,操作系统的调度器也要适度的考虑公平问题,比如我把 A 进程优先级搞到 -300,对我来讲,A 进程老是得到资源,别人长时间得不到资源,这种就叫饥饿问题。好比你在打饭窗口排着队呢,老是有些人觉得自己优先级高往前插队,那么你就长时间打不到饭,导致最后吃不到饭。所以 CPU 也是有度的来根据优先级调度。

其中 pri 的优先级是多少就是多少,但实际上 Linux 的优先级是可以被修正的,nice 值就是优先级的修正数据 [-20 ~ 19],一共 40 个级别,其中 -20 优先级最高,19 优先级最低。也就是说想修改某进程的优先级,就要设置 nice 值,而后这个进程的优先级就会重新被计算。

PRI 值越小越快被执行,那么加入 nice 值后,将会使得 PRI 变为:PRI(new) = PRI(old) + nice,这里的 old 永远是 80,下面解释。

调整进程优先级,在 Linux 下,就是调整进程 nice 值。需要强调的是,进程的 nice 值不是进程的优先级,他们不是一个概念,但是进程 nice 值会影响到进程的优先级变化。可以理解 nice 值是进程优先级的修正数据

PID 是当前进程的专属标识;PPID 是当前进程的父进程的专属标识;TTY 可以理解为终端设备;CMD 是当前进程的命令

UID 是执行者的身份。

ll后,其中可以看到我:

在这里插入图片描述

ll -n,就可以看到我的 ID:
在这里插入图片描述

也就是说在 Linux 中标识一个用户,并不是通过用户名 ubuntu,而是通过用户的 UID 1000。比如 qq 里,每人都有一个昵称,如果昵称可以随便改的话,就意味着昵称不是标识你的唯一方式,而是通过 qq 号码来唯一标识你。所以对于操作系统来说,当你新建用户时,除了你自己给自己起的名称之外,还有操作系统所分配给你的 UID。原因是因为计算机比较擅于处理数据

所以可以看到这里的进程是我启动的:

在这里插入图片描述

💦 调整优先级

ps -al查看当前进程优先级(注意,为了避免权限麻烦,这里使用root演示):
注意 ps al和ps -al是有区别的,可以自行查阅资料,这里加 -al ,以PID为908003作修改。
在这里插入图片描述

top命令查看所有进程相关信息:

在这里插入图片描述

r命令后输入要调整的 pid:

在这里插入图片描述

给 908003进程 Renice 要调整的 nice 值:
在这里插入图片描述

q 退出 top,然后 ps -al 验证:
在这里插入图片描述

继续调整
在这里插入图片描述

调整+70
在这里插入图片描述

ps -al查看进程优先级
在这里插入图片描述

之前第一次调整后的优先级是 93,随后第二次调整后的优先级应该是 163,但是却是 99❓

其中我们在 Linux 中进行优先级调整时,pri 永远是默认的 80,也就是说即使你曾经调整过 nice 值,当你再次调整 nice 值时,你的优先级依旧是从 80 开始的,也就是说PRI(new) = PRI(old) + nice 中的 old 永远是 80,这里这样设计的原因下面会解释,我们继续往下走。

上面说每次调整优先级永远是从 80 开始,上面又说 nice 值的最小值是 -20,最大值是 19,这意味着 nice 值是 70,不会真正的设置到 70,而是设置成了 nice 值的最大值 19:

在这里插入图片描述

ps -al 验证:我们发现最小的 nice 值就是 -20,即使我们给nice值为-100,它也只是-20,而它的优先级最高只能到 60
在这里插入图片描述

同理:尽管我们设置的 nice 值是 1000,但不会真的设置到 1000,而是设置到 nice 值的最大值 19,所以此时调整后的优先级是 99。

每次我们重新计算新的优先级时, old 为啥默认都是 80 ❓

其一,有一个基准值,方便调整。你都想调整了,意味着你不想要老的优先级,那么我给你一个基准点,下次就方便许多了,否则你每次调整之前,还得先查一下当前进程现在的优先级。
其二,大佬并不想让我们对一个进程的优先级设置的很高或很低,用户可能会钻空子,比如每次设置 1,不断叠加,让优先级越来越低,但是显然人家考虑到了,所以每次设置时,pri 又都会默认从 80 开始,old 每次都是 80,同时 nice 值区间是 [-20, 19],最终你的优先级区间 [60, 99],这样的设计,成本不高。

nice 值是 [-20, 19],意味着当前的 nice 值是一种可控状态,为啥 ❓

也就意味着这个值,你可以往大了设置,也可以往小了设置,但始终不会超过这个区间。进程是被操作系统调度的,如果可以让一个用户按他的需求去定制当前进程的优先级,比如我把我的进程优先级搞成 1,其它进程优先级搞成 10000,那么这样调度器就没有公平可言了。就是说操作系统可以让用户调整优先级,但是优先级必须是可控状态,因为不可控,就没有公平高效可言了。就像你妈让你出去玩,但规定你必须 8 点钟回来。所以本质是操作系统中的调度器要公平且较高效的调度,这是基本原则。

调度器的公平 ❓

这里不是指平均。有多个进程,不是说我现在给你调度了 5 毫秒,就一定要给其它进程都调度 5 毫秒。而必须得结合当前进程的特性去进行公平调度的算法。所以这里的公平可以理解为我们是尽量的给每个进程尽快、尽好的调度,尽量不落下任何一个进程,但并不保证我们在同一时间上启动的所有进程在调度时间上完全一样,而只能说是大致一样,因为调度器是服务计算机上所有进程的。

【写在后面】

可以看到 Linux 它的进程状态,一会僵尸,一会孤儿,感觉 Linux 操作系统很惨的样子。实际上后面我们还会再学一种守护进程(精灵进程)。

如果一个进程是 D 状态是不能 kill -9 的;但如果一个进程是 Z 状态,那么它能 kill -9 吗 ❓

如果一个人已经死了,你上去踢它两脚,有用吗 ?所以一个进程是 Z 状态,你去 kill 它是杀不掉的。

[几个小问题 ]:什么样的进程杀不死

D 状态进程和 Z 状态进程。因为一个是在深度休眠,操作系统都得叫大哥,一个是已经死了。

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

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

独立性:多进程运行,需要独享各种资源,多进程运行期间互不干扰。独立性也是操作系统设计进程的一个原则,不管你是 Linux、Windows、Macos、Android 都需要遵守,代码共享,数据各自私有就是为了实现独立性原则。

在这里插入图片描述

注意这里的除 0 操作在 vs 下是直接编译不过的,也不会执行 sleep。但在 linux 下可心编译过,也会执行 sleep。这里子进程等 5 秒后执行除 0 错误后一定会退出,此时子进程就变成了僵尸,且不会影响父进程执行。

在这里插入图片描述

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


  • 4
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

史嘉庆

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

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

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

打赏作者

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

抵扣说明:

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

余额充值