【Linux】进程控制(进程创建、进程终止、进程等待、进程替换)

本文详细介绍了Linux系统中进程的创建、终止、等待及程序替换。首先,通过系统调用fork创建进程,讨论了fork的返回值和写时拷贝策略。接着,探讨了进程的终止,包括main函数的返回值、进程退出码、不同退出情况及进程的正常终止方式。接着,阐述了进程等待的必要性和wait、waitpid函数的使用,包括获取子进程状态和信号。最后,讲解了进程的程序替换,通过exec系列函数实现代码和数据的替换,以及进程替换的原理和应用场景。
摘要由CSDN通过智能技术生成

一、进程创建

目前学习到的进程创建的两种方式:

  1. 命令行启动命令(程序、指令等) 。
  2. 通过程序自身,调用 fork 函数创建出子进程。

1.1 认识系统调用 fork

Linux 中的系统接口 fork 函数是非常重要的函数,它从已存在进程(父进程)中创建一个新进程(子进程):

#include<unistd.h>
pid_t fork(void);  // 返回值:子进程中返回0,父进程中返回子进程id,出错返回-1

进程调用 fork,当控制转移到内核中的 fork 函数代码后,操作系统内核会做

  • 分配新的内存块和内核数据结构(task_struct)给子进程。
  • 以父进程为模板)将父进程内核数据结构中的部分内容拷贝至子进程。
  • 添加子进程到系统进程列表当中(因为进程要被调度和执行)。
  • fork 函数返回后,开始调度器调度。

fork 的常规用法

  • 一个父进程希望复制自己,使父子进程同时执行不同的代码段。

    例如:父进程等待客户端请求,生成子进程来处理请求。

  • 一个进程要执行一个不同的程序。

    例如:子进程从 fork 返回后,调用 exec 函数。

fork 调用失败的原因:

  • 系统中有太多的进程,系统资源不足。
  • 实际用户的进程数超过了限制。

1.2 理解 fork 的返回值

当一个进程调用 fork 之后,在不写入的情况下,用户的代码和数据是父子进程共享的。就有两个二进制代码相同的进程。子进程运行fork()下面的代码,但是父进程上面的代码也被子进程继承了,并且可以被子进程访问,但不推荐访问,看如下程序。

#include<stdio.h>  // perror
#include<unistd.h> // getpid, getppid, fork

int main()  
{
     
    // ...
    pid_t ret = fork(); // 返回时发生了写时拷贝
    if (ret == 0) {
   
        // child process
        while (1) {
   
            printf("child process, pid:%u, ppid:%u\n", getpid(), getppid());
            sleep(1);
        }
    }
    else if (ret > 0) {
   
        // father process
        while (1) {
   
            printf("father process, pid:%u, ppid:%u\n", getpid(), getppid());
            sleep(1);
        }
    }
    else {
   
        // failure
        perror("fork");
    }
    return 0;
}

fork 之前父进程独立执行,fork 之后父子进程分别执行。注意:fork 之后谁先执行完全由调度器决定。

画图理解 fork 函数:

image-20220626220957412

思考

  1. 为什么 fork 有两个返回值,从而使父子进程进入不同的业务逻辑。为什么 fork 的返回值会返回两次呢?

    父进程调用fork函数后,为了创建子进程,fork函数内部将会进行一系列操作,包括创建子进程的进程控制块、创建子进程的进程地址空间、创建子进程对应的页表等等。子进程创建完毕后,操作系统还需要将子进程的进程控制块添加到系统进程列表当中,此时子进程便创建完毕了。

    image-20221004231018046

    也就是说,在fork函数内部执行return语句之前,子进程就已经创建完毕了,那么之后的return语句不仅父进程需要执行,子进程也同样需要执行,这就是fork函数有两个返回值的原因。

  2. 返回值 ret 变量名相同,为什么会有两个不同的值呢?

    变量名相同,有两个不同的值,本质是因为被映射到了不同的物理地址处。

  3. 为何要给子进程返回0,给父进程返回子进程的pid?

    一个父进程可以有多个子进程,而一个子进程只能有一个父进程。因此,对于子进程来说,父进程是不需要被标识的。站在父进程立场,子进程需要标识,因为创建子进程是要执行任务的,父进程需要能区分子进程。

1.3 写时拷贝策略

当子进程刚刚被创建时,子进程和父进程的数据和代码是共享的,即父子进程的代码和数据通过页表映射到物理内存的同一块空间。只有当父进程或子进程需要修改数据时,才将父进程的数据在内存当中拷贝一份,然后再进行修改。

image-20221004231110785

