自学内容网 自学内容网

Linux进程控制

进程地址空间

在这里插入图片描述

堆向高地址开辟空间,栈向低地址开辟空间 堆栈相对而生

对于命令行参数环境变量,无论是表还是对表内具体指向的项目,都是在栈的上部

未初始化数据和初始化数据,会在我们进程运行期间一直存在。

什么是进程地址空间?

进程地址空间是数据结构,具体到进程中,就是特定数据结构的对象。

我们的地址空间,不具备对我们的代码和数据的保存能力,而是在物理内存中存放的。

是通过给我们的进程提供一张映射表–页表,从而将地址空间上的虚拟地址转化到物理内存中。

分页&虚拟地址空间

在这里插入图片描述

我们所用到的所有地址,全部都不是物理地址,这种地址叫做虚拟地址/线性地址。

父子进程的同一个变量,只是虚拟地址相同,其实是被映射到了不同的物理地址。

进程是具有独立性的,进程的PCB是一种数据结构,PCB中会有一个指针指向进程地址空间,进程地址空间也是一种数据结构,而每个进程地址空间的数据结构中也会有指针指向页表,页表也是一种数据结构

为什么要有地址空间+页表

  1. 将物理内存从无序变为有序,让进程以统一的视角看待内存。
  2. 将进程管理和内存管理进行解耦合。
  3. 地址空间+页表是保护内存安全的重要手段。

进程=内核数据结构+自己的代码和数据

子进程会继承父进程的代码和数据,因为代码是只读的,所以并不需要拷贝一份,而对于数据来说,则是进行写时拷贝的方法。在物理内存中并不会立即拷贝一份,而是当检测到数据需要修改时才进行拷贝一份到内存中作为子进程的数据。

malloc/new 申请内存

申请的内存我们并不一定会立即使用,并且申请内存的本质其实是在进程的虚拟地址空间中申请,所以如果我们没有立即使用的话,可能操作系统并不会立即在物理内存中开辟一个空间,而当我们使用的时候才会开辟空间给我们申请的内存,通过虚拟地址和页表进行映射。

这样充分保证了内存的使用率,不会空转,并且提升了new或者malloc的速度,因为操作系统一定要为效率和资源使用率负责

进程创建

fork函数

在linux中fork函数时非常重要的函数,它从已存在进程中创建一个新进程。新进程为子进程,而原进程为父进程。

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

进程调用fork,当控制转移到内核中的fork代码后,内核做:

  • 分配新的内存块和内核数据结构给子进程
  • 将父进程部分数据结构内容拷贝至子进程
  • 添加子进程到系统进程列表当中
  • fork返回之后,开始调度器调度

fork之前父进程独立执行,fork之后,父子两个执行流分别执行。但是谁先执行完全由调度器决定。

fork常规用法

  • 一个父进程希望复制自己,使父子进程同时执行不同的代码段。例如,父进程等待客户端请求,生成子进程来处理请求 。

  • 一个进程要执行一个不同的程序。例如子进程从fork返回后,调用exec函数。

fork调用失败的原因

  • 系统中有太多的进程
  • 实际用户的进程数超过了限制

写时拷贝

通常,父子代码共享,父子再不写入时,数据也是共享的,当任意一方试图写入,便以写时拷贝的方式各自一份副本。

在这里插入图片描述

为什么要写时拷贝

Linux和许多现代操作系统采用写时拷贝(Copy-On-Write, COW)技术,主要是出于性能和资源利用的考虑。写时拷贝是一种优化策略,用于进程间共享和复制资源(如内存页)时减少不必要的资源复制,从而提高效率和节省内存。具体来说,写时拷贝有以下几个主要原因和优势:

1. 节省内存资源

写时拷贝允许多个进程共享同一份数据的只读副本,而不是为每个进程创建数据的物理副本。只有在进程尝试写入这些共享数据时,操作系统才会为该进程创建数据的私有副本。这种方法显著减少了内存的使用,特别是在有许多进程读取相同数据但很少修改它时。

2. 提高性能

