Linux进程基础

文章目录

  • 1.进程概念
  • 2.进程描述
  • 3.进程操作(一)
    • 3.1.进程查看
    • 3.2.进程获取
    • 3.3.进程终止
    • 3.4.进程创建
  • 4.进程状态
    • 4.1.进程状态理论
      • 4.1.1.粗略理解
      • 4.1.2.深入理解
    • 4.2.进程状态实现
      • 4.2.1.运行状态和浅度/深度睡眠
      • 4.2.2.暂停状态和停止并跟踪状态
      • 4.2.3.终止状态和僵尸状态
  • 5.进程类别
  • 6.进程优先
    • 6.1.优先级概念
    • 6.2.优先级范围
    • 6.3.优先级查看
    • 6.4.优先级修改
  • 7.进程调度
  • 8.进程空间
    • 8.1.进程地址
      • 8.1.1.进程地址的打印
      • 8.1.2.进程地址的划分
      • 8.1.3.进程地址的现象
    • 8.2.映射地址
    • 8.3.地址现象
    • 8.4.地址生成
    • 8.5.细致划分
  • 9.进程操作(二)
    • 9.1.进程创建
      • 9.1.1.进程创建原理
      • 9.1.2.创建多个子进程
    • 9.2.进程终止
      • 9.2.1.进程终止的情况
      • 9.2.2.进程终止的信息
      • 9.2.3.进程终止的方法
        • 9.2.3.1.正常终止
          • 9.2.3.1.1.return
          • 9.2.3.1.2.exit
          • 9.2.3.1.3.\_exit()
        • 9.2.3.2.异常退出
    • 9.3.进程等待
      • 9.3.1.进程等待的目的
      • 9.3.2.进程等待的方法
        • 9.3.2.1.wait()
        • 9.3.2.2.waitpid()
      • 9.3.3.进程等待的原理
    • 9.4.进程替换
      • 9.4.1.进程替换概念
      • 9.4.2.进程替换函数
        • 9.4.2.1.execl()
        • 9.4.2.2.execv()
        • 9.4.2.3.execlp()
        • 9.4.2.4.execvp()
        • 9.4.2.5.execle()

1.进程概念

我们启动一个软件,本质就是启动了一个进程。在Windows下如果我们启动了某个应用程序,然后打开资源管理器(常见的快捷键是[ctrl+alt+delete],或者直接打开系统的“搜索”输入“资源管理器”点击即可打开)就可以看到有一个对应的软件出现在进程列表中:

Linux进程基础_第1张图片

实际上运行程序的时候,程序中的数据和代码就会加载到内存中,然后创建出一个进程。上述资源管理器里显示的就是进程列表。

补充:这也就是为什么应用运行的多的时候,有些软件会变卡甚至崩溃的原因。因为内存上堆满了大量进程,而一条空间大小有限的内存条,一次性加载太多软件,会导致内存空间溢出,有的进程无法被获取所有的数据而正确运行,最后造成软件崩溃或者静止不动的状态。

而在Linux下运行一条命令./某可执行文件,和Windows点击运行程序是类似的,也会将程序加载进内存中,最终转化成“进程”。

实际上,程序被加载到内存中后,就不能叫作“程序”了,而应该叫“进程”才对(这个原因后面解释)!

Linux也可以同时加载多个程序,也就是可以同时运行多个进程在系统中。而系统中存在大量的进程,那么操作系统就必须要管理好这些大量的进程。

那么Linux是怎么管理这些进程的呢?实际上也是“先描述再管理”。

补充1:多个进程可以构成“作业”,一个作业至少由一个进程组成。

补充2:一个程序可以被多次运行,产生多个进程。

2.进程描述

操作系统会给每个加载进内存的程序申请一个结构体,也就是PCB数据结构(全称Printed circuit board进程控制块),这个结构体内部保存了所有代码和数据的属性。

有了这个结构体来描述进程,将来就可以定义出相应的进程对象,而我们可以把这些对象使用链表的方式连接起来(这种链表就是一个进程队列,但是实际上不一定呈现出链表的形式,也可能使用其他数据结构混杂起来,而这里只是为了好理解一种粗略说法。不过,Linux内核采用的是双链表实现),也就将进程组织起来了。

因此对进程的管理转化为了对PCB结构体的管理(增删查改)。

因此什么是进程呢?进程=对应的“代码和数据”+形成的“PCB结构体”。

但是有很多人会误认为程序加载进内存就成为了进程,这种理解有些不太准确。

计算机管理的也不是直接管理程序的数据,而是这些对象,每一个PCB对象就代表一个进程。

接下来让我们来看看PCB具体是什么样的。不同的操作系统对PCB的具体实现不一样(也就是说PCB只是概念,具体实现要看系统),Linux里的是task_structtask_structLinux内核级别的结构体。

因此我们可以查看一下Linux的内核实现,关于Linux内核源代码,您可以访问Linux的官网来获取,不过文件可能有点大(您可以选择一些较低版本的)。

task_struct{/*...*/};
  1. 标识符PID:描述进程的唯一标识符,区分于别的进程

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

  3. 优先级:相对于其他进程的优先级,优先级高的进程会先被CPU调度(调度就是进程能被CPU进行计算,进程们被计算的先后顺序被称为“调度顺序/进程调度”)

  4. 程序计数器:程序中即将执行的下一条指令的地址(一个进程不可能长时间占用CPU,否则整个系统看起来就像“卡”住了一样。因此进程被CPU计算到一定程度时,就有可能被CPU暂时停止计算并且退出,而下一次进程又加载进来的时候,只需要查看程序计数器,直接到达还未被CPU计算的指令处,而不必从头开始执行指令。)

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

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

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

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

3.进程操作(一)

本节主要是侧向进程的使用,而进程操作(二)更倾向于操作的原理,并且更加细化。