写时拷贝是一种延时操作的策略,为什么要有写时拷贝呢?写时拷贝的好处是什么?

  1. 为了保证父子进程的独立性!(数据各自私有一份)【为什么要有写时拷贝】
  2. 不是所有的数据,都有必要被拷贝一份(比如只读的数据)。写时拷贝可以节约资源,不写入时父子进程共享数据:父子进程对应的页表指向的是同一块物理内存 。
  3. fork 时,如果把所有的数据都拷贝一份,是需要花费时间的,降低了效率。写时拷贝可以提高 fork 执行的效率。
  4. fork 创建子进程本身就是向操作系统要资源,如果把所有的数据都拷贝一份,要更多的资源,更容易导致 fork 失败。写时拷贝可以减少 fork 失败的概率。

为什么不在创建子进程的时候就进行数据的拷贝

  • 子进程不一定会使用父进程的所有数据,并且在子进程不需要对数据写入修改时,没有必要对数据进行拷贝,子进程需要数据的时候,按需分配。延时分配:本质,可以高效使用任何内存空间。【进程不一定会立刻被调度,调度时再分配空间】

补充:代码基本不会写时拷贝,不代表不能。(进程替换)

二、进程终止

2.1 main 函数的返回值

我们在写 C/C++ 代码时,main 函数里面我们总是会返回 0,比如:

#include<stdio.h>
int main()
{
   
    printf("hello world\n");
    return 0;
}

思考:为什么 main 函数中总是会返回 0 ( return 0; )呢?

  • main 函数中的这个返回值叫做:「进程退出码」,用来表示进程退出时,其执行结果是否正确。
  • 返回的 0 是给操作系统看的,来确认进程的执行结果是否正确。(0 通常表示成功)

用户可以通过命令 echo $? 查看最近一次执行的程序的「进程退出码」,比如:

[yzy@VM-4-4-centos day9]$ ./test
hello world
[yzy@VM-4-4-centos day9]$ echo $?  # 查看最近一次执行的程序的退出码
0

2.2 进程退出的几种情况(🌟)

  1. 代码跑完,结果正确。(退出码:0)

  2. 代码跑完,结果不正确。(一般是代码逻辑有问题,但没有导致程序崩溃,退出码:非0)

  3. 代码异常终止。(这种情况下,退出码已经没有意义了,是由信号来终止,比如 ctrl+c)

    代码异常终止 -》 程序崩溃 -》 退出码没有意义

2.3 进程退出码

父进程创建子进程的目的是为了让子进程给我们完成任务,父进程需要通过「子进程的退出码」知道子进程把任务完成的怎么样。

比如在生活中,网页打不开时,用户需要通过返回的一串错误代码得知网页出错的原因:

image-20220704165815635

退出码可以人为的定义,也可以使用系统的错误码列表(错误码 (int) 与错误码描述 (string) 之间的映射表)

比如:C 语言库中提供一个接口strerror,可以把「错误码」转换成对应的「错误码描述」,程序如下:

#include<stdio.h>
#include<string.h> // strerror

int main()
{
   
  for (int i = 0; i < 10; i++) {
   
    printf("%d -- %s\n", i, strerror(i)); // char *strerror(int errnum);
  } 
  return 0;
}

运行结果:

[yzy@VM-4-4-centos day9]$ ./test
0 -- Success
1 -- Operation not permitted
2 -- No such file or directory
3 -- No such process
4 -- Interrupted system call
5 -- Input/output error
6 -- No such device or address
7 -- Argument list too long
8 -- Exec format error
9 -- Bad file descriptor

注意:错误码描述不止有10个,这里放大 i 的取值范围可以看到更多对应的描述信息。

2.4 终止正常进程:return、exit、_exit ⭐

注意

  • 只有 main 函数中的 return 表示的是终止进程,非 main 函数中的 return 不是终止进程,而是结束函数,代表函数返回值。
  • 在任何函数中调用 exit 函数,都表示直接终止该进程。

库函数:exit

使进程退出,参数是退出码,在任意地方调用都是终止进程,exit 或者main return 本身就会要求系统进行缓冲区刷新

#include <stdlib.h>//头文件
void exit(int status);  // 终止正常进程
// 参数 status: 定义了进程的终止状态,父进程通过 wait 函数来获取该值

系统调用:_exit

终止进程,强制终止进程,不进行进程的后续收尾工作,比如不刷新缓冲区。

#include <unistd.h>
void _exit(int status);  // 终止正在调用的进程

系统调用接口 _exit 的功能也是终止正在调用的进程,它和库函数 exit 有什么区别呢?

  • exit:在进程退出的时候,会进行后续资源处理(比如刷新缓冲区)。
  • _exit:在进程退出的时候,不会进行后续资源处理,直接终止进程。

补充

其实,库函数 exit 最后也会调用系统接口 _exit,但在调用 _exit 之前,还做了其他工作:

  1. 执行用户通过 atexit 或 on_exit 定义的清理函数。
  2. 关闭所有打开的流,所有的缓存数据均被写入。
  3. 调用 _exit

image-20220629214908972

2.5 站在 OS 角度:理解进程终止

