Linux | 深入探究Linux进程控制:从fork到进程等待再到进程替换

目录

1、进程的创建:fork函数

2、父子进程的奇怪现象:为什么同一个地址有不同的值?——区分内存的虚拟地址和物理地址

代码:利用fork函数的返回值进行父子进程分流,执行不同的代码块

虚拟地址和物理地址:

fork调用和地址空间的关系:

3、进程的终止

进程终止的三种方式:return、exit 和 _exit

return

exit

_exit

注意:

进程退出码

4、进程等待:wait和waitpid

进程等待作用概述:

进程等待的方法:

wait 函数:

waitpid 函数:

进程退出的场景:

status

WIFEXITED(status)

WEXITSTATUS(status)

示例:

5、进程程序替换:exec函数族

函数声明:

系列函数的命名规则:

示例:


 

Linux操作系统以其强大的多任务处理能力和丰富的系统调用而著称。在Linux中,进程是执行中程序的实例,而进程控制是操作系统中一个至关重要的概念。本文将深入探讨Linux进程的创建、终止以及进程间的通信方式,并通过实现一个简单的shell程序来加深理解。

 

1、进程的创建:fork函数

在Linux中,fork函数是创建新进程的核心。当一个进程调用fork时,它会复制自己,生成一个几乎完全相同的副本,即子进程。这个子进程具有父进程的内存、文件描述符等资源的副本。fork函数的返回值在父进程中是子进程的PID,在子进程中则是0。

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

pid_t fork(void);

创建新的子进程通常有一下两种用法:

  1. 利用fork函数的返回值进行父子进程分流,执行不同的代码块。
  2. 进程替换,执行不同的程序。

 

fork() 函数的返回值有三种情况:

  1. 在父进程中:如果 fork() 成功,它将返回新创建的子进程的PID。这个值是一个正整数。
  2. 在子进程中:如果 fork() 成功,它将返回0。子进程认为自己是新创建的,并且它的PID是0。
  3. 在出错时:如果 fork() 调用失败,它将在父进程中返回-1,并设置全局变量 errno 以指示错误的原因。常见的错误原因包括但不限于:
    • EAGAIN:系统达到其进程数量的最大值,无法创建更多的进程。
    • ENOMEM:系统内存不足,无法分配足够的内存来创建新的进程。

示例:

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

int main() {
    pid_t pid = fork();  // 创建子进程

    if (pid < 0) {
        // fork失败
        fprintf(stderr, "Fork failed");
        return 1;
    } else if (pid == 0) {
        // 子进程
        printf("I am the child process, PID: %d\n", getpid());
    } else {
        // 父进程
        printf("I am the parent process, PID: %d, Child PID: %d\n", getpid(), pid);
    }

    return 0;
}

 

2ad484d3dc0a5f1c01c5429e2e417c86.png

 

2、父子进程的奇怪现象:为什么同一个地址有不同的值?——区分内存的虚拟地址和物理地址

代码:利用fork函数的返回值进行父子进程分流,执行不同的代码块

#include<stdio.h>
#include<unistd.h>
#include<stdlib.h>
int main()
{   
    int x = 1000;
    pid_t id = fork();
    if(id < 0)
    {
        printf("fork() error\n");
        return -1;
    }
    else if(id == 0)
    {
        // child
        x = 10086; // 子进程修改x的值(其实就是修改对应内存啦~)
        printf("child: x = %d, &x->%p\n", x, &x);
    }
    else
    {
        // father 父进程没有就该x的值
        printf("father: x = %d, &x->%p\n", x, &x);
    }

    return 0;
}

运行结果:

 

d2b254cafeff531f8ea9f6ac65352250.png

虚拟地址和物理地址:

  1. 虚拟地址:每个进程都有自己的虚拟地址空间,这是操作系统为进程提供的抽象。虚拟地址空间中的地址可以通过内存管理单元(MMU)转换为物理地址。
  2. 物理地址:物理地址是实际存储在内存芯片上的地址。物理内存是有限的,而虚拟地址空间通常远大于物理内存。

解释:在子进程中,x 的值被修改为 10086。这个修改会让操作系统对子进程分配新的虚拟地址和物理地址的映射关系,(也就是新的页表~),最终只影响子进程的虚拟地址空间对应的新的物理内存区域,而虚拟地址不变(相同也不要紧)。也就是说,C语言中的地址其实是虚拟地址,不是真实的物理地址!!!

 