3.1.进程查看

  1. 查看正在运行的进程可以使用ps axj,该指令详细全面列出正在运行进程的信息(较常用)
  • ps -a:显示所有用户的所有进程,包括其他终端(tty)和守护进程(daemon
  • ps -x: 显示没有控制终端的进程(指那些没有与终端设备,如:键盘、鼠标、显示器等直接交互的进程)
  • ps -j: 使用类似BSD风格(BSD是一系列类Unix操作系统)的输出格式显示进程信息
  • ps -p :显示特定进程的信息,其中是进程的识别号(其中PID是“进程ID”(是每一个进程的唯一标识)
  • 只看自己此时运行的程序就可以使用管道ps axj | grep <您的程序名>,或者使用head -1 && ps axj | grep <您的程序名>命令也可以
  1. 也可以使用top指令(类似Windows下的资源管理器),不过这个显示的进程太多了因此用的比较少

  2. 进程的信息也可以直接通过/proc系统文件夹查看。例如:要查看PID1的进程属性和信息,就可以查看/proc/1这个文件夹,查询其他PID的进程也是一样的。

Linux进程基础_第2张图片

  • 该文件内部有一个exe链接文件,链接的地方指向的是可执行程序的地址(这意味着进程可以知道自己的源文件所在地)。这里有一个有趣的现象值得注意:当我们运行某个C程序后,如果把该程序exe指向的源文件和可执行程序删除,那么该进程有时依旧可以正常进行。这是因为代码和数据已经被加载进内存形成进程,已经和源文件和可执行程序无关了
  • 还有一个cmd链接文件,cmd是指向进程的当前工作目录,这也可以解释一些C语言函数的现象:如果在C程序中使用fopen(),第一个参数只使用了文件名字,默认打开的就是当前工作路径下的这个文件,所谓“当前工作路径”也就是这个cmd指向的位置。而每当代码被编译运行后,每个进程都会有一个属性,来保存自己所在的工作目录,由cmd来链接
  • 对于Linux来说,进程是以文件的形式给出的,因此proc目录也必然是一个动态存储目录,内部文件经常发生变动

3.2.进程获取

我们还可以在C代码中获取本进程的标识ID,这样就变相获得了一个进程。需要注意的是,C代码内所有的函数都只有在程序转化为进程的时候才会被调用,因此系统接口getpid()也只有在程序转化为进程的时候才会获取到本进程PID

Linux进程基础_第3张图片

运行代码后就会得到左侧的输出,这个时候我们验证一下进程的PID是否符合:

Linux进程基础_第4张图片

除了gitpid()还有一个gitppid()的调用,这个系统调用可以获取当前进程的父进程PPID,这里如果我们利用ps命令就会发现这个PPID实际上就是bash。一般情况下,使用系统命令和运行我们自己编写的程序所产生的进程,其父进程永远都是bash

我们把代码改getpid()getppid()再运行代码就可以获取bashid,也就是您代码转化为进程的父进程id

3.3.进程终止

这里只给出如何使用信号来杀死进程,而不讲解信号的原理。

结合上述的进程pid,使用kill -9 可以杀死进程标识为PID的进程。-9实际上是一个信号,即:给目标文件传递9号信号,这里关于信号的知识我们以后还会再提及。

我们还可以尝试杀死父进程bash。执行后就可以发现,bash已经没有办法正常工作了,有的时候甚至会直接退出bash界面(奔溃)…

需要注意的是,父子进程是独立运行的。

3.4.进程创建

除了运行可执行程序来创建进程(Linux使用命令来创建进程,Windows使用鼠标点击快捷方式创建进程),我们还可以在代码中指使进程创建子进程。在C代码内可以使用fork()函数来创建子进程,但是这个fork()对比其他的普通函数会显得比较奇怪:失败返回-1,成功的时候具有两个返回值(你没看错,两个返回值):

  1. 给父进程返回子进程的pid

  2. 给子进程返回0

#include  
#include  
#include 
int main() 
{
    //fork之前是父进程     
    printf("1.进程PID:%d 父进程PPID:%d\n", getpid(), getppid());
    printf("father-you can see me!\n");
    printf("father-you can see me!\n");
    printf("father-you can see me!\n");
    //fork之后就会创建一个子进程,具有和父进程同样的代码     
    fork();
    
    printf("father-child-you can see me!\n");
    printf("2.进程PID:%d 父进程PPID:%d\n", getpid(), getppid());
    sleep(1); 
    return 0;
}

一般而言,fork()之后的代码是父子共享的(两者都可以看到/使用),同时运作的,但是实际上我们真正需要的不是让父子进程(任务)做一样的事情,而是父进程做一部分,子进程做一部分,以此来提高运行的效率。因此根据fork()的返回值,我们可以这么做:

#include  
#include  
#include  
int main()
{
    //fork之前是父进程
    printf("进程PID:%d 父进程PPID:%d\n", getpid(), getppid());
    pid_t id = fork();
    //从这里以后父子进程的代码都是共享的,会根据if来做调整
    if (id < 0)
    {
        //进程创建失败    
        perror("fork");
        return 1;
    }
    else if (id == 0)
    {
        //子进程做的事情
        while (1)
        {
            printf("I am child,PID:%d PPID:%d\n", getpid(), getppid());
            sleep(1);
        }
    }
    else
    {
        //父进程做的事情
        while (1)
        {
            printf("I am father,PID:%d PPID:%d\n", getpid(), getppid());
            sleep(1);
        }
    }
    printf("you can see me!\n");
    sleep(1);
    return 0;
}

这个时候您的第一份真正意义上的多进程代码就出来了(虽然并没有做什么事情…),两个父子循环是同时进行的!如果我们使用ps不断测试,就会发现的确存在两个进程,并且两者的PID/PPID呈现父子关系。

Linux进程基础_第5张图片

那么为什么pid_t这个C语言变量会有两个值?这“不符合”我们以前的学习逻辑(一个内存空间中存储了两个值?)。这个现象我们在进程的地址空间中再来解答,现阶段只要知道怎么使用就可以了。

不过我们可以先来理解一个问题:为什么设计为子进程返回0,父进程返回子进程的PID呢?因为父进程需要得到子进程的PID来进行管理,子进程只需要知道是否被建立成功就可以。

父子进程哪一个先运行呢?这是不一定的,这是由操作系统的调度器决定的。

关于杀死进程和创建进程的更多细节,我们在后面还会再重新理解,这里提前提及只是为了让您更快上手一些简单的进程操作。

计算机内有大大小小的进程,数量极多。为了方便管理交给CPU调度,会以某种数据结构形成调度队列,由操作系统查看PCB中的调度信息还有调度算法来决定先调度哪一个进程,因此父子进程谁先被调度,对于用户来说是不确定的。

4.进程状态

4.1.进程状态理论

4.1.1.粗略理解

所谓进程状态,可以使用类似下面的代码来理解:

#define NEW 1			//状态1
#define RENNING 2		//状态2
#define BLOCK 3			//状态3
//...
pcb->status = NEW;		//将进程PCB内的状态变量设置为状态1
//...
if(pcb->status == NEW)	//判断不同状态的进程,执行一些不同状态下的操作
{/*..*/}			

到此我帮您建立起对进程状态的一个粗略认知,接下来我会帮您建立更加详细的理论体系,需要注意的是不同地方在某些说法上有些许不同,这对您来说可能会造成一些阅读困难。

4.1.2.深入理解

在计算机系统理论上,对于单核CPU来说(这里不考虑多核),为了提高效率,会配备一个属于自己的调度队列/运行队列,这个队列也会被描述起来,假设描述为struct runqueue{/*...*/};,里面会包含锁、字段等信息(很多东西,但是我们先不理会),还有存储了进程的个数count,以及还有一个pcb*的指针,指向一个PCB结构体数据结构(有可能是链表,只需要将所有运行起来的进程串起来就可以)

  1. 运行状态CPU在调度进程的时候就是依靠这个struct runqueue{/*...*/};的,只需要调用pcb指针指向的数据结构(链表),就可以访问所有的进程。 因此只要在运行队列中的进程,就可以称进程处于”运行状态“(在老的系统中,只有进程放在CPU中才算是”运行状态“,但是现代系统不是这样,而是进程放入运行队列即可。

    因此对于现代系统来说:”创建/新建“、”就绪“、”执行“这三种进程状态已经没有太大的区分了),而pcb指向的一串正在等待CPU资源的进程就是”运行队列“。

    再次强调,运行状态不是指该进程正在被CPU计算中,只有老系统才这么定义。另外,多核的情况下,就存在多个”运行队列“。

    这里普及一下就绪状态,该状态表示进程已经从非CPU设备那里获取到了资源,已经准备好摆CPU执行,但由于CPU正在执行其他进程,该进程暂时无法获得CPU时间或者说无法获取CPU资源,这种状态就是”就绪状态“,也就是说,在运行队列中除了正在CPU中的计算的进程,运行队列的其他进程都处于”就绪状态“。

  2. 阻塞状态:系统中不只存在CPU资源,还有网卡、磁盘、显卡、键盘等其他设备资源。而这些资源往往有限,进程又太多,每一个进程需要这些资源。这个时候有的进程在被CPU计算之前,需要先去访问其他非CPU资源,因此我们称这个进程处于“阻塞状态”

    而这一串正在访问某个非CPU资源而暂时无法被CPU执行的进程队列也被称为“阻塞/等待队列”,多个非CPU设备就有多个阻塞队列。比如:在C语言中使用scanf()函数的时候,不可能让CPU一直在等待它输入,CPU此时去调动其他进程了,而scanf()此时处于阻塞状态,一直在等待输入设备资源的输入。

    在有些时候下载会”卡住“就是为了等待网络资源,此时也是处于阻塞状态。而如果操作系统察觉到该进程已经访问好非CPU资源了,因此将其链接到”运行队列“中,这就是所谓的”将该进程唤醒“。

  3. 挂起状态:如果内存即将被占满,此时操作系统会将长时间处于”阻塞等待“的进程代码和数据换出到磁盘中,这就是进程的“挂起状态”,而这个磁盘空间就是SWAP磁盘分区(即使写入磁盘效率比较慢,但也总比系统挂掉好)

    而且基本很难被填满(一般是和内存大小的差不多,不可以设置太大,否者就会导致系统过于依赖SWAP分区,频繁调用置换算法,造成系统变慢),如果操作系统还扛不住就会造成奔溃,也就是发生了“宕机”(挂起还可以分为”就绪挂起“和”阻塞挂起“)。

因此状态变化说白了就是:修改进程PCB对象所处的队列和PCB对象内某些表示进程状态的成员变量。 也许您会问,这样频繁的切换进程状态,使得进程在不同的队列中,会不会造成丢失和效率问题呢?不会,因为一个进程PCB不一定只在一个队列之中。

补充:由于而整个过程由于CPU太快了,看起来好像多个运行队列都被CPU同时运行着。

4.2.进程状态实现

上述的理论落实到Linux的系统中是怎么样的呢?让我们仔细研究一下内核里的状态。

这一部分的东西可以看一下内核源代码。源代码内部有对进程状态的描述,下面是保存Linux进程状态的指针数组(注释内的数字是字符串对应的标识数字):

static const char* const task_struct_array[] =
{
    "R(running)", /*0:运行中的进程,可能在等CPU资源,也有可能被CPU调度中*/
    "S(sleeping)", /*1:睡眠中的进程*/
    "D(disk sleep)", /*2:磁盘睡眠中的进程*/
    "T(stopped)", /*4:暂停的进程*/
    "t(tracing stop)", /*8:追踪停止的进程*/
    "Z(zombie)", /*16:僵尸进程*/
    "X(dead)" /*32:已经终止的进程*/
};

4.2.1.运行状态和浅度/深度睡眠

接下来让我们写两份死循环代码生成的进程对比一下:

#include 
int main()
{
    while(1);
    return 0;
}
#include 
int main()
{
    while(1)
        printf("%d\n", 1);
    return 0;
}

可以使用以下shell命令来分别查看两个进程:

while : 
do
 ps axj | head -n 1
 ps ajx | grep a.out | grep -v grep
 sleep 1 
done

下面是两份代码的运行结果,和脚本现象:

Linux进程基础_第6张图片

Linux进程基础_第7张图片

为什么第二段代码有的时候是“运行状态R”,有的时候“休眠状态S呢”?因为CPU实在是太快了,第二段代码一直在访问非CPU资源的时候,而I/O又太慢了,就处于经常处于睡眠状态。+说明这个进程属于“前台进程”,前台进程一旦启动,执行命令就没有任何效果了,而且可以被[ctrl + z]。如果希望自己的进程可以在后台运行,那么可以使用&符号运行程序,这个时候还会回显一个PID

Linux进程基础_第8张图片

这个时候我们可以看到这个进程已经少了+标志了。而要想杀死这个进程有很多方法,这里我们依旧使用kill命令的-9信号来杀死这个进程(-9信号的权限很高,几乎所有进程都要响应),需要注意的是:无法使用[ctrl + c]杀死这个进程。

补充:在Linux一旦启动前台进程,bash就没有办法再接受您的指令了。而启动后台进程的状态下,bash依旧可以执行您的指令。这在有的时候下载某些资源的时候非常有用,不至于让我们原地干等着。

Linux进程基础_第9张图片

“休眠状态S”(这里的睡眠有的时候也可以叫做可“中断睡眠”)实际上就是在等待某种资源或者事件完成,由于我们没有学过事件没有概念,可以暂时理解成阻塞状态。

可中断睡眠的意思就是:如果代码假设内有sleep(100),进程运行中处于“休眠状态S”状态,并且可以使用-19可以停止进程,使进程变成“暂停状态T”,也就是说这个进程在睡眠阶段被中断了。不仅可以使用kill -9 也可以使用[ctrl + c]杀死。

这种休眠状态也被叫“浅度休眠状态”。

Linux进程基础_第10张图片
Linux进程基础_第11张图片

而“磁盘休眠状态D”,也是一种睡眠状态,又可叫“深度休眠状态”,而且在目前的机器状态下很难模拟出来,和S状态的区别就是:不可中断睡眠状态,不可被被动唤醒。

由于当计算机压力过大,操作系统会通过一定手段杀掉一些睡眠的进程来起到节省空间的作用。而之所以设置这个状态是因为操作系统在迫不得已的情况下会kill一些可中断睡眠的进程,为了避免某些重要的进程数据丢失,就可以设置深度睡眠,禁止被CPU杀掉,也就变得不可中断,保护了数据安全,只能等进程自动醒来。

深度睡眠是专门用来让进程访问磁盘设备时,防止进程被操作系统在极端情况被误杀的一种保护状态,只有在进程读取完磁盘数据的时候才能自动醒来,甚至使用-9kill信号都无法杀掉处于“磁盘休眠状态D”的进程。

那么我们真的没有其他办法杀掉这个处于“磁盘休眠状态D”的进程么?还是有的,软件不行,硬件来凑,关机大法好!甚至很可能出现:只能使用拔除电源的硬关机方式杀死,因为使用内置的关机命令,有可能因为此时的磁盘还正在写入,导致软关机的方式关不了。

补充:不过倒是可以使用dd状态来模拟演示“磁盘休眠状态D”,这点可以当拓展来看即可,有时间再来研究。

Linux中,dd命令被广泛用于数据的复制和转换操作。尽管dd命令本身并不会直接演示D状态,但它可能会导致进程进入D状态的情况。

当使用dd命令进行磁盘复制或读写操作时,它会与磁盘进行大量的I/O交互。如果所涉及的数据量较大或I/O速度较慢,就可能导致进程在等待I/O完成时进入D状态。

  1. 例如:当使用dd命令从一个设备(如硬盘)读取数据时,如果目标设备上的数据尚未准备好或读取速度较慢,dd命令所在的进程将会被阻塞,进入D状态,直到读取操作完成。

  2. 例如:类似地,当使用dd命令向设备写入数据时,如果目标设备无法及时处理写入请求或写入速度较慢,进程也会进入D状态,等待写入操作完成。

在使用dd命令时,如果遇到进程长时间停留在D状态的情况,可能是由于磁盘操作的特性或环境造成的,可以适当调整命令参数或优化I/O性能来提高执行效率。

另外有的时候如果磁盘的转速太低,而需要磁盘资源的进程有太多,也有可能导致出现大量的D状态进程…如果这样的进程太多了,操作系统有可能会被挂掉,此时操作系统处于“宕机”或者“半昏迷”的状态,这个时候只能选择断电。

为什么磁盘的转速会降低呢?有两个原因:

  1. 磁盘“挂”掉了,已经不能被正常运行了
  2. 有时为了减少功率损耗,会降低磁盘的转速,甚至有的磁盘不工作的时候就直接不转动了

注意:有关磁盘的知识之后还会再详细提及。

4.2.2.暂停状态和停止并跟踪状态

那么“暂停状态T”和“停止并跟踪状态t”有什么区别么?

首先我们来模拟一下T或者t状态:-19号信号就可以做到终止进程的目的,而-18号信号就可以使得进程继续运行。使用-19信号就可以处于“暂停状态T”。

使用gdb调试某个代码并且打入断点,r操作后调试停在断点出,在另外一个控制台就可以查看出这个进程正处于t状态,也就是“停止并跟踪状态t”。也就是说:这个状态更多用在调试代码打断点上。

处于这两个状态时,进程暂时不会访问任何资源,处于一种“停滞”状态,这实际上也是一种“阻塞状态”,只不过是等待用户的指令罢了。。

Linux进程基础_第12张图片

Linux进程基础_第13张图片

Linux进程基础_第14张图片

那么T状态的应用场景在哪里?典型的地方就在于调试,实际上在编写代码时所使用的断点调用就是利用的T状态来实现的。Tt的当一个进程被调试器(例如:gdb调试器)所追踪时,其状态通常会显示为t。这意味着该进程当前处于被调试状态,而我们手动使用kill停止的显示T状态。

4.2.3.终止状态和僵尸状态

而“终止状态X”就是:如果需要销毁的进程实在太多,不可能一个进程被终止了就立刻被操作系统销毁了,因此这种状态是为操作系统做标记,好在操作系统处理好其他事情后根据X标记来销毁这些已经结束的进程(已经做好被操作系统回收的准备了)。因此这个X状态也很难看到和捕捉,瞬时性非常强。

补充:操作系统回收进程的核心工作实际上就是将占据空间的PCB对象、代码、数据全部释放掉。

而剩下的一个状态就是“僵尸状态Z”,“僵尸状态Z”是指:一个进程已经退出,但是还不允许被操作系统回收(最多回收数据和代码),PCB对象处于一个被检测返回结果的持续状态(需要检测退出的原因等,是任务成功了?还是任务失败了?并且该返回结果是通过return或者exit()写入到PCB对象里),只有检测完了才可以标记为“终止状态X”,等待操作系统回收。

那么是谁在进行检测呢?一般是“父进程”或者“操作系统”来进行检测读取(这个读取后续也会详细提及)子进程剩下的PCB对象,只有等到检测完毕,子进程才可以被操作系统回收(也就是改成“终止状态X”)。在此之前,子进程的PCB对象内的数据必须被OS维护(并且设置了“僵尸状态Z”)。

正常来说是不会出现僵尸进程的,但如果父进程一直在执行某项任务,没来得及检查子进程的PCB对象内写入的数据,就有可能出现僵尸进程。下面演示“僵尸状态”:

Linux进程基础_第15张图片

Linux进程基础_第16张图片

并且左侧有回显“”,由于进程处于“僵尸状态z”,创建出来的PCB并没有被释放掉,会被OS一直维护,占用资源,因此这也是一种内存泄漏。

僵死进程会以终止状态保持在进程表中,并且会一直在等待父进程或者操作系统读取退出状态代码。

吐槽:和僵尸电影里的僵尸不是很类似么,明明死去却依旧以“半死不活的状态(进程结束)”留停在“人间(计算机内)”,就需要“道士(父进程或者操作系统回收)”进行回收。

那么是否可以创建一个恶意程序,让父进程不断创建出僵尸状态的进程来占取大量内存来“卡死”计算机呢?这种事情是有可能的,会发生严重的内存泄露!因此,我们在后续编写代码中必须要想办法回收僵尸进程。而关于僵尸进程的解决办法,我后续再提及。

注意:僵尸进程已经处于进程退出的状态了,是无法使用-9信号杀死的!

下面总结一下状态之间的动态变化:

Linux进程基础_第17张图片

5.进程类别

进程也有类别,其中孤儿进程就是一种进程类别(注意不是进程状态,要和上面的进程状态概念做区分)。

父进程如果提前退出,那么子进程就会被称为“孤儿进程”,注意和“僵尸状态”做概念上的区分。

区分:孤儿进程和僵尸状态

  1. 如果子进程退出了,而父进程没有退出并且也不理会这个子进程(回收),那么此时的子进程就处于“僵尸状态”。如果理会了子进程,就是子进程被成功回收。
  2. 如果父进程先挂掉了,无论子进程是否结束,都可以叫此时的子进程为“孤儿进程”,若是子进程结束,则子进程又陷入了“僵尸状态”。

在代码编写逻辑错误的时候,如果出现了孤儿进程就会被“1init进程”领养,并且成为后台进程,下面我们来写一段代码来感受一下:

#include  
#include  
int main() 
{ 
    pid_t id = fork();
    if(id == 0)
    {
        //child
        while(1)
        {
            printf("hello i am child\n");
            sleep(1);
        }
    } 
    else 
    {
        //fatcher
        int n = 6;
        while(n >= 0)
        {
            printf("hello i am fatcher\n %d", n);
            sleep(1);
            n--;
        }
    }
    return 0; 
}

Linux进程基础_第18张图片

可以看到父进程一结束,子进程的PPID瞬间切换为1,也就是被1号进程init/systemd所“领养”,这个进程可以简单理解为“系统本身”。

但是为什么父进程退出后,子进程要被“领养”呢?因为回收子进程的代码一般处于父进程中,如果子进程变成孤儿进程则没有人来回收该进程,那么就需要被其他进程领养进行回收。

而且从上面的结果图我们可以看到,如果子进程变成了孤儿进程,那就会变成后台运行的进程,这就意味着,我们无法直接使用[ctrl+c]快捷键方式终止这个进程(命令行显示该快捷键为^C),必须使用-9信号杀死。

吐槽:不过比较好玩的是,基本是在左侧不断输出后台进程的bash界面中依旧是可以正常输入命令的,只不过输入命令显得的有点乱……

补充:守护进程/精灵进程实际上就是孤儿进程,他们的父进程是1init进程,退出后不会变成僵尸进程,一般孤儿进程的出现都是刻意为之,脱离了终端和登录会话的所有联系,可以用来独立执行一些周期性任务,因此这样的进程不算是内存泄露。

6.进程优先

6.1.优先级概念

  1. CPU资源分配的先后顺序就是指进程的优先权,之所以设计优先级是因为:CPU资源是有限的、稀缺的,但是进程太多。

  2. 优先权高的进程有优先执行权利,配置进程优先级别对多环境的Linux很有用,可以改善系统性能。

  3. 优先级在具体实现为PCB结构体内部的某个整数数据,交给调度器评判优先级来对进程队列进行“调度”。

区分:优先级和权限

优先级是“已经保证能够得到申请的某种资源,就是要等候(已经有权限了,不然连等待都不行)”,而权限是“能否得到某种资源”。

6.2.优先级范围

一般是60~99,默认进程的优先级是80.

6.3.优先级查看

Linux优先级=老的优先级+nice值nice值是什么呢?

下面我们来编写一个代码,并且使用命令ps -la的形式输出详细的进程列表,或者使用ps -al | head -1 && ps -la | grep a.out输出。

Linux进程基础_第19张图片

Linux进程基础_第20张图片

我们梳理一下这里出现的几个重要的进程信息:

  1. UID:代表执行者的身份

  2. PID:代表该进程的代号

  3. PPID:代表该进程的父进程代号

  4. PRI:代表这个进程可被执行的优先级,其值越小越早被执行

  5. NI:代表这个进程的nice值,表示进程可以被执行的优先级的修正数值,也就是说Linux中的进程优先级是可以被调整的,调整进程的nice值就是调整进程的优先级。如果nice值为负数,那么该程序的优先级会变小,反之变高,PRI(new)=PRI(old)+nice

6.4.优先级修改

另外还可以使用top工具来查看进程的优先级,进入top后输入r然后再输入某进程的PID,接着输入想要的nice值即可修改进程的优先级。

Linux进程基础_第21张图片

为什么PRI只加了19呢?因为我们规定了nice的取值范围是[-20,19],一共有40个级别。

Linux进程基础_第22张图片

如果需要高优先级,那么就必须使用管理员权限来调整nice值,否则大概率只能调低优先级,调高就会出现上面的错误提示,下面我们使用sudo top来修改优先级。

Linux进程基础_第23张图片

需要注意的是每次修改优先级是根据默认PRI值,即:80来结合nice值的,也就是说:每次设置nice值的时候,公式PRI(new)=PRI(old)+nice中的PRI(old)默认值为80

Linux进程基础_第24张图片

一款优秀的操作系统在能提供优先级的同时还可以在调整优先级的时候尽量不打破调度平衡,因此nice值本身也不会特别大。

另外,类似的指令还有nicerenice指令、setpriority()等也可以做到上述的优先级调整。

补充:下面再普及一些有关进程调度的相关术语。

  1. 竞争性:系统进程数量众多,而CPU资源只有少量,甚至只有一个,所以进程之间是有竞争关系的,为了高效完成任务,更加合理竞争资源,也就有了优先级

  2. 独立性:多进程运行,需要独享各种资源,多进程运行期间互不干扰,而父子进程之间也是具有独立性的。只有一个CPU的情况下理应只有一个进程在运行,但是一个时间端内不一定。

  3. 并发:多个进程在一个CPU下采用进程切换的方式,在一端时间之内,让多个进程都得以推进,称之为“并发”(现在的个人电脑大多是单核的)。

  4. 并行:多个进程在多个CPU先分别同时运行,这称之为“并行”。通常并行的多个CPU内部也是采用并发的设计。

  5. 时间片:一个进程不可能一直占用CPU,要不然其他进程都会表现出卡死的状态,因此我们给一个进程设置了时间片,让该进程只能运行一个时间片段的时间,能运行多少看进程数据和CPU的计数,过了这段时间后CPU切换另外一个进程进行处理,也是按照一定时间段来运行这个进程,这样不断切换切换,达到“雨露均沾”的效果。因此哪怕是写出一个关于死循环的进程,也不会导致其他进程“卡死”的状态(但是实际上在Linux内)。

  6. 抢占与出让:如果操作系统发现有优先级更高的进程,哪怕当前CPU处理的程序没有过完一个时间片,也会“出让”给优先级更高的进程来“抢占”,Linux就支持这种操作,也就是说Linux是基于时间片的轮转式抢占式内核。

  7. 切换与上下文:CPU内部存在大量的寄存器,进程加载到CPU实际上是将数据加载到寄存器。如果进程A正在被运行,那么CPU内部的寄存器里面此时是进程A的临时数据,也叫做A的“上下文”,“上下文”数据在短期内不可以被丢弃,否则这个正在运行的A进程就废掉了。可是这样就有问题了,因为进程A是不可能一直把上下文存放在CPU内部的寄存器的,因此经过一个时间片后后,进程A在被其他进程切换时必须保存好自己的上下文数据,等到下次进程A又被CPU执行的时候,CPU通过重新加载上下文才不会忘记这个进程的执行情况。有了上下文保存才能使进程有切换的可能。

    不过需要注意的是,现在的操作系统可能不会让PCB直接保存上下文数据(这样的PCB对象太大了),有可能会利用TSS任务状态段(struct tss_struct{/.../}),在PCB对象中包含一个struct tss_struct tss,来达到同样的目的(但是也是和PCB对象有关联的,实际情况可能会很复杂)。

7.进程调度

这里我们做一个知识拓展,主要是讲解Linux 2.6.32(32位平台)内核种的调度队列和调度原理(大O(1)调度)。

首先需要普及一个知识,一般操作系统可以按照调度的原理分为两种系统:

  1. 分时系统的普通优先级:以调度器来作为核心原则来平衡调度进程,即使有优先级,也有可能进行进程切换达到平衡
  2. 实时系统的实时优先级:也就是一次调度,无需频繁切换,严格按照优先级来进行调度,这适合在追求响应速度的场景

而我们使用的Linux这两种模式都支持,其中交给用户使用的[60,99]号的优先级也就是普通优先级。图中的的queue[140][100, 139]中的40个元素映射成[60,99]号优先级,供用户自由调整进程优先级。

Linux进程基础_第25张图片

而为什么维护两份进程队列呢?这是因为有一些特殊情况:假设在99优先级进程前插入了许多80优先级的进程,那么对于99优先级的进程来说就完全没有被调度到的机会。

因此设置了两个指针activeexpired,分别维护两份运行队列。对于新进来的进程,会在过期运行队列里进行插入。因此这样活跃运行队列的进程会越来越少,而过期运行队列的进程会越来越多,此时达到一定程度后,交换activeexpired内的内容,这样就可以达到快速拷贝,并且平衡进程的效果。

而所谓的进程抢占,其本质就是:“不把进程插入到过期运行队列,而是直接插入到活跃运行队列”。

而每次都要遍历queue数组的开销会比较大,因此就可以使用位图int bitmap[5]来加快扫描遍历,5*8=40个比特位,位为1则代表有进程需要被调度,0则没有。因此,只需要做简单的位操作就可以直接生成下标调度对应优先级的进程。

而另外一个nr_active则代表整个调度队列里一共有多少个进程正在被调度,通常发现nr_active == 0时,就会交换activeexpired指针。

进程阻塞的时候,先让进程去获取非CPU资源,然后放入过期运行队列里等待下次指针交换被唤醒即可。

而整个查找过程很接近O(1),也就是内核的大O(1)调度算法。这种调度策略既保证了优先级的意义,又保证了平衡性。

8.进程空间

8.1.进程地址

8.1.1.进程地址的打印

我们以Linux 2.6.32(32位平台)为研究背景,探究一下对一个进程来说,自己可用的内存及其分布:

Linux进程基础_第26张图片

关于这个地址空间分布我们可以通过C代码来证明:

#include 
#include 
int g_value_2;
int g_value_1 = 10;
int main(int argc, char* argv[], char* env[])
{
    printf("code addr<代码区/正文>: %p\n\n", main);
    const char* str = "hello word!";
    /*
        注意“hello word!”是存储在正文代码区域(说),实际上所有的字面常量都是硬编码进代码的
        而代码是只读的,不可以被修改的        
        而str变量的空间开辟在栈上,
        但是str这个指针变量保存的是处于静态数据区内的“hello word!”里'h'的地址,
        故打印str就是打印静态数据区的地址
    */

    printf("read only addr<静态区>: %p\n\n", str);

    printf("init g_value_1 global addr<已初始化全局变量区>: %p\n\n", &g_value_1);//static变量也会放在这里,您可以自己试一下在这里加上一个static变量(这也就是为什么static变量只会初始化一次的原因)

    printf("uninit g_value_2 global addr<未初始化全局变量区>: %p\n\n", &g_value_2);

    int* p1 = (int*)malloc(sizeof(int) * 10);
    int* p2 = (int*)malloc(sizeof(int) * 10);
    printf("heap addr<堆区>: %p\n", p1);
    printf("heap addr<堆区>: %p\n\n", p2);

    printf("stack addr<栈区>: %p\n", &str);
    printf("stack addr<栈区>: %p\n", &p1);
    printf("stack addr<栈区>: %p\n\n", &p2);

    for (int i = 0; i < argc; i++)
    {
        printf("command line paramete<命令行参数>r:argv[%d] = %p\n", i, argv[i]);
    }
    printf("\n");
    for (int j = 0; env[j]; j++)
    {
        printf("command line parameter<环境变量>:env[%d] = %p\n", j, env[j]);
    }

    free(p1);
    free(p2);
    return 0;
}
$ ./a.out a ab abc abcd abcde #后面是随意输入的参数
code addr<代码区/正文>: 0x40060d

read only addr<静态区>: 0x400882

init g_value_1 global addr<已初始化全局变量区>: 0x60104c

uninit g_value_2 global addr<未初始化全局变量区>: 0x601054

heap addr<堆区>: 0x1974010
heap addr<堆区>: 0x1974040

stack addr<栈区>: 0x7ffddd6b80d0
stack addr<栈区>: 0x7ffddd6b80c8
stack addr<栈区>: 0x7ffddd6b80c0

command line paramete<命令行参数>r:argv[0] = 0x7ffddd6ba346
command line paramete<命令行参数>r:argv[1] = 0x7ffddd6ba34e
command line paramete<命令行参数>r:argv[2] = 0x7ffddd6ba350
command line paramete<命令行参数>r:argv[3] = 0x7ffddd6ba353
command line paramete<命令行参数>r:argv[4] = 0x7ffddd6ba357
command line paramete<命令行参数>r:argv[5] = 0x7ffddd6ba35c

command line parameter<环境变量>:env[0] = 0x7ffddd6ba362
command line parameter<环境变量>:env[1] = 0x7ffddd6ba378
command line parameter<环境变量>:env[2] = 0x7ffddd6ba38c
command line parameter<环境变量>:env[3] = 0x7ffddd6ba3a3
command line parameter<环境变量>:env[4] = 0x7ffddd6ba3b7
command line parameter<环境变量>:env[5] = 0x7ffddd6ba3c7
command line parameter<环境变量>:env[6] = 0x7ffddd6ba3d5
command line parameter<环境变量>:env[7] = 0x7ffddd6ba3f6
command line parameter<环境变量>:env[8] = 0x7ffddd6ba412
command line parameter<环境变量>:env[9] = 0x7ffddd6ba41b
command line parameter<环境变量>:env[10] = 0x7ffddd6baad3
command line parameter<环境变量>:env[11] = 0x7ffddd6bab7a
command line parameter<环境变量>:env[12] = 0x7ffddd6bac29
command line parameter<环境变量>:env[13] = 0x7ffddd6bac42
command line parameter<环境变量>:env[14] = 0x7ffddd6bac68
command line parameter<环境变量>:env[15] = 0x7ffddd6bac78
command line parameter<环境变量>:env[16] = 0x7ffddd6bac97
command line parameter<环境变量>:env[17] = 0x7ffddd6baca6
command line parameter<环境变量>:env[18] = 0x7ffddd6bacae
command line parameter<环境变量>:env[19] = 0x7ffddd6bad30
command line parameter<环境变量>:env[20] = 0x7ffddd6bad3c
command line parameter<环境变量>:env[21] = 0x7ffddd6bad7c
command line parameter<环境变量>:env[22] = 0x7ffddd6badaa
command line parameter<环境变量>:env[23] = 0x7ffddd6bae02
command line parameter<环境变量>:env[24] = 0x7ffddd6bae25
command line parameter<环境变量>:env[25] = 0x7ffddd6bae8a
command line parameter<环境变量>:env[26] = 0x7ffddd6baeb3
command line parameter<环境变量>:env[27] = 0x7ffddd6baf16
command line parameter<环境变量>:env[28] = 0x7ffddd6baf87
command line parameter<环境变量>:env[29] = 0x7ffddd6bafa6
command line parameter<环境变量>:env[30] = 0x7ffddd6bafbc
command line parameter<环境变量>:env[31] = 0x7ffddd6bafd0
command line parameter<环境变量>:env[32] = 0x7ffddd6bafda

这里,打印出来的地址大小越来越大,也就是从低地址到高地址。您也可以通过一些文本指令倒过来打印。

$ ./a.out a ab abc abcd abcde | tac
command line parameter<环境变量>:env[32] = 0x7ffe2d93bfda
command line parameter<环境变量>:env[31] = 0x7ffe2d93bfd0
command line parameter<环境变量>:env[30] = 0x7ffe2d93bfbc
command line parameter<环境变量>:env[29] = 0x7ffe2d93bfa6
command line parameter<环境变量>:env[28] = 0x7ffe2d93bf87
command line parameter<环境变量>:env[27] = 0x7ffe2d93bf16
command line parameter<环境变量>:env[26] = 0x7ffe2d93beb3
command line parameter<环境变量>:env[25] = 0x7ffe2d93be8a
command line parameter<环境变量>:env[24] = 0x7ffe2d93be25
command line parameter<环境变量>:env[23] = 0x7ffe2d93be02
command line parameter<环境变量>:env[22] = 0x7ffe2d93bdaa
command line parameter<环境变量>:env[21] = 0x7ffe2d93bd7c
command line parameter<环境变量>:env[20] = 0x7ffe2d93bd3c
command line parameter<环境变量>:env[19] = 0x7ffe2d93bd30
command line parameter<环境变量>:env[18] = 0x7ffe2d93bcae
command line parameter<环境变量>:env[17] = 0x7ffe2d93bca6
command line parameter<环境变量>:env[16] = 0x7ffe2d93bc97
command line parameter<环境变量>:env[15] = 0x7ffe2d93bc78
command line parameter<环境变量>:env[14] = 0x7ffe2d93bc68
command line parameter<环境变量>:env[13] = 0x7ffe2d93bc42
command line parameter<环境变量>:env[12] = 0x7ffe2d93bc29
command line parameter<环境变量>:env[11] = 0x7ffe2d93bb7a
command line parameter<环境变量>:env[10] = 0x7ffe2d93bad3
command line parameter<环境变量>:env[9] = 0x7ffe2d93b41b
command line parameter<环境变量>:env[8] = 0x7ffe2d93b412
command line parameter<环境变量>:env[7] = 0x7ffe2d93b3f6
command line parameter<环境变量>:env[6] = 0x7ffe2d93b3d5
command line parameter<环境变量>:env[5] = 0x7ffe2d93b3c7
command line parameter<环境变量>:env[4] = 0x7ffe2d93b3b7
command line parameter<环境变量>:env[3] = 0x7ffe2d93b3a3
command line parameter<环境变量>:env[2] = 0x7ffe2d93b38c
command line parameter<环境变量>:env[1] = 0x7ffe2d93b378
command line parameter<环境变量>:env[0] = 0x7ffe2d93b362

command line paramete<命令行参数>r:argv[5] = 0x7ffe2d93b35c
command line paramete<命令行参数>r:argv[4] = 0x7ffe2d93b357
command line paramete<命令行参数>r:argv[3] = 0x7ffe2d93b353
command line paramete<命令行参数>r:argv[2] = 0x7ffe2d93b350
command line paramete<命令行参数>r:argv[1] = 0x7ffe2d93b34e
command line paramete<命令行参数>r:argv[0] = 0x7ffe2d93b346

stack addr<栈区>: 0x7ffe2d939be0
stack addr<栈区>: 0x7ffe2d939be8
stack addr<栈区>: 0x7ffe2d939bf0

heap addr<堆区>: 0x19a9040
heap addr<堆区>: 0x19a9010

uninit g_value_2 global addr<未初始化全局变量区>: 0x601054

init g_value_1 global addr<已初始化全局变量区>: 0x60104c

read only addr<静态区>: 0x400882

code addr<代码区/正文>: 0x40060d

通过上述代码的地址变化,我们可以验证进程地址空间是真实存在的。

注释1:未初始化数据全称为“未初始化全局数据区”、已初始化数据全称为“已初始化全局数据区”

注释2:栈空间的整体空间开辟使用规律向下增长,堆空间的整体空间开辟使用规律向上增长(但是在使用空间都是向上使用的,利用类型这种“偏移量”的方式使用)。不过这些现象仅限于Linux中,Windows操作系统为了系统安全考虑,在这方面改动比较多,这也就是为什么我限定了32Linux背景的缘故。

注释3:栈区和堆区中间有巨大的“镂空”,这里的共享区等我们以后讲到共享内存的时候会详细学习。

注释4:同时根据多个栈地址和多个堆地址,我们可以发现栈和堆相向而生。

注释5:上述代码的地址都是程序运行后才打印出来的,也就是进程自己打印出来的。

32位下一个进程的地址空间的取值范围是0x0000 0000 ~ 0xFFFF FFFF。其中[0, 3GB]为用户空间,[3GB, 4GB]为内核空间。往后我们理解地址空间,一定要想到这4GB的空间,而不仅仅是那3GB的空间。

8.1.2.进程地址的划分

内核中的“进程地址空间”的本质是一种“数据结构”的描述,本质也就是一种“数据结构”的定义,依靠这个数据结构来划分地址范围(在32位下,全部地址从0x 0000 0000开始编址到0x FFFF FFFF为止,进程地址空间细化了这块范围)。

//进程地址空间地址划分
struct mm_struct//这个结构体就是用来划分地址范围的,这里只是写了一个大概伪代码,其成员不是真的是下面这些,但是在实际实现中类似
{
    //代码区
    int code_start;
    int code_end;

    //
    int init_start;
    int init_end;

    int uninit_start;
    int uninit_end;

    int heep_start;
    int heep_end;  

    int stack_start;
    int stack_end;

    //...
};

但是,很多初学者会误认为进程地址空间分布是内存地址空间分布,但是实际上进程地址空间是一个抽象的概念,并不是内存的布局!

就连以前我们在C语言内打印的指针地址也不是真正的内存地址(是一个虚拟内存地址)。以前学习C语言的时候只是为了方便说明,因此没有在地址这里深入探究。

8.1.3.进程地址的现象

为什么说我们以前在C语言提到的内存地址不是真实内存地址而是虚拟内存地址呢?换句话来说,以前我们在语言层面说的内存难道不是真实的内存吗?我们首先可以通过一个C程序观察一下现象:

#include 
#include 
int g_val = 100;
int main()
{
    pid_t id = fork();//创建子进程

    if(id == 0)//子进程代码
    {
        int cnt = 0;
        while(1)
        {
            printf("I am a child. pid = %d, ppid = %d, g_val = %d, &g_val = %p.\n", \
            getpid(), getppid(), g_val, &g_val);
            sleep(1);
            cnt++;
            if(cnt == 3)
            {
                g_val = 200;
                printf("child chage g_val = 100 --> g_val = 200\n");
            }
        }
    }
    else//父进程代码
    {
        while(1)
        {
            printf("I am a father. pid = %d, ppid = %d, g_val = %d, &g_val = %p.\n", \
            getpid(), getppid(), g_val, &g_val);
            sleep(1);
        }
    }
    return 0;
}
$ ./a.out
I am a father. pid = 6946, ppid = 24702, g_val = 100, &g_val = 0x60105c.
I am a child. pid = 6947, ppid = 6946, g_val = 100, &g_val = 0x60105c.
I am a father. pid = 6946, ppid = 24702, g_val = 100, &g_val = 0x60105c.
I am a child. pid = 6947, ppid = 6946, g_val = 100, &g_val = 0x60105c.
I am a father. pid = 6946, ppid = 24702, g_val = 100, &g_val = 0x60105c.
I am a child. pid = 6947, ppid = 6946, g_val = 100, &g_val = 0x60105c.
I am a father. pid = 6946, ppid = 24702, g_val = 100, &g_val = 0x60105c.
child chage g_val = 100 --> g_val = 200
I am a child. pid = 6947, ppid = 6946, g_val = 200, &g_val = 0x60105c.
I am a father. pid = 6946, ppid = 24702, g_val = 100, &g_val = 0x60105c.
I am a child. pid = 6947, ppid = 6946, g_val = 200, &g_val = 0x60105c.
I am a father. pid = 6946, ppid = 24702, g_val = 100, &g_val = 0x60105c.
I am a child. pid = 6947, ppid = 6946, g_val = 200, &g_val = 0x60105c.
I am a child. pid = 6947, ppid = 6946, g_val = 200, &g_val = 0x60105c.
I am a father. pid = 6946, ppid = 24702, g_val = 100, &g_val = 0x60105c.
I am a father. pid = 6946, ppid = 24702, g_val = 100, &g_val = 0x60105c.
I am a child. pid = 6947, ppid = 6946, g_val = 200, &g_val = 0x60105c.
I am a father. pid = 6946, ppid = 24702, g_val = 100, &g_val = 0x60105c.
I am a child. pid = 6947, ppid = 6946, g_val = 200, &g_val = 0x60105c.

欸!我们发现了一个离谱的现象:子进程修改的全局变量不会影响父进程的输出该全局变量的值,也就是说”在同一个地址不同的两次访问,出现了不同值“。

那么这就让我们怀疑一个事实,全局变量的分别打印出来的两个相同数值地址,真的是“相同”的么?

也就是说,我们通过printf()&打印出来的地址绝对不是物理意义(或者叫”实际意义“)上的内存地址,因为如果是真实的地址,是不可能同时存储两个值的。

实际在C语言出来的地址是虚拟地址(在Linux里也叫”线性地址“)。每个进程都认为自己用的地址是真实地址(认为自己独享整个内存空间,拥有一个进程地址空间),实际上它被操作系统”欺骗“了,它使用的是虚拟地址,这些虚拟地址整体构成虚拟空间/进程地址空间。

补充1:实际上几乎所有带有”地址“概念的语言使用的地址都是虚拟地址。

补充2:不止是CPU有寄存器,其他外设或者显卡也有寄存器,这些地址也应该被利用,所以我们给计算机一个页表,使得虚拟地址可以通过页表来一一映射到内存地址、显卡寄存器地址等等真实地址,而我们的程序在调用的时候也会误认为自己用的是内存地址,从而达到统一对待真实地址的目的。

8.2.映射地址

在很久以前,多个进程的确是直接写入物理内存,也就是直接使用物理地址的。但是,一旦在运行某个进程的过程有可能出现一些危险的情况:

  1. 野指针问题:对野指针的访问有可能出现篡改其他进程数据的情况,这是极其危险的。而且对于黑客来说,如果某个进程是需要密钥等方式才可以进入,那么就会出现某些黑客软件在运行过程中通过野指针窃取该进程数据的可能,导致数据不安全。

  2. 内存碎片问题:如果直接加载进内物理存,就极有可能出现内存碎片问题,导致内存空间分配不合理,空间效率底下。

因此直接写进物理空间的方式极其不安全、不合理。于是就出现了虚拟地址空间,每个进程通过虚拟地址空间,都认为自己占用了整个进程地址空间,实际上这是操作系统的一种“骗术”,操作系统在管理每一个进程的虚拟地址空间,再一一映射到物理内存,这样子就可以解决上面的两个问题。

页表1<虚拟地址,物理地址>
页表2<虚拟地址,物理地址>
页表3<虚拟地址,物理地址>
页表4<虚拟地址,物理地址>
页表n<虚拟地址,物理地址>
映射
操作系统(处理虚拟地址)
进程1
进程地址空间1(虚拟空间1)
进程2
进程地址空间2(虚拟空间2)
进程3
进程地址空间3(虚拟空间3)
进程4
进程地址空间4(虚拟空间4)
进程n
进程地址空间n(虚拟空间n)
物理内存

task_struct结构体中,有一个成员mm_struct* mm,指向该一个进程所拥有的虚拟地址空间(也就是之前讲到的类似struct mm_struct{/*...*/};的东西),而操作系统通过某种映射关系(或者叫“页表”)来把虚拟地址映射到物理内存中。地址空间对象和页表(用户级)是每一个进程都私有一份的。

只要保证每一个进程的页表映射的是不同区域,就能做到进程之间相互独立、安全。

补充1:有了操作系统映射,我们不仅仅可以将虚拟地址映射到内存,甚至可以映射到其他硬件内部的类似寄存器的存储物件,让数据直接写入到硬件里。

补充232位操作系统的内存寻址能力就是4G,即使安装了16G内存条,也只能识别和使用其中的4G。这是由于32位系统的地址空间最大只有4G。然而,实际上,32位系统一般只能识别到3.25G的内存。因此,如果您的电脑安装了32位操作系统,且拥有超过4G的内存,会有至少12G的内存是永远用不到的,这无疑是一种浪费。

对于拥有4G4G以上内存的设备,推荐使用64位操作系统。64位系统目前最高可以识别192G左右的内存。此外,PAE(物理地址扩展)允许32位操作系统在特定情况下使用大于4G的物理内存。Linux在开启PAE的模式下能支持在32位系统中使用超过4G的内存。Windows XP系列虽然支持PAE,但实际在使用中最大内存限制在了4G

补充3CPU在执行进程代码时,进程将虚拟地址给CPU,而CPU内部的CR3寄存器存储当前进程页表的地址(页表对象也是数据,肯定在物理地址上存储,这里不能存放页表的虚拟地址,会出现因果困境问题),辅助CPU通过”进程传递过来的虚拟地址“和”CR3指向的页表“进行访址的操作,而切换进程的时候就会更换该寄存器的内容。

补充4:有关于“页表”,实际上不仅仅会存储虚拟地址和物理地址,还会存储一个权限字段,代表指向的物理地址可读还是可写,可以对访存进行安全检查。

补充5:页表内还有一个标志字段,用来表明虚拟地址对应的物理地址是否分配了以及是否有内容,这样就可以让一些进程在阻塞的时候,判断是单纯的阻塞(阻塞就设置没有分配,但是有内容)还是阻塞挂起(阻塞挂起就设置分配没有了,内容也没有了)。而进程如果被挂起,该进程的虚拟地址对应的物理地址就可以让给别的进程使用,达到效率优化,避免过大的内存被一个进程全部占用。

而如果进程在使用虚拟内存访问物理内存的时候,标志字段还没有设置好(没有分配,并且也没有内容),这个时候操作系统就会暂时停止进程的访址需求,去给进程在物理内存申请物理地址,填充好对应的内容,并且给给进程的页表建立虚拟地址和物理地址的联系,再让进程继续访址。而这个过程,就叫”缺页中断“(并且对于进程来说这一切是看不见的)。

补充6:“进程地址空间”、“线性地址空间”、“虚拟地址空间”是同一个概念。

8.3.地址现象

经过前面的铺垫,我们现在终于可以解释前面父子进程代码的问题所在了。父子进程使用的同名的全局变量,在写入时发生了临时拷贝,虚拟地址一样,但是从物理地址上看根本就是两个变量!

子进程会继承很多父进程的数据,但是也不是全部照搬复制,也是有所修改的,其中就包括地址空间。可以看到虚拟内存都是一样的,一开始还没有修改的时候,由于分页一样,所以物理内存是一样的。

但是如果子进程修改了g_val,操作系统会重新开辟一块物理内存,并且修改分页映射中的物理地址,但是虚拟地址没有被改变,因此此时父子进程能在同一个虚拟地址访问不同的两个物理内存的数据(这种策略也叫“写时拷贝”,后面还会继续详谈)。

补充:此时我们还可以开始回答之前遗留的问题。

fork()为什么会有两个返回值?这是因为在代码pid_t id = fork();中,fork()返回的值实际上是给id变量做一种写入,就会发生写时拷贝,导致id有在两个物理内存中存储,但是在父子进程各自页表中的虚拟地址是一样的。

而由于父子进程的代码时一样的,都会执行if-else的判断。

父子进程通过各自的页表从内存中获取id,而父子进程在物理内存中id的地址是不同的,因此会有两个返回值。而在父子进程各自的虚拟空间中,id都是一样的地址值。

这样,从代码表面上来看,if-else的两个部分都会被执行。

8.4.地址生成

当我们的程序在编译的时候,在生成可执行程序且还没有加载到内存中的时候存在地址么?答案是:可执行程序在编译的时候,内部实际上早就有地址了!

补充:因此,我们之前讲过mm_struct{/*...*/};对虚拟地址做了划分,但是实际的每一个虚拟地址从哪里来呢?答案是在可执行程序里本身就具有虚拟地址,需要交给操作系统自己去读取。

地址空间不要仅仅是0S内部要遵守的,其实编译器也要遵守,即:编详器编译代码的时候,就已经给我们形成了“各个区域”。并且采用和Linux内核中一样的编址方式,给每一个变量,每一行代码都进行了虚拟编址(对于磁盘中的可执行程序,除了存储代码本身,还存储了每一句和变量对应的地址。这些地址是虚拟地址,由编译器编址,方便编译做跳转)。

故程序在编译的时候,每一个字段早已经具有了一个虚拟地址。

而虚拟地址也是数据,因此代码被加载到内存中的时候,不仅仅是加载了代码,实际上虚拟地址也被加载进去了。

程序内部地址使用的是地址,依旧是编译器编好的地址,当程序加载到内存,每行代码、每个变量就被操作系统安排了对应的物理地址,并且制作了进程自己的映射页表。

并且CPU读取的是虚拟地址。根据程序的第一个虚拟地址,通过进程结构内的进程地址空间范围,再根据页表的映射关系,查找到物理内存内的代码和虚拟空间,又拿取到虚拟地址再循环上面的步骤进行处理。

8.5.细致划分

实际上,地址进程空间要比我们想象的还要复杂,不仅仅只是分为几个区域,还能再被划分。

而这个划分的依据就是vm_area_struct{/*...*/};,在内核中的具体实现如下:

struct vm_area_struct {
	/* The first cache line has the info for VMA tree walking. */

	union {
		struct {
			/* VMA covers [vm_start; vm_end) addresses within mm */
			unsigned long vm_start;//开始
			unsigned long vm_end;//结束
		};
#ifdef CONFIG_PER_VMA_LOCK
		struct rcu_head vm_rcu;	/* Used for deferred freeing. */
#endif
	};

	struct mm_struct *vm_mm;	/* The address space we belong to. */
	pgprot_t vm_page_prot;   
	/*...*/
}

Linux进程基础_第27张图片

实际上,在Linux中,mm_struct被称为“内存描述符”,vm_area_struct被称为“线性空间”,合起来才是地址空间,这里只是简单一提。

9.进程操作(二)

这里的进程操作相比[进程操作(一)](# 3.进程操作(一))要更加详细,偏重原理和底层,并且有一些补充。

9.1.进程创建

前面讲得fork()已经足够多了,但是这里再复习和补充以下。

9.1.1.进程创建原理

Linux中,fork()可以从已经存在的进程中创建一个新进程,新进程为子进程,原进程为父进程。

#include //需要包含的头文件
pid_t fork(void);
//返回值:
//1.子进程中返回0
//2.父进程返回子进程id,出错返回-1

具体的使用如下:

#include 
#include 
int main()
{
    printf("我是父进程\n");
    pid_t id = fork();
    if (id < 0)
    {
        printf("创建子进程失败 n");
        return 1;
    }
    else if (id == 0)
    {
        // 子进程
        while(1)
        {
            printf("我是子进程: pid: %d,ppid: %d\n", getpid(), getppid());
            sleep(1);
        }
    }
    else 
    {
        //父进程
        while(1)
        {
            printf("我是父进程: pid: %d,ppid: %d\n", getpid(), getppid());
            sleep(1);
        }
    }
    return 0;
}

fork()使得系统多了一个进程,父进程在调用fork()时,内核做了以下事情:

  1. 分配新的内存块和创建新的内核数据结构task_struct对象

  2. 以父进程为模板,将父进程大部分数据内容深拷贝到子进程的task_struct对象中(不能是浅拷贝,有的数据是每个进程独有的,比如:进程PID就会不一样)

  3. 添加子进程到进程列表中

  4. fork()返回,系统开始使用调度器调度

由于子进程没有自己的代码和数据,所以子进程只能共享/使用父进程的代码和数据。

Linux进程基础_第28张图片

  1. 而对于代码:都是不可写的,只可读,所以父子共享(共享所有的代码)没有问题(后面会有一种操作导致代码数据也会被写时拷贝)。

  2. 而对于数据:不能直接共享,有可能需要隔离开,避免互相影响(隔离是通过页表来实现的)。

    对“不会访问”或者“只做读取”的数据不需要拷贝多一份副本出来。

    对于有可能会做修改的数据,操作系统虽然需要拷贝出一份副本给子进程使用,但是操作系统没有立刻进行拷贝(因为有可能就算给了子进程副本,子进程也暂时用不到),而是使用了“写时拷贝”技术实现父子间数据分离。

    也就是说:只有写入修改的时候才进行拷贝副本,这样做可以提高效率。

补充1:写时拷贝不仅仅发生在子进程修改父进程数据的的情况,还发生在父进程和子进程共享父进程数据的时候,父进程自己也修改了自己数据的情况中!

补充2:写时拷贝的发生时机就是“缺页中断”的时候。

在使用fork()其中,会设置父子进程内部对应的物理地址都是只读的(就是设置页表内的权限字段,这是fork()的工作之一),只有当子进程需要对地址指向的内容进行修改时,会向操作系统发出类似“错误”的报告,操作系统检查后,认为这种“错误”不是真的错误,而是子进程需要新的空间进行写入。

此时操作系统就会通过这种“触发策略”来向内存申请空间,把父进程的内容拷贝到新空间内,再重新映射子进程的页表,指向这块新开辟的空间,并且将页表内的字段改为“可读写”。

但是为什么一定要拷贝父进程的东西呢?反正都要写入不是么?原因很简单,覆盖(全部修改)和修改(部分修改)是不一样的。有可能会再父进程原有数据的基础上做部分修改而已,比如:++i,就需要根据原有的i值来递增并作修改。

另外,虽然子进程可以看到fork()之前的代码(也必须看得到,否者类似定义和声明语句就会失效造成代码出现问题),但是依旧只会执行fork()后面的代码,这是为什么呢?这是为了避免出现父进程创建子进程,子进程创建子子进程…这种死循环情况。

那为什么操作系统怎么知道从哪里开始执行呢?我们之前在[2.进程描述](# 2.进程描述)里有提到过程序计数器的概念,由于进程有可能会被中断(可能没有执行完),因此下次继续执行该进程的时候就需要知道从哪行代码继续开始,这个时候就需要PCpointer code)指针(也就是EIP寄存器)来记录当前进程的执行位置,在进程退出CPU后就将这歌寄存器内的数据还给进程,等待下次进程被CPU计算时重新又进程交给PC寄存器。

而子进程也会从父进程中继承该寄存器存储的数据,可以根据这个数据直接找到子进程后续要执行的代码,因此子进程中不会重复调用fork()造成循环调用。

fork()系统调用之所以有两个返回值,是因为父进程代码会被子进程共享,就会有两次调用,导致有两个返回值。

而为什么同一个地址的变量可以存储两个返回值呢?这是因为:父子进程都会通过returnid这个变量进行写入,所以就会发生写时拷贝,使得父子各有一个id变量,可以存储不同的值。因此这两个返回值一定是存储在不同地方的,但是为什么父子打印出来的地址是一样的呢?这就需要利用之前的[进程空间](# 8.进程空间)知识,这里打印的地址不是物理地址,而是编译器分配的虚拟地址。

9.1.2.创建多个子进程

先创建多个子进程:

#include 
#include 
#include 
#define N 10

void Worker()
{
    int cnt = 10;
    while(cnt--)
    {
        printf("I am child process, [pid:%d], [ppid:%d], [cnt:%d]\n", getpid(), getppid(), cnt);
        sleep(1);
    }
}

int main()
{
    int i;
    for(i = 0; i < N; i++)
    {
        sleep(1);
        pid_t id = fork();
        if(id == 0)
        {
            //child进程只会执行一次循环
            printf("[creat child %d]\n", i);
            Worker();
            exit(0);//子进程退出,暂时变成僵尸状态
        }
        //father进程执行循环,但是不会进入if语句
    }
    //这里只有父进程走到这里
    sleep(100);//由于我们还没有讲解僵尸进程的解决方法,因此这里就让父进程多等一会,直到全部子进程结束再来回收

    return 0;
}

运行上述代码之前,先写一个test.sh脚本,然后先使用bash test.sh运行shell脚本再运行a.out,来观察进程直接发生的变化(不过打印输出的先后顺序取决于调度器)。

while :
    do ps ajx | head -1 && ps ajx | grep a.out |grep -v grep
    echo '------------'
    sleep 1
done

9.2.进程终止

进程终止原理上就是创建进程反动作:销毁pcb结构体、销毁地址空间、页表等等资源销毁。

9.2.1.进程终止的情况

进程终止存在以下情况:

  1. 代码运行完毕,结果正确

  2. 代码运行完毕,结果错误

  3. 代码异常终止,程序崩溃

9.2.2.进程终止的信息

以前我们在写main()的时候,结尾总是会写return 0;但是这个语句到底是什么呢?实际上main()会被其他函数调用,因此这个返回值就会交给这个函数,但是这个函数又需要交付返回值给谁呢?实际上是作为进程退出码交付给了父进程bash,可以在运行一个C代码后使用echo $?来查看最近一次父进程bash得到的进程退出码。

$ cat test.c
int main()
{
    return 123;
}

$ gcc test.c
$ ./a.out
$ echo $?
123

main()返回0代表第一种情况(代码运行完毕,结果正确),非0代表第二种情况(代码运行完毕,结果错误)。而成功我们就无需关心了,错误就会返回多种非零进程退出码。

进程退出码(也就是main()的返回值)我们是有了,但是只有一串数字,这是无法直接进行错误探究的,所以我们需要将进程退出码人为映射转化为包含错误信息字符串的方案(使用strerror(<退出码>)即可将退出码转化为Linux下的错误信息字符串)。

  1. 自己设计一套退出方案

  2. 使用系统/语言规定的退出码方案

    $ cat test.c
    #include
    #include
    int main()
    {
        int i;
        for(i = 0; i < 200; i++)
        {
            printf("%s\n", strerror(i));
        }
        return 0;
    $ gcc test.c
    $ ./a.out
    Success
    Operation not permitted
    No such file or directory
    No such process
    Interrupted system call
    Input/output error
    No such device or address
    Argument list too long
    Exec format error
    Bad file descriptor
    No child processes
    Resource temporarily unavailable
    Cannot allocate memory
    Permission denied
    Bad address
    Block device required
    Device or resource busy
    File exists
    Invalid cross-device link
    No such device
    Not a directory
    Is a directory
    Invalid argument
    Too many open files in system
    Too many open files
    Inappropriate ioctl for device
    Text file busy
    File too large
    No space left on device
    Illegal seek
    Read-only file system
    Too many links
    Broken pipe
    Numerical argument out of domain
    Numerical result out of range
    Resource deadlock avoided
    File name too long
    No locks available
    Function not implemented
    Directory not empty
    Too many levels of symbolic links
    Unknown error 41
    No message of desired type
    Identifier removed
    Channel number out of range
    Level 2 not synchronized
    Level 3 halted
    Level 3 reset
    Link number out of range
    Protocol driver not attached
    No CSI structure available
    Level 2 halted
    Invalid exchange
    Invalid request descriptor
    Exchange full
    No anode
    Invalid request code
    Invalid slot
    Unknown error 58
    Bad font file format
    Device not a stream
    No data available
    Timer expired
    Out of streams resources
    Machine is not on the network
    Package not installed
    Object is remote
    Link has been severed
    Advertise error
    Srmount error
    Communication error on send
    Protocol error
    Multihop attempted
    RFS specific error
    Bad message
    Value too large for defined data type
    Name not unique on network
    File descriptor in bad state
    Remote address changed
    Can not access a needed shared library
    Accessing a corrupted shared library
    .lib section in a.out corrupted
    Attempting to link in too many shared libraries
    Cannot exec a shared library directly
    Invalid or incomplete multibyte or wide character
    Interrupted system call should be restarted
    Streams pipe error
    Too many users
    Socket operation on non-socket
    Destination address required
    Message too long
    Protocol wrong type for socket
    Protocol not available
    Protocol not supported
    Socket type not supported
    Operation not supported
    Protocol family not supported
    Address family not supported by protocol
    Address already in use
    Cannot assign requested address
    Network is down
    Network is unreachable
    Network dropped connection on reset
    Software caused connection abort
    Connection reset by peer
    No buffer space available
    Transport endpoint is already connected
    Transport endpoint is not connected
    Cannot send after transport endpoint shutdown
    Too many references: cannot splice
    Connection timed out
    Connection refused
    Host is down
    No route to host
    Operation already in progress
    Operation now in progress
    Stale file handle
    Structure needs cleaning
    Not a XENIX named type file
    No XENIX semaphores available
    Is a named type file
    Remote I/O error
    Disk quota exceeded
    No medium found
    Wrong medium type
    Operation canceled
    Required key not available
    Key has expired
    Key has been revoked
    Key was rejected by service
    Owner died
    State not recoverable
    Operation not possible due to RF-kill
    Memory page has hardware error
    Unknown error 134
    Unknown error 135
    Unknown error 136
    Unknown error 137
    Unknown error 138
    Unknown error 139
    Unknown error 140
    Unknown error 141
    Unknown error 142
    Unknown error 143
    Unknown error 144
    Unknown error 145
    Unknown error 146
    Unknown error 147
    Unknown error 148
    Unknown error 149
    Unknown error 150
    Unknown error 151
    Unknown error 152
    Unknown error 153
    Unknown error 154
    Unknown error 155
    Unknown error 156
    Unknown error 157
    Unknown error 158
    Unknown error 159
    Unknown error 160
    Unknown error 161
    Unknown error 162
    Unknown error 163
    Unknown error 164
    Unknown error 165
    Unknown error 166
    Unknown error 167
    Unknown error 168
    Unknown error 169
    Unknown error 170
    Unknown error 171
    Unknown error 172
    Unknown error 173
    Unknown error 174
    Unknown error 175
    Unknown error 176
    Unknown error 177
    Unknown error 178
    Unknown error 179
    Unknown error 180
    Unknown error 181
    Unknown error 182
    Unknown error 183
    Unknown error 184
    Unknown error 185
    Unknown error 186
    Unknown error 187
    Unknown error 188
    Unknown error 189
    Unknown error 190
    Unknown error 191
    Unknown error 192
    Unknown error 193
    Unknown error 194
    Unknown error 195
    Unknown error 196
    Unknown error 197
    Unknown error 198
    Unknown error 199
    

    可以看到只提供了[0,133]范围的错误码对应字符。

C语言提供了一个全局变量errno,如果在调用C接口的时候发生错误,那么就会设置该变量为对应的错误码,可以使用strerror(errno)输出原因。

因此退出码和错误码是有区别的,一个描述进程,一个描述函数调用,这两种码可以一起结合使用,也可以自定义。

#include
#include
#include
int main()
{
    int ret = 0;
    
    printf("before: %d\n", errno);
    FILE* fp = fopen("./log.txt", "r");//注意这个文件是不存在的
    if(fp == NULL)
    {
    	printf("after: %d, error string: %s\n", errno, strerror(errno));
        ret = errno;
    }
    
    return ret;//错误码和退出码达成一致
}

补充:有些时候我们会发现,Linux内的有些系统调用,也会设置errno,这是为什么呢?这不是C才有的全局变量么?怎么系统调用也可以设置?原因很简单,很多Linux下的系统调用实际上也是用C写的,自然可以设置这个语言级别的全局变量。

如果一个代码形成的进程运行起来出现异常了,那么其退出码就没有意义了,因为这个进程是中间就因为异常(比如:空指针解引用写入(不允许用户写入到内核空间)、除0错误)崩溃了(崩溃是语言概念,更深一步就是操作系统将这个进程杀死了)。

而进程出现的异常信息,会被操作系统检测到,转化为对应的信号(可以用kill -l查看系统规定的信号),此时操作系统就会给进程发生对应的信号,最终杀掉进程(有关信号的原理我们以后再来深入)。

因此,我们可以做到让一个正常的代码收到操作系统信号而终止:

$ cat test.c
#include
#include
int main()
{
    while(1)
    {
        printf("I am a code!%d\n", getpid());
        sleep(1);
    }
    return 0;//错误码和退出码达成一致
$ gcc test.c
$ ./a.out
I am a code!26422
I am a code!26422
I am a code!26422
I am a code!26422
I am a code!26422
I am a code!26422
I am a code!26422
Floating point exception
$ kill -l
 1) SIGHUP       2) SIGINT       3) SIGQUIT      4) SIGILL       5) SIGTRAP
 6) SIGABRT      7) SIGBUS       8) SIGFPE       9) SIGKILL     10) SIGUSR1