通过避免不必要的数据复制,写时拷贝减少了CPU和内存的工作负担,从而提高了系统的整体性能。特别是在创建新进程(如使用fork()系统调用)时,操作系统不需要立即复制整个父进程的地址空间,而只是在必要时(即写入操作发生时)才进行复制。这使得fork()操作更加高效。

3. 优化进程创建

在Linux中,fork()系统调用广泛使用写时拷贝技术。由于fork()创建的子进程开始时几乎与父进程相同,使用写时拷贝可以使子进程初始状态下共享父进程的所有内存页。只有当父进程或子进程尝试修改这些共享页时,才会真正进行物理内存页的复制。这样不仅提高了fork()的效率,也加快了进程创建的速度。

4. 支持高效的虚拟内存管理

写时拷贝是虚拟内存管理的一个关键组成部分,它允许操作系统更灵活地管理物理内存。通过推迟数据复制直到实际需要,系统能够更有效地分配和管理有限的物理内存资源,同时还支持更复杂的内存映射和共享机制。

总结

写时拷贝是一种高效的资源管理技术,它通过仅在必要时复制数据,来优化内存使用、提高系统性能、简化进程创建,并支持高效的虚拟内存管理。这种技术的应用显著提高了现代操作系统在处理大量并发进程和内存密集型应用时的效率和响应性。

进程终止

退出码

退出码(Exit code)是一个整数值,用于表示程序或命令的执行结果。在Unix、Linux和类Unix系统中,命令或程序执行完成后会返回一个退出码,用于指示执行的结果。

通常,退出码为 0 表示成功,非零值表示出现了某种错误或异常情况。不同的退出码对应着不同的错误类型,但具体的含义可能因命令或程序的不同而有所差异。一般而言,可以根据退出码的值来判断命令或程序的执行情况。

一般0,表示进程执行成功 非零,表示失败 不同的数字,表示不同的失败原因

main函数return返回的时候,表示进程退出,return XXXXXX为退出码,可以设置退出码的字符串含义

其他函数return退出,仅仅表示函数调用完毕。

在使用exit(status)时,代表着进程直接退出。

在终端中,您可以使用 $? 特殊变量来获取上一个命令的退出码。例如,可以通过输入 echo $? 来显示上一个命令的退出码。

查看退出码 echo $?

要查看上一个命令的退出码,您可以使用特殊变量 $?。在Linux或Unix系统中,命令执行后,会将退出状态码存储在 $? 变量中。退出状态码为 0 表示命令执行成功,非零值表示出现错误。

您可以通过在终端中输入 echo $? 来打印上一个命令的退出码。这将显示上一个命令执行完毕后的状态。

main函数的返回值,叫做进程的退出码

errnostrerror()

errnostrerror() 都是 C 标准库中用于处理错误的相关工具,它们通常用于一起处理错误信息。

  1. errno

    • errno 是一个全局变量,通常定义在 <errno.h> 头文件中。
    • 它用于保存在函数调用期间发生错误时的错误码。
    • 函数调用返回错误时,会将相应的错误码写入 errno 中。
    • 错误码是一个整数值,可用于识别错误类型。
    • 通常情况下,当函数调用成功时,errno 会被重置为零,当函数调用失败时,errno 会被设置为非零值,表示具体的错误类型。
  2. strerror()

    • strerror() 是一个函数,用于将错误码转换为对应的错误描述字符串。
    • 它接受一个错误码作为参数,并返回一个描述该错误的字符串。
    • strerror() 函数根据给定的错误码,在系统错误表中查找相应的错误描述,然后返回该描述字符串。
    • 错误描述通常是与系统相关的,因此在不同的系统上可能会有所不同。

这两个工具通常一起使用,以便在程序中处理和输出错误信息。例如,在发生函数调用失败时,可以通过 errno 获取错误码,然后使用 strerror() 将其转换为对应的错误描述,以便更好地了解和处理错误情况。

以下是一个简单的示例,演示了如何使用 errnostrerror() 来处理错误信息:

#include <stdio.h>
#include <errno.h>
#include <string.h>

int main() {
    FILE *file;

    // 尝试打开一个不存在的文件
    file = fopen("nonexistent_file.txt", "r");

    // 检查文件是否成功打开
    if (file == NULL) {
        // 如果文件打开失败,检查 errno 获取错误码
        int error_code = errno;

        // 使用 strerror() 将错误码转换为错误描述
        const char *error_description = strerror(error_code);

        // 输出错误信息
        printf("Error opening file: %s\n", error_description);
    } else {
        // 文件成功打开,进行进一步的操作
        // 例如:读取文件内容、处理数据等
        fclose(file);
    }

    return 0;
}

在这个例子中,我们尝试打开一个不存在的文件。如果文件打开失败,我们使用 errno 获取错误码,然后使用 strerror() 将错误码转换为对应的错误描述字符串。最后,我们将错误描述输出到控制台。

需要注意的是,strerror() 返回的错误描述通常是与系统相关的,因此可能会因操作系统而异。

错误码转化为错误描述:

  1. 使用语言和系统自带的方法,进行转化。

    ​ 如使用errnostrerror()

  2. 可以自定义。

函数退出时我们怎样知道函数的执行情况和错误原因?

返回值:函数通常会返回一个值,这个返回值可以提供有关函数执行情况的信息。例如,如果函数成功执行并产生了结果,返回一个表示成功的值;如果函数执行失败,可以返回一个表示失败的特定值或错误码。

进程退出的场景:

  1. 进程代码执行完,结果是正确的。
  2. 进程代码执行完,结果是不正确的。
  3. 进程代码没有执行完,进程出异常了。

对于进程代码没有执行完,进程出异常这种情况,是进程收到了异常信号,每个信号都有不同的编号,不同的信号编号表明不同异常的原因。

所以代码运行出异常,本质是收到了信号。

进程常见退出方法

正常终止(可以通过echo $?查看进程退出码):

  1. main返回return
  2. 调用exit
  3. _exit

异常退出

ctrl+c,信号终止

_exit函数:

#include <unistd.h>
void _exit(int status);
//参数:status 定义了进程的退出状态,父进程通过wait来获取该值

说明:虽然status是int,但是仅有低8位可以被父进程所用。所以_exit(-1)时,在终端执行$?发现返回值是255。

_exit 是一个用于进程异常退出的系统调用函数。它在C语言中作为库函数存在,并用于立即终止当前进程的执行。

exit 函数不同,_exit 函数并不执行正常的退出处理,比如关闭文件流、释放内存等。它会直接终止进程的执行,并立即返回操作系统。由于不执行正常的退出处理,因此在调用 _exit 函数时,任何尚未刷新的缓冲区都不会被刷新,任何在 atexit 注册的函数也不会被调用。

在C语言中,可以使用 _exit 函数来实现进程异常退出,示例代码如下:

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

int main() {
    printf("Before _exit\n");
    _exit(1); // 使用 _exit 函数异常退出进程,返回值为 1
    printf("After _exit\n"); // 这行代码不会被执行
    return 0;
}

在这个示例中,当 _exit 被调用时,进程立即终止执行,printf("After _exit\n"); 不会被执行。

需要注意的是,_exit 函数接受一个整数参数,通常用于指示进程的退出状态,返回给操作系统。通常情况下,返回值为 0 表示进程正常退出,非零值表示进程异常退出,可以用来表示不同的错误类型。

exit函数:

#include <unistd.h>
void exit(int status);

exit 是一个C语言标准库函数,用于终止程序的执行。它的原型定义在 <stdlib.h> 头文件中:

exit就是用来终止进程的,exit(退出码)

在我们的进程代码中,任意地方调用exit,都表示进程退出

exit 函数接受一个整数参数 status,用于指示程序的退出状态。这个状态码会返回给调用进程,通常用于指示程序的执行结果或状态。在大多数情况下,一个程序的退出状态为 0 表示成功,非零值表示出现了某种错误或异常情况。

