【Linux】进程管理(1):进程及概念精讲

前言:本节内容包含进程管理操作的各种基础概念精讲,同时部分板块包含Linux操作系统与一般操作系统的概念对比。不仅包含“书面概念”,还包含详细操作以及通俗讲解。

目录

一、进程概念引入

 二、进程的描述与组织:进程控制块(PCB)与进程标识符(PID)

三、fork函数:创建子进程

 四、进程的地址空间:虚拟内存与写时复制

五、进程查看

六、进程状态

七、父子进程、孤儿进程、僵尸进程

 孤儿进程:

僵尸进程

八、进程的优先级

九、环境变量

【选学】 十、Linux操作系统进程的调度

1、活动队列详解

 2、过期队列详解

3、O(1)调度算法 


一、进程概念引入

在Linux系统中:当触发一个事件时,系统都会将它定义为一个进程,并且给予这个进程一个ID,称为PID,同时根据触发这个进程的用户与相关属性关系,给予这个PID一组有效的权限设置。自此,这个PID能够在系统上执行的操作就与这个PID的权限有关。

可见,一个进程的产生离不开:触发事件。那我们如何才能在系统中触发一个事件呢?

其实很简单:执行一个程序或者命令就可以触发一个事件,进而产生一个进程。我们所说的“程序与命令”,在操作系统中本质上就是一个:二进制可执行文件。我们知道,系统只认识二进制文件,所以我们要让系统工作的时候,当然是启动一个二进制可执行文件,这个二进制文件就是程序,它们通常放置在存储媒介中(如硬盘、光盘、软盘、磁带等),以物理文件的形式存在。

 我们再来了解下什么是PID:

PID是进程标识符(Process IDentifier)的缩写,是Linux和其他类Unix操作系统中用来唯一标识一个正在运行的进程的数字。每个进程都有一个唯一的PID,该PID是由操作系统分配的,并且在系统范围内保持唯一性。

PID的主要作用包括:

  1. 唯一标识进程:PID能够在系统范围内唯一标识一个进程,即使在多个用户空间或不同的终端中。

  2. 进程管理:通过PID,系统管理员可以轻松地查找、监视、控制和终止特定的进程。

  3. 进程通信:某些进程间通信(IPC)机制可能需要使用PID来标识目标进程,以便发送消息或执行其他操作。

  4. 错误排查:在排查系统问题时,PID可以帮助定位特定进程可能引发的错误或异常情况。

总之,PID是Linux和类Unix系统中用于唯一标识正在运行的进程的数字标识符,是进程管理和通信的重要组成部分。

 在本节内容中,我们仅需了解前两个概念即可。我们知道PID是由操作系统在创建一个进程时为该进程分配的一个具有唯一性的进程标识符(通常为一个整数)。通过PID,我们不仅可以便于对进程进行管理,同时,操作系统还可以通过这个PID来判断该进程是否具有执行权限

有了上面基础概念的铺垫后,我们正式来进入进程概念的理解:

程序一般是放在物理磁盘中,通过用户的执行来触发。触发后,程序会加载到内存中成为一个个体,这就是进程。通俗来讲,进程可以为被理解为程序的一个执行实例 正在执行的程序

为了让操作系统管理这个进程,操作系统(内核)会将此程序的执行者的权限与属性、程序的代码和所需的属性都会被加载到内存中,同时为程序分配可能需要使用系统资源,如内存、CPU时间、文件描述符等,并在执行过程中进行管理和调度。操作系统还可能需要对程序进行一些初始化操作,如设置程序的运行环境变量、加载动态链接库等。

 进程概念详解:进程角度与内核角度 

进程是计算机中正在执行的程序的实例。从进程本身和内核的角度来看,我们可以分别讨论进程的概念:

从进程本身的角度:

  1. 程序执行的实例:进程是程序在执行过程中的一个实例。当程序被加载到内存中并开始执行时,它就成为一个进程。

  2. 拥有独立的地址空间:每个进程都有自己独立的地址空间,包括代码段、数据段、堆和栈等。这意味着每个进程可以在自己的地址空间中执行,并且不会直接访问其他进程的内存空间。

  3. 具有状态:进程可以处于不同的状态,例如运行、就绪、等待等。这些状态反映了进程当前的活动和可用性。

  4. 拥有标识符:每个进程都有一个唯一的标识符,称为进程标识符(PID)。PID用于在系统中唯一标识和管理进程。

  5. 可以创建其他进程:进程可以创建其他进程,从而形成父子进程关系。子进程会继承父进程的一些属性,并且可以拥有自己的独立执行流。

从内核的角度:

  1. 任务调度单位:内核将进程视为调度的基本单位。内核通过调度算法决定哪些进程可以获得CPU的使用权,并且负责在不同进程之间进行上下文切换。

  2. 资源分配:内核负责分配系统资源给进程,包括CPU时间、内存、文件描述符等。它会根据进程的需求和系统的状况来进行资源管理,以保证系统的正常运行和性能优化。

  3. 提供系统调用:内核提供了一系列系统调用,用于进程的创建、销毁、同步和通信等操作。这些系统调用允许进程与内核进行交互,并且访问系统提供的服务和功能。

  4. 提供进程间通信机制:内核提供了多种进程间通信的机制,如管道、消息队列、共享内存等,以便进程之间进行数据交换和同步操作。

  5. 提供进程管理功能:内核负责管理系统中所有进程的状态和资源使用情况,包括进程的创建、销毁、状态转换等。

总之,从进程本身和内核的角度来看,进程是程序执行的实例,同时也是内核调度和管理的基本单位,它具有独立的地址空间、状态、标识符等特征,并且可以通过内核提供的接口与系统进行交互和管理。

 二、进程的描述与组织:进程控制块(PCB)与进程标识符(PID)

 Linux操作系统中进程组织与描述的方法:

在Linux中,进程的描述与组织方式与一般的操作系统相似,但也有一些特定的特点和工具:

  1. 进程控制块(Process Control Block,PCB):在Linux中,PCB被称为任务结构(Task Structure),是用来描述和管理进程的数据结构。Linux中的任务结构包含了进程的状态信息、程序计数器、寄存器的内容、进程的内存分配情况、进程优先级等等,与其他操作系统的PCB类似。

  2. 进程状态:Linux中的进程状态通常包括运行态、就绪态、睡眠态等。可以使用命令 ps 或者 top 来查看系统中运行的进程及其状态。

  3. 进程队列:Linux内核维护了多个进程队列,如就绪队列、等待队列等。就绪队列存放等待运行的进程,而等待队列存放因为等待某些事件而被阻塞的进程。

  4. 进程调度:Linux内核采用不同的调度算法来决定哪个进程获得CPU时间。常见的调度算法包括CFS(Completely Fair Scheduler)和O(1)调度器等。

  5. 进程间通信(IPC):Linux提供了多种IPC机制,如管道、消息队列、信号量、共享内存等,用于实现进程间的通信和数据共享。

  6. 进程的创建和销毁:在Linux中,可以使用fork()系统调用来创建新的进程,也可以使用exec()系列函数来加载新的程序替换当前进程的内存映像。进程的销毁通常是通过exit()系统调用来实现。