11) SIGSEGV     12) SIGUSR2     13) SIGPIPE     14) SIGALRM     15) SIGTERM
16) SIGSTKFLT   17) SIGCHLD     18) SIGCONT     19) SIGSTOP     20) SIGTSTP
21) SIGTTIN     22) SIGTTOU     23) SIGURG      24) SIGXCPU     25) SIGXFSZ
26) SIGVTALRM   27) SIGPROF     28) SIGWINCH    29) SIGIO       30) SIGPWR
31) SIGSYS      34) SIGRTMIN    35) SIGRTMIN+1  36) SIGRTMIN+2  37) SIGRTMIN+3
38) SIGRTMIN+4  39) SIGRTMIN+5  40) SIGRTMIN+6  41) SIGRTMIN+7  42) SIGRTMIN+8
43) SIGRTMIN+9  44) SIGRTMIN+10 45) SIGRTMIN+11 46) SIGRTMIN+12 47) SIGRTMIN+13
48) SIGRTMIN+14 49) SIGRTMIN+15 50) SIGRTMAX-14 51) SIGRTMAX-13 52) SIGRTMAX-12
53) SIGRTMAX-11 54) SIGRTMAX-10 55) SIGRTMAX-9  56) SIGRTMAX-8  57) SIGRTMAX-7
58) SIGRTMAX-6  59) SIGRTMAX-5  60) SIGRTMAX-4  61) SIGRTMAX-3  62) SIGRTMAX-2
63) SIGRTMAX-1  64) SIGRTMAX
$ kill -8 26422