站在操作系统角度,如何理解进程终止?

  1. “ 释放 ” 曾经为了管理该进程,在内核中维护的所有数据结构对象(PCB)。

    注意:这里的 “ 释放 ” 不是真的把这些数据结构对象销毁,即占用的内核空间还给 OS;而是设置成不用状态,把相同类型的对象归为一类(如进程控制块就是一类),保存到一个 “ 数据结构池 ” 中,凡是有不用的对象,就链入该池子中。

    我们知道在内核空间中维护一个内存池,减少了用户频繁申请和释放空间的操作,提高了用户使用内存的效率,但每次从内存池中申请和使用一块空间时,还需要先对这块空间进行类型强转,再初始化。

    现在有了这些 “ 数据结构池 ” ,比如:当创建新进程时,需要创建新的 PCB,不需要再从内存池中申请一块空间,进行类型强转并初始化,而是从 “ 数据结构池 ” 中直接获取一块不用的 PCB 覆盖初始化即可,减少了频繁申请和释放空间的过程,提高了使用内存的效率。

    这种内存分配机制在 Linux 中叫做 slab 分配器。

    image-20220629225257315

  2. 释放程序代码和数据占用的内存空间。

    注意:这里的释放不是把代码和数据清空,而是把占用的那部分内存设置成「未使用」就可以了。

  3. 取消曾经该进程的链接关系。

总结

进程退出,0S层面做了什么呢?
系统层面,少了一个进程,所以要 free PCB,free mm_struct,free页表和各种映射关系,代码 + 数据中请的空间也要给释放掉!

三、进程等待

3.1 进程等待的必要性

  • 子进程退出,父进程还在运行,但父进程没有读取到子进程状态,就可能造成「僵尸进程」的问题,进而导致内存泄漏。

    退出状态本身要用数据维护,也属于进程的基本信息,所以保存在 task_struct(PCB) 中,换句话说,僵尸进程一直不退出,它对应的 PCB 就要一直维护。

  • 另外,进程一旦变成僵尸状态,命令 kill -9 也无能为力,因为没有办法杀死一个已经死去的进程。

  • 最后,父进程需要知道派给子进程的任务完成的如何。(如:子进程运行完成,运行结果对不对,有没有正常退出,还有根据进程退出信息制定出错时的一些策略)


思考:为什么要有进程等待?

  1. 获取子进程的退出信息,能够得知子进程执行结果。—— 不是必须的,需要就获取,不需要就不获取。

    因为父进程需要知道派给子进程的任务完成的如何,有没有正常退出,还可以根据进程退出信息制定出错时的一些策略。

  2. 尽量保证父进程要晚于子进程退出,可以规范化的进行资源回收。—— 这是编码方面的要求,并非系统。

  3. 子进程退出的时候会先进入僵尸状态,造成内存泄露的问题,需要通过父进程wait,释放该子进程占用的资源(kill -9 杀不掉僵尸进程)

总结:父进程通过进程等待的方式:回收子进程资源,防止内存泄漏获取子进程的退出信息。⭐

3.2 如何「进程等待」:wait、waitpid 函数

系统调用 waitwaitpid - 等待任意一个子进程改变状态,子进程终止时,函数才会返回。(其实就是等待进程由 R/S(运行/睡眠) 状态变成 Z(僵尸) 状态,然后父进程读取子进程的状态,操作系统回收子进程)

① wait 函数

#include <sys/types.h>//头文件
#include <sys/wait.h>//头文件

pid_t wait(int *status);
/*
* wait() 系统调用:暂停正在调用的进程的执行,直到它的一个子进程终止(状态变化)。
* 调用 wait(&status) 等价于 waitpid(-1, &status, 0);
*/

参数:

  • status:输出型参数,获取子进程退出状态,不关心则可以设置成为 NULL。

返回值:

  • 返回一个pid_t类型,返回你等待成功并且终止的进程id,出错时,返回 -1。

👉 实例1:等待一个子进程

#include<stdio.h>
#include<stdlib.h>    // exit
#include<sys/types.h> // getpid, getppid
#include<sys/wait.h>  // wait
#include<unistd.h>    // fork, sleep, getpid, getppid

int main()
{
   
    pid_t cpid = fork();

    if (cpid == 0) {
            // child process
        int count = 5;
        while (count) {
         // 子进程运行5s
            printf("child is running: %ds, pid: %d, ppid: %d\n", count--, getpid(), getppid());
            sleep(1);
        }
        
        printf("child quit...!\n");
        exit(1);             // 终止子进程
    }
    else if (cpid > 0) {
        // father process
        printf("father is waiting...\n");
        
        pid_t ret = wait(NULL);  // 等待子进程终止,不关心子进程退出状态
        
        printf("father waits for success, cpid: %d\n", ret);  // 输出终止子进程的pid
    }
    else {
      // fork failure
        perror("fork");
        return 1;       // 退出码设为1,表示fork失败
    }

    return 0;
}

运行结果

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

Morning_Yang丶

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

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

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

打赏作者

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

抵扣说明:

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

余额充值