Linux:进程的基本理解
1. 冯诺依曼体系结构1.1 基础概念冯・诺依曼体系结构是现代计算机的经典基础架构核心思想是采用二进制、存储程序并由控制器自动执行。它主要由五大部件组成输入设备、输出设备、运算器、控制器、存储器。该结构规定程序和数据以二进制形式共同存放在内存中 即冯诺依曼体系结构当中的存储器大家可以直接理解为内存。计算机按顺序自动读取指令并执行奠定了现代计算机的基本工作模式。我们常见的计算机如笔记本、台式机等不常见的计算机如服务器。其大部分都遵守冯诺依曼体系截至目前我们所认识的计算机都是由一个个的硬件组件组成输入单元包括键盘、鼠标、扫描仪、手写板等中央处理器(CPU)含有运算器和控制器等输出单元显示器、打印机等。1.2 体系中内存的重要性接下来有一个比较重要的问题为什么冯诺依曼体系结构当中需要内存为了能更好的理解这个问题我们有两个前置知识点需要掌握1. 存储分级的问题这张图片展示了计算机存储层次结构核心特点是从上到下速度变慢、容量变大、成本变低。具体分级如下L0 寄存器处于最顶端速度最快、容量最小、成本最高由 CPU 直接使用。L1/L2/L3 高速缓存依次围绕寄存器均为 SRAM 材质用于缓解 CPU 与主存的速度差层级越高容量越大、速度稍慢。L4 主存即内存DRAM容量远大于缓存是程序运行的主要内存空间从磁盘加载数据供 CPU 使用。L5 本地二级存储如本地磁盘容量更大、速度更慢用于长期保存数据。L6 远程二级存储如远程服务器、网络文件系统是最大规模的存储层级通过网络访问。整个结构通过数据逐级缓存的方式平衡了存储设备速度与容量的矛盾最大化提升计算机整体存储访问效率。2.体系结构效率问题因为输入设备的处理数据的速度是毫秒级但是CPU的处理速度是纳秒级如果说输入设备直接和CPU对接就会导致数据处理时间的冗余。因为CPU一直要等输入设备把数据处理好发给它。因此就需要存储器来承担中间人的责任。存储器的作用就是暂时存放正在运行的程序和需要处理的数据让 CPU 可以快速读写避免一直等待慢速的外存。如果没有存储器CPU 会频繁访问低速存储整体效率会大幅下降。至此我们就可以解释到底为什么需要内存存储器了。首先主存速度远快于外存容量又比 CPU 内部的寄存器、缓存大得多既解决了 CPU 与外存的速度鸿沟又能支撑程序完整运行。如果没有高效的主存CPU 会长期等待数据整个体系结构的效率会急剧下降。并且因为主存速度相对较快且容量大价格相对寄存器更加便宜就使计算机的制造变得更具有性价比让普通人能以更合适的价格购买并使用计算机。而一旦被普通人所更能接受才能形成全球范围内的网民数量不断增多的迹象才能形成互联网。1.3 总结在数据层面上CPU不会和外设直接打交道CPU读写数据只会和内存打交道。那么我们口中的输入和输出是站在谁的角度考虑问题的其实就是站在内存的角度更准确的说是站在加载到内存中的程序的角度。大家在学习C语言和C的时候编写代码时都会包含头文件比如#include stdio.h 、 #include iostream 这两个头文件里面都有 io 实际上就是 Input 和 Output 。从输入设备到存储器的过程就叫 Input 从存储器到输出设备的过程就叫 Output。2. 操作系统2.1 操作系统的概念关于操作系统的概念我在之前的文章中已经讲过为了保证文章的连续性所以还需要提到这一题目但详情内容请转至下面这篇文章https://blog.csdn.net/2502_91842264/article/details/159127853?fromshareblogdetailsharetypeblogdetailsharerId159127853sharereferPCsharesource2502_91842264sharefromfrom_link2.2 操作系统的向下运作逻辑我们首先要记住一句话操作系统就是一个进行软硬件资源管理的软件。用下面这幅图做进一步解释我们可以把整个计算机系统类比成一所学校三者的层级和分工非常清晰1. 操作系统 校长校长是学校的最高管理者统筹全校所有事务对应图中最上层的系统软件部分。就像校长负责全校的人员调度进程管理、资源分配内存管理、档案管理文件管理、对接各部门负责人驱动管理一样操作系统是整个计算机的核心管理者统一调度所有软硬件资源给上层应用提供稳定的运行环境同时向下对接所有硬件的管理需求。校长不会直接去管每一个学生的具体事务只需要对接各部门的负责人。2. 驱动程序 辅导员辅导员是校长和学生之间的桥梁对应图中中间的驱动程序层。就像每个辅导员负责对接特定的学生群体比如网卡驱动对应网卡硬件、硬盘驱动对应硬盘硬件驱动程序是操作系统和底层硬件之间的翻译官它把操作系统下发的通用指令翻译成对应硬件能听懂的专属命令同时把硬件的状态、数据反馈给操作系统。没有辅导员校长无法直接和学生沟通没有驱动程序操作系统无法直接操控硬件。3. 底层硬件 学生学生是学校的执行主体对应图中最下层的硬件部分。就像网卡、硬盘、其他硬件是计算机的执行部件一样学生负责完成具体的学习、活动任务网卡负责收发网络数据硬盘负责存储数据就像学生完成辅导员布置的具体任务。学生只听从辅导员的安排不会直接响应校长的指令硬件只执行驱动程序下发的命令无法直接和操作系统交互。完整流程对应校长操作系统下达 “发送消息” 的指令 → 辅导员网卡驱动把指令翻译成网卡能执行的操作 → 学生网卡硬件执行操作完成网络数据发送同时学生网卡把接收的数据反馈给辅导员网卡驱动 → 辅导员整理后上报给校长操作系统完成整个数据流转。三者缺一不可共同构成了计算机系统的完整运行链路。用C语言中结构体的知识点来讲解就是硬件是最底层的实体比如键盘、网卡、显示器。每一种硬件都可以看作一个结构体变量。驱动程序就是为这个结构体写的操作函数集合。它定义了怎么初始化、怎么读、怎么写、怎么控制硬件。相当于结构体的成员方法。操作系统是一个更大的顶层结构体。它里面包含了所有硬件的结构体、所有驱动的函数指针。操作系统不直接碰硬件而是通过调用驱动函数来管理硬件。至此我们就可以得出一个结论在操作系统当中最重要的就是数据结构因为只有依靠数据结构才能去用其对应结构的增删查改的方法更高效便利的处理数据。那么就衍生出了一个问题C中为什么要有类和STL因为我们说操作系统本质是一个进行软硬件资源管理的软件所谓的管理就是先对对象进行描述再组织合理的方式去执行。总结成六个字就是先描述再组织。所以C中之所以有类和STL就是先用类把事物的属性和行为描述清楚再用 STL 把这些对象高效地组织、管理起来。类的作用是 “先描述”。它把数据和操作封装在一起描述出一类对象长什么样、能做什么。没有类就只能零散地写变量和函数无法清晰描述一个完整实体。STL 的作用是 “再组织”。有了描述好的对象后需要容器、算法、迭代器来存放、遍历、处理它们。STL 提供统一、通用的结构让我们不用重复写链表、排序等代码直接组织已描述好的类对象。所以总结就是类负责描述世界STL 负责组织数据先通过类描述再用 STL 组织程序结构才清晰、复用性才强。2.3 操作系统的向上运作逻辑我们前面提到操作系统要对底层硬件进行管理本质上的目的其实是为了给使用操作系统的用户提供一种更高效更稳定更安全的操作环境。那么这意味着我们可以随意使用操作系统了吗它都给我提供安全高效的操作环境了。事实上是不行的。用户没有办法直接去访问操作系统中的各个数据结构而是需要通过 system call -- 系统调用接口去间接的使用操作系统这也是为了保证操作系统内部的数据结构以及数据的安全防止有用户恶意的去修改操作系统中的内容。这就好比你要到银行去办理业务你要办的业务是取钱那么你必须要到银行中的取钱的窗口出示身份信息和银行卡等凭证然后由工作人员帮你办理业务从银行中取钱。如果没有了这个窗口那难道让你自己去银行的金库里面直接拿钱吗这是不现实的。同样的比如你现在想要申请开辟一块内存就需要调用内存管理的系统接口通过这个系统接口去访问操作系统然后再给你开辟内存空间。不同的目的会对应不同的系统接口。再拿银行举例如果你想去银行办理某个业务但是对银行的运营流程不太熟悉此时银行内部的经理就会引导你帮助你完成你所想要办理的业务你只需要给他提供一些必要的信息即可。这个大堂经理的身份就相当于用户操作接口。就好比我们在学习C语言、C的时候调用的各个库函数本质上就是对系统调用接口的一个封装即用户操作接口。比如printf、scanf、cin、cout等等。3. 进程3.1 基本概念和基本操作进程是操作系统分配资源和调度运行的基本单位可以简单理解为正在运行的程序。进程 内核数据结构 程序的代码和数据内核数据结构是操作系统为进程创建的管理信息比如进程 ID、内存布局、打开的文件、状态等系统靠它识别、管理、调度这个进程。即下图中的 struct。程序的代码和数据是磁盘上可执行文件加载到内存里的指令、变量、常量等是进程真正要执行和处理的内容。即下图中内存里的可执行程序。一个程序启动后先由磁盘转入到内存然后操作系统为它建立内核数据结构把代码和数据载入内存两者合在一起、独立运行、独立占有资源这就是一个进程。只有内核数据结构没有代码数据就只是空壳只有代码数据没有内核管理结构系统无法识别和运行。两者合在一起被系统独立调度、独立运行的基本单位就是进程。因此当我们在Xshell里输入 ./cmd 或者手机上点击 app 软件时本质上都是在启动进程。3.1.1 进程的描述——PCB进程信息被放在⼀个叫做进程控制块的数据结构中可以理解为进程属性的集合。前面我们说了进程 内核数据结构 程序的代码和数据那么把进程当作一个集合PCB 就是描述进程的结构体全称 Process Control Block进程控制块。它是进程在内核里的 “身份档案”没有 PCB系统就不知道这个进程存在、也无法管理它。PCB 是一个概念统称凡是操作系统里用来描述进程的那个结构体都叫 PCB。不同操作系统这个结构体的具体名字、实现、成员都不一样但功能都是描述和管理进程。PCB 这个结构体里一般包含这些成员进程标识PID、状态运行 / 就绪 / 阻塞寄存器信息PC 程序计数器、通用寄存器等切换进程用内存信息代码段、数据段、栈段的地址资源信息打开的文件、占用的设备、优先级调度信息时间片、排队信息等。Linux操作系统下的PCB的名称叫做task_struct。3.1.2 task_struct简单介绍一些task_struct中存储的内容标识符描述本进程的唯一标识符用来区别其他进程。状态任务状态退出代码退出信号等。优先级相对于其他进程的优先级。程序计数器程序中即将被执行的下一条指令的地址。内存指针包括程序代码和进程相关数据的指针还有和其他进程共享的内存块的指针上下文数据进程执行时CPU处理器的寄存器中的数据。IO 状态信息包括显示的 I/O 请求分配给进程的 IO 设备和被进程使用的文件列表。记账信息可能包括处理器时间总和使用的时钟数总和时间限制记账号等。这是Linux内核的2.6.18版本的源代码大家就可以看到 task_struct这个结构体在这里要着重讲的是上下文数据它就是用来保存进程暂停时 CPU 寄存器里的所有内容目的是让进程下次能接着上次停下的地方继续运行。上下文数据被统一封装在了一个结构体当中这个结构体中有很多个long类型的变量比如eip、ebp.....等等这些都是用来存储寄存器中的数据的变量而寄存器是CPU中的硬件是 CPU 内部集成的、速度极快的小型存储单元是实实在在的物理电路不是软件概念这些存储单元可以存储数据。所以上下文数据和task_struct的关系是这样的接着要介绍一下时间片的概念时间片就是操作系统给每个正在运行的进程分配的一小段 CPU 时间比如几毫秒。在这段时间里进程独占 CPU 执行指令时间片一结束即分配的这段CPU时间结束了但如果这个进程还没执行完但时间片到了操作系统就会直接把它暂停切换去运行别的进程。具体过程是这样的1. 操作系统把当前 CPU 寄存器里的所有数据保存到这个进程 PCBtask_struct的上下文数据里相当于给执行现场拍个快照。2. 把这个进程的状态从运行态改为就绪态扔到就绪队列里排队。3. 挑选下一个进程把它 PCB 里的上下文数据恢复到 CPU 寄存器让它接着运行。4. 等下次 CPU 再次轮到这个进程时操作系统会把之前保存的上下文数据重新加载回寄存器进程就从上次暂停的位置继续执行就像没被打断过一样完全不会影响最终执行结果。因为切换极快人感觉不到停顿就实现了多个程序 “同时运行” 的效果。时间片决定了进程每次能连续跑多久是操作系统实现并发调度的核心机制。所以上下文数据可以这样简单理解进程在运行时所有临时数据、运算中间值、当前执行位置都存在CPU 寄存器里。当该进程的时间片结束了操作系统要切换到别的进程时必须把寄存器里的所有数据保存一份副本这个副本就是上下文数据存放在task_struct中。等下次该进程再次被调度运行时内核会把这些数据重新放回 CPU 寄存器进程就像没被打断过一样继续执行。int add(int x, int y) { int z x y; return z; } int main() { int result add(1, 2); return 0; }以这段代码为例假设当前进程正在执行add函数CPU 里的寄存器已经存了x1、y2、z3正要执行return z。这时候操作系统时间片到了要切去别的进程运行。内核就会把此刻 CPU 寄存器里的这些数据全部保存到该进程task_struct的上下文数据中。等下次该进程重新被调度时内核再把这些值恢复回寄存器进程就能继续执行return把 3 传给result。这里的上下文数据就是进程被打断时寄存器里的现场快照作用是保证切走再切回来后运算状态不丢失、能接着运行。它只管进程切换时的状态保存恢复不管函数之间怎么传值。3.1.3 查看进程在我们的根目录当中会存在着一个叫做 proc 的文件夹它实际上就是 process 的意思。我们使用 ls -al /proc 的这个指令就可以查看我们Linux中所有的进程这些标蓝色的文件名就是当前正在运行的进程的PID。那么这个进程PID既然是个文件里面装的是什么呢比如现在就有一个进程的PID叫29218我们通过ls去查看这里面存储的其实就是这个进程的 task_struct 即进程的属性。属性当中最着重要讲的是 cwd cwd全称为current working directory即当前工作目录是 Linux 内核进程控制块task_struct中的核心属性用于记录进程运行时所在的默认文件系统目录是进程访问文件、解析相对路径的默认参照位置。每个进程独立拥有专属的 cwd互不干扰内核通过该字段完成进程的相对路径查找与文件定位是./等相对路径能够正确生效的底层基础。我们之前一直使用的 cd 指令可以实现跳转到指定的路径当中 而cd命令的本质就是通过chdir ( )系统调用,动态修改当前 bash 进程的 cwd。同时子进程通过fork()创建时会完整继承父进程的 cwd这也是终端中运行的程序默认工作目录与 bash 完全一致的核心原因。大家可以回想一下在学习C语言的时候一定学习过文件管理的内容那一定会遇到过这样的语句FILE *fp fopen ( xxx.txt,w); 这个语句当中我们并没有指定这个文件要放在哪个位置但系统会自动调用cwd中的信息将此时的进程的工作路径添加到 xxx.txt 前面。这就是我们创建文件时会默认放在当前路径下的原因。所以当我执行这个程序的时候就可以直接将 xxx.txt 这个文件直接放在我们的当前路径下。同时还可以直接调用 chadir 去手动的修改我们要保存的文件的位置并且此时的进程的 cwd 也被修改了。3.1.4 进程创建机制前面我们讲解了进程的概念那么现在就看看在实际操作系统当中进程到底怎么表示出来长什么样子ps axj 是 Linux 下查看进程关系与详细信息的命令每个字母含义如下单个参数含义a显示所有用户的进程不只当前终端x显示没有控制终端的进程后台守护进程j显示任务控制 / 作业相关信息重点展示PPID父进程 IDPID进程 IDPGID进程组 IDSID会话 ID用来清晰看出父子进程关系、进程组、会话这里ps出来得到的进程的信息其实就是上面在查看进程中提到的存储在proc目录下的对应PID文件中的属性信息。我们先创建一个可执行程序 myprocess编译后我们使用组合命令来查找我们编写的可执行程序 myprocess 大家就可以看到我们可以找到指定的进程。但是上面还提到了一个PPID这代表父进程我们来调用看看大家会发现父进程ID是一个不同于进程ID的东西并且如果当我们多次执行myprocess可执行程序时进程ID会发生变化但父进程ID会一直保持不变进程ID从26578变化到26581但是父进程ID一直保持是26091。首先要解释一下这里的进程ID之所以会呈现递增的样子是因为Linux 内核在创建新进程时会从上一次分配的 PID 往后继续找最小可用值而不是每次都从 1 开始扫。在此场景中前序进程退出后 PID 立即空闲新进程直接接续分配所以呈现26091 → 26078 → 26058 → 26579 → 26580 → 26581这样的连续递增。其次我们来看看这个父进程到底是什么大家可以看到这里的父进程是一个 bash 的文件。bash 是 Linux/macOS 系统里最常用的「命令行解释器Shell」就是我们打开的Linux的窗口终端背后的程序负责把我们输入的命令翻译成系统能执行的操作。讲到这里我们现在就具有几个问题1. 我们一直使用的./文件名这里的./的含义到底是什么2. 为什么./就能做到执行一个可执行程序并且这个程序的进程的父进程还是 bash3. 为什么一定要有父进程而不能直接创建一个进程。这时因为在 Linux 系统中输入./文件名并不是./本身具有 “执行” 的魔力./仅表示当前目录作用是告诉 Shell 在当前路径下查找可执行文件避免系统去环境变量 .PHONY 中搜索。真正触发程序运行的是当前终端的 Shell通常是 bash在识别到这是一个可执行文件后通过调用操作系统内核提供的系统调用完成的。具体执行流程如下用户在终端输入./可执行文件并回车bash 解析命令确认这是一个可执行程序bash 调用fork 系统调用创建一个与自身相同的子进程子进程立即调用exec( )系列函数将自身的代码段、数据段替换为目标可执行文件的内容最终子进程运行目标程序而父进程依然是 bash。因此我们看到的现象是每次运行程序PID 都会变化但 PPID 始终不变。至于为什么必须通过父进程创建新进程而不能 “凭空” 创建一个进程是由 Linux 的进程机制决定的Linux 中除了 1 号系统进程init/systemd之外所有用户进程都必须通过fork()从已有进程复制产生操作系统内核并不提供直接 “凭空创建” 进程的接口。这里的 1 系统进程就是在 Linux 系统启动过程中内核完成初始化后会主动创建第一个用户态进程这个进程的 PID 固定为 1通常被称为1 号进程。早期 Linux 系统中1 号进程是init现代主流发行版如 Ubuntu、CentOS 7、Debian 等则统一使用systemd作为 1 号进程。它是整个系统中唯一没有父进程、由内核直接创建的进程也是系统中所有其他进程的 “共同祖先”。1 号进程的主要作用包括负责启动系统各项基础服务如网络、日志、设备管理、服务守护等管理系统的启动、运行、关机流程作为所有孤儿进程的 “养父”回收退出进程的资源避免产生僵尸进程维护整个系统的进程树结构。我们在终端中运行的程序父进程是 bash而 bash 的父进程最终也会追溯到 1 号进程 systemd。fork()的设计思想就是 “复制父进程 → 创建子进程”子进程再通过exec()切换成真正要运行的程序。这也就意味着任何用户态可执行程序在运行时必然存在父进程而在终端中运行的程序其父进程就是负责解析命令、管理任务的 bash 进程。上面的解释当中一直提到 fork ( ) 这个函数到底是什么呢3.1.5 初识 fork ( )fork ( ) 是 Linux 中用于创建进程的系统调用在用户态以 C 标准库函数的形式供程序调用底层通过 CPU 特权指令触发内核态执行完成子进程的创建与资源分配。我们用下面的代码先来看看fork的效果我们会发现我们源代码中明明只写了两个printf的语句但是打印出来确是三个语句并且前两个语句的PID和PPID都相同第三句语句的PID和PPID和前两个语句的都不相同并且它的PPID是前两个语句的PID。这是因为程序运行时bash 首先通过fork()exec()创建 PID 为28427的父进程执行main函数中的代码。当父进程执行到fork()系统调用时会复制自身创建一个 PID 为28428的子进程两个进程拥有独立的执行流与 PCB从fork()调用处开始各自独立执行后续代码。因此父进程28427与子进程28428会分别执行两次printf输出最终呈现出两组不同 PID、相同 PPID 的打印结果。接着我们来改一下代码编译运行之后就会出现这个现象大家会发现连续的两个fork后的printf语句打印出来的 PID 和 PPID 属于是父子关系我们通过查看进程发现也确实存在两个./myprocess 那么第一个./myprocess的父进程又是谁呢查看之后发现是 bash 所以这就印证了进程创建的一个父子关系机制。我们现在已经知道了fork的大致概念但fork到底有什么实际作用呢fork()的实际作用是复制当前调用它的进程生成一个子进程从而让系统中从一条执行流分裂为两条独立的执行流。子进程会继承父进程的大部分运行环境包括代码段、数据段、堆栈、当前工作目录 cwd、文件描述符、用户权限与环境变量等信息二者仅在 PID、PPID 等少数标识属性上存在区别。fork()被调用后会有两次返回在父进程中返回新创建子进程的 PID在子进程中返回 0程序可以通过返回值区分父子进程并执行不同逻辑。那既然如此我们就可以实现利用fork创建子进程来达到父子进程分流以解决不同情况下的问题。不过讲到这里我们至少有三个问题要问1. 为什么给子进程返回的是0给父进程返回的是子进程的pid?2. fork() 系统调用怎么做到返回两次值的3. 一个id怎么能接受两个不同的值? 因为我们代码中的else和else if语句都执行了即 0又 0?首先回答第一个问题之所以给子进程返回的是 0 是因为子进程在 fork () 执行完成后已经拥有了自己独立的 PID且可以通过 getpid ()、getppid () 系统调用直接获取自身 PID 和父进程 PID无需再通过 fork () 的返回值获取自身PID返回 0 作为子进程的专属标识用来让程序区分「我现在是子进程」且不会和任何PID发生命名冲突因为系统中的进程ID是从 1 开始的。而给父进程返回的是子进程的PID是因为一个父进程可以拥有多个子进程返回一个子进程的PID可以区分其子进程的身份以满足进程管理的实际需求。这样操作才能给父子进程建立联系。第二个问题因为fork()执行过程中会创建出一个全新的独立进程让两个不同的进程分别执行了一次return操作。当父进程调用fork()时内核会复制父进程的地址空间、执行流与上下文环境创建出一个几乎完全相同的子进程父子进程会共享fork()调用之后的所有代码逻辑当进程创建完成后原本的父进程从fork()中返回一次新创建的子进程也从fork()中返回一次两个进程拥有独立的执行流与返回值从外部视角看就形成了 “一个函数调用返回两次” 的现象这并不是同一个进程返回了两次而是两个独立进程分别完成了一次返回。第三个问题同一个id变量能同时等于 0 又大于 0本质上并不是同一个变量在同一个进程里存了两个值而是fork()创建了两个独立进程每个进程都拥有自己独立的id变量副本两个副本分别存储了不同的返回值在各自的执行流中独立生效。当父进程执行fork()时内核会复制父进程的地址空间、栈区数据与执行上下文创建出几乎完全相同的子进程id变量作为栈上的局部变量会被完整复制到子进程的地址空间中形成两个完全独立、互不干扰的变量副本。fork()执行完成后父进程的id变量被赋值为子进程的 PID大于 0因此会进入else分支执行父进程逻辑子进程的id变量被赋值为 0因此会进入else if(id 0)分支执行子进程逻辑。从代码视角看仿佛是同一个id变量同时满足了两个条件但实际上是两个独立进程的两个独立变量分别存储了不同的值各自执行对应的分支不存在同一个变量同时存两个值的情况。大家可以先这么去理解因为“内核会复制父进程的地址空间、栈区数据与执行上下文创建出几乎完全相同的子进程”这句话还是有一点偏于表面如果我们要从底层角度去深入理解创建子进程的过程需要讲解虚拟地址空间和写时拷贝的知识这两块知识会在后面的段落中提到。本文到此结束感谢各位读者的阅读如果有讲解的不到位或者错误的地方欢迎各位读者的批评或指正。