可以看到明明代码没有浮点错误(一般除以0就会出现这个错误),在接受到-8信号后进程依旧以该异常而终止了。因此判断一个进程是否出异常,只要看有没有收到信号即可。

补充:其中需要注意,信号是从1开始的,因此0给进程就是表示没有收到信号,非0信号给进程就会造成进程因为异常而终止。

总结:父进程只需要根据“退出码”和“信号”即可完整查看子进程的所有运行状况。

9.2.3.进程终止的方法

9.2.3.1.正常终止
9.2.3.1.1.return

main函数的返回值叫做进程退出码,除了0还可以是其他值。

可以使用其他值(例如:return 10)试试,然后通过echo $?可以查看最近一次进程返回的退出码。

$ ll
total 0

$ vim test.c
$ cat test.c
#include 
int main()
{
    const char* s = "Hello, I am limou~\n";
    printf("%s", s);
    return 10;
}

$ gcc test.c
$ ./a.out
Hello, I am limou~

$ echo $?
10

return对于mian()是进程终止,但对于其他被main()调用的子函数来说只是函数的返回值。也就是说:retrun会根据所处地不同,语义也不同。

9.2.3.1.2.exit

在代码中手动调用exit(),将会引起正常运行的进程发生终止,该函数头文件是。该函数在代码的任何地方语义都是一样的(都是终止进程,不同于return语句,因此一般推荐使用这个函数终止进程)