当程序调用 exit 函数时,它会立即终止执行,并在终止前执行一些清理工作,如关闭文件流、释放资源等。随后,程序会返回指定的状态码给其父进程或操作系统。

以下是一个简单的示例,演示了如何使用 exit 函数退出程序并返回状态码:

#include <stdlib.h>

int main() {
    // 执行一些操作

    // 退出并返回状态码 0,表示成功
    exit(0);
}

在这个例子中,程序执行完毕后会调用 exit(0) 退出,并返回状态码 0 给其父进程或操作系统。

exit_exit

exit最后也会调用 _exit,但在调用 _exit 之前,还做了其他工作:

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

exit刷新缓冲区,而_exit不会。

exit底层就是封装_exit,也就是库函数封装了系统调用

return退出

return是一种更常见的退出进程方法。执行return n等于执行exit(n),因为调用main的运行时函数会将main的返回值当做exit的参数

函数return表示函数结束,但exit表示进程退出

main函数中return表示进程退出

缓冲区

我们之前所谈的缓冲区,绝对不是操作系统里面的缓冲区,叫库级别缓冲区,在库里,在上层。

任何进程最终的执行情况,我们都可以使用退出码和信号编号两个数字表明具体执行的情况:

在操作系统中,一般情况下,我们可以用两个数字来描述任何进程的最终执行情况:

  1. 退出状态码(Exit Status Code)
    进程在退出时通常会返回一个状态码,用于表示进程的最终执行情况。通常情况下,状态码为0表示进程成功执行并正常退出,而其他非零状态码通常表示进程在执行过程中发生了某种错误或异常情况。不同的状态码可以表示不同的失败原因,因此可以根据状态码来判断进程的执行情况。

  2. 信号编号(Signal Number)
    如果进程是因为接收到信号而退出的,我们可以用一个信号编号来表示信号的类型。例如,SIGTERM表示终止信号,SIGSEGV表示段错误信号等。通过查看信号编号,我们可以了解到导致进程退出的具体信号类型,从而判断进程的执行情况。

这两个数字通常由操作系统提供给父进程,父进程可以通过相关的系统调用(如wait()、waitpid()等)来获取子进程的退出状态,并从中提取状态码和信号编号。通过这些信息,可以了解到子进程的最终执行情况,并根据需要进行相应的处理。

用户是不能直接拿到这两个数字的,只能系统调用。操作系统设计的一个基本原则是进程之间的隔离,每个进程都有自己的地址空间和权限,不能直接访问其他进程的内部信息。

进程等待

为什么要进行进程等待?

  1. 父进程通过wait方式,回收子进程的资源
  2. 通过wait方式,获取子进程的退出信息

进程等待方法

wait方法:

#include<sys/types.h>
#include<sys/wait.h>
pid_t wait(int*status);
//返回值:
//成功返回被等待进程pid,失败返回-1。
//参数:
//输出型参数,获取子进程退出状态,不关心则可以设置成为NULL

waitpid方法:

pid_ t waitpid(pid_t pid, int *status, int options);
//返回值:
//当正常返回的时候waitpid返回收集到的子进程的进程ID;
//如果设置了选项WNOHANG(非阻塞模式),而调用中waitpid发现没有已退出的子进程可收集,则返回0;
//如果调用中出错,则返回-1,这时errno会被设置成相应的值以指示错误所在;
//参数:
//pid:
//Pid=-1,等待任一个子进程。与wait等效。
//Pid>0.等待其进程ID与pid相等的子进程。
//status:
//WIFEXITED(status): 若为正常终止子进程返回的状态,则为真。(查看进程是否是正常退出)
//WEXITSTATUS(status): 若WIFEXITED非零,提取子进程退出码。(查看进程的退出码)
//options:
//WNOHANG(非阻塞模式): 若pid指定的子进程没有结束,则waitpid()函数返回0,不予以等待。若正常结束,则返回该子进程的ID。