fork调用和地址空间的关系:

  • fork() 被调用时,子进程获得父进程的虚拟地址空间的副本。这意味着子进程有自己的虚拟地址到物理地址的映射,但是初始时这个映射与父进程相同。
  • 子进程对虚拟地址空间的写操作会导致写时拷贝(copy-on-write, COW)。如果子进程尝试修改其地址空间中的任何页面,操作系统会首先为该页面创建一个新的物理页面,然后将该页面的内容复制到新的物理页面,并将子进程的虚拟地址映射到这个新的物理页面。

 

90645dd13257a0aef821616c4f6d6dcd.png

Linux中的写时拷贝机制是一种优化内存使用的技术。在父子进程共享数据时,只有当数据被修改时,才会为修改它的进程创建数据的副本。这减少了不必要的内存复制,提高了效率。(原理就是在实际场景中,读数据频率远大于写数据,比如你看的博客数量一定大于你写不博客的数量~)

 

 

3、进程的终止

进程的终止可以是正常或异常的。正常终止可以通过从main函数返回、调用exit函数或_exit函数来实现。exit函数在终止前会执行一些清理工作,如关闭文件描述符、执行注册的退出处理函数等。而_exit函数则直接终止进程,不进行任何清理。

 

进程终止的三种方式:returnexit_exit

return

    • return 是一个C语言关键字,用于从函数返回到调用者。
    • 它通常用于返回一个值给调用函数,并且可以用于任何函数,包括 main 函数。
    • 当在 main 函数中使用 return 语句时,程序会正常终止,并且返回一个退出状态码给操作系统。
    • return 可以带一个整数值,这个值通常用于表示程序的退出状态,其中 0 通常表示成功,非零值表示错误或异常退出。

示例:

int main() {
    // ...
    return 0; // 正常退出,返回状态码0
}

exit

    • exit 是C标准库函数,定义在 <stdlib.h> 头文件中。
    • exit 用于立即终止程序的执行,并返回一个状态码给操作系统。
    • exit 调用会进行一些清理操作,比如关闭所有打开的文件描述符、刷新标准I/O缓冲区、调用注册的退出处理函数等。
    • exit 同样接受一个整数值作为参数,表示退出状态码。

示例:

#include <stdlib.h>

int main() {
    // ...
    exit(0); // 正常退出,返回状态码0
}

_exit

    • _exit 是一个原始的系统调用,通常定义在 <unistd.h> 头文件中。
    • _exit 用于立即终止程序的执行,并返回一个状态码给操作系统,但它不会执行任何清理操作。
    • exit 不同,_exit 不会刷新标准I/O缓冲区或调用任何退出处理函数,它直接终止进程。
    • 这使得 _exitexit 快,但只应在确定不需要进行任何清理工作时使用。

示例:

#include <unistd.h>

int main() {
    // ...
    _exit(0); // 立即退出,不进行任何清理工作
}

注意:

  • return 是C语言关键字,用于从函数返回,包括 main 函数。
  • exit 是C标准库函数,用于正常退出程序,并进行必要的清理工作。
  • _exit 是系统调用,用于立即退出程序,不进行任何清理工作,通常用于紧急情况或确定不需要清理的场景。
  • return n 等价于 exit(n)
  • C标准库中的exit 在实现上,调用系统 _exit 接口之后,还会执行刷新缓冲区数据等善后操作。我们也可以从中发现,C语言的缓冲区不是在系统内部的,而是C语言自身开辟管理的空间。(因为系统提供的_exit不刷新缓冲区)。

 

8bd8c314d8aa6537f6379e18042ced4e.png

int main()
{
 printf("this is exit()\n");
 exit(0);
}
运行结果:
[lhy@localhost linux]# ./a.out
[lhy@localhost linux]# this is exit()

int main()
{
 printf("this is _exit()\n");
 _exit(0);
}
运行结果:
[lhy@localhost linux]# ./a.out
[lhy@localhost linux]#
                      // 这里因为退出后缓冲区没被刷新 所以没有显示print内容

进程退出码

进程的退出码是一个重要的状态指示,它告诉父进程或操作系统进程是如何终止的(就是你return 或者 exit 后面带的值)。退出码可以通过在终端中使用$?来查看。

 

11215a7f85e1c8d08744d84c31a557c0.png

 

 

4、进程等待:wait和waitpid

为了防止僵尸进程的产生并回收子进程资源,同时子进程来获取其退出状态,了解子进程是否成功完成任务,以及任务执行的结果。父进程需要等待子进程的结束。waitwaitpid函数允许父进程等待子进程的终止,并获取其退出状态。

对僵尸进程没有概念的同学可以参考这篇博客~