总的来说,在Linux中,进程的描述与组织遵循着与其他操作系统类似的原则,但也有一些特定的实现方式和工具,以适应Linux系统的特点和需求。

 通过上述概念我们了解到:进程信息被放在一个叫做进程控制块的数据结构中,可以理解为进程属性的集合,而在Linux中描述进程的结构体叫做task_struct,它是PCB的一种。

下面我们来详细介绍一下Linux系统中是如何管理进程的。

这时候就要请出我们的六字真言:先描述,再组织!操作系统管理进程也是一样的,操作系统作为管理者是不需要直接和被管理者(进程)直接进行沟通的,当一个进程出现时,操作系统就立马对其进行描述,之后对该进程的管理实际上就是对其描述信息的管理。

我们在学习数据结构时,对于一个个体,我们通常是使用一个struct结构体来对一个对象的属性进行描述,进而将所有的个体组织起来同一进行管理。例如我们写学生管理系统时,我们首先要对一个学生进行描述,如学号、班级、年龄、成绩等信息构成的结构体。接着,我们将一个个描述学生的结构体通过单链表/双链表的形式组织起来,由此,我们通过对这一数据结构的管理就可以代替我们对学生的管理。如:我们需要管理一名学生时,我们可以直接在链表上进行增删查改操作。大大降低了我们管理的复杂度和效率,实现高效的管理操作。

 类似的,Linux系统对待进程的管理亦是如此:

在Linux操作系统中,在Linux中描述进程的结构体叫做task_struct,task_struct是Linux内核的一种数据结构,它会被装载到内存中并包含进程的信息。

task_ struct内容分类

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

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

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

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

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

  6. 上下文数据: 进程执行时处理器的寄存器中的数据[休学例子,要加图CPU,寄存器]。

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

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

  9. 其他信息

 进程控制块在操作系统中的组织形式:

所有运行在系统里的进程都以task_struct链表的形式存在内核里

 我们了解到进程在创建时,操作系统会为其分配唯一的进程标识符PID,那么我们如何得到进程的标识符呢?接下来,让我们认识两个系统调用函数:getpid() 和 getppid()

getpid()是一个系统调用函数,用于获取当前进程的标识符PID(Process ID),它的基本用法如下:

#include <unistd.h>

pid_t getpid(void);
  • getpid()函数不需要任何参数。
  • 调用getpid()函数时,操作系统会返回当前进程的PID。
  • 返回值类型为pid_t,即一个整数类型,用于表示进程的PID。

getppid()是一个系统调用函数,用于获取当前进程的父进程的标识符PPID(Parent Process ID),它的基本用法如下:

#include <unistd.h>

pid_t getppid(void);
  • getppid()函数不需要任何参数。
  • 调用getppid()函数时,操作系统会返回当前进程的父进程的PID。
  • 返回值类型为pid_t,即一个整数类型,用于表示进程的PID。

下面是它们的基本用法示例:

#include <stdio.h>
#include <unistd.h>

int main() {
    pid_t pid, ppid;

    // 获取当前进程的PID
    pid = getpid();
    printf("当前进程的PID为:%d\n", pid);

    // 获取当前进程的父进程的PID
    ppid = getppid();
    printf("当前进程的父进程的PID为:%d\n", ppid);

    return 0;
}

在上面的示例中,getpid()getppid()函数分别用于获取当前进程的PID和其父进程的PID,并将它们打印输出。

三、fork函数:创建子进程

我们在命令行中输入man fork 即可得到fork函数的函数接口的函数的使用方法。

 我们可以看到,fork函数位于man手册的第2部分,由于第2部分通常是用于描述系统调用和库函数,所以我们可以了解到fork函数实际是一个系统调用函数。

接下来,我们先了解一下什么是系统调用函数?

系统调用函数是操作系统提供给用户程序或应用程序的一组接口,通过这些接口,用户程序可以请求操作系统执行特定的操作,如文件操作、进程管理、网络通信等。系统调用函数允许用户程序访问操作系统的底层功能,以完成对硬件资源的管理和控制。

系统调用函数与一般的函数调用有所不同。一般的函数调用是在用户程序内部进行的,而系统调用函数是用户程序与操作系统之间的通信方式。当用户程序调用系统调用函数时,会触发一个特殊的处理机制,将控制权转移给操作系统内核,执行相应的操作,然后将结果返回给用户程序。

系统调用函数通常是由操作系统提供的库函数封装的,以便用户程序更方便地调用。这些函数通常包含在标准库中,例如在 C 语言中,可以通过 unistd.h 头文件来访问系统调用函数。

常见的系统调用函数包括 fork()exec()open()read()write() 等,它们提供了对文件系统、进程管理、内存管理、网络通信等底层功能的访问。系统调用函数是编写操作系统相关程序和系统编程的重要工具,也是操作系统与用户程序之间的桥梁。

如果不理解,我们先记住加粗蓝字描述的部分。 

在操作系统中,用户程序处于用户态(用户层),而操作系统内核处于内核态(核心层)。用户程序不能直接访问系统的硬件资源或执行特权指令,而是通过系统调用接口来请求操作系统执行特定的任务,包括对硬件资源的管理和控制。

通过系统调用接口,用户程序可以向操作系统发出请求,比如读写文件、创建进程、进行网络通信等。操作系统会根据请求执行相应的操作,然后将结果返回给用户程序。这样的设计有效地保护了系统的稳定性和安全性,同时也提供了一种方便而有效的方式,让用户程序与系统进行交互。

 fork函数详解:

fork函数从已存在进程中创建一个新进程。新进程为子进程,而原进程为父进程。

  • 接口

    #include <unistd.h>
    pid_t fork(void);
    
  • 作用
    fork() 函数用于创建一个新的进程,该进程是调用进程的副本。子进程与父进程几乎完全相同,包括代码段、数据段、堆栈等。在子进程中,fork() 返回 0,而在父进程中,它返回新创建子进程的 PID(进程标识符)。

  • 返回值

    • 在父进程中,fork() 返回新创建子进程的 PID。
    • 在子进程中,fork() 返回 0。
    • 如果 fork() 失败,返回值为 -1,表示创建子进程失败。
  • 进程的执行

    • 子进程从 fork() 返回的地方【return】开始执行,而父进程则继续执行它的代码。这意味着在 fork() 调用之后,父进程和子进程会并行执行。
  • 错误处理
    如果 fork() 失败,返回值为 -1。失败的原因可能是系统资源不足或者进程数达到了限制。

  • 注意事项

    • 在 fork() 后,父子进程共享文件描述符,这意味着在一个进程中打开的文件在另一个进程中也是打开的。如果不适当地处理,可能会导致意想不到的结果。
    • 子进程通常需要调用 exec 系列函数来加载新的程序,以便替换掉自己的内存映像。否则,子进程将继承父进程的内存映像,可能会导致一些意外的行为。

 接下来我们来看一段程序:

