Susan,在那命运月台前面,再上车,春天开始落叶..................................................................
文章目录
前言
一、【认识进程】
1、【进程基本概念引入】
2、【进程的描述与组织——进程控制块(PCB)与进程标识符(PID)】
【Linux中的PCB————task_struct】
【task_struct的内容分类】
【task_struct中的PID】
【获取进程的PID和PPID】
二、【了解进程操作】
2.1、【查看进程】
1、通过系统目录查看
2、通过ps命令查看
3、通过top命令查看进程
2.2、【fork函数】
1、使用fork函数创建子进程
2、使用if进行分流
三、【进程状态】
1、【进程的6种状态】
创建状态(New)
就绪状态(Ready)
运行状态(Running)
阻塞状态(Blocked)
挂起状态(suspend)
终止状态(Terminated)
总结
2、【Linux中的进程状态】
运行状态-R
浅度睡眠状态-S
深度睡眠状态-D
暂停状态-T
死亡状态-X
3、【父子进程、僵尸进程和孤儿进程】
【父子进程总结】
【僵尸进程】
【孤儿进程】
四、【进程优先级】
1、【基本概念】
2、【查看优先级信息——PRI和NI】
3、【进程优先级的修改】
通过top命令更改进程的nice值
通过renice命令更改进程的nice值
4、【四个重要概念】
五、【环境变量】
1、【环境变量的介绍】
2、【测试PATH】
3、【测试HOME】
4、【测试SHELL】
5、【环境变量相关命令】
6、【环境变量的组织方式】
7、【获取环境变量】
【通过命令行参数获取环境变量】
【通过第三方变量environ获取】
【通过系统调用获取环境变量】
六、【进程地址空间】
1、【引入】
2、【虚拟内存——进程地址空间的介绍】
3、【进程空间存在的意义和几个重要的问题】
【进程空间存在的意义】
【解答几个重要的问题】
1、为什么fork函数会有两个返回值呢?
2、为什么父进程接收子进程的PID,而子进程返回0或-1?
3、我们定义的id变量是如何做到存储两个值的?
4、为什么数据要进行写时拷贝?
5、为什么不在创建子进程的时候就进行数据的拷贝?
6、代码会不会进行写时拷贝?
7、为什么要有进程地址空间?
8、现阶段应该如何理解进程创建?
9、为什么子进程一开始不直接创建自己的物理内存空间而是进行写时复制呢?
七、【Linux系统中进程的调度】
【概念引入】
【活动队列】
【过期队列】
【active指针和expired指针】
总结
前言
本篇博客主要是对进程概念进行讲解,其中还涉及了环境变量,进程地址空间等内容,请耐心观看。
一、【认识进程】
1、【进程基本概念引入】
【我们先来看看课本上对进程是如何定义的】:程序一般是放在物理磁盘中,通过用户的执行来触发。触发后,程序会加载到内存中成为一个个体,这就是进程。通俗来讲,进程可以为被理解为程序的一个执行实例 / 正在执行的程序。
【再从内核的观点】: 进程是参与分配系统资源(主要指CPU时间,内存)的实体。所以为了让操作系统管理进程,操作系统(内核)会将此程序的执行者的权限与属性、程序的代码和所需的属性都会被加载到内存中,同时为程序分配可能需要使用系统资源,如内存、CPU时间、文件描述符等,并在执行过程中进行管理和调度。操作系统还可能需要对程序进行一些初始化操作,如设置程序的运行环境变量、加载动态链接库等。
【最后我们再看看进程在Linux系统中是如何定义】:当触发一个事件时,系统都会将它定义为一个进程,并且给予这个进程一个标识符,称为PID,同时根据触发这个进程的用户与相关属性关系,给予这个PID一组有效的权限设置。自此,这个PID能够在系统上执行的操作就与这个PID的权限有关。
可见,一个进程的产生离不开触发事件。那我们如何才能在系统中触发一个事件呢?
其实很简单:执行一个程序或者命令就可以触发一个事件,进而产生一个进程。我们所说的“程序与命令”,在操作系统中本质上就是一个:二进制可执行文件。我们知道,系统只认识二进制文件,所以我们要让系统工作的时候,当然是启动一个二进制可执行文件,这个二进制文件就是程序,它们通常放置在存储媒介中(如硬盘、光盘、软盘、磁带等),以物理文件的形式存在。
总结:从进程本身和内核的角度来看,进程是程序执行的实例,同时也是内核调度和管理的基本单位,它具有独立的地址空间、状态、标识符等特征,并且可以通过内核提供的接口与系统进行交互和管理。
下面我们来看一个例子:
我们都知道,当我们写的代码进行编译链接后便会生成一个可执行程序,这个可执行程序本质上是一个二进制文件,是放在磁盘上的。当我们双击这个可执行程序将其运行起来时,本质上是将这个程序加载到内存当中了,因为只有加载到内存后,CPU才能对其进行逐行的语句执行,而一旦将这个程序加载到内存后,我们就不应该将这个程序再叫做程序了,严格意义上将应该将其称之为进程。
下面让我们注意这样一个问题,程序在加载到内存中时,“加载”是指什么,以及这个过程是谁完成的?
实际上这里的加载本质上就是拷贝,是操作系统参与完成,操作系统将程序从磁盘加载到内存,就是将程序从磁盘拷贝到内存。
好了,我们知道了进程是什么,知道了是操作系统将可执行程序从磁盘拷贝到内存从而形成进程,既然操作系统参与了进程的形成,那么如果系统中有大量进程存在时,必然少不了对进程的管理。
我们知道管理的本质是,先描述,再组织,下面让我们看看操作系统中是如何对进程进行描述和组织的。
2、【进程的描述与组织——进程控制块(PCB)与进程标识符(PID)】
我们要知道,系统当中可以同时存在大量进程,使用命令ps aux便可以显示系统当中存在的所有进程。
而当你开机的时候启动的第一个程序就是我们的操作系统(即操作系统是第一个加载到内存的),我们都知道操作系统是做管理工作的,而其中就包括了进程管理。
但是系统内是存在大量进程的,那么操作系统是如何对进程进行管理的呢?
这时我们就应该想到管理的六字真言:先描述,再组织。操作系统管理进程也是一样的,操作系统作为管理者是不需要直接和被管理者(进程)直接进行沟通的,当一个进程出现时,操作系统就立马对其进行描述,之后对该进程的管理实际上就是对其描述信息的管理。
进程信息被放在一个叫做进程控制块的数据结构中,可以理解为进程属性的集合,称之为PCB(process control block)。操作系统将每一个进程都进行描述,形成了一个个的进程控制块(PCB),并将这些PCB以双链表的形式组织起来。这样一来,操作系统只要拿到这个双链表的头指针,便可以访问到所有的PCB。此后,操作系统对各个进程的管理就变成了对这条双链表的一系列操作。
下面我们看一个例子:
创建一个进程实际上就是先将该进程的代码和数据加载到内存,紧接着操作系统对该进程进行描述形成对应的PCB,并将这个PCB插入到该双链表当中。而退出一个进程实际上就是先将该进程的PCB从该双链表当中删除,然后操作系统再将内存当中属于该进程的代码和数据进行释放或是置为无效。
总的来说,操作系统对进程的管理实际上就变成了对该双链表的增、删、查、改等操作。【Linux中的PCB————task_struct】
进程控制块(PCB)是描述进程的,在C++当中我们称之为面向对象(类),而在C语言当中我们称之为结构体,既然Linux操作系统是用C语言进行编写的,那么Linux当中的进程控制块必定是用结构体来实现的。
- PCB实际上是对进程控制块的统称,在Linux中描述进程的结构体叫做task_struct。
- task_struct是Linux内核的一种数据结构,它会被装载到RAM(内存)里并且包含进程的信息。
【task_struct的内容分类】
task_struct就是Linux当中的进程控制块,task_struct当中主要包含以下信息:
- 标示符: 描述本进程的唯一标示符,用来区别其他进程,PID和PPID就是一种标识符。
- 状态: 任务状态,退出代码,退出信号等。
- 优先级: 相对于其他进程的优先级。
- 程序计数器(pc): 程序中即将被执行的下一条指令的地址。
- 内存指针: 包括程序代码和进程相关数据的指针,还有和其他进程共享的内存块的指针。
- 上下文数据: 进程执行时处理器的寄存器中的数据。
- I/O状态信息: 包括显示的I/O请求,分配给进程的I/O设备和被进程使用的文件列表。
- 记账信息: 可能包括处理器时间总和,使用的时钟总和,时间限制,记账号等。
- 其他信息。
对应如下:
struct task_struct {volatile long state; //说明了该进程是否可以执行,还是可中断等信息unsigned long flags; //Flage 是进程号,在调用fork()时给出int sigpending; //进程上是否有待处理的信号mm_segment_t addr_limit; //进程地址空间,区分内核进程与普通进程在内存存放的位置不同//0-0xBFFFFFFF for user-thead//0-0xFFFFFFFF for kernel-thread//调度标志,表示该进程是否需要重新调度,若非0,则当从内核态返回到用户态,会发生调度volatile long need_resched;int lock_depth; //锁深度long nice; //进程的基本时间片//进程的调度策略,有三种,实时进程:SCHED_FIFO,SCHED_RR, 分时进程:SCHED_OTHERunsigned long policy;struct mm_struct* mm; //进程内存管理信息int processor;//若进程不在任何CPU上运行, cpus_runnable 的值是0,否则是1 这个值在运行队列被锁时更新unsigned long cpus_runnable, cpus_allowed;struct list_head run_list; //指向运行队列的指针unsigned long sleep_time; //进程的睡眠时间//用于将系统中所有的进程连成一个双向循环链表, 其根是init_taskstruct task_struct* next_task, * prev_task;struct mm_struct* active_mm;struct list_head local_pages; //指向本地页面 unsigned int allocation_order, nr_local_pages;struct linux_binfmt* binfmt; //进程所运行的可执行文件的格式int exit_code, exit_signal;int pdeath_signal; //父进程终止时向子进程发送的信号unsigned long personality;//Linux可以运行由其他UNIX操作系统生成的符合iBCS2标准的程序int did_exec : 1;pid_t pid; //进程标识符,用来代表一个进程pid_t pgrp; //进程组标识,表示进程所属的进程组pid_t tty_old_pgrp; //进程控制终端所在的组标识pid_t session; //进程的会话标识pid_t tgid;int leader; //表示进程是否为会话主管struct task_struct* p_opptr, * p_pptr, * p_cptr, * p_ysptr, * p_osptr;struct list_head thread_group; //线程链表struct task_struct* pidhash_next; //用于将进程链入HASH表struct task_struct** pidhash_pprev;wait_queue_head_t wait_chldexit; //供wait4()使用struct completion* vfork_done; //供vfork() 使用unsigned long rt_priority; //实时优先级,用它计算实时进程调度时的weight值//it_real_value,it_real_incr用于REAL定时器,单位为jiffies, 系统根据it_real_value//设置定时器的第一个终止时间. 在定时器到期时,向进程发送SIGALRM信号,同时根据//it_real_incr重置终止时间,it_prof_value,it_prof_incr用于Profile定时器,单位为jiffies。//当进程运行时,不管在何种状态下,每个tick都使it_prof_value值减一,当减到0时,向进程发送//信号SIGPROF,并根据it_prof_incr重置时间.//it_virt_value,it_virt_value用于Virtual定时器,单位为jiffies。当进程运行时,不管在何种//状态下,每个tick都使it_virt_value值减一当减到0时,向进程发送信号SIGVTALRM,根据//it_virt_incr重置初值。unsigned long it_real_value, it_prof_value, it_virt_value;unsigned long it_real_incr, it_prof_incr, it_virt_value;struct timer_list real_timer; //指向实时定时器的指针struct tms times; //记录进程消耗的时间unsigned long start_time; //进程创建的时间//记录进程在每个CPU上所消耗的用户态时间和核心态时间long per_cpu_utime[NR_CPUS], per_cpu_stime[NR_CPUS];//内存缺页和交换信息://min_flt, maj_flt累计进程的次缺页数(Copy on Write页和匿名页)和主缺页数(从映射文件或交换//设备读入的页面数); nswap记录进程累计换出的页面数,即写到交换设备上的页面数。//cmin_flt, cmaj_flt, cnswap记录本进程为祖先的所有子孙进程的累计次缺页数,主缺页数和换出页面数。//在父进程回收终止的子进程时,父进程会将子进程的这些信息累计到自己结构的这些域中unsigned long min_flt, maj_flt, nswap, cmin_flt, cmaj_flt, cnswap;int swappable : 1; //表示进程的虚拟地址空间是否允许换出//进程认证信息//uid,gid为运行该进程的用户的用户标识符和组标识符,通常是进程创建者的uid,gid//euid,egid为有效uid,gid//fsuid,fsgid为文件系统uid,gid,这两个ID号通常与有效uid,gid相等,在检查对于文件//系统的访问权限时使用他们。//suid,sgid为备份uid,giduid_t uid, euid, suid, fsuid;gid_t gid, egid, sgid, fsgid;int ngroups; //记录进程在多少个用户组中gid_t groups[NGROUPS]; //记录进程所在的组//进程的权能,分别是有效位集合,继承位集合,允许位集合kernel_cap_t cap_effective, cap_inheritable, cap_permitted;int keep_capabilities : 1;struct user_struct* user;struct rlimit rlim[RLIM_NLIMITS]; //与进程相关的资源限制信息unsigned short used_math; //是否使用FPUchar comm[16]; //进程正在运行的可执行文件名//文件系统信息int link_count, total_link_count;//NULL if no tty 进程所在的控制终端,如果不需要控制终端,则该指针为空struct tty_struct* tty;unsigned int locks;//进程间通信信息struct sem_undo* semundo; //进程在信号灯上的所有undo操作struct sem_queue* semsleeping; //当进程因为信号灯操作而挂起时,他在该队列中记录等待的操作//进程的CPU状态,切换时,要保存到停止进程的task_struct中struct thread_struct thread;//文件系统信息struct fs_struct* fs;//打开文件信息struct files_struct* files;//信号处理函数spinlock_t sigmask_lock;struct signal_struct* sig; //信号处理函数sigset_t blocked; //进程当前要阻塞的信号,每个信号对应一位struct sigpending pending; //进程上是否有待处理的信号unsigned long sas_ss_sp;size_t sas_ss_size;int (*notifier)(void* priv);void* notifier_data;sigset_t* notifier_mask;u32 parent_exec_id;u32 self_exec_id;spinlock_t alloc_lock;void* journal_info; };
【task_struct中的PID】
PID是进程标识符(Process IDentifier)的缩写,是Linux和其他类Unix操作系统中用来唯一标识一个正在运行的进程的数字。每个进程都有一个唯一的PID,该PID是由操作系统分配的,并且在系统范围内保持唯一性。
PID的主要作用包括:
唯一标识进程:PID能够在系统范围内唯一标识一个进程,即使在多个用户空间或不同的终端中。
进程管理:通过PID,系统管理员可以轻松地查找、监视、控制和终止特定的进程。
进程通信:某些进程间通信(IPC)机制可能需要使用PID来标识目标进程,以便发送消息或执行其他操作。
错误排查:在排查系统问题时,PID可以帮助定位特定进程可能引发的错误或异常情况。
总之,PID是Linux和类Unix系统中用于唯一标识正在运行的进程的数字标识符,是进程管理和通信的重要组成部分。我们知道PID是由操作系统在创建一个进程时为该进程分配的一个具有唯一性的进程标识符(通常为一个整数)。通过PID,我们不仅可以便于对进程进行管理,同时,操作系统还可以通过这个PID来判断该进程是否具有执行权限,一句话就是PID存在的目的是为了便于操作系统对进程进行管理!
我们了解到进程在创建时,操作系统会为其分配唯一的进程标识符PID,那么我们如何得到进程的标识符呢?接下来,让我们认识两个系统调用函数:getpid() 和 getppid()
【获取进程的PID和PPID】
首先我们要知道进程的PID和PPID本质上是进程的编号存在于进程的task_struct中,每个进程都有自己的PID和PPID,其中PID是唯一的,而进程的PPID不一定唯一,它们存在的根本目的是方便操作系统对进程进行管理。
- 进程id(PID):使用系统调用函数getpid()可以获取。
- 父进程id(PPID):使用系统调用函数getppid()可以获取。
我们可以通过一段代码来进行测试,代码如下:
当运行该代码生成的可执行程序后,便可循环打印该进程的PID和PPID。
我们可以通过ps命令查看该进程的信息,即可发现通过ps命令得到的进程的PID和PPID与使用系统调用函数getpid和getppid所获取的值相同。
所以通过这两个函数我们就可以获得进程的PID和PPID。
二、【了解进程操作】
2.1、【查看进程】
我们前面知道了进程是什么,以及我们该如何描述进程,现在让我们看看进程是什么样子的。
1、通过系统目录查看
在根目录下有一个名为proc的系统文件夹。
文件夹当中包含大量进程信息,其中有些子目录的目录名为数字。
这些数字其实是某一进程的PID,对应文件夹当中记录着对应进程的各种信息。我们若想查看PID为1的进程的进程信息,则查看名字为1的文件夹即可。
2、通过ps命令查看
单独使用ps命令,会显示所有进程信息。
ps命令与grep命令搭配使用,即可只显示某一进程的信息。
ps aux | head -2 && ps aux | grep proc | grep -v grep
ps
命令的选项用于指定输出的格式和显示的内容。下面是一些常用的ps
命令选项及其含义:
-e
:显示所有进程,等同于-A
。-f
:显示详细的进程信息,包括进程的 UID、PID、PPID、C、STIME、TTY、TIME 和 CMD。-u
:以用户格式显示进程信息。-a
:显示终端上的所有进程,包括其他用户的进程。-x:显示没有控制终端的进程。
j :以用户友好的格式显示进程信息,包括进程的 PID(进程 ID)、PPID(父进程 ID)、PGID(进程组 ID)、SID(会话 ID)、UID(用户 ID)、STIME(启动时间)、TTY(控制终端)、TIME(CPU 时间)、CMD(命令)等。
-ww
:使用最宽的输出格式。aux
:同时列出所有的进程,包括其他用户的进程,并且显示详细信息。ajx
:会列出所有进程的详细信息,并以用户友好的格式显示。3、通过top命令查看进程
top
命令是一个非常有用的Linux系统监视工具,可以显示系统中正在运行的进程以及相关的系统资源使用情况。以下是关于top
命令的详细解释:启动
top
命令:
在终端中输入top
并按下回车键即可启动top
命令。默认情况下,top
将会以交互方式显示当前系统的运行状况,最后使用ctrl+c退出即可。交互式界面:
top
命令以一个交互式界面展示系统资源的使用情况。在默认模式下,它会按照 CPU 使用率排序显示进程列表。实时更新:
top
命令会持续更新显示系统资源使用情况和进程信息。默认情况下,它每隔 3 秒钟刷新一次。显示信息:
top 命令默认会显示如下信息:
- 系统整体信息,包括系统时间、运行时间、登录用户数量、系统负载等。
- 进程信息,包括进程 ID、用户、优先级、CPU 占用率、内存占用等。
- CPU 使用情况,包括用户态、系统态、空闲等。
- 内存使用情况,包括总内存、已用内存、空闲内存等。
- 交换空间使用情况,包括交换总量、已用交换、空闲交换等。
交互命令:
top 命令支持一些交互命令,可以在其运行时进行操作。h:显示帮助信息,列出可用的交互命令。
k:结束一个进程。
q:退出 top 命令。
r:改变进程的优先级。
Space:切换 CPU 时间的显示单位。
1:显示多核 CPU 每个核的详细信息。
参数设置:
你可以使用一些参数来定制 top 命令的行为。例如,top -n 5 将只显示前 5 次更新的信息。2.2、【fork函数】
fork是一个系统调用级别的函数,可以使用指令man fork 即可得到fork函数的函数接口的函数的使用方法。
接下来,我们先了解一下什么是系统调用函数?
系统调用函数是操作系统提供给用户程序或应用程序的一组接口,通过这些接口,用户程序可以请求操作系统执行特定的操作,如文件操作、进程管理、网络通信等。系统调用函数允许用户程序访问操作系统的底层功能,以完成对硬件资源的管理和控制。
系统调用函数与一般的函数调用有所不同。一般的函数调用是在用户程序内部进行的,而系统调用函数是用户程序与操作系统之间的通信方式。当用户程序调用系统调用函数时,会触发一个特殊的处理机制,将控制权转移给操作系统内核,执行相应的操作,然后将结果返回给用户程序。
系统调用函数通常是由操作系统提供的库函数封装的,以便用户程序更方便地调用。这些函数通常包含在标准库中,例如在 C 语言中,可以通过 unistd.h 头文件来访问系统调用函数。
常见的系统调用函数包括 fork()、exec()、open()、read()、write() 等,它们提供了对文件系统、进程管理、内存管理、网络通信等底层功能的访问。系统调用函数是编写操作系统相关程序和系统编程的重要工具,也是操作系统与用户程序之间的桥梁。
1、使用fork函数创建子进程
fork是一个系统调用级别的函数,其功能就是创建一个子进程,fork函数从已存在进程中创建一个新进程。新进程为子进程,而原进程为父进程,使用时注意包头文件<unistd.h>。
若是代码当中没有fork函数,我们都知道代码的运行结果就是循环打印该进程的PID和PPID。而加入了fork函数后,代码运行结果如下:
运行结果是循环打印两行数据,第一行数据是该进程的PID和PPID,第二行数据是代码中fork函数创建的子进程的PID和PPID。我们可以发现fork函数创建的进程的PPID就是proc进程的PID,也就是说proc进程与fork函数创建的进程之间是父子关系。
我们要知道每出现一个进程,操作系统就会为其创建PCB,fork函数创建的进程也不例外。那么fork函数创建的子进程PCB该如何创建呢?
实际上操作系统会将其父进程PCB当作模板创建其子进程的PCB,创建完成以后将其组织在一起,方便管理。
我们知道加载到内存当中的代码和数据是属于父进程的,那么fork函数创建的子进程的代码和数据又从何而来呢?
我们先看看以下代码的运行结果:运行结果:
实际上,使用fork函数创建子进程,进程从 fork() 返回的地方【return】开始执行,而父进程则继续执行它的代码。这意味着在 fork() 调用之后,父进程和子进程会并行执行。而在fork函数被调用之前的代码被父进程执行,而fork函数之后的代码,则默认情况下父子进程都可以执行。需要注意的是,父子进程虽然代码共享,但是父子进程的数据各自开辟空间(采用写时拷贝)。
注意: 使用fork函数创建子进程后就有了两个进程,这两个进程被操作系统调度的顺序是不确定的,这取决于操作系统调度算法的具体实现。
2、使用if进行分流
上面说到,fork函数创建出来的子进程与其父进程共同使用一份代码,但我们如果真的让父子进程做相同的事情,那么创建子进程就没有什么意义了。
实际上,在fork之后我们通常使用if语句进行分流,即让父进程和子进程做不同的事,在此之前,我们先来看看fork函数的返回值。fork函数的返回值:
1、如果子进程创建成功,在父进程中返回子进程的PID,而在子进程中返回0。
2、如果子进程创建失败,则在父进程中返回 -1。既然父进程和子进程获取到fork函数的返回值不同,那么我们就可以据此来让父子进程执行不同的代码,从而做不同的事。
例如,以下代码:fork创建出子进程后,子进程会进入到 if 语句的循环打印当中,而父进程会进入到 else if 语句的循环打印当中,我们就会看到下面的场景。
这里我们留意下面这样两个问题,后续进行解答:
1、为什么fork函数会有两个返回值呢?是如何做到的?
2、我们定义的id变量是如何做到存储两个值的?
三、【进程状态】
1、【进程的6种状态】
一个进程从创建而产生至撤销而消亡的整个生命期间,有时占有处理器执行,有时虽可运行但分不到处理器,有时虽有空闲处理器但因等待某个时间的发生而无法执行,这一切都说明进程和程序不相同,进程是活动的且有状态变化的,于是就有了进程状态这一概念。
操作系统的进程的状态有: 创建,就绪,运行,阻塞,挂起,终止。
各状态反映到PCB中其实本质就是整型变量status:
创建状态(New)
新创建的进程,正在等待分配资源或初始化。
就绪状态(Ready)
进程已经准备好运行,但由于CPU资源有限或者其他进程正在执行,因此暂时无法执行。进程在就绪队列中等待CPU时间片。
运行状态(Running)
每一个CPU在系统层面都会维护一个运行队列,运行状态简单的理解就是,PCB在运行队列中排队,或者正在被cpu执行。也就是说处于运行队列中的进程就是运行状态。这时进程是万事俱备随时可以被调度。
操作系统中,会存在非常多的队列,运行队列,阻塞队列以及等待硬件的设备等待队列等,
并且所有系统内的进程是用双链表链接起来的。阻塞状态(Blocked)
进程(进程 = PCB + 可执行程序(狭义上这么说))排队这件事,是进程所对应的PCB来排队的。进程在排队的时候,一定是在等待某种资源(一般是系统资源,比如键盘一些外部设备)。而当我们的进程需要访问的资源没有准备好时,处于运行状态的进程正在等待系统资源时我们称该进程处于阻塞状态。
对于以下代码:
int main() {int a;scanf("%d", &a);printf("%d\n", a);return 0; }
当程序走到了scanf 时,那么程序就会卡住,等待从键盘上获取资源。这时候操作系统会将该进程的pcb中的某个节点 连接到 对应的阻塞队列上去,同时该进程已经不在运行队列了,那么该进程的状态要从运行状态改为阻塞状态!!!
当我们的进程正在等待软硬件资源的时候,资源如果没有就绪,我们进程PCB 只能将自己设置为阻塞状态,并将自己的PCB连接到该资源提供的等待队列。
我们称进程从运行队列变化到阻塞队列导致其状态从运行状态变化到阻塞状态的这一过程称为状态变化,其本质其实是改变进程PCB中的status整形变量,以及将其PCB链入到不同队列。
当一个进程阻塞了,我们会看到什么现象?为什么??
1.进程卡住了。
2.pcb没有在运行队列中&&状态不是running,CPU不调度你的进程了。挂起状态(suspend)
挂起状态:当进程处于阻塞状态时,恰逢操作系统内存吃紧,会将进程的数据置换到外设,从而缓解内存,此时该进程就处于挂起状态。
终止状态(Terminated)
进程执行结束,释放了占用的资源,并等待被操作系统回收。
总结
这些状态通常由操作系统的调度程序和内核来管理和维护。进程在不同状态之间转换的过程由操作系统的调度算法控制,以实现对进程的合理调度和资源管理。
2、【Linux中的进程状态】
那么在Linux种进程的状态具体是怎样的呢,下面是Linux中的几种状态:
这里我们具体谈一下Linux操作系统中的进程状态,Linux操作系统的源代码当中对于进程状态有如下定义:
/* * 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 *task_state_array[] = {"R (running)", /* 0*/"S (sleeping)", /* 1*/"D (disk sleep)", /* 2*/"T (stopped)", /* 4*/"T (tracing stop)", /* 8*/"Z (zombie)", /* 16*/"X (dead)" /* 32*/ };
小贴士: 进程的当前状态是保存到自己的进程控制块(task_struct)当中的,在Linux操作系统当中也就是保存在task_struct当中的。
在Linux操作系统当中我们可以通过 ps aux 命令进行查看。
也可以使用 ps axj 命令查看进程的状态。
运行状态-R
一个进程处于运行状态(running),并不意味着进程一定处于运行当中,运行状态表明一个进程要么在运行中,要么在运行队列里。也就是说,可以同时存在多个R状态的进程。小贴士: 所有处于运行状态,即可被调度的进程,都被放到运行队列当中,当操作系统需要切换进程运行时,就直接在运行队列中选取进程运行。
浅度睡眠状态-S
一个进程处于浅度睡眠状态(sleeping),意味着该进程正在等待某件事情的完成(这里也可以是等待外设资源),处于浅度睡眠状态的进程随时可以被唤醒,也可以被杀掉(这里的睡眠有时候也可叫做可中断睡眠(interruptible sleep))本质上也是一种阻塞状态。
看下面的代码:
代码当中调用sleep函数进行休眠100秒,在这期间我们若是查看该进程的状态,则会看到该进程处于浅度睡眠状态。
而处于浅度睡眠状态的进程是可以被杀掉的,我们可以使用kill命令将该进程杀掉。
深度睡眠状态-D
一个进程处于深度睡眠状态(disk sleep),表示该进程不会被杀掉,即便是操作系统也不行,只有该进程自动唤醒才可以恢复。该状态有时候也叫不可中断睡眠状态(uninterruptible sleep),处于这个状态的进程通常会等待IO的结束,本质上也是一种阻塞状态。举个例子:
——在系统有一个进程叫“小张”,磁盘有一个东西主要进程数据的存储叫“小陈”。
“小张”要把数据存放到磁盘中,拜托“小陈”来存,由于磁盘中的东西较多,“小陈”要找一段时间,而在这个时间段,系统中的正在执行的进程越来越多,最后操作系统看见“小张”“占着茅坑不拉屎”,就把“小张”给踢出去了,之后”小陈“存放数据失败了,找”小张“闻着数据是删掉还是再存放一次,然而”小张“已经被操作系统干掉了,”小陈“得不到回响,不知道怎么办。为了防止这个情况的发生,操作系统就搞了个D状态。
这种状态(D)的进程杀不死,并且我们几乎在进程列表里没有机会看到该状态。
暂停状态-T
在Linux当中,我们可以通过发送SIGSTOP信号使进程进入暂停状态(stopped),发送SIGCONT信号可以让处于暂停状态的进程继续运行。
例如,我们对一个进程发送SIGSTOP信号,该进程就进入到了暂停状态。
我们再对该进程发送SIGCONT信号,该进程就继续运行了。
小贴士: 使用kill命令可以列出当前系统所支持的信号集。
kill命令及常用信号介绍:
kill
命令是用于向指定进程发送信号的工具。除了发送终止信号之外,还可以发送其他一些信号来控制进程的行为,其中包括暂停信号、重新运行信号、强制终止信号和终止信号。以下是关于这些信号的介绍:暂停信号(SIGSTOP):
- 信号编号:19
- 作用:暂停(挂起)目标进程的执行,使其停止运行。
- 例子:可以使用
kill -SIGSTOP <进程ID>
或kill -19 <进程ID>
命令来发送暂停信号。重新运行信号(SIGCONT):
- 信号编号:18
- 作用:恢复被暂停的进程的执行,使其继续运行。
- 例子:可以使用
kill -SIGCONT <进程ID>
或kill -18 <进程ID>
命令来发送重新运行信号。强制终止信号(SIGKILL):
- 信号编号:9
- 作用:强制终止目标进程的执行,立即结束进程的运行,不给进程执行清理操作的机会。
- 例子:可以使用
kill -SIGKILL <进程ID>
或kill -9 <进程ID>
命令来发送强制终止信号。终止信号(SIGTERM):
- 信号编号:15
- 作用:通知目标进程正常退出,允许进程执行清理操作后退出。
- 例子:可以使用
kill -SIGTERM <进程ID>
或kill -15 <进程ID>
命令来发送终止信号。僵尸状态-Z
当一个进程将要退出的时候,在系统层面,该进程曾经申请的资源并不是立即被释放,而是要暂时存储一段时间,以供操作系统或是其父进程进行读取,而每个进程退出时都会生成自己的推出信息,如果退出信息一直未被读取,则相关数据是不会被释放掉的,一个进程若是正在等待其退出信息被读取,那么我们称该进程处于僵尸状态(zombie)。首先,僵尸状态的存在是必要的,因为进程被创建的目的就是完成某项任务,那么当任务完成的时候,调用方是应该知道任务的完成情况的,所以必须存在僵尸状态,使得调用方得知任务的完成情况,以便进行相应的后续操作。
例如,我们写代码时都在主函数最后返回0。实际上这个0就是返回给操作系统的,告诉操作系统代码顺利执行结束。在Linux操作系统当中,我们可以通过使用echo $?命令获取最近一次进程退出时的退出码。
小贴士: 进程退出的信息(例如退出码),是暂时被保存在其进程控制块当中的,在Linux操作系统中也就是保存在该进程的task_struct当中。
死亡状态-X
死亡状态只是一个返回状态,当一个进程的退出信息被读取后,该进程所申请的资源就会立即被释放,该进程也就不存在了,所以不会在任务列表当中看到死亡状态(dead)。
3、【父子进程、僵尸进程和孤儿进程】
【父子进程总结】
前面我们已经对父子进程进行了概述,接下来我们总结一下父子进程的关系:
父进程:在Linux中,父进程是生成其他进程的进程。通常情况下,init进程(PID为1)是所有其他进程的祖先,它负责系统的初始化和进程的管理。
子进程:在Linux中,子进程是由父进程生成的进程。当一个进程调用
fork()
系统调用时,操作系统会创建一个新的进程(子进程),并将父进程的所有资源复制给子进程。子进程会继承父进程的文件描述符、信号处理方式等属性,并与父进程共享代码段、数据段和堆栈,但拥有独立的地址空间。现在来详细解释一下0号、1号、2号进程及其父子关系:
0号进程:通常情况下,0号进程指的是内核线程或系统启动时的第一个进程。在Linux中,0号进程可能是内核线程(如kthreadd)或者是用于特定任务的内核进程。
1号进程:在Linux中,1号进程通常指的是init进程,它是所有其他进程的祖先,负责系统的初始化和进程的管理。
2号进程:在Linux中,2号进程通常指的是kthreadd进程,它是内核线程创建进程,负责创建和管理内核线程。
在创建子进程后,父进程和子进程会继续并发执行,它们之间的执行顺序取决于调度器的调度策略。
【僵尸进程】
前面说到,一个进程若是正在等待其退出信息被读取,那么我们称该进程处于僵尸状态。而处于僵尸状态的进程,我们就称之为僵尸进程。
例如,对于以下代码,fork函数创建的子进程在打印5次信息后会退出,而父进程会一直打印信息。也就是说,子进程退出了,父进程还在运行,但父进程没有读取子进程的退出信息,那么此时子进程就进入了僵尸状态。
#include <stdio.h> #include <stdlib.h> #include <unistd.h> int main() {printf("I am running...\n");pid_t id = fork();if(id == 0){ //childint count = 5;while(count){printf("I am child...PID:%d, PPID:%d, count:%d\n", getpid(), getppid(), count);sleep(1);count--;}printf("child quit...\n");exit(1);}else if(id > 0){ //fatherwhile(1){printf("I am father...PID:%d, PPID:%d\n", getpid(), getppid());sleep(1);}}else{ //fork error}return 0; }
运行该代码后,我们可以通过以下监控脚本,每隔一秒对该进程的信息进行检测。
while :; do ps axj | head -1 && ps axj | grep proc | grep -v grep;echo "######################";sleep 1;done
检测后即可发现,当子进程退出后,子进程的状态就变成了僵尸状态。
通过监视我们可以看到,子进程先退出后,由于父进程还在一直运行,无法对子进程进行回收,这就导致子进程进入“僵死状态”,即Z状态。虽然子进程已经退出,但其进程ID和部分进程信息仍然保留在系统中,可能导致进程表膨胀,从而影响系统的正常运行。虽然僵尸进程本身不再消耗 CPU 资源或执行任何任务,但其 PCB 仍然占用系统内存空间,并需要操作系统来管理和维护。但其不再消耗 CPU 资源或执行任何任务,当僵尸进程被回收时,该进程的PCB及相关资源才会被彻底清理。
解决僵尸进程问题的一种常见方法是确保父进程及时对其子进程进行回收。这可以通过在父进程中捕获 SIGCHLD 信号,并在信号处理函数中调用
wait()
或waitpid()
来实现。此外,Linux系统的init进程也会定期扫描并回收僵尸进程,以确保系统的稳定性和性能。僵尸进程的危害
- 僵尸进程的退出状态必须一直维持下去,因为它要告诉其父进程相应的退出信息。可是父进程一直不读取,那么子进程也就一直处于僵尸状态。
- 僵尸进程的退出信息被保存在task_struct(PCB)中,僵尸状态一直不退出,那么PCB就一直需要进行维护。
- 若是一个父进程创建了很多子进程,但都不进行回收,那么就会造成资源浪费,因为数据结构对象本身就要占用内存。
- 僵尸进程申请的资源无法进行回收,那么僵尸进程越多,实际可用的资源就越少,也就是说,僵尸进程会导致内存泄漏。
【孤儿进程】
在Linux当中的进程关系大多数是父子关系,若子进程先退出而父进程没有对子进程的退出信息进行读取,那么我们称该进程为僵尸进程。但若是父进程先退出,那么将来子进程进入僵尸状态时就没有父进程对其进行处理,此时该子进程就称之为孤儿进程。
若是一直不处理孤儿进程的退出信息,那么孤儿进程就会一直占用资源,此时就会造成内存泄漏。因此,当出现孤儿进程的时候,孤儿进程会被1号init进程领养,此后当孤儿进程进入僵尸状态时就由int进程进行处理回收。孤儿进程是指其父进程已经退出或者被终止,但是该进程还在继续执行的情况下产生的进程。在Linux系统中,孤儿进程会被
init
进程(即1号进程)接管。init
进程会成为孤儿进程的新父进程,并负责对其进行收养和管理,确保进程能够正常执行。孤儿进程的产生常见于以下情况:
- 父进程意外终止,但子进程仍在运行。
- 父进程在子进程之前退出,导致子进程成为孤儿进程。
- 父进程忽略或者未能正确处理子进程的退出信号。
例如,对于以下代码,fork函数创建的子进程会一直打印信息,而父进程在打印5次信息后会退出,此时该子进程就变成了孤儿进程。
#include <stdio.h> #include <stdlib.h> #include <unistd.h> int main() {printf("I am running...\n");pid_t id = fork();if(id == 0){ //childint count = 5;while(1){printf("I am child...PID:%d, PPID:%d\n", getpid(), getppid(), count);sleep(1);}}else if(id > 0){ //fatherint count = 5;while(count){printf("I am father...PID:%d, PPID:%d, count:%d\n", getpid(), getppid(), count);sleep(1);count--;}printf("father quit...\n");exit(0);}else{ //fork error}return 0; }
观察代码运行结果,在父进程未退出时,子进程的PPID就是父进程的PID,而当父进程退出后,子进程的PPID就变成了1,即子进程被1号进程领养了。
四、【进程优先级】
1、【基本概念】
正式开始之前我们先看看下面的两个问题:
什么是优先级?
优先级实际上就是获取某种资源的先后顺序,而进程优先级实际上就是进程获取CPU资源分配的先后顺序,就是指进程的优先权(priority),优先权高的进程有优先执行的权力。优先级存在的原因?
优先级存在的主要原因就是资源是有限的,而存在进程优先级的主要原因就是CPU资源是有限的,一个CPU一次只能跑一个进程,而进程是可以有多个的,所以需要存在进程优先级,来确定进程获取CPU资源的先后顺序。2、【查看优先级信息——PRI和NI】
在Linux或者Unix操作系统中,用ps -l命令会类似输出以下几个内容:
也可以使用ps -al命令查看该进程优先级的信息:
列出的信息当中有几个重要的信息,如下:
- UID:代表执行者的身份。
- PID:代表这个进程的代号。
- PPID:代表这个进程是由哪个进程发展衍生而来的,亦即父进程的代号。
- PRI:代表这个进程可被执行的优先级,其值越小越早被执行。
- NI:代表这个进程的nice值。
我们知道UID是执行者的身份,PID和PPID我们也已经介绍过。那么PRI和NI是什么呢?它们是如何对进程的优先级造成影响的呢?
我们先来了解一下两者的概念:
在Linux中,
PRI
代表进程的静态优先级(Static Priority),NI
代表进程的调度优先级(Nice Value)。这两个值是用来确定进程调度顺序的重要参数。
静态优先级(PRI):数值越小表示优先级越高。在进程的调度中,静态优先级决定了进程在就绪队列中的顺序。进程的静态优先级可以通过nice命令调整。
调度优先级(NI):也称为Nice值,它是用来调整进程的静态优先级的偏移量。Nice值的范围一般是-20到19,数值越大表示优先级越低,即进程更“nice”,占用更少的CPU资源。可以通过nice和renice命令来调整进程的调度优先级。
这两个值共同决定了进程在CPU上执行的优先级。体现为:PRI(new) = PRI(old) + NI 通过调整PRI和NI的值,可以对进程的调度行为进行影响,以满足不同的性能需求和系统资源分配策略。
总结:
- PRI代表进程的优先级(priority),通俗点说就是进程被CPU执行的先后顺序,该值越小进程的优先级别越高。
- NI代表的是nice值,其表示进程可被执行的优先级的修正数值。
- PRI值越小越快被执行,当加入nice值后,将会使得PRI变为:PRI(new) = PRI(old) + NI。
- 若NI值为负值,那么该进程的PRI将变小,即其优先级会变高。
- 调整进程优先级,在Linux下,就是调整进程的nice值。
- NI的取值范围是-20至19,一共40个级别。
注意: 在Linux操作系统当中,PRI(old)默认为80,即PRI = 80 + NI。
3、【进程优先级的修改】
我们了解到,PRI是是由操作系统分配给进程的初始优先级,并且通常情况下是由系统管理员或具有特定权限的用户通过一些工具或命令来修改的。可见作为普通用户我们并没有权限直接对程序的PRI进行直接修改。
那么当我们需要更改进程优先级时,又该如何做呢?
通过top命令更改进程的nice值
前面我们知道top命令能够查看进程信息,就相当于Windows操作系统中的任务管理器,它能够动态实时的显示系统当中进程的资源占用情况。
使用top命令后按“r”键,会要求你输入待调整nice值的进程的PID。
输入进程PID并回车后,会要求你输入调整后的nice值。
输入nice值后按“q”即可退出,如果我们这里输入的nice值为10,那么此时我们再用ps命令查看进程的优先级信息,即可发现进程的NI变成了10,PRI变成了90(80+NI)。
需要注意的是,普通用户无法将NI值设置为负数,即无法提升进程的优先级。如若是想将NI值调为负值,也就是将进程的优先级调高,需要使用sudo命令提升权限。
sudo nice -n -10 <command> [arguments] //<nice_value> 是你想设置的 nice 值。 //<command> 是你想运行的命令。 //[arguments] 是该命令的参数(如果有的话)。
可以看到我们使用sudo提权后,我们成功将NI值设置为了负数,成功提高了进程的优先级。
通过renice命令更改进程的nice值
使用renice命令,后面跟上更改后的nice值和进程的PID即可。
之后我们再用ps命令查看进程的优先级信息,也可以发现进程的NI变成了10,PRI变成了90(80+NI)。
注意: 若是想使用renice命令将NI值调为负值,也需要使用sudo命令提升权限。
renice <nice_value> -p <pid> //<nice_value> 是你想设置的新 nice 值。 //-p 是一个选项,用于指定后面跟的是进程 ID(PID)。 //<pid> 是你想修改 nice 值的进程的 ID。
4、【四个重要概念】
- 独立性: 多进程运行,需要独享各种资源,多进程运行期间互不干扰
并行: 多个进程在多个CPU下分别,同时进行运行,这称之为并行
竞争性: 系统进程数目众多,而CPU资源只有少量,甚至1个,所以进程之间是具有竞争属性的。为了高效完成任务,更合理竞争相关资源,便有了优先级。
并发: 多个进程在一个CPU下采用进程切换的方式,在一段时间之内,让多个进程都得以推进,称之为并发。
五、【环境变量】
1、【环境变量的介绍】
环境变量(environment variables)一般是指在操作系统中用来指定操作系统运行环境的一些参数。
例如,我们编写的C/C++代码,在各个目标文件进行链接的时候,从来不知道我们所链接的动静态库在哪里,但是照样可以链接成功,生成可执行程序,原因就是有相关环境变量帮助编译器进行查找。
并且环境变量通常具有某些特殊用途,并且在系统当中通常具有全局特性。
总结为一下几类:
系统环境变量:这些变量在整个系统范围内可用,并影响所有用户和进程。例如,
PATH
变量定义了系统在哪些目录中查找可执行文件。用户环境变量:这些变量是针对特定用户的,并且只影响该用户的会话。例如,
HOME
变量指定了用户的主目录路径。临时环境变量:这些变量通常由 shell 临时设置,并在当前 shell 会话中有效。它们通常用于在特定操作中使用,而不是永久性地影响系统或用户的配置。
永久环境变量:这些变量在系统启动时由配置文件设置,并且在整个系统的生命周期内有效。例如,在
.bashrc
或/etc/profile
中定义的变量。常见环境变量
- PATH: 指定命令的搜索路径。
- HOME: 指定用户的主工作目录(即用户登录到Linux系统中的默认所处目录)。
- SHELL: 当前Shell,它的值通常是/bin/bash。
查看环境变量的方法
我们可以通过echo命令来查看环境变量,方式如下:
echo $NAME //NAME为待查看的环境变量名称
2、【测试PATH】
大家有没有想过这样一个问题:为什么执行ls命令的时候不用带./就可以执行,而我们自己生成的可执行程序必须要在前面带上./才可以执行?
容易理解的是,要执行一个可执行程序必须要先找到它在哪里,既然不带./就可以执行ls命令,说明系统能够通过ls名称找到ls的位置,而系统是无法找到我们自己的可执行程序的,所以我们必须带上./,以此告诉系统该可执行程序位于当前目录下。
而系统就是通过环境变量PATH来找到ls命令的,查看环境变量PATH我们可以看到如下内容:
可以看到环境变量PATH当中有多条路径,这些路径由冒号隔开,当你使用ls命令时,系统就会查看环境变量PATH,然后默认从左到右依次在各个路径当中进行查找。
而ls命令实际就位于PATH当中的某一个路径下,所以就算ls命令不带路径执行,系统也是能够找到的。通过上面我们知道,原来ls并不是不需要被找到,而是ls存在的位置被存放在环境变量中了,当使用ls指令时,如果不加上其存在的位置,会默认从环境变量中的各路径中对ls进行查找。
那可不可以让我们自己的可执行程序也不用带路径就可以执行呢?
当然可以,下面给出两种方式:
方式一:将可执行程序拷贝到环境变量PATH的某一路径下。
既然在未指定路径的情况下系统会根据环境变量PATH当中的路径进行查找,那我们就可以将我们的可执行程序拷贝到PATH的某一路径下,此后我们的可执行程序不带路径系统也可以找到了。sudo cp proc /usr/bin
方式二:将可执行程序所在的目录导入到环境变量PATH当中。
将可执行程序所在的目录导入到环境变量PATH当中,这样一来,没有指定路径时系统就会来到该目录下进行查找了。export PATH=$PATH:/home/xzc/work/linux/proc
3、【测试HOME】
任何一个用户在运行系统登录时都有自己的主工作目录(家目录),环境变量HOME当中即保存的该用户的主工作目录。
4、【测试SHELL】
我们在Linux操作系统当中所敲的各种命令,实际上需要由命令行解释器进行解释,而在Linux当中有许多种命令行解释器(例如bash、sh),我们可以通过查看环境变量SHELL来知道自己当前所用的命令行解释器的种类。
5、【环境变量相关命令】
echo:显示某个环境变量的值。
export:设置一个新的环境变量。
env:显示所有的环境变量。
set:显示本地定义的shell变量和环境变量。
unset:清除环境变量。
永久性配置:要永久性地配置环境变量,可以将设置添加到用户的配置文件中,例如
.bashrc
或.bash_profile
。6、【环境变量的组织方式】
在系统当中,环境变量的组织方式如下:
每个程序都会收到一张环境变量表,环境表是一个字符指针数组,每个指针指向一个以’\0’结尾的环境字符串,最后一个字符指针为空。
7、【获取环境变量】
【通过命令行参数获取环境变量】
你知道main函数其实是有参数的吗?
main函数其实有三个参数,只是我们平时基本不用它们,所以一般情况下都没有写出来。
我们可以在Windows下的编译器进行验证,当我们调试代码的时候,若是一直使用逐步调试,那么最终会来到调用main函数的地方。在这里我们可以看到,调用main函数时给main函数传递了三个参数。
我们先来说说main函数的前两个参数。
在Linux操作系统下,编写以下代码,生成可执行程序并运行。
运行结果如下:
现在我们来说说main函数的前两个参数,main函数的第二个参数是一个字符指针数组,数组当中的第一个字符指针存储的是可执行程序的位置,其余字符指针存储的是所给的若干选项,最后一个字符指针为空,而main函数的第一个参数代表的就是字符指针数组当中的有效元素个数。
下面我们可以尝试编写一个简单的代码,该代码运行起来后会根据你所给选项给出不同的提示语句。
#include <stdio.h> #include <string.h> int main(int argc, char *argv[], char* envp[]) {if(argc > 1){if(strcmp(argv[1], "-a") == 0){printf("you used -a option...\n");}else if(strcmp(argv[1], "-b") == 0){printf("you used -b option...\n");}else{printf("you used unrecognizable option...\n");}}else{printf("you did not use any option...\n");}return 0; }
代码运行结果如下:
现在我们来说说main函数的第三个参数。
main函数的第三个参数接收的实际上就是环境变量表,我们可以通过main函数的第三个参数来获取系统的环境变量。
例如,编写以下代码,生成可执行程序并运行。运行结果就是各个环境变量的值:
总结:
int main(int argc, char *argv[], char *env[])
是C/C++语言中main
函数的标准声明形式,它的参数含义如下:
argc:表示命令行参数的数量(argument count),即程序运行时传递给程序的参数的个数。这个参数至少为1,因为程序名本身也算一个参数。
argv:是一个指向字符串数组的指针(argument vector),其中每个字符串都是一个命令行参数。
argv[0]
存储的是程序的名称,而argv[1]
到argv[argc-1]
存储的是传递给程序的命令行参数。env:是一个指向字符串数组的指针(environment),其中每个字符串都是一个环境变量的定义。每个环境变量都以形如 “NAME=value” 的格式存储。数组的最后一个元素通常是一个空指针,用于指示环境变量列表的结束。
通过这些参数,程序可以获取到命令行传递的参数和环境变量的值,从而进行相应的处理。
【通过第三方变量environ获取】
除了使用main函数的第三个参数来获取环境变量以外,我们还可以通过第三方变量environ来获取。
#include <stdio.h> int main(int argc, char *argv[]) { // environ是一个外部声明,它声明了一个指向环境变量的指针数组的全局变量 environ。 // environ 指针数组就是指向这些储存环境变量字符串数组的指针数组extern char **environ;int i = 0;for(; environ[i]; i++){printf("%s\n", environ[i]);}return 0; }
运行该代码生成的可执行程序,我们同样可以获得环境变量的值:
注意: libc中定义的全局变量environ指向环境变量表,environ没有包含在任何头文件中,所以在使用时要用extern进行声明。
【通过系统调用获取环境变量】
除了通过main函数的第三个参数和第三方变量environ来获取环境变量外,我们还可以通过系统调用getenv函数来获取环境变量,getenv
函数是一个C标准库函数,用于获取指定名称的环境变量的值。其原型通常在stdlib.h
头文件中声明:char *getenv(const char *name);
该函数接受一个参数
name
,表示要获取的环境变量的名称,返回一个指向该环境变量值的指针。如果指定名称的环境变量存在,则返回该环境变量的值;如果不存在,则返回空指针(NULL)。以下是一个简单的示例,演示了如何使用
getenv
函数来获取指定环境变量的值:#include <stdio.h> #include <stdlib.h>int main() {// 获取名为 "PATH" 的环境变量的值char *path_value = getenv("PATH");if (path_value != NULL) {printf("PATH环境变量的值:%s\n", path_value);} else {printf("未找到PATH环境变量\n");}return 0; }
在这个示例中,程序首先调用 getenv("PATH") 来获取名为 “PATH” 的环境变量的值,并将其存储在 path_value 变量中。然后,程序检查 path_value 是否为NULL,如果不是NULL,则输出该环境变量的值;如果是NULL,则输出未找到该环境变量的消息。
再看一个例子,使用getenv函数获取环境变量PATH的值:
运行结果:
注意:环境变量具有全局性,子进程会继承父进程的环境变量。
六、【进程地址空间】
1、【引入】
下面这张空间布局图相信大家都见过,也叫做程序地址空间:
在Linux操作系统中,我们可以通过以下代码对该布局图进行验证:
运行结果如下,与布局图所示是吻合的:
实际上,我们在语言层面上打印出来的地址都不是物理地址,而是虚拟地址。物理地址用户一概是看不到的,是由操作系统统一进行管理的,也就是说我们上面的程序地址空间并不是真正的物理内存,所以其上的地址也不是真正的物理地址,但是无论怎么说,我们定义了变量,必然就要开辟空间,变量自然也是要存在物理空间也就是内存中的,那么我们就会有一个疑问:这些地址是虚拟地址指向的也是虚拟内存,那为何我们对其解引用仍能获取到我们定义的变量呢?
这是因为,我们的操作系统,为了简化对内存的管理以及提高内存利用率,创建了程序地址空间这个虚拟空间,使用虚拟空间中的虚拟地址经过某种转化使其映射到内存的物理空间上,我们可以想象一下,对于一段代码,我们定义的各种变量,是如何在内存上申请空间呢?毕竟我们的内存又没有进行程序地址空间那样的分区,所以实际我们每定义一个变量,在内存是任意申请空间的,这样对我们的内存来说实际上是十分混乱的,并且对于程序代码来说,因为有程序地址空间去映射,所以定义的那些变量都会存在于对应的分区中,所以程序地址空间存在的本质就是,无论物理内存中的空间资源是如何申请利用的,对于我们的每一份程序代码来说,我们定义的任何变量,开辟的任何空间消耗的都是程序地址空间中对应分区的空间资源。
注意: 虚拟地址和物理地址之间的转化由操作系统完成。
再让我们先来看一段代码:可以知道代码中使用if对父子进程进行分流,并定义了一个全局变量g_value。
代码当中用fork函数创建了一个子进程,其中让子进程相将全局变量g_val该从100改为200后打印,而父进程先休眠3秒钟,然后再打印全局变量的值。
按道理来说子进程打印的全局变量的值为200,而父进程是在子进程将全局变量改后再打印的全局变量,那么也应该是200,但是代码运行结果:可以看到父进程打印的全局变量g_val的值仍为之前的100,更奇怪的是在父子进程中打印的全局变量g_val的地址是一样的,也就是说父子进程在同一个地址处读出的值不同。
如果说我们是在同一个物理地址处获取的值,那必定是相同的,而现在在同一个地址处获取到的值却不同,这只能说明我们打印出来的地址绝对不是物理地址!!!
还是那句话,我们在语言层面上打印出来的地址都不是物理地址,而是虚拟地址。物理地址用户一概是看不到的,是由操作系统统一进行管理的。
所以就算父子进程当中打印出来的全局变量的地址(虚拟地址)相同,但是两个进程当中全局变量的值却是不同的,这是因为经过某种转化以后,父子进程虽然有相同的虚拟地址,但是映射的是不同的物理空间。
2、【虚拟内存——进程地址空间的介绍】
实际上,我们之前将那张布局图称为程序地址空间实际上是不准确的,那张布局图实际上应该叫做进程地址空间,进程地址空间本质上是内存中的一种内核数据结构,在Linux当中进程地址空间具体由结构体mm_struct实现。
进程地址空间就类似于一把尺子,尺子的刻度由0x00000000到0xffffffff,尺子按照刻度被划分为各个区域,例如代码区、堆区、栈区等。而在结构体mm_struct当中,便记录了各个边界刻度,例如代码区的开始刻度与结束刻度,如下图所示:
在结构体mm_struct当中,各个边界刻度之间的每一个刻度都代表一个虚拟地址,这些虚拟地址通过页表映射与物理内存建立联系。由于虚拟地址是由0x00000000到0xffffffff线性增长的,所以虚拟地址又叫做线性地址。
那么问题来了:虚拟内存是如何与物理内存联系起来的呢?以及页表是什么东西?
我们先来看看虚拟内存是如何与物理内存联系起来的呢?
我们学过一种数据结构:哈希表。而虚拟内存与物理内存之间的联系就相当于哈希映射,即键值对的映射。
与之类似,虚拟内存与物理内存之间的联系确实可以类比于哈希表的键值对映射。在虚拟内存系统中,虚拟地址就像是哈希表的键,而物理地址则是对应的值。操作系统通过页表这样的数据结构来实现这种映射关系。
当一个进程访问其虚拟地址时,操作系统首先会将这个虚拟地址作为键进行哈希运算,得到对应的哈希值。这个哈希值通常对应着页表中的一个索引位置。然后,操作系统在页表中查找这个索引位置,以确定该虚拟地址对应的物理地址。
如果在页表中找到了对应的物理地址,那么就可以直接访问物理内存中的数据。如果没有找到,则可能会触发缺页异常,这时操作系统会根据某种页替换算法,将一些不常用的页面换出到磁盘上,然后将需要访问的页面从磁盘加载到物理内存中,并更新页表的映射关系。
还有什么是页表呢,让我们再深入了解一下页表:
页表(Page Table)是操作系统中用于管理虚拟内存和物理内存映射关系的数据结构。在现代计算机系统中,虚拟内存是指程序所见到的内存空间,而物理内存是真正的计算机内存。
当程序在运行时,它所使用的内存地址是虚拟地址,而不是实际的物理地址。虚拟地址需要通过页表转换为物理地址,才能在物理内存中找到相应的数据。
页表的主要作用包括:
地址转换:将程序的虚拟地址映射到物理内存中的实际地址。通过页表,操作系统可以根据程序提供的虚拟地址找到相应的物理地址。
内存保护:通过设置页表中的权限位(例如读、写、执行权限),可以对内存进行保护,防止未经授权的访问。
内存管理:页表可以跟踪每个页的使用情况,以便进行页面置换和内存回收等管理操作。
内存分配:当程序需要更多内存时,操作系统可以根据页表信息动态分配新的物理页。
页表通常是一个由操作系统维护的数据结构,存储在内存中。在进行地址转换时,CPU会根据页表中的信息将虚拟地址转换为物理地址。
扩展知识:
堆向上增长以及栈向下增长实际就是改变mm_struct当中堆和栈的边界刻度。
我们生成的可执行程序实际上也被分为了各个区域,例如初始化区、未初始化区等。当该可执行程序运行起来时,操作系统则将对应的数据加载到对应内存当中即可,大大提高了操作系统的工作效率。而进行可执行程序的“分区”操作的实际上就算编译器,所以说代码的优化级别实际上是编译器说了算。
我们来看一下具体的过程:
每个进程被创建时,其对应的进程控制块(task_struct)和进程地址空间(mm_struct)也会随之被创建。而操作系统可以通过进程的task_struct找到其mm_struct,因为task_struct当中有一个结构体指针存储的是mm_struct的地址。
例如,父进程有自己的task_struct和mm_struct,该父进程创建的子进程也会以父进程的task_struct和mm_struct为模板,创建其自己的task_struct和mm_struct,父子进程的进程地址空间当中的各个虚拟地址分别通过页表映射到物理内存的某个位置,如下图:而当子进程刚刚被创建时,子进程和父进程的数据和代码是共享的,即父子进程的代码和数据通过页表映射到物理内存的同一块空间。由于进程之间存在独立性,父子进程虽然共享数据,但是不能因为某一个进程需要修改数据而影响另一个进程,所以当子进程需要修改数据时,才将父进程的数据在内存当中拷贝一份,然后再进行修改,这里的拷贝采用的就是写时拷贝。
例如,子进程需要将全局变量g_val改为200,那么此时就在内存的某处存储g_val的新值,并且改变子进程当中g_val的虚拟地址通过页表映射后得到的物理地址即可。
这种在需要进行数据修改时再进行拷贝的技术,称为写时拷贝技术。
3、【进程空间存在的意义和几个重要的问题】
【进程空间存在的意义】
进程地址空间是指每个运行中的进程所拥有的虚拟内存空间,包含了该进程运行所需的代码、数据以及堆栈等信息。进程地址空间的意义主要体现在以下几个方面:
隔离性:每个进程都拥有独立的地址空间,使得各个进程之间的内存相互隔离,互不干扰。这种隔离性可以防止进程之间的数据共享和相互干扰,提高了系统的稳定性和安全性。
保护性:进程地址空间可以通过设置权限位和访问控制来保护其中的数据,防止未经授权的进程访问和修改。这种保护性可以有效地保护进程的私有数据和系统关键信息,提高了系统的安全性。
共享性:虽然进程地址空间是相互隔离的,但系统可以通过内存映射等机制实现进程间的内存共享。这种共享性可以提高系统资源的利用效率,加快进程间通信的速度,促进进程间的协作与交互。
动态性:进程地址空间的大小可以根据进程的需要动态调整,使得进程能够灵活地管理和利用内存资源。这种动态性可以使系统更加高效地分配和利用内存,提高了系统的性能和响应速度。
【解答几个重要的问题】
首先来看看我们之前遗留的问题:
1、为什么fork函数会有两个返回值呢?
再回答之前我们先来看看,fork函数具体做了什么,当进程调用
fork()
函数时,实际上,fork()
函数的代码会转移到操作系统内核中执行 。在内核中,fork()
函数主要完成以下操作:
创建新的进程控制块(PCB):内核会为新的子进程分配一个唯一的进程标识符(PID),并在内存中为其创建一个新的进程控制块(PCB)。这个 PCB 将包含子进程的运行状态、程序计数器、堆栈指针、文件描述符等信息。
复制父进程的地址空间以创建自己的地址空间:在大多数情况下,
fork()
函数会创建子进程的完整副本,包括代码段、数据段、堆栈等。这意味着子进程将会获得与父进程几乎完全相同的内存映像。只有子进程需要修改内存时才会进行实际的复制操作,这一步通常通过 Copy-On-Write(写时复制)技术来实现。将子进程的状态设置为就绪:一旦子进程的地址空间准备好,内核将其状态设置为就绪态,以便在合适的时机可以被调度执行。
返回不同的值:由于fork之后父子进程代码共享,而fork函数内部的return语句,也是代码,所以父子进程都会执行,所以在内核中,
fork()
函数会返回两次,一次是在父进程的上下文中返回子进程的 PID,另一次是在子进程的上下文中返回 0。这样,父进程和子进程可以根据返回值来执行不同的代码路径。在fork函数内部,在执行 return pid 之前,子进程就已经创建完成,所以 return pid 实际也是父子进程的共享代码部分,所以父进程会执行一次,返回子进程的pid;而子进程也会执行一次 return pid 返回进程是否创建完成的信息。
2、为什么父进程接收子进程的PID,而子进程返回0或-1?
父进程接收子进程的PID:父进程在调用
fork()
函数后,会得到子进程的PID作为返回值。通过这个PID,父进程可以对子进程进行跟踪、管理和通信。例如,父进程可能会使用子进程的PID来等待子进程的结束状态(通过waitpid()
函数),或者向子进程发送信号(通过kill()
函数)等。子进程返回0或-1:子进程在
fork()
函数返回时,需要确定自己是父进程还是子进程。因此,子进程通常会检查fork()
的返回值来确定自己的身份。具体来说:如果fork()
返回0,则表示当前进程是子进程。子进程可以通过这个返回值来区别自己和父进程,并且通常会在这个基础上执行特定的任务或代码段。如果fork()
返回-1,则表示进程创建失败。通常这种情况会发生在系统资源不足或者其他错误发生时。子进程在这种情况下会立即退出或者采取相应的错误处理措施。3、我们定义的id变量是如何做到存储两个值的?
这里我们首先可以确定这里id的地址绝对不是物理地址,而是虚拟地址,因为物理地址是无论如何无法做到一个地址存放两个值,原来子进程被创建以后,以父进程的task_struct和mm_struct为模板创建自己的,但是在物理内存上还是共享的父进程的代码和数据,由于fork函数对于父子进程返回值不同,所以子进程要改变自己对应的id的值来接收fork函数的返回值,子进程就会通过写时拷贝在物理空间中创建一份自己的id,并将页表中的虚拟地址与其映射,最终实现一个变量存储两个值。
4、为什么数据要进行写时拷贝?
进程具有独立性。多进程运行,需要独享各种资源,多进程运行期间互不干扰,不能让子进程的修改影响到父进程。
5、为什么不在创建子进程的时候就进行数据的拷贝?
子进程不一定会使用父进程的所有数据,并且在子进程不对数据进行写入的情况下,没有必要对数据进行拷贝,我们应该按需分配,在需要修改数据的时候再分配(延时分配),这样可以高效的使用内存空间。
6、代码会不会进行写时拷贝?
90%的情况下是不会的,但这并不代表代码不能进行写时拷贝,例如在进行进程替换的时候,则需要进行代码的写时拷贝。
7、为什么要有进程地址空间?
有了进程地址空间后,就不会有任何系统级别的越界问题存在了。例如进程1不会错误的访问到进程2的物理地址空间,因为你对某一地址空间进行操作之前需要先通过页表映射到物理内存,而页表只会映射属于你的物理内存。总的来说,虚拟地址和页表的配合使用,本质功能就是包含内存。
有了进程地址空间后,每个进程都认为看得到都是相同的空间范围,包括进程地址空间的构成和内部区域的划分顺序等都是相同的,这样一来我们在编写程序的时候就只需关注虚拟地址,而无需关注数据在物理内存当中实际的存储位置。
有了进程地址空间后,每个进程都认为自己在独占内存,这样能更好的完成进程的独立性以及合理使用内存空间(当实际需要使用内存空间的时候再在内存进行开辟),并能将进程调度与内存管理进行解耦或分离。
8、现阶段应该如何理解进程创建?
一个进程的创建实际上伴随着其进程控制块(task_struct)、进程地址空间(mm_struct)以及页表的创建。
9、为什么子进程一开始不直接创建自己的物理内存空间而是进行写时复制呢?
当一个子进程被创建时,通常会通过复制父进程的地址空间来创建自己的地址空间。如果直接复制父进程的物理内存空间,那么可能会浪费大量的内存资源,特别是当子进程立即执行exec()系统调用来加载新的程序时,因为此时父进程的内存内容对于子进程来说是无用的。
因此,为了避免不必要的内存复制和浪费,操作系统采用了写时复制技术。写时复制允许子进程与父进程共享相同的物理内存空间,只有在子进程或父进程尝试修改内存中的数据时,才会执行实际的内存复制操作,将要修改的数据复制到子进程的独立内存空间中。这样可以节省内存空间,并且减少了不必要的内存复制操作,提高了系统的性能和效率。
总的来说,写时复制技术使得子进程能够延迟对父进程内存空间的复制,只在需要修改时才进行复制,从而节省内存资源并提高系统的性能。
总结:
总的来说,进程地址空间的意义在于提供了一个独立、隔离、保护、共享和动态的内存空间,为进程的正常运行和系统的稳定性、安全性提供了重要的基础。
七、【Linux系统中进程的调度】
相信我们们学完进程优先级后一定会好奇,在Linux系统中,进程如此繁多,操作系统到底是如何对每个进程进行调度的呢?Linux系统下的调度方式与一般的操作系统并不相同。
接下来我们先引出三个概念:活动队列、过期队列和O(1)调度器。
【概念引入】
Linux活动队列(运行队列):
活动队列是Linux内核中用于存储正在运行和等待运行的进程的队列。活动队列中的进程是具有时间片的,它们正在等待CPU执行。内核通过活动队列来决定下一个要执行的进程。过期队列:
过期队列是Linux内核中的一个数据结构,用于存储已经用完时间片的进程。当进程的时间片用尽时,它会被移动到过期队列中等待重新调度。在过期队列中的进程需要等待一个新的时间片以便重新执行。O(1)调度器:
O(1)调度器是Linux内核中一种优化的进程调度算法。它的设计目标是在常数时间内(即O(1)时间复杂度)完成进程调度,而不受进程数量的影响。O(1)调度器使用了活动队列和过期队列以及一些其他数据结构,以便快速地选择下一个要执行的进程。这种调度器的设计旨在提高系统的响应速度和性能。总的来说,Linux活动队列和过期队列是用于管理进程调度的数据结构,而O(1)调度器是一种基于这些数据结构设计的高效调度算法,它能够在常数时间内完成进程调度,从而提高系统的性能和响应速度。
我们进程的都是普通的优先级,前面说到nice值的取值范围是-20~19,共40个级别,依次对应queue当中普通优先级的下标100~139。
注意: 实时优先级对应实时进程,实时进程是指先将一个进程执行完毕再执行下一个进程,现在基本不存在这种机器了,所以对于queue当中下标为0~99的元素我们不关心。
【活动队列】
时间片还没有结束的所有进程都按照优先级放在活动队列当中,其中nr_active代表总共有多少个运行状态的进程,而queue[140]数组当中的一个元素就是一个进程队列,相同优先级的进程按照FIFO规则进程排队调度。
过程如下:
- 从0下标开始遍历queue[140]。
- 找到第一个非空队列,该队列必定为优先级最高的队列。
- 拿到选中队列的第一个进程,开始运行,调度完成。
- 接着拿到选中队列的第二个进程进行调度,直到选中进程队列当中的所有进程都被调度。
- 继续向后遍历queue[140],寻找下一个非空队列。
bitmap[5]:bitmap[5]是一个无符号整型数组(unsigned long),用于表示进程队列中的进程是否在运行。数组的每个元素都是一个32位的整数,这些整数的每一位对应着进程队列中的一个进程状态,即是否在运行或已结束。queue数组当中一共有140个元素,即140个优先级,一共140个进程队列,为了提高查找非空队列的效率,就可以用5 × \times× 32个比特位表示队列是否为空。
假设我们有140个进程需要表示,而一个32位整数可以表示32个进程状态。因此,我们需要至少5个32位整数(共160个比特位)来表示这些进程。这就是为什么使用bitmap[5]数组。
在这个数组中,每个比特位表示一个进程的状态,通常用1来表示进程在运行队列中,用0表示进程已经结束。这种方式可以有效地节省内存空间,并且能够快速地进行进程状态的检查和修改。
使用位运算可以方便地对bitmap进行操作,比如设置某个进程的状态、检查某个进程的状态等。这便是O(1)调度算法,因为该算法是很小的常数级算法,高效率且所需内存空间小,因此它在操作系统中被广泛应用。
总结: 在系统当中查找一个最合适调度的进程的时间复杂度是一个常数,不会随着进程增多而导致时间成本增加,我们称之为进程调度的O(1)算法。
【过期队列】
- 过期队列和活动队列的结构相同。
- 过期队列上放置的进程都是时间片耗尽的进程。
- 当活动队列上的进程被处理完毕之后,对过期队列的进程进行时间片重新计算。
【active指针和expired指针】
- active指针永远指向活动队列。
- expired指针永远指向过期队列。
由于活动队列上时间片未到期的进程会越来越少,而过期队列上的进程数量会越来越多(新创建的进程都会被放到过期队列上),那么总会出现活动队列上的全部进程的时间片都到期的情况,这时将active指针和expired指针的内容交换,就相当于让过期队列变成活动队列,活动队列变成过期队列,就相当于又具有了一批新的活动进程,如此循环进行即可。
总结
本篇博客到这里就结束了,可能我在进程概念说明中有未涉及到的点,希望大家自行学习,关于进程该如何控制,下一篇博客再进行介绍。
.................................................................................................................苏三说,我的苏三说
————《Susan说》