下面的示例展示了如何使用 waitpid() 来等待特定的子进程结束:

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

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

    if (pid == -1) {
        // fork失败
        perror("fork failed");
        exit(EXIT_FAILURE);
    } else if (pid == 0) {
        // 子进程
        printf("Child PID is %d\n", getpid());
        // 模拟工作
        sleep(2);
        exit(10);  // 特定退出状态
    } else {
        // 父进程
        int status;
        pid_t waitedPid = waitpid(pid, &status, 0);  // 等待特定的子进程结束

        if (waitedPid == -1) {
            // 错误处理
            perror("waitpid failed");
            exit(EXIT_FAILURE);
        }

        if (WIFEXITED(status)) {
            printf("Child %d exited with status %d\n", waitedPid, WEXITSTATUS(status));
        }
    }

    return 0;
}

这段代码创建了一个子进程,然后父进程使用 waitpid() 函数等待这个特定的子进程结束。waitpid() 使得父进程能够获知子进程的结束状态,并且能够根据这个状态做出相应的处理。

waitwaitpid

  • 如果子进程已经退出,调用wait/waitpid时,wait/waitpid会立即返回,并且释放资源,获得子进程退出信息。

  • 如果在任意时刻调用wait/waitpid,子进程存在且正常运行,则进程可能阻塞。

  • 如果不存在该子进程,则立即出错返回。

wait:

默认会进行阻塞等待,等待任意一个子进程。返回值大于0,等待成功,表示子进程的pid,返回值小于0,等待失败。

waitpid:

pid=-1,等待任一个子进程,与wait等效。pid>0,等待其进程ID与pid相等的子进程。

对于status,有自己的格式,包含退出码和退出信号

获取子进程status

  • wait和waitpid,都有一个status参数,该参数是一个输出型参数,由操作系统填充。

  • 如果传递NULL,表示不关心子进程的退出状态信息。

  • 否则,操作系统会根据该参数,将子进程的退出信息反馈给父进程。

  • status不能简单的当作整形来看待,可以当作位图来看待,具体细节如下图(只研究status低16比特位):

在这里插入图片描述

exitstatuswaitstatus不同

exit中,status 定义了进程的退出状态,虽然status是int,但是仅有低8位可以被父进程所用。所以_exit(-1)时,在终端执行$?发现返回值是255。父进程通过wait来获取该值

wait中的status为输出型参数,获取子进程退出状态,不能当作简单的整形看待,可以看作是一个位图,包含了进程的退出码和信号编号退出码为高8位,信号编号为低8位

在这里插入图片描述

进程的非阻塞等待

#include <sys/wait.h>

pid_t waitpid(pid_t pid, int *status, int options);

waitpid的第三个参数设为WNOHANG,父进程就会执行非阻塞等待,基于非阻塞的轮询访问。

waitpid 函数中,可以使用 WNOHANG 选项来实现非阻塞等待。这样,如果没有子进程退出,waitpid 函数将立即返回,而不会阻塞调用进程。

以下是一个简单的示例代码,演示如何使用 waitpid 函数进行非阻塞等待:

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

int main() {
    pid_t child_pid;
    int status;

    // 创建子进程
    child_pid = fork();

    if (child_pid == -1) {
        perror("fork");
        exit(EXIT_FAILURE);
    } else if (child_pid == 0) {
        // 子进程
        printf("Child process with PID %d is running.\n", getpid());
        sleep(3); // 模拟子进程执行一段时间
        printf("Child process with PID %d is exiting.\n", getpid());
        exit(EXIT_SUCCESS);
    } else {
        // 父进程
        printf("Parent process with PID %d is waiting for child process with PID %d to finish.\n", getpid(), child_pid);

        // 使用 waitpid 进行非阻塞等待子进程结束
        while (waitpid(child_pid, &status, WNOHANG) == 0) {
            printf("Parent process is still waiting...\n");
            sleep(1); // 等待1秒后再次检查子进程状态
        }

        if (WIFEXITED(status)) {
            printf("Child process with PID %d exited normally with status %d.\n", child_pid, WEXITSTATUS(status));
        } else if (WIFSIGNALED(status)) {
            printf("Child process with PID %d terminated by signal %d.\n", child_pid, WTERMSIG(status));
        }
    }

    return 0;
}