#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <stdlib.h>
int main()
{
  printf("父进程开始运行!!!\n");
  pid_t id = fork();
  if(id == 0)
  {
    printf("我是子进程!!!\n");
    sleep(1);
  }
  else if(id > 0)
  {
    printf("我是父进程!!!\n");
    sleep(1);
  }
  else 
  {
    perror("进程创建失败!!!\n");
  }
  return 0;
}

 这段代码的结果是这样的:

 接下来我们来详细聊一下fork函数。相信大家都有这样的疑问:

1、为什么fork()函数可以有两个返回值,也就是函数会返回两次,这和我们平时见到的函数不同。

当进程调用 fork() 函数时,控制会转移到操作系统内核中执行 fork() 函数的代码。在内核中,fork() 函数主要完成以下操作:

  1. 创建新的进程控制块(Process Control Block,PCB):内核会为新的子进程分配一个唯一的进程标识符(PID),并在内存中为其创建一个新的进程控制块(PCB)。这个 PCB 将包含子进程的运行状态、程序计数器、堆栈指针、文件描述符等信息。

  2. 复制父进程的地址空间以创建自己的地址空间:在大多数情况下,fork() 函数会创建子进程的完整副本,包括代码段、数据段、堆栈等。这意味着子进程将会获得与父进程几乎完全相同的内存映像。这一步通常通过 Copy-On-Write(写时复制)技术来实现,即在子进程需要修改内存时才会进行实际的复制操作。

  3. 将子进程的状态设置为就绪:一旦子进程的地址空间准备好,内核将其状态设置为就绪态,以便在合适的时机可以被调度执行。

  4. 返回不同的值:在内核中,fork() 函数会返回两次,一次是在父进程的上下文中返回子进程的 PID,另一次是在子进程的上下文中返回 0。这样,父进程和子进程可以根据返回值来执行不同的代码路径。

  

 在fork函数内部,在执行 return pid 之前,子进程就已经创建完成,所以 return pid 实际也是父子进程的共享代码部分,所以父进程会执行一次,返回子进程的pid;而子进程也会执行一次 return pid 返回进程是否创建完成的信息。

 2、为什么父进程接收子进程的PID,而子进程返回0或-1?

  1. 父进程接收子进程的PID:父进程在调用fork()函数后,会得到子进程的PID作为返回值。通过这个PID,父进程可以对子进程进行跟踪、管理和通信。例如,父进程可能会使用子进程的PID来等待子进程的结束状态(通过waitpid()函数),或者向子进程发送信号(通过kill()函数)等。

  2. 子进程返回0或-1:子进程在fork()函数返回时,需要确定自己是父进程还是子进程。因此,子进程通常会检查fork()的返回值来确定自己的身份。具体来说:

    • 如果fork()返回0,则表示当前进程是子进程。子进程可以通过这个返回值来区别自己和父进程,并且通常会在这个基础上执行特定的任务或代码段。
    • 如果fork()返回-1,则表示进程创建失败。通常这种情况会发生在系统资源不足或者其他错误发生时。子进程在这种情况下会立即退出或者采取相应的错误处理措施。

由于fork()函数具有以上两个特性,我们可以采取 if---else 语句对父子进程进行分流,这样就可以让父子进程去做不同的事情,这也是我们后续进行进程替换的基础。

3、父子进程哪个先运行? 

在一般情况下,无法确定父进程和子进程哪一个先运行。这取决于操作系统的调度策略以及各种竞争条件的发生情况。

通过上面的知识,我们了解到在fork函数内部子进程创建完成之后,父子进程共享进程创建完成之后的代码,包括fork函数内部的代码。

好的,在我们学完以上知识后会不会有这样的疑问:既然子进程延用父进程代码、数据等,是父进程的一个副本,那在内存中,子进程的内存是不是也是完全拷贝父进程的内存,进而形成自己的内存空间呢?我们来看一下下图,判断一下下图的正确性:

 这时候可能有同学会发出这样的疑问:难道子进程不是直接按照父进程的规格在内存中直接进行一次拷贝吗?为什么上图是错误的?我们需要了解到,子进程虽然创建的与父进程完全一样的副本但这只是在操作系统层面的,但其本质还是与父进程其实是同处于一块物理内存当中。

上面的解释仍比较模糊,想真正理解,这时候就需要引出我们下一个重点:虚拟内存、写时复制和进程的地址空间。

 四、进程的地址空间:虚拟内存与写时复制

    我们先来看一段代码:

#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <stdlib.h>

int g_value = 2024;
int main()
{
  printf("父进程开始运行!!!\n");
  pid_t id = fork();
  if(id == 0)
  {
    g_value = 2025;
    printf("我是子进程!!!,g_value:%d, %p\n", g_value, &g_value);
    sleep(1);
  }
  else if(id > 0)
  {
    sleep(3);//让父进程延迟3秒运行,即让子进程先运行
    printf("我是父进程!!!, g_value:%d, %p\n",g_value, &g_value);
    sleep(1);
  }
  else 
  {
    perror("进程创建失败!!!\n");
  }
  return 0;
}

 

相信大部分同学在看到程序运行后的结果会比较诧异。按理说子进程共享父进程的代码、数据和饥内存,那么在子进程修改全局变量g_value后,父进程打印出来的结果不应该和子进程的结果一样吗?为什么同一个内存地址可以储存不同的变量,这明显”不符合逻辑“呀。 变量内容不一样,所以父子进程输出的变量绝对不是同一个变量。但地址值是一样的,说明,该地址绝对不是物理地址!

难道子进程与父进程不处于同一块物理空间?但是打印出来的地址是相同的呀!那到底是因为什么才会出现这种情况呢?接下来,让我们来深入理解Linux操作系统中真正的进程地址空间。

在Linux地址下,这种地址叫做 虚拟地址 。我们在用C/C++语言所看到的地址,全部都是虚拟地址!物理地址,用户一概看不到,由操作系统统一管理!

虚拟内存【进程地址空间】:

进程地址空间本质上是内存中的一种内核数据结构,在Linux当中进程地址空间具体由结构体mm_struct实现。 进程地址空间就类似于一把尺子,尺子的刻度由0x00000000到0xffffffff,尺子按照刻度被划分为各个区域,例如代码区、堆区、栈区等。而在结构体mm_struct当中,便记录了各个边界刻度,例如代码区的开始刻度与结束刻度。这也就是我们口中所说的:在操作系统层面的虚拟内存