9.2.3.1.3._exit()

上面的exit()C语言提供的,而实际上还有一个系统接口方案_exit()/_Exit(),头文件为。虽然也是终止进程的,但是和exit()也还有一些差别。

补充:exit()_exit()的区别

exit()的会刷新缓冲区数据,但是_exit()不会刷新。

也就是说C提供的exit()多了一些“动作”(执行用户的权力、冲刷缓冲、关闭流等等),然后才终止进程。而在实际开发上,我们更加推荐使用exit()

#include 
#include 
#include 
int main()
{
 printf("You can see me!");
 sleep(3);
 exit(10);//会刷新缓冲区
 return 0;
}
#include 
#include 
#include 
int main()
{
 printf("You can see me!");
 sleep(3);
 _exit(10);
 return 0;
}

前一份代码运行后会刷新出文本,后一段代码运行后则不会刷新出文本(被当作垃圾数据了)。

刷新只有几种情况,程序自己调用fflush()、一些刷新条件(比如:\n)、进程结束退出时系统强制要求刷新缓冲区。

另外,这个缓冲区在哪里呢?但是我们可以肯定:缓冲区一定不在操作系统内部。如果由操作系统内部维护的话,那么_exit()也可以刷新,这个缓冲区应该是C标准库维护的。这样子就可以封装_exit()和语言上的缓存区(以及一些其他动作)成库函数exit()