在这个例子中,父进程使用 waitpid 函数进行非阻塞等待子进程结束,通过设置 WNOHANG 选项,如果子进程尚未退出,waitpid 将立即返回 0。父进程每隔一秒检查一次子进程的状态,直到子进程退出。

进程替换

替换原理

fork创建子进程后执行的是和父进程相同的程序(但有可能执行不同的代码分支),子进程往往要调用一种exec函数 以执行另一个程序。当进程调用一种exec函数时,该进程的用户空间代码和数据完全被新程序替换,从新程序的启动 例程开始执行。调用exec不创建新进程,所以调用exec前后该进程的id并未改变。

替换函数

有六种以exec开头的函数,统称exec函数:

#include <unistd.h>`
int execl(const char *path, const char *arg, ...);
int execlp(const char *file, const char *arg, ...);
int execle(const char *path, const char *arg, ...,char *const envp[]);
int execv(const char *path, char *const argv[]);
int execvp(const char *file, char *const argv[]);

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

函数解释

  • 这些 exec 函数会将当前进程的内容替换为新的程序,新程序从 main 函数开始执行。替换完成不创建新的程序
  • 如果执行成功,这些函数不会返回;如果执行失败,则会返回 -1,并设置 errno 错误码。
  • 所以exec函数只有出错的返回值而没有成功的返回值。
  • 创建一个进程,是先创建pcb,地址空间,页表等,再将程序(代码和数据) 加载到内存,程序替换所做的本质工作就是加载

需要注意的是,使用 exec 函数族进行进程替换后,原来进程的代码、数据和堆栈都会被替换为新程序的内容,因此在调用 exec 函数后,原来的进程的状态(包括打开的文件描述符、信号处理器等)都会丢失。

子进程在进行进程替换时,会对代码和数据进行修改,如果之前从未修改过代码和数据,那么此时是与父进程共享的代码和数据,所以会发生写时拷贝

命名理解

  • l(list) : 表示参数采用列表
  • v(vector) : 参数用数组
  • p(path) : 有p自动搜索环境变量PATH
  • e(env) : 表示自己维护环境变量
  1. execlexeclpexecle:这些函数会接受一系列参数,用于指定要执行的程序的路径以及程序的参数。其中:
    • execl 接受一个参数列表,每个参数都是一个字符串,以 NULL 结束。
    • execlpexecl 类似,但会在 PATH 环境变量中搜索可执行文件。
    • execle 类似于 execl,但允许指定环境变量。
  2. execvexecvpexecve:这些函数与上述函数类似,但它们接受一个字符串数组作为参数,用于指定要执行的程序和参数。

在这里插入图片描述

exec调用举例

exec调用举例如下:

#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("/bin/ps", "ps", "-ef", NULL, envp);
 //带v的,参数用数组
 execv("/bin/ps", argv);
 
 // 带v的,参数用数组
 // 带p的,可以使用环境变量PATH,无需写全路径
 execvp("ps", argv);
 // 带v的,参数用数组
 // 带e的,需要自己组装环境变量
 execve("/bin/ps", argv, envp);
 exit(0);
}

事实上,只有execve是真正的系统调用,其它五个函数最终都调用 execve,所以execve在man手册 第2节,其它函数在 man手册第3节。

在这里插入图片描述

进程替换时的环境变量

子进程默认可以通过地址空间继承的方式拿到环境变量,进程程序替换,不会替换环境变量数据。

如果是子进程单纯的新增环境变量可以用putenv,然后进程替换后的进程也会有新增变量。

如果想要设置全新的环境变量,则需要用到系统调用时的第三个参数envp


原文地址:https://blog.csdn.net/m0_62697214/article/details/136989720

免责声明:本站文章内容转载自网络资源,如本站内容侵犯了原著者的合法权益,可联系本站删除。更多内容请关注自学内容网(zxcms.com)!