通过这些信息,内核可以有效地管理进程的地址空间,包括分配内存、释放内存以及进行内存保护等操作。这种基于结构体的设计使得 Linux 内核能够灵活地管理进程的内存空间,确保各个进程之间的内存隔离和安全性。  

 那么问题来了:虚拟内存是如何与物理内存联系起来的呢?我们学过一种数据结构:哈希表。而虚拟内存与物理内存之间的联系就相当于哈希映射,即键值对的映射。

虚拟内存与物理内存之间的联系确实可以类比于哈希表的键值对映射。在虚拟内存系统中,虚拟地址就像是哈希表的键,而物理地址则是对应的值。操作系统通过页表这样的数据结构来实现这种映射关系。

当一个进程访问其虚拟地址时,操作系统首先会将这个虚拟地址作为键进行哈希运算,得到对应的哈希值。这个哈希值通常对应着页表中的一个索引位置。然后,操作系统在页表中查找这个索引位置,以确定该虚拟地址对应的物理地址。

如果在页表中找到了对应的物理地址,那么就可以直接访问物理内存中的数据。如果没有找到,则可能会触发缺页异常,这时操作系统会根据某种页替换算法,将一些不常用的页面换出到磁盘上,然后将需要访问的页面从磁盘加载到物理内存中,并更新页表的映射关系。

 什么是页表呢,让我们再深入了解一下页表:

页表(Page Table)是操作系统中用于管理虚拟内存和物理内存映射关系的数据结构。在现代计算机系统中,虚拟内存是指程序所见到的内存空间,而物理内存是真正的计算机内存。

当程序在运行时,它所使用的内存地址是虚拟地址,而不是实际的物理地址。虚拟地址需要通过页表转换为物理地址,才能在物理内存中找到相应的数据。

页表的主要作用包括:

  1. 地址转换:将程序的虚拟地址映射到物理内存中的实际地址。通过页表,操作系统可以根据程序提供的虚拟地址找到相应的物理地址。

  2. 内存保护:通过设置页表中的权限位(例如读、写、执行权限),可以对内存进行保护,防止未经授权的访问。

  3. 内存管理:页表可以跟踪每个页的使用情况,以便进行页面置换和内存回收等管理操作。

  4. 内存分配:当程序需要更多内存时,操作系统可以根据页表信息动态分配新的物理页。

页表通常是一个由操作系统维护的数据结构,存储在内存中。在进行地址转换时,CPU会根据页表中的信息将虚拟地址转换为物理地址。

 有了上面的知识铺垫,我们来引入Linux操作系统中真正的进程地址空间:

每个进程被创建时,其对应的进程控制块(task_struct)和进程地址空间(mm_struct)也会随之被创建。由于task_struct当中有一个结构体指针存储的是mm_struct的地址,所以操作系统可以通过进程的task_struct找到其mm_struct。

例如,父进程有自己的task_struct和mm_struct,该父进程创建的子进程也有属于其自己的task_struct和mm_struct,父子进程的进程地址空间当中的各个虚拟地址分别通过页表映射到物理内存的某个位置,如下图:

 那么如何解释之前的问题:同一个地址空间中可以打印出不同的变量值呢?这时需要引出另一个概念:写时复制

写时复制(Copy-on-Write,COW)是一种延迟复制技术,通常用于共享内存的场景,以节省内存和时间成本。它的核心思想是在需要修改共享内存时才进行复制,而不是在创建共享内存时立即复制。

以下是写时复制的详细解释:

  1. 共享内存的创建:当父进程创建子进程时,子进程会继承父进程的内存空间,包括代码段、数据段、堆栈等。这些内存区域在物理内存中的页表映射关系与父进程相同。

  2. 共享内存的修改:当父进程和子进程共享相同的内存页时,它们在虚拟内存中的地址映射到相同的物理内存页上。当其中一个进程尝试修改共享内存时,写时复制机制就会触发。

  3. 触发写时复制:当子进程尝试修改共享内存时,操作系统会检测到这一修改操作。为了保证进程间的独立性,操作系统不会直接修改原始的物理内存页,而是会执行写时复制操作。

  4. 复制并更新页表:操作系统会创建一个新的物理内存页,将修改后的数据复制到新页中。然后,操作系统会更新子进程的页表,使得子进程的虚拟地址指向这个新的物理页,而不是指向原始的共享内存页。

  5. 进程间的独立性:通过写时复制,父进程和子进程各自拥有了共享内存的独立副本,彼此之间的修改不会相互影响。这样可以保证进程间的独立性和隔离性。

写时复制技术通过延迟复制操作,避免了不必要的内存复制开销,同时确保了进程间的独立性和隔离性。

我们知道,子进程创建时会获得与父进程相同的内存镜像,即相同的代码段、数据段、堆栈、页表等。而我们使用C/C++或其他语言获取内存地址时,得到的实际是页表中的虚拟内存的地址。当我们在子进程中对父子共用的变量g_value进行更改时,由于操作系统需要保证进程间的独立性和进程互不影响,这时操作系统就会控制子进程进行数据的写时复制——即当父进程或子进程需对共享的数据或代码等进行更改时,操作系统会控制进程在物理内存中重新开辟一块与原变量相同大小的空间,并将改变的值放入新开辟的物理内存中,同时更改子进程页表中对应的物理内存的地址值,而虚拟内存中的地址并不进行改变,仅仅是改变了虚拟内存地址和物理内存地址的键值映射。

 那么操作系统为什么这么设计呢?接下来我们思考这么几个问题:

1、进程空间存在的意义

进程地址空间是指每个运行中的进程所拥有的虚拟内存空间,包含了该进程运行所需的代码、数据以及堆栈等信息。进程地址空间的意义主要体现在以下几个方面:

  1. 隔离性每个进程都拥有独立的地址空间,使得各个进程之间的内存相互隔离,互不干扰。这种隔离性可以防止进程之间的数据共享和相互干扰,提高了系统的稳定性和安全性。

  2. 保护性:进程地址空间可以通过设置权限位和访问控制来保护其中的数据,防止未经授权的进程访问和修改。这种保护性可以有效地保护进程的私有数据和系统关键信息,提高了系统的安全性。

  3. 共享性:虽然进程地址空间是相互隔离的,但系统可以通过内存映射等机制实现进程间的内存共享。这种共享性可以提高系统资源的利用效率,加快进程间通信的速度,促进进程间的协作与交互。

  4. 动态性:进程地址空间的大小可以根据进程的需要动态调整,使得进程能够灵活地管理和利用内存资源。这种动态性可以使系统更加高效地分配和利用内存,提高了系统的性能和响应速度。