9.2.3.2.异常退出

使用快捷键[ctrl+c]发生信号给进程,来终止进程(适用于前台运行进程)。

或者使用kill命令,也是发送信号终止进程(适用于后台运行进程)。

9.3.进程等待

9.3.1.进程等待的目的

如果子进程退出,父进程不再理会,就有可能造成僵尸进程,使用kill -9也无法去除(因为这个进程已经“死”了)。此时进程占用着资源,造成内存泄露。因此父进程给子进程派遣任务后,需要知道子进程的运行结果,是否正确、是否退出。这个时候父进程就通过进程等待的方式,回收子进程资源,获取子进程退出信息(获取与释放资源)。

当然,父进程不一定需要通过进程等待的方式获取两个数字(错误码和信号),但是系统调用必须要有能让父进程等待的接口设计。

9.3.2.进程等待的方法

9.3.2.1.wait()
#include  
#include  
pid_t wait(int* status);
//参数:是一个输出型参数,用于获取子进程退出状态,不关心则可以设置成为 NULL,这个参数后面讲解 waitpid() 时会详细解释(实际上就是退出结果,但是包含了退出码和异常状态)
//返回值:返回被等待进程 pid,失败返回 -1

父进程调用wait()后,处于阻塞状态,等待任意一个子进程变成“僵尸”状态,然后回收子进程的资源,并且返回该进程的pid。下面用一个例子来演示该接口:

#include 
#include 
#include 
#include 
#include 

void Worker()
{
    int cnt = 5;
    while(cnt)
    {
        printf("I am child process, pid: %d, ppid: %d, cnt: %d\n", getpid(), getppid(), cnt--);
        sleep(1);
    }
}

int main()
{
    pid_t id = fork();
    if(id == 0)
    {
        //child
        Worker();
        exit(10);
    }
    else
    {
        //father
        sleep(10);//在后10秒子进程处于僵尸状态
        pid_t rid = wait(NULL);
        if(rid == id)
        {
            printf("I am father process, wait success, pid: %d\n", getpid());
        }
    }

    return 0;
}

若父进程没有sleep(10),子进程还处于没有返回的状态,那是否还会调用wait()呢?实际上是会的,此时父进程处于阻塞状态,会一直等待子进程结束返回。

补充:虽然我们无法确认是哪一个进程先开始被CPU调度运行,但是通过wait()我们可以控制让父进程一定在最后退出。

这种阻塞式的等待比较暴力,一旦阻塞,父进程就会一直等待,什么事情都没有办法做,因此我们通常使用waitpid()会多一些,该接口可以让父进程一边做自己的事情,一边等待回收子进程。

9.3.2.2.waitpid()
#include  
#include  
pid_ t waitpid(pid_t pid, int* status, int options);  
//参数:
	//1.pid(等谁):
		//1.1.Pid == -1,表示等待任一个子进程,和与 wait() 的方式等效(有可能存在多个子进程的情况)
         //1.2.Pid > 0,等待其进程 ID 与 pid 相等的子进程
         //1.3.pid == 0,表示 TODO,后面讲
    //2.status(退出结果):
		//该参数是一个输出型参数,可以使用一些宏或者函数提取出子进程的退出码和异常状态
         //2.1.WEXITSTATUS(status): 若 WIFEXITED 非零,则提取返回子进程退出码(查看进程的退出码)  
         //2.2.WIFEXITED(status): 若正常终止子进程就返回真(查看进程是否是正常退出)
    //3.options(怎么等):
         //设置阻塞等待:父进程处于阻塞状态等待回收子进程则设置为 0,该参数默认为 0
         //设置非阻塞等待:使用 WNOHANG 时(Wait No Hang),若 pid 指定的子进程没有结束,则 waitpid() 函数返回 0,不予以等待,父进程继续执行自己的代码。若子进程结束,则返回该子进程的 ID

