Linux系统编程学习 NO.11——进程的概念(2)
谈谈进程的性质
进程的竞争性
由于CPU资源是稀缺的,进程数量是众多的。不可避免需要造成进程排队等待CPU资源的动作,内核的设计者为了让操作系统合理的去调度这这些进程,就产生了进程优先级的概念。设置合理的进程优先级能让不同进程公平的去竞争CPU资源,从而使操作系统运行更加高效。
进程的独立性
操作系统启动后,会运行许许多多个进程。由于硬件资源都是一个个独立的,进程也独享各种系统的资源,所以在进程被设计之初,就要求其在运行期间不干扰别的正在运行的进程。在生活中,我们写代码时控制台程序崩溃了,并不影响我们电脑上正在运行的微信。在前文fork()创建子进程的部分,当子进程修改代码时,父进程运行的结果也不受子进程的干扰。这就是进程独立性的概念。具体为什么需要独立?怎么做到独立?那就等下面谈进程地址空间时再展开说。
进程的并行和并发的概念
进程的并行是指在同一时刻,有多个任务(线程或进程)在多个处理器核心上同时执行。这要求系统具备多核心CPU或支持多线程处理的硬件架构。现在大家用的手机、电脑、平板电脑等设备都是多核心架构的CPU,所以我们可以一边打游戏一边听歌,甚至再屏幕录制啥的。这就是进程的并行。
进程的并发指的是在同一时间段内,有多个任务(线程或进程)被处理,这些任务在宏观上看似同时执行,但在微观上可能是交替执行的。并发强调的是任务之间的交替执行,而不一定要求同时执行。在单个处理器核心上,并发通常是通过时间片轮转(即分时复用)的方式实现的,多个任务轮流使用CPU资源。
进程的并发具体在LInux系统中,通过基于进程切换和基于时间片轮转的调度算法。上文中介绍了Linux内核2.6版本的大O(1)调度算法。它从原理的层面介绍了内核进行进程切换的方式吗,通过维护两个开散列哈希桶以及两个指针变量,就能让进程的PCB根据优先级被CPU公平的调度。
进程切换
进程上下文的概念
为什么C/C++语言的函数返回局部变量时,调用方能在函数调用结束后拿到结果呢?因为函数返回值被存到了CPU的寄存器中。如,return a; -> mov eax 10。寄存器资源时最稀缺的,所以,当返回值占的空间过大时,我们需要返回指向定义在堆区上的对象的指针。
操作系统如何得知进程当前执行到哪一行代码了?通过程序计数器pc或者是eip寄存器。eip寄存器用于记录当前进程正在执行指令的下一条指令的地址。寄存器分为可见寄存器和不可见寄存器。通用寄存器有eax、ebx、ecx、edx。栈帧寄存器有ebp、esp、eip等。状态寄存器有eflags、status等。那寄存器主要扮演什么角色?寄存器主要存储进程被高频次访问的数据,以提高进程的效率。所以CPU寄存器里保存的通常是进程相关的数据。这些数据称之为进程的上下文。
进程切换的概念
进程切换就是将正在被CPU调度的进程PCB从CPU上扒下来,把运行队列中排队的的进程PCB换上去调度执行。当进程并发时,操作系统需要高频次的进行进程切换以达到让多个进程交替执行的效果。当进程的时间片用完了,代码还没执行完,要从CPU上被“拔”下来了。此时CPU的寄存器内存放着这个进程的上下文。那寄存器内的上下文就要被进程打包带走。下次调度这个进程的时候,再将上下文恢复到寄存器中,然后继续执行代码。所以进程切换的核心动作就是保存上下文和恢复上下文。
环境变量
在学习JAVA的时候,配置开发环境需要用到环境变量。那环境变量具体是什么?下面就带大家看看Win11系统下的环境变量如何查看。
下面再通过几个小实验看看LInux下的环境变量。先介绍PATH环境变量。编写一份简答的C代码。然后编译后执行它。
在前面的学习中可以知道,我们写的C/C++程序编程可执行程序后也是一个指令。但是与Linux原生的指令如ls、pwd等相比。我们支持自己写的指令时需要带 ./ + 进程名才能执行它。而操作系统携带的指令则不需要。这是为什么呢?
这是因为BASH在匹配这些指令时,会去系统的PATH这个环境变量下所有的路径中查看是否有该指令。这也就意味着当我们把我们的写的指令的当前路径加入的PATH环境变量中,就不要 ./ 也能执行我们写的代码了。
当我将原本的PATH环境变量全部删掉,就会导致系统指令如ls、clear等无法使用。不过此时不用担心,PATH是内存级别的环境变量。进需要重启终端即可恢复,因为每次BATH启动后,回去配置文件中获取PATH环境变量的值。
下面再介绍一些常见的环境变量,通过env指令就能查看当前的环境变量。
下面简单介绍一下getenv()接口。它用于获取一个环境变量的值。char *getenv(const char *name)。若name不存在则返回空,存在则返回它对应的值。
有了上面的认知后,再谈一谈环境变量。环境变量是操作系统提供的一组 变量名=Value 形式的变量。不同的用户有不同的环境变量,且环境变量通常具有全局属性。
命令行参数
什么是命令行参数?在平时我用终端连接云服务器时,用一些指令如ls时,难免需要携带命令行参数如 -l、-a等等参数,这些指令通常是C/C++代码实现的。具体它是如何过命令行参数来实行不同的功能呢?下面通过一段demo代码来看一看。首先,需要直到main函数是可以带参的。int main(int argc, char* argv[]);这种形式不知道大家见没见过。这里的argv就是用于存放命令行参数的。而argc表示它的长度(参数个数)。虽然,我们没有显示的传递argc这个参数,但是系统是可以通过argv这个指针数组的有效元素个数来进行对argc进行初始化的
通过上图样例可以发现argv默认是可执行程序(指令的名称),它的默认长度是1,注意:数组下标从0开始访问。而当我们输入参数时,以空格作为分隔符。bash命令行输入的参数会被传到main函数的argv中。所以,当我们使用命令行参数时,程序可以通过argv进行接收,然后对我们进行对应逻辑的相应。
其实在Windows中也可以使用命令 + 命令行参数的形式来启动程序,如使用shutdown命令时,可以带-t、-s等等选项
main函数的环境变量向量表
main函数不仅有一个命令行参数向量表接收传参,还有一个环境变量向量表。它的本质和argv也是一样的,是一个char* 的指针数组。下面通过一个简单的实验带大家看看它的存在。
不仅如此,还可以使用一个全局的指针变量environ来获取当前进程从bash父进程那里继承的变量。environ指针本质是指向了父进程的地址空间中的环境变量向量表的起始地址位置。
下面先看看env命令。
下面,在运行一下上面的C代码。
可以发现,好像模拟实现的env命令了。这是因为我们在使用终端SSH远端云服务器时,需要和bash进程进行交互,所以在bash命令行启动的进程都是bash的子进程。bash进程在启动时,会从操作系统的配置文件中获取对应的环境变量,而我们./启动进程会从bash的环境变量向量中继承系统的环境变量。进程获取环境变量的方式可以分为三种,系统调用接口获取 和 main函数的env向量获取以及environ指针获取。
下面通过一个简单的实验验证一下上面我们说的./运行的程序会继承bash的环境变量。首先,输入export name=value来导入一个环境变量。然后验证一下结论。取消导入的环境变量用 unset name。
本地变量
本地变量是一个只在bash内部有效的变量值,本地变量不会被子进程给继承。可以用set指令查看当前的环境变量+本地变量。而定义一个本地变量的方式是 name=value的方式,就是定义一个本地变量。
不是说本地变量不能被子进程继承吗?不是说bash会创建一个子进程去执行对应的指令吗?为什么echo能在显示器上输出我们的本地变量值呢?这就需要引入一个概念内建命令。前面我们说的bash会创建一个子进程去执行对应的指令,这类指令通常是常规指令。向echo、cd等需要bash亲自去执行的指令,称之为内建命令。
下面通过一个实验来看一看内建命令cd是如何改变当前路径的。通过代码修改当前路径需要使用一个接口chdir(char* path)。然后,我们由于编写的程序./启动后是bash的子进程。所以通过休眠来对比下具体进程的动作,观察一下内建命令和常规命令的区别。
由此可以观察到当我们在bash中输入命令时,当argv[0] == cd时,bash会亲自去调用chdir修改当前路径。而对普通命令他会fork()一个子进程去让子进程执行对应的函数。
进程地址空间
重新认识地址空间
在学习C/C++的时候,从语言的角度上了解了地址空间的概念。但是,这部分概念是有缺失。我们从语言层面上学习的地址空间自上往下有栈区、堆区、静态区以及常量区。而今天就从操作系统的层面上再认识一下地址空间。
从操作系统的角度出发,可以将地址空间自上往下分为栈区、堆区、全局变量区(未初始化全局变量、已初始化全局变量)、字符常量区以及代码区。下面通过代码进行验证。
从上图的验证结果可以看到Linux系统中的地址空间分布,以及栈区空间从高地址往低地址处增长,堆区空间从低地址处往高地址处增长。static修饰的局部变量的地址会处于全局变量区。
虚拟地址
下来看看下面的程序的运行结果。
通过上面的程序我们可以发现,当cnt减到0的时候,子进程将全局变量g_val修改成了200。此时,父进程和子进程都在访问这个变量,这个变量的地址都是0x5558d69ec010。但是,父进程访问时,它是100,子进程访问时,它是200。同一个变量,同一个地址。通过这个现象可以得出一个结论,这个0x5558d69ec010一定不是物理地址。它是虚拟地址(线性地址)。由此也可以引申出,我们编写的C/C++代码的指针变量并不是操作物理地址,而是操作虚拟地址。下面通过引入新的概念,增加我们对这一现象理解。
地址空间的概念
实际上在创建一个进程时,系统都会为它维护一块地址空间,称之为进程地址空间。上面的地址都是虚拟地址,由于数据终究是要存在物理空间上的。如何将虚拟地址转化成物理地址呢?答案是用一个kv映射关系结构的数组,称之为页表。
在父进程创建时,操作系统会为它维护一块进程地址空间和一份页表。当fork()创建子进程时,父子进程共享一块进程地址空间和页表。当子进程将它进程地址空间上的g_val值修改成两百,此时操作系统会进行写实拷贝,在物理内存上开辟新的空间,并将页表中key(虚拟地址)映射的value(物理地址)值修改成新开辟的物理地址。然后将物理内存地址上的值修改200。而从进程地址空间的角度来看,页表如何修改物理空间的映射都与它没有关系,进程依旧可以通过虚拟地址映射关系访问物理地址。这就是宏观层面上,同一个变量同一块地址却有不同的值的原因。
什么是地址空间呢?地址总线是用来连接计算机的各个硬件的,使它们能够完成数据通信和计算的。32位平台下,地址总线的根数是32根。每一根总线有0和1两种情况,0对应低电频,1对应高电频。而CPU中有一个地址寄存器,32位平台下它的大小是32位。它可以表示2^32种地址排列组合的方式。所以地址空间的本质是地址排列组合的方式形成的范围[0, 2^32]。
如何理解地址空间的区域划分呢?想必各位在学生时期都会经历和同桌画38线来划分桌子的区域把。假如一个桌子长150cm,你和你的同桌决定平分区域,那么你占有0cm到74cm的空间,你的同桌占有76cm到150cm的空间。这也就意味着你和同桌各自占用一段连续的空间,这就是一段线性地址(虚拟地址),这个就是对于桌子的区域划分。用计算机语言表示如下。
struct destop_area
{
int me_start;
int me_end;
int deskmate_start;
int deskmate_end;
};
所以,在属于我的桌子区域中,每一厘米的空间都属于一块独立的区域,有独立的地址标识,可以直接被我使用。比如说,在20cm到40厘米处放着我的铅笔盒。所谓地址空间就是描述进程可视范围的大小,地址空间一定存在着区域划分,通过修改end和start就能够对区域进行增大或者缩小。而地址空间需要被操作系统所管理,而管理的本质就是先描述,在组织。 要描述进程地址空间,就需要定义不同区域的start、end字段来划分不同区域。
//32位平台
struct mm_struct
{
long code_start, code_end; //代码区
long str_start, str_end; //字符串常量区
long init_start, init_end; //已初始化全局数据
long uninit_start, uninit_end; //未初始化全局数据
long heap_start, heap_end; //堆区
long stack_start, stack_end; //栈区
};
而在经典的2.6内核中关于地址空间区域划分的成员变量如下图。
再谈进程以及地址空间
现在,我们对于进程的理解可以更进一步了。进程就是内核数据结构(task_struct && mm_struct && 页表) 加上 代码和数据构成的。站在上帝视角,操作系统是一个富裕但是私生活丰富的老爸,每一个进程都是它的私生子。这意味着,进程之间不了解彼此,也不关心彼此。而操作系统给每个进程都画了一张大饼,即告诉每个进程你的进程地址空间有2^32大小的空间(4GB,32位平台的上限)。每一个进程的都认为这4GB空间是我的,只是我的“老爹”帮我暂时保管,我不够就管他要。只要不太过分,他都会给我足够的空间。
虚拟地址(线性地址)和页表的出现,使得进程在访问物理内存空间时,需要一个系统层面上的映射转化。在这个过程中,操作系统会对转化的安全性和合法性进行查验。一旦进程访问异常的内存空间时,会在系统层面进行拦截访问,不让数据到达物理内存,以到达保护物理内存的安全。
页表
页表它是被操作系统管理的,操作系统管理它是通过CPU的一个寄存器即cr3寄存器(X86架构)。cr3寄存器保存了页表的地址(物理地址)。当进程进行上下文切换时,cr3内部保存的数据也是进程上下文的一部分。需要在进程调度结束后带走,以及调度进程时加载。
页表是一个key value模型的容器,里面存放的键值对是虚拟地址和物理地址的映射关系。不仅如此,它还记录了当前不同虚拟地址空间的权限。咱们学习语言是熟知的代码区和字符串常量区是不能被修改的。原因就是当我们修改字符串常量区的内容时,页表在映射物理内存时,发现该区域的权限为只读。然后就会将进程的状态设置成异常。然后,我们的进程就会被操作系统杀掉。所以,在应用层看来我们的进程会异常退出,并且错误码被设置。
页表在设计层面上完成了进程地址空间的虚拟地址和物理内存上的物理地址进行了解耦操作。哪怕两个进程同时指向一个虚拟地址,实际上在物理内存的层面上,操作系统会维护两块不同的物理地址。当一个进程退出后,依旧不影响另一个进程去使用这个虚拟地址以及访问虚拟地址映射的物理地址。
页表还有一个关键的作用,让所有进程以统一的视角去看待物理内存。如果让操作系统直接通过物理内存地址去管理进程的代码和数据时,由于物理存储是无序的,所以管理的成本极高。当由页表映射虚拟地址和物理内存后,管理进程的代码和数据,可以通过页表操作的虚拟地址来访问对应的物理地址。在逻辑层面上可以认为虚拟地址是连续且有序的。这样大幅度减小了管理物理内存空间上代码和数据的成本。
挂起状态的介绍
Linux操作系统中有没有挂起状态呢?答案是有这个状态。挂起状态是指进程被暂时停止执行,并且被移出内存,保存在外设(通常是磁盘)中,等待特定的事件发生后再恢复执行。Linux中没有一个具体的状态描述符来表示挂起状态。当进程收到SIGSTOP信号而暂停时,此时进程就处于暂停挂起状态。当进程因为等待资源而长时间处于这种状态时,也可以认为是一种广义的挂起状态。所以在Linux系统中需要根据进程的实际状态和挂起的原因来判断。
关于进程(程序)加载的理解
首先,需要有一个共识,现代操作系统几乎不会做浪费空间和浪费时间的事。在我们玩大型游戏的时候,不可能一股脑将所有的代码都加载到内存中。因为当前主流的内存大小是16GB、32GB。而一个大型游戏动则几十上百GB的大小。这也就意味着,我们在玩大型游戏时,内存中始终只有有一部分的代码在被执行。这种运行方式称之为惰性加载模式。因为游戏的代码和数据没有办法全部加载到内存里。但是,操作系统如何判断当前的需要的代码是否在物理内存里呢?答案是通过页表的一个标记位字段来进行确认。因为在进程运行时,操作系统会将页表的虚拟地址都进行填写。当进程访问到一个虚拟地址对应的物理地址为空时,就会产生缺页中断。 注:这个概念先暂时提出来不详细谈。然后,操作系统会去物理内存上开辟空间,然后将磁盘的代码和数据拷贝到物理内存中。最后,将物理地址和虚拟地址的映射关系建立。这样才能正常的玩游戏。
现在我们不能仅仅是将进程加载理解为将程序的代码和数据加载到内存中。程序的加载应该是先由操作系统创建对应的内核数据结构对象,如task_struct、mm_struct、页表等。然后在操作系统的统筹内存管理下,将对应的映射关系建立好后,将程序的代码和数据加载到内存中。
再谈谈进程切换的概念
有了上述知识的铺垫,我们对进程切换的理解也应该有所改观。进程切换不仅仅是PCB要进行切换。对应的进程地址空间、页表以及代码和数据都要切换。而这一切的动作都是操作系统自动为我们做的。虽然,在设备高度精密化、芯片性能越来越棒的今天,这进程切换对于用户的感知是不明显的。但是,进程切换依旧是一个不小的工程。
如何做到进程独立?
首先,创建一个进程需要维护对应的PCB数据结构对象、进程地址空间数据结构对象以及页表数据结构对象。这些数据结构对象,都是一个个独立的对象。创建的每一个进程都需要维护这三个数据结构对象以达到进程独立性。
其次,进程的代码数据加载到物理内存中。通过页表的映射下,虚拟地址可以一样,而物理地址完全不一样,这样每个进程的代码数据和虚拟地址就解耦了。对于父子进程而言,它们指向同一块代码,但是数据区各自私有一份。哪怕其中一个进程结束了,依旧值释放与自己相关的PCB、页表以及对应的物理内存空间。并不会影响到其他人。