2、为什么子进程一开始不直接创建自己的物理内存空间而是进行写时复制呢? 

当一个子进程被创建时,通常会通过复制父进程的地址空间来创建自己的地址空间。如果直接复制父进程的物理内存空间,那么可能会浪费大量的内存资源,特别是当子进程立即执行exec()系统调用来加载新的程序时,因为此时父进程的内存内容对于子进程来说是无用的。

因此,为了避免不必要的内存复制和浪费,操作系统采用了写时复制技术。写时复制允许子进程与父进程共享相同的物理内存空间,只有在子进程或父进程尝试修改内存中的数据时,才会执行实际的内存复制操作将要修改的数据复制到子进程的独立内存空间中。这样可以节省内存空间,并且减少了不必要的内存复制操作,提高了系统的性能和效率。

总的来说,写时复制技术使得子进程能够延迟对父进程内存空间的复制,只在需要修改时才进行复制,从而节省内存资源并提高系统的性能。

总的来说,进程地址空间的意义在于提供了一个独立、隔离、保护、共享和动态的内存空间,为进程的正常运行和系统的稳定性、安全性提供了重要的基础。

五、进程查看

 1、通过系统目录/proc来查看进程

ls /proc

 这些目录名为数字的文件是进程的PID,如果我们想详细查看某一个进程,可以直接查看其目录,例如,查看1号进程的详细信息:

2、通过ps命令来查看进程

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 :会列出所有进程的详细信息,并以用户友好的格式显示。

 由于ps命令选项过多,我们现阶段实际使用ps aux 或 ps ajx 与 grep和管道符“ | ”搭配使用即可。

 我们可以使用以下命令模板对需要监测的进程进行每秒一次打印信息的实时监测:

 while :; do ps axj | head -1 && ps axj | grep app | grep -v grep;echo "==============================";sleep 1;done

3、通过top命令查看进程

 top命令是一个非常有用的Linux系统监视工具,可以显示系统中正在运行的进程以及相关的系统资源使用情况。以下是关于top命令的详细解释:

  1. 启动 top 命令
    在终端中输入 top 并按下回车键即可启动 top 命令。默认情况下,top 将会以交互方式显示当前系统的运行状况。

  2. 交互式界面
    top 命令以一个交互式界面展示系统资源的使用情况。在默认模式下,它会按照 CPU 使用率排序显示进程列表。

  3. 实时更新
    top 命令会持续更新显示系统资源使用情况和进程信息。默认情况下,它每隔 3 秒钟刷新一次。

  4. 显示信息
    top 命令默认会显示如下信息:

    • 系统整体信息,包括系统时间、运行时间、登录用户数量、系统负载等。
    • 进程信息,包括进程 ID、用户、优先级、CPU 占用率、内存占用等。
    • CPU 使用情况,包括用户态、系统态、空闲等。
    • 内存使用情况,包括总内存、已用内存、空闲内存等。
    • 交换空间使用情况,包括交换总量、已用交换、空闲交换等。
  5. 交互命令
    top 命令支持一些交互命令,可以在其运行时进行操作。例如:

    • h:显示帮助信息,列出可用的交互命令。
    • k:结束一个进程。
    • q:退出 top 命令。
    • r:改变进程的优先级。
    • Space:切换 CPU 时间的显示单位。
    • 1:显示多核 CPU 每个核的详细信息。
  6. 参数设置
    你可以使用一些参数来定制 top 命令的行为。例如,top -n 5 将只显示前 5 次更新的信息。

六、进程状态

操作系统中的进程状态:

操作系统中,进程通常会处于以下几种状态之一:

  1. 创建(New):新创建的进程,正在等待分配资源或初始化。

  2. 就绪(Ready):进程已经准备好运行,但由于CPU资源有限或者其他进程正在执行,因此暂时无法执行。进程在就绪队列中等待CPU时间片。

  3. 运行(Running):进程正在CPU上执行指令。

  4. 阻塞(Blocked):进程因为某些原因(如等待I/O完成、等待消息等)暂时无法继续执行,进入阻塞状态。在等待期间,进程不占用CPU资源。

  5. 终止(Terminated):进程执行结束,释放了占用的资源,并等待被操作系统回收。

这些状态通常由操作系统的调度程序和内核来管理和维护。进程在不同状态之间转换的过程由操作系统的调度算法控制,以实现对进程的合理调度和资源管理。

Linux操作系统中的进程状态:

在Linux系统中,进程状态通常包括以下几种:

  1. 运行(Running,R):进程正在CPU上执行指令。

  2. 浅度睡眠(Sleep,S):进程已经准备好运行,但由于CPU资源有限或者其他进程正在执行,因此暂时无法执行。进程在就绪队列中等待CPU时间片,进程处于可中断睡眠状态(可被信号唤醒)

  3. 深度睡眠(Disk Sleeping,D):进程由于等待某个事件的发生(如I/O操作完成、信号等),而被挂起,暂时无法执行。进程处于不可中断睡眠状态(不可被信号唤醒)

  4. 停止(Stopped,T):进程被暂停执行,通常是由于接收到了SIGSTOP、SIGTSTP、SIGTTIN或SIGTTOU信号而被挂起。可以通过发送SIGCONT信号来恢复进程执行。

  5. 僵尸(Zombie,Z):进程已经终止,但其父进程还没有调用wait()或waitpid()函数来获取其终止状态,因此进程的退出状态信息还保留在系统中,成为僵尸进程。僵尸进程会占用系统资源,需要被及时清理。

  6. 死亡(Dead,X)这个状态只是一个返回状态,你不会在任务列表里看到这个状态。

这些状态在Linux系统中由进程控制块 task_strcut 中的状态字段来表示和管理。操作系统通过调度算法和内核来管理进程状态的转换和调度,以实现对系统资源的合理分配和进程的高效管理。

Linux进程状态的内核源码:

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 */
};

kill命令及常用信号介绍:在命令行输入kill -l 即可查看所有信号 

kill 命令是用于向指定进程发送信号的工具。除了发送终止信号之外,还可以发送其他一些信号来控制进程的行为,其中包括暂停信号、重新运行信号、强制终止信号和终止信号。以下是关于这些信号的介绍:

  1. 暂停信号(SIGSTOP)

    • 信号编号:19
    • 作用:暂停(挂起)目标进程的执行,使其停止运行。
    • 例子:可以使用 kill -SIGSTOP <进程ID> 或 kill -19 <进程ID> 命令来发送暂停信号。
  2. 重新运行信号(SIGCONT)

    • 信号编号:18
    • 作用:恢复被暂停的进程的执行,使其继续运行。
    • 例子:可以使用 kill -SIGCONT <进程ID> 或 kill -18 <进程ID> 命令来发送重新运行信号。
  3. 强制终止信号(SIGKILL)

    • 信号编号:9
    • 作用:强制终止目标进程的执行,立即结束进程的运行,不给进程执行清理操作的机会。
    • 例子:可以使用 kill -SIGKILL <进程ID> 或 kill -9 <进程ID> 命令来发送强制终止信号。
  4. 终止信号(SIGTERM)

    • 信号编号:15
    • 作用:通知目标进程正常退出,允许进程执行清理操作后退出。
    • 例子:可以使用 kill -SIGTERM <进程ID> 或 kill -15 <进程ID> 命令来发送终止信号。