Linux | Linux进程万字全解:内核原理、进程状态转换、优先级调度策略与环境变量-CSDN博客

进程等待作用概述:

  1. 避免僵尸进程:当子进程完成其任务并退出时,它会转变为僵尸进程,等待父进程回收其资源。如果父进程不执行等待操作,子进程的进程描述符和部分资源不会被释放,导致系统资源浪费。
  2. 资源回收:父进程通过等待子进程,可以回收子进程使用的资源,包括内存、文件描述符等,确保系统资源的有效利用。
  3. 获取子进程状态:父进程可以通过等待子进程来获取其退出状态,了解子进程是否成功完成任务,以及任务执行的结果。
  4. 异常处理:如果子进程因为错误或异常退出,父进程可以通过等待子进程来获取这些信息,并进行相应的异常处理。
  5. 同步执行:在某些情况下,父进程可能需要等待所有子进程完成其任务后才能继续执行,进程等待提供了一种同步机制。

 

进程等待的方法:

waitwaitpid 是两个在 Unix 和类 Unix 系统中用于父进程等待子进程状态的系统调用。它们允许父进程获取子进程的退出状态,并在子进程退出后回收其资源。

wait 函数:

  • 函数原型
#include <sys/wait.h>
pid_t wait(int *status);
  • 功能wait 函数挂起父进程的执行,直到有一个子进程退出。如果有多个子进程已经退出,wait 将唤醒并返回第一个退出的子进程的信息。
  • 参数status 是一个输出型参数,用指向整数的指针来接收子进程的退出状态。
  • 返回值:返回终止的子进程的 PID。如果出现错误,返回 -1 并设置 errno
  • 特点wait 函数只能等待任何单个子进程的退出,不能指定等待特定的子进程。

 

waitpid 函数:

  • 函数原型
#include <sys/wait.h>
pid_t waitpid(pid_t pid, int *status, int options);
  • 功能waitpid 函数允许父进程等待指定的子进程 pid 退出。与 wait 不同,waitpid 可以等待特定的子进程。当pid参数设置为-1是,等待任何子进程。
  • 返回值:返回终止的子进程的 PID。如果指定的子进程没有退出,且 options 包含 WNOHANG,则返回 0。如果出现错误,返回 -1 并设置 errno
  • 参数
    • pid:指定要等待的子进程的 PID。如果设置为 -1,则等待任何子进程。
    • status:与 wait 相同,用于接收子进程的退出状态。
    • options:指定等待操作的一些选项,常用的选项有 WNOHANGWUNTRACED,也可以设置为0表示阻塞等待进程退出。

options解释:

0 :如果设置此选项,跟wait函数效果相同,阻塞等待进程退出。

WNOHANG:

  • WNOHANG 选项使得 waitpid 调用变为非阻塞。如果没有任何子进程已经终止,waitpid 会立即返回而不是挂起调用进程的执行。这种模式下,如果子进程尚未结束,waitpid 返回0,而不是等待子进程结束 。
  • 例如,在服务器程序中,你可能希望在服务请求的同时检查子进程的状态。使用 WNOHANG 可以避免服务器因为等待子进程而停止处理其他请求 。

WUNTRACED:

  • WUNTRACED 选项允许 waitpid 报告已经停止的子进程,而不仅仅是已经终止的子进程。这通常用于调试目的,当子进程被其他进程(如调试器)暂停时,waitpid 可以报告这些信息 。
  • 这个选项对于常规应用程序来说使用较少,主要是在需要跟踪子进程状态的特定场景下使用 。

 

 

进程退出的场景:

1、 运行完毕,结果正确——没人关心

2、运行完毕,结果错误——看错误码

3、根本没运行完就崩了——错误码失效

注意:当进程非正常退出,退出码已经失去原有意义!!!

比如当进程空指针直接错误终止返回,此时退出码没有来得及修改,返回值为0,但显然程序已经不是正常退出了

 

status

status 是一个整数变量,用于存储由 waitwaitpid 函数返回的子进程状态信息。当 waitwaitpid 被调用时,如果子进程已经终止,这些函数会将子进程的退出信息存储在 status 变量中。status 的值可以表示多种子进程终止的情况,包括正常退出、被信号终止、停止执行等。

status内部的结构:

 

458615c283b2eb5c492300b6b7d08a3d.png

WIFEXITED(status)

WIFEXITED 是一个宏((status >> 8) & 0xFF),用于检查 status 变量是否表示子进程是因调用 exit()_exit() 函数正常退出的。如果子进程正常退出,则 WIFEXITED(status) 宏返回一个非零值(通常是1),表示子进程的退出状态可用;如果子进程不是正常退出的,则返回0。