//返回值:  
    //1.当正常返回的时候 waitpid() 返回收集到的子进程的进程ID
    //2.如果设置了选项 WNOHANG,而调用中 waitpid() 发现没有已退出的子进程可收集,则返回 0(也就是调用该函数成功,但是子进程并未全部退出,注意是“全部”)
    //3.如果调用中出错,则返回 -1,这时 errno 会被设置成相应的值以指示错误所在(比如:指定的 pid 填错了)

注意:waitpid()可以通过参数设定转化为wait(NULL)的等价模式,wait(NULL) <=> waitpid(-1, NULL, 0)

#include 
#include 
#include 
#include 
#include 

void Worker()
{
    int cnt = 5;
    while(cnt)
    {
        printf("I am child process, pid: %d, ppid: %d, cnt: %d\n", getpid(), getppid(), cnt--);
        sleep(1);
    }
}

int main()
{
    pid_t id = fork();
    if(id == 0)
    {
        //child
        Worker();
        exit(10);
    }
    else
    {
        //father
        int status = 0;
        pid_t rid = waitpid(id, &status, 0);//和使用 wait() 是一样的
        if(rid == id)
        {
            printf("I am father process, wait success, pid: %d\n", getpid());
        }
    }

    return 0;
}

我们重点讲解一下status输出型参数,该参数可以查验进程的退出码和异常状态,两个信息结合为一个二进制序列,是按照32比特位的方式整体使用的,我们只了解低的16位就可以(实际上这个二进制序列还包含其他信息,不过这些我们暂时不用去了解)。其中:

  1. 次低8位表示退出码,可以使用(status>>8) & 0xFF位操作获取,通过返回码判断进程结果是否正确
  2. 7位表示进程收到的信号(即异常状态),可以使用status & 0x7F位操作获取(信号0表示程序正常运行,非0为奔溃),通过信号判断进程是否异常
  3. 还有1个比特位是core dump标志,这个我们之后讲信号再来谈及

因此我们需要对输出做一定位操作,才能得到子进程使用exit()返回的退出码,以及进程的异常状态。

$ cat main.c
#include 
#include 
#include 
#include 
#include 

void Worker()
{
    int cnt = 5;
    while(cnt)
    {
        printf("I am child process, pid: %d, ppid: %d, cnt: %d\n", getpid(), getppid(), cnt--);
        sleep(1);
    }
}

int main()
{
    pid_t id = fork();
    if(id == 0)
    {
        //child
        Worker();
        exit(10);
    }
    else
    {
        //father
        int status = 0;
        pid_t rid = waitpid(id, &status, 0);//和使用 wait() 是一样的
        if(rid == id)
        {
            printf("I am father process, wait success, pid: %d, rpid: %d, exit code: %d, exit sig: %d\n"
            , getpid(), rid, (status >> 8) & 0xFF, status & 0x7F);
        }
    }

    return 0;
}
$ gcc main.c
$ ./a.out
I am child process, pid: 21558, ppid: 21557, cnt: 5
I am child process, pid: 21558, ppid: 21557, cnt: 4
I am child process, pid: 21558, ppid: 21557, cnt: 3
I am child process, pid: 21558, ppid: 21557, cnt: 2
I am child process, pid: 21558, ppid: 21557, cnt: 1
I am father process, wait success, pid: 21557, rpid: 21558, exit code: 10, exit sig: 0

实际上操作系统已经为我们提供了相关的位操作函数或宏:WEXITSTATUS(status)WIFEXITED(status)

#include 
#include 
#include 
#include 
#include 
#include 
#include 

void Worker()
{
    int cnt = 5;
    while(cnt)
    {
        printf("I am child process, pid: %d, ppid: %d, cnt: %d\n", getpid(), getppid(), cnt--);
        sleep(1);
    }
}

int main()
{
    pid_t id = fork();
    if(id == 0)
    {
        //child
        Worker();

        //下面这两句可以测试进程因为异常退出的情况
        //int a = 10;
        //a /= 0;

        exit(10);
    }
    else
    {
        //father
        int status = 0;
        pid_t rid = waitpid(id, &status, 0);//和使用 wait() 是一样的
        if(rid == id)
        {
            if(WIFEXITED(status))//如果进程正常终止,返回真
            {
                printf("child process normal quit, exit code: %d\n", WEXITSTATUS(status));
            }
            else
            {
                printf("child process quit except\n");
            }
        }
    }

    return 0;
}

但是父进程处于阻塞状态时有些浪费资源,因此我们可以让waitpid()的第三个参数options = WNOHANG来使父进程和未结束的子进程一起运行,让父进程不发生阻塞,并且一边等待子进程结束返回(而这个结口就注定要被重复使用/非阻塞轮询)。

9.3.3.进程等待的原理

wait()/waitpid()能够获取进程退出码和异常状态的本质是:读取了子进程的task_struct对象内部的进程退出结果和信号,这点可以从内核的源代码中查看到,在task_struct{/*...*/};中存在字段int exit_code, exit_signal

task_struct {
    /*...*/
#endif

	struct mm_struct		*mm;
	struct mm_struct		*active_mm;

	int				exit_state;
	int				exit_code;
	int				exit_signal;
	/* The signal sent when the parent dies: */
	int				pdeath_signal;
	/* JOBCTL_*, siglock protected: */
    
    /*...*/
};

而这个两个函数是“系统调用”的一种,当然有权限访问PCB对象内部这一字段的信息。

我们之前在讲解阻塞的时候有提到过,阻塞的本质是硬件提供了进程队列,只要进程还在队列中没有读取到硬件的资源就会处于阻塞状态。

而软件也同样可以这样处理,实际上每一个PCB对象里面都有一个内置的等待队列,父进程等待子进程的时候就是需要获取子进程的资源,因此只需要把父进程加入到子进程PCB对象的等待队列里即可,这样父进程就变成阻塞状态了,这种阻塞就也叫做“软件条件”。

9.4.进程替换

9.4.1.进程替换概念

之前我们是父进程创建子进程,子进程共享父进程的代码,那有没有办法做到子进程单独使用自己的程序呢?可以使用程序替换就可以做到。

程序替换是通过特定的接口,加载磁盘上的一个权限的程序(代码和数据),加载到调用进程的进程地址空间中。也就是说,子进程往往要调用一种exec函数来执行另一个程序。当进程调用该函数时,该进程的用户空间代码和数据完全被新程序替换,从新程序的启动例程开始执行。调用exec()并不创建新进程,所以调用exec前后的进程id没有改变。

9.4.2.进程替换函数

exec系列函数本质是加载程序的函数(加载器)。

#include 

int execl(const char *path, const char *arg, ...);

int execv(const char *path, char *const argv[]);

int execlp(const char *file, const char *arg, ...);

int execvp(const char *file, char *const argv[]);

int execle(const char *path, const char *arg, ..., char *const envp[]);
9.4.2.1.execl()

path是程序的路径,arg和可变参数列表传入命令行参数,并且要以NULL结尾表示命令结束。

一旦execl()调用成功,后续的代码就会被替换(实际上前面的代码也会被替换,但是前面的代码先运行了)

如果调用execl()后失败,依旧会继续执行后面的代码,而不会进行替换。

execl()调用成功是不会有返回值的,因为被替换前的代码已经全部被替换了,也不需要返回值了。

因此不需要判断返回值类查看是否成功替换,失败就直接在execl()后使用exit()退出即可。

如果没有子进程,就必须替换父进程,此时就会影响父进程(子进程存在的意义就在此,父进程像包工头:揽活,子进程就像工人:干活)。

在加载新程序之前,父子进程的数据和代码关系:代码共享、数据写时拷贝。加载新程序后,实际上也是一种数据写入,那么代码需不需要写时拷贝,将父子的代码隔离?是的,必须要分离。因此在进程替换这一环节,数据和代码都是进行写时拷贝。

9.4.2.2.execv()

可以把execl()中的l看作list理解,把execv()中的v看作vector理解。因此两个函数只是传参方式有些许不同,其他都一样。

9.4.2.3.execlp()

这个p就是指path,会在环境变量中查找程序名字进行替换

9.4.2.4.execvp()

这个函数就是execv()execlp()的结合版本,但是会多出一些信息(inode)。

9.4.2.5.execle()

可以向目标进程传递环境变量。

无论是什么变成语言最后都会变成进程,因此上述的函数可以做到调用其他语言的进程。

另外,上述函数都不是严格意义上的系统接口,真正的系统接口是execve(const char* filename, char* const argv[], char* const envp[]);,上述六个函数都是调用这个系统接口的派生函数。

你可能感兴趣的:(操作系统学习笔记,linux,c++,java,服务器)