对于以上命令与信号的使用,实际操作并不难,大家可以自行实验,我们不再过多进行赘述。

七、父子进程、孤儿进程、僵尸进程

前面我们已经对父子进程进行了概述,接下来我们再次深入了解一下父子进程的关系:

  1. 父进程:在Linux中,父进程是生成其他进程的进程。通常情况下,init进程(PID为1)是所有其他进程的祖先,它负责系统的初始化和进程的管理。

  2. 子进程:在Linux中,子进程是由父进程生成的进程。当一个进程调用fork()系统调用时,操作系统会创建一个新的进程(子进程),并将父进程的所有资源复制给子进程。子进程会继承父进程的文件描述符、信号处理方式等属性,并与父进程共享代码段、数据段和堆栈,但拥有独立的地址空间。

现在来详细解释一下0号、1号、2号进程及其父子关系:

  • 0号进程:通常情况下,0号进程指的是内核线程或系统启动时的第一个进程。在Linux中,0号进程可能是内核线程(如kthreadd)或者是用于特定任务的内核进程。

  • 1号进程:在Linux中,1号进程通常指的是init进程,它是所有其他进程的祖先,负责系统的初始化和进程的管理。

  • 2号进程:在Linux中,2号进程通常指的是kthreadd进程,它是内核线程创建进程,负责创建和管理内核线程。

在创建子进程后,父进程和子进程会继续并发执行,它们之间的执行顺序取决于调度器的调度策略

 我们综合使用fork、getpid、getppid函数来观察一下夫子进程的关系:

#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <stdlib.h>

int main()
{
  pid_t id = fork();
  if(id == 0)
  { 
    printf("我是子进程!!!,my_pid:%d; ppid:%d\n", getpid(),getppid());
    sleep(1);
  }
  else if(id > 0)
  {
    printf("我是父进程!!!,my_pid:%d; ppid:%d\n", getpid(),getppid());
    sleep(1);
  }
  else 
  {
    perror("进程创建失败!!!\n");
  }
  return 0;
}

 从结果中我们可以验证新生成的进程确实是父进程的子进程,那父进程的父进程又是谁呢?

在这里,我们需要使用ps aux 或 ps ajx 命令进行进程信息的查看。

 在命令行中输入如下命令,即可查看父进程的父进程信息:

ps ajx | head -1 && ps ajx | grep 24885 | grep -v grep

 可以看到,父进程的父进程实际是bash shell进程,即启动该进程的命令行进程。

 孤儿进程:

孤儿进程是指其父进程已经退出或者被终止,但是该进程还在继续执行的情况下产生的进程。在Linux系统中,孤儿进程会被init 进程(即1号进程)接管。init 进程会成为孤儿进程的新父进程,并负责对其进行收养和管理,确保进程能够正常执行。

孤儿进程的产生常见于以下情况:

  1. 父进程意外终止,但子进程仍在运行。
  2. 父进程在子进程之前退出,导致子进程成为孤儿进程。
  3. 父进程忽略或者未能正确处理子进程的退出信号。

 我们用代码来模拟一下孤儿进程的产生:

#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <stdlib.h>

int main()
{
  pid_t id = fork();
  if(id == 0)
  { 
    while(1)//让子进程一直运行,父进程推出后成为孤儿进程,之后被1号进程领养
    {
      printf("我是子进程!!!,my_pid:%d; ppid:%d\n", getpid(),getppid());
      sleep(1);
    }
  }
  else if(id > 0)
  {
    int cnt = 3;
    while(cnt--)//让父进程先退出
    {
      printf("我是父进程!!!,my_pid:%d; ppid:%d\n", getpid(),getppid());
      sleep(1);
    }
    printf("父进程已退出!!!\n");
    
  }
  else 
  {
    perror("进程创建失败!!!\n");
  }
  return 0;
}

通过监视,我们确实可以看到父进程退出后,子进程依然在运行。但由于父进程已退出,无法对子进程进程回收,所以子进程由1号进程进行领养,子进程仍然可以继续执行并在必要时被1号进程正确回收。

僵尸进程

在Linux系统中,僵尸进程是指已经完成执行任务的子进程,但其父进程尚未对其进行善后处理,导致子进程处于僵死状态的情况。

僵尸进程不再执行任何任务,但它们的进程ID和部分进程信息仍然保留在系统中,可能导致进程表膨胀,从而影响系统的正常运行。

解决僵尸进程问题的一种常见方法是确保父进程及时对其子进程进行回收。这可以通过在父进程中捕获 SIGCHLD 信号,并在信号处理函数中调用 wait() 或 waitpid() 来实现。此外,Linux系统的init进程也会定期扫描并回收僵尸进程,以确保系统的稳定性和性能。

  我们用代码来模拟一下僵尸进程的产生:

#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <stdlib.h>

int main()
{
  pid_t id = fork();
  if(id == 0)
  { int cnt = 3;
    while(cnt--)//让子进程提前退出
    {
      printf("我是子进程!!!,my_pid:%d; ppid:%d\n", getpid(),getppid());
      sleep(1);
    }
    printf("子进程已退出!!!\n");
  }
  else if(id > 0)
  {
    while(1)//让父进程一直运行
    {
      printf("我是父进程!!!,my_pid:%d; ppid:%d\n", getpid(),getppid());
      sleep(1);
    }
    
  }
  else 
  {
    perror("进程创建失败!!!\n");
  }
  return 0;
}

 通过监视我们可以看到,子进程先退出后,由于父进程还在一直运行,无法对子进程进行回收,这就导致子进程进入“僵死状态”,即Z状态。虽然子进程已经退出,但其进程ID和部分进程信息仍然保留在系统中,可能导致进程表膨胀,从而影响系统的正常运行。虽然僵尸进程本身不再消耗 CPU 资源或执行任何任务,但其 PCB 仍然占用系统内存空间,并需要操作系统来管理和维护。但其不再消耗 CPU 资源或执行任何任务,当僵尸进程被回收时,该进程的PCB及相关资源才会被彻底清理。