WEXITSTATUS(status)

WEXITSTATUS 是一个宏(status & 0x7F),用于提取子进程的退出状态码。当 WIFEXITED(status) 返回非零值,表明子进程是正常退出的,此时可以使用 WEXITSTATUS(status) 宏来获取子进程的退出码。退出码是由子进程在退出时通过 exit() 函数的参数指定的,通常用于指示程序的退出原因或状态。

示例:

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

int main() {
    pid_t pid = fork(); // 创建子进程

    if (pid == -1) {
        perror("fork failed");
        exit(EXIT_FAILURE);
    } else if (pid == 0) {
        // 子进程
        exit(123); // 正常退出
    } else {
        // 父进程
        int status;
        pid_t terminated_pid = wait(&status); // 等待子进程结束

        if (WIFEXITED(status)) {
            printf("子进程退出,返回状态码:%d\n", WEXITSTATUS(status));
        } else {
            printf("子进程非正常退出\n");
        }
    }

    return 0;
}

 

4ccfc88f93ce1403f365f19a1352f487.png

在这个示例中,父进程使用 wait() 系统调用等待子进程结束,并获取子进程的退出状态。通过 WIFEXITED() 宏检查子进程是否正常退出,并使用 WEXITSTATUS() 宏获取子进程的退出状态码。

 

 

5、进程程序替换:exec函数族

exec函数族用于替换当前进程的映像为新的程序。这在shell中常用于执行用户输入的命令。进程替换函数族包括execlexeclpexecleexecvexecvpexecve等。

函数声明:

#include <unistd.h>

// 在当前进程映像中执行一个新的程序,path 是新程序的路径,arg 是主参数(通常是程序名)
// 后续参数是通过可变参数列表 (...) 传递的,以 NULL 结尾
int execl(const char *path, const char *arg, ...);

// 类似于 execl,但在系统环境变量 PATH 中搜索程序文件
int execlp(const char *file, const char *arg, ...);

// 类似于 execl,但允许传递环境指针 envp,envp 是环境变量数组,以 NULL 结尾
int execle(const char *path, const char *arg, ..., char *const envp[]);

// 在当前进程映像中执行一个新的程序,path 是新程序的路径
// argv 是指向参数列表的指针数组,参数列表以 NULL 结尾
int execv(const char *path, char *const argv[]);

// 类似于 execv,但在系统环境变量 PATH 中搜索程序文件
int execvp(const char *file, char *const argv[]);

系列函数的命名规则:

虽然函数杂乱,但是都是按照此规则命名的exec+数据容器(list列表或vector数组)+环境变量(路径或者自己维护的环境变量数组)

 

c5fd4a11d52ac2564522a3a2de65bb0b.png

#include <unistd.h>
int main()
{
 char *const argv[] = {"ps", "-ef", NULL};
 char *const envp[] = {"PATH=/bin:/usr/bin", "TERM=console", NULL};

 execl("/bin/ps", "ps", "-ef", NULL);
 
// 带p的,可以使用环境变量PATH,无需写全路径
 execlp("ps", "ps", "-ef", NULL);
 
// 带e的,需要自己组装环境变量
 execle("ps", "ps", "-ef", NULL, envp);

 execv("/bin/ps", argv);
 
// 带p的,可以使用环境变量PATH,无需写全路径
 execvp("ps", argv);
 
// 带e的,需要自己组装环境变量
 execve("/bin/ps", argv, envp);

 exit(0);
}

 

示例:

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

int main() {
    pid_t id = fork();
    if (id < 0) {
        // fork失败
        return -1;
    } else if (id == 0) {
        // 子进程
        // 执行 /bin/ps 程序并显示 -al 选项的结果
        execl("/bin/ps", "ps", "-al", NULL);
        // 如果execl成功执行,将不会到达这里
        perror("execl failed");
        exit(EXIT_FAILURE);
    } else {
        // 父进程
        int status = 0;
        // 等待子进程结束
        wait(&status);
        // 检查子进程是否正常退出,并打印退出码
        if (WIFEXITED(status)) {
            printf("exit_code: %d\n", WEXITSTATUS(status));
        } else {
            // 子进程非正常退出
            printf("exit_fail\n");
        }
    }

    return 0;
}

运行结果,发现子进程被替换为ps命令进行执行。其实bash的原理也不过如此~

 

b007a04e297c7ae7a6ac9314a921733e.png

 

 

  • 17
    点赞
  • 13
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值