僵尸进程的危害:

  1. 内存资源浪费与泄露:若是一个父进程创建了很多子进程,但都不进行回收,那么就会造成资源浪费,因为task_struct(PCB)本身就要占用内存。僵尸进程申请的资源如果不进行回收,那么僵尸进程越多,实际可用的资源就越少,也就是说,僵尸进程可能会导致内存泄漏。

  2. 影响系统性能: 当进程表中存在大量僵尸进程时,操作系统需要在管理这些进程上花费额外的时间和资源。这可能导致系统调度器效率降低,从而影响系统的整体性能。

  3. 进程管理混乱: 大量僵尸进程的存在可能会导致进程管理变得混乱,使得管理员或开发人员难以准确监控和诊断系统中的活动进程。

八、进程的优先级

在linux或者unix系统中,用ps – l 命令则会类似输出以下几个内容:

我们很容易注意到其中的几个重要信息:
  • 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值和较低的NI值将导致进程更频繁地被调度执行,而较低的PRI值和较高的NI值则会导致进程较少地被调度执行。

通过调整PRI和NI的值,可以对进程的调度行为进行影响,以满足不同的性能需求和系统资源分配策略。

 我们了解到,PRI是是由操作系统分配给进程的初始优先级,并且通常情况下是由系统管理员或具有特定权限的用户通过一些工具或命令来修改的。可见作为普通用户我们并没有权限直接对程序的PRI进行直接修改。

那么当我们需要更改进程优先级时,又该如何做呢?这时就不得不提出NI值了。

  • NI值就是我们所要说的nice值了,其表示进程可被执行的优先级的修正数值
  • PRI值越小越快被执行,那么加入nice值后,将会使得PRI变为:PRI(new)=PRI(old)+nice
  • 当nice值为负值的时候,那么该程序将会优先级值将变小,即其优先级会变高,则其越快被执行
  • 所以,调整进程优先级,在Linux下,就是调整进程nice值
  • NI :代表这个进程的nice值

通常情况下,Nice值可以由普通用户通过一些特定的命令或工具来修改,而无需特殊权限,但普通用户仅能将Nice值设置为正数,修改为负数需要进行sudo提权。

在Linux和类Unix系统中,可以使用像nice和renice这样的命令来调整进程的Nice值。nice命令用于启动新进程并指定其Nice值,而renice命令用于修改已经运行的进程的Nice值。

例如,要启动一个新的进程并将其Nice值设置为10,可以使用以下命令:

nice -n 10 ./my_process

要修改已经运行的进程的Nice值,可以使用以下命令:

renice -n 5 -p <PID>

其中,-n选项指定要设置的Nice值,-p选项指定要修改的进程的PID(进程ID)。

我们通过实际操作来观察一下:

 我们可以通过ps -al 命令来实时观察进程的信息

可以看到,初始时由可执行文件app本身以及所衍生出来的子进程的初始PRI值都为80,当我们使用nice命令将app的NI值设置为10后,父子进程的PRI值均发生了改变。

当然,我们也可以通过renice命令分别在进程运行时对父子进程做出不同的改变。

其实,除了nice和renice命令,在Linux系统中,还有一个top命令可以帮助我们进行NI值的修改,接下来我们看一下如何使用Linux系统中的任务管理器—top命令对进程的优先级进行更改:

具体操作:进入top后按“r”–>输入进程PID–>输入nice值
1、命令行输入:top 后回车 按 r 

2、输入需要更改的进程的pid,并按回车

3、输入需要更改的NI值,并按回车

4、完成以上操作后,按“q”退出top命令

5、我们使用ps -al 命令查看pid为7643的进程优先级是否已经被修改

 可以看到,由于我们输入的nice值为10,所以该进程的PRI也由初始的80变为了90。

 由于top相当于我们Windows下的任务管理器,是实时的,所以我们可以在进程运行时对其优先级进行更改。

【注意】:普通用户无法将NI值设置为负数,即无法提升进程的优先级。如果仍要将NI值设置为负数,我们可以进行sudo提权,使用root的身份对NI值进行更改为负数。

sudo top
sudo nice -n -10 <command>
sudo renice -n -10 <pid>

 可以看到我们使用sudo提权后,我们成功将NI值设置为了负数,成功提高了进程的优先级。

关于进程优先级的其他概念:

  •  竞争性: 系统进程数目众多,而CPU资源只有少量,甚至1个,所以进程之间是具有竞争属性的。为了高效完成任务,更合理竞争相关资源,便具有了优先级
  • 独立性: 多进程运行,需要独享各种资源,多进程运行期间互不干扰
  • 并行: 多个进程在多个CPU下分别,同时进行运行,这称之为并行
  • 并发: 多个进程在一个CPU下采用进程切换的方式,在一段时间之内,让多个进程都得以推进,称之为 并发。 

九、环境变量

  • 环境变量(environment variables)一般是指在操作系统中用来指定操作系统运行环境的一些参数
  • 我们在编写C/C++代码的时候,在链接的时候,从来不知道我们的所链接的动态静态库在哪里,但 是照样可以链接成功,生成可执行程序,原因就是有相关环境变量帮助编译器进行查找。
  • 环境变量通常具有某些特殊用途,还有在系统当中通常具有全局特性

基本概念包括:

  1. 系统环境变量:这些变量在整个系统范围内可用,并影响所有用户和进程。例如,PATH 变量定义了系统在哪些目录中查找可执行文件。

  2. 用户环境变量:这些变量是针对特定用户的,并且只影响该用户的会话。例如,HOME 变量指定了用户的主目录路径。

  3. 临时环境变量:这些变量通常由 shell 临时设置,并在当前 shell 会话中有效。它们通常用于在特定操作中使用,而不是永久性地影响系统或用户的配置。

  4. 永久环境变量:这些变量在系统启动时由配置文件设置,并且在整个系统的生命周期内有效。例如,在 .bashrc 或 /etc/profile 中定义的变量。

  5. 设置环境变量:可以使用 export 命令在 shell 中设置环境变量,例如:

    export MY_VAR="value"
    
  6. 查看环境变量:可以使用 echo 命令或 env 命令查看当前环境中的变量,例如:

    echo $MY_VAR    //查看具体的环境变量的信息
    env             //查看系统中所有的环境变量
    
  7. 清除环境变量:可以使用unset命令清除用户自定义的环境变量

    unset <环境变量名>
  8. 显示本地定义的shell变量和环境变量:可以使用set命令

    set
    
  9. 永久性配置:要永久性地配置环境变量,可以将设置添加到用户的配置文件中,例如 .bashrc 或 .bash_profile

以下是几个常见的环境变量:

 

 

 环境变量的组织方式:

 那我们如何在代码中获取环境变量呢?通常有以下方式:

1、通过命令行参数获取

#include <stdio.h>

int main(int argc, char *argv[], char *env[])
{
 int i = 0;
 for(; env[i]; i++){
 printf("%s\n", env[i]);
 }
 return 0;
}

main函数参数介绍:

int main(int argc, char *argv[], char *env[]) 是C/C++语言中 main 函数的标准声明形式,它的参数含义如下:

  1. argc:表示命令行参数的数量(argument count),即程序运行时传递给程序的参数的个数。这个参数至少为1,因为程序名本身也算一个参数。

  2. argv:是一个指向字符串数组的指针(argument vector),其中每个字符串都是一个命令行参数。argv[0] 存储的是程序的名称,而 argv[1] 到 argv[argc-1] 存储的是传递给程序的命令行参数。

  3. env:是一个指向字符串数组的指针(environment),其中每个字符串都是一个环境变量的定义。每个环境变量都以形如 “NAME=value” 的格式存储。数组的最后一个元素通常是一个空指针,用于指示环境变量列表的结束。

通过这些参数,程序可以获取到命令行传递的参数和环境变量的值,从而进行相应的处理。

2、通过第三方变量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声明。

3、通过系统调用函数获取环境变量

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,则输出未找到该环境变量的消息。

【注意】:环境变量具有全局性,子进程会继承父进程的环境变量。

【选学】 十、Linux操作系统进程的调度

 相信同学们学完进程优先级后一定会好奇,在Linux系统中,进程如此繁多,操作系统到底是如何对进程进行调度的呢?Linux系统下的调度方式与一般的操作系统并不相同。

接下来我们先引出三个概念:活动队列、过期队列和O(1)调度器

  1. Linux活动队列
    活动队列是Linux内核中用于存储正在运行和等待运行的进程的队列。活动队列中的进程是具有时间片的,它们正在等待CPU执行。内核通过活动队列来决定下一个要执行的进程。

  2. 过期队列
    过期队列是Linux内核中的一个数据结构,用于存储已经用完时间片的进程。当进程的时间片用尽时,它会被移动到过期队列中等待重新调度。在过期队列中的进程需要等待一个新的时间片以便重新执行。

  3. O(1)调度器
    O(1)调度器是Linux内核中一种优化的进程调度算法。它的设计目标是在常数时间内(即O(1)时间复杂度)完成进程调度,而不受进程数量的影响。O(1)调度器使用了活动队列和过期队列以及一些其他数据结构,以便快速地选择下一个要执行的进程。这种调度器的设计旨在提高系统的响应速度和性能。

总的来说,Linux活动队列和过期队列是用于管理进程调度的数据结构,而O(1)调度器是一种基于这些数据结构设计的高效调度算法,它能够在常数时间内完成进程调度,从而提高系统的性能和响应速度。

 

1、活动队列详解

  1. 时间片还没有结束的所有进程都按照优先级放在该队列

  2. nr_active: 总共有多少个运行状态的进程

  3. queue[140]: 一个元素就是一个进程队列,相同优先级的进程按照FIFO规则进行排队调度,所以,数组下标就是优先级!

  4. 从该结构中,选择一个最合适的进程,过程是怎么的呢?

    1. 从0下表开始遍历queue[140]

    2. 找到第一个非空队列,该队列必定为优先级最高的队列

    3. 拿到选中队列的第一个进程,开始运行,调度完成!

    4. 遍历queue[140]时间复杂度是常数!但还是太低效了!

  5. bitmap[5]:一共140个优先级,一共140个进程队列,为了提高查找非空队列的效率,就可以用5*32个比特位表示队列是否为空,这样,便可以大大提高查找效率。

 2、过期队列详解

  1. 过期队列和活动队列结构一模一样。

  2. 过期队列上放置的进程,都是时间片耗尽的进程。

  3. 当活动队列上的进程都被处理完毕之后,对过期队列的进程进行时间片重新计算。

3、O(1)调度算法 

 在这里,我们对上述bitmap[5]数组和active指针、expired指针的作用进行简要介绍:

unsigned long bitmap[5]

【个人见解】:为什么数据类型是long而不是int呢?因为int无论在32位还是64位操作系统下都是4个字节,而long在32位系统下是4字节,在64位系统下则是8字节。使用unsigned long类型能使位图在不同的操作系统下具有更强的适应能力,这确保了位图在不同的操作系统下仍能存储足够多的位数。 

bitmap[5]是一个无符号整型数组,用于表示进程队列中的进程是否在运行。数组的每个元素都是一个32位的整数,这些整数的每一位对应着进程队列中的一个进程状态,即是否在运行或已结束。

假设我们有140个进程需要表示,而一个32位整数可以表示32个进程状态。因此,我们需要至少5个32位整数(共160个比特位)来表示这些进程。这就是为什么使用bitmap[5]数组。

在这个数组中,每个比特位表示一个进程的状态,通常用1来表示进程在运行队列中,用0表示进程已经结束。这种方式可以有效地节省内存空间,并且能够快速地进行进程状态的检查和修改。

使用位运算可以方便地对bitmap进行操作,比如设置某个进程的状态、检查某个进程的状态等。这便是O(1)调度算法,因为该算法是很小的常数级算法,高效率且所需内存空间小,因此它在操作系统中被广泛应用。

active指针和expired指针

在操作系统中,特别是在调度算法中,"active"指针和"expired"指针是两个重要的指针,用于管理活动队列(active queue)和过期队列(expired queue)。

  1. active指针

    • "active"指针通常指向活动队列中当前正在运行或等待运行的进程。
    • 当操作系统需要选择下一个要执行的进程时,它会从活动队列中选择一个进程来执行。因此,"active"指针指向的是被选中的进程或者下一个要执行的进程。
  2. expired指针

    • "expired"指针通常指向过期队列中的进程,即已经用完时间片的进程。
    • 当一个进程的时间片用尽时,它会被移动到过期队列中等待重新调度。因此,"expired"指针指向的是等待重新调度的进程集合。

这两个指针在调度算法中起着关键作用,操作系统通过这些指针来管理进程的调度和执行顺序。通常情况下,"active"指针用于选择下一个要执行的进程,而"expired"指针用于管理已经用完时间片的进程,确保它们能够及时重新获得执行机会。 

注意: 

active指针永远指向活动队列

expired指针永远指向过期队列

可是活动队列上的进程会越来越少,过期队列上的进程会越来越多,因为进程时间片到期时一直都存在的。

但是这些都不是问题,在合适的时候,调度器能够交换active指针和expired指针的内容—即改变指针指向,就相当于有具有了一批新的活动进程!

 本节到此结束,博主用了3天的空闲时间整理出本篇文章,进程管理的基本概念的各种细节基本都包含在内。本节主要包含进程管理相关概念的精讲,在学习如何控制进程前,牢靠的基础知识才是我们掌握进程控制的关键。为防止篇幅过长,影响大家的阅读。关于进程控制的具体操作,我们下节博客见。

  • 26
    点赞
  • 25
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

这题怎么做?!?

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

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

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

打赏作者

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

抵扣说明:

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

余额充值