【探索Linux】—— 强大的命令行工具 P.10(进程的控制——创建、终止、等待、程序替换)

首页 linux 【探索Linux】—— 强大的命令行工具 P.10(进程的控制——创建、终止、等待、程序替换)

一、进程创建
1. fork函数初识
⭕基本概念

fork函数是操作系统中的一个系统调用,用于创建一个新的进程,该进程是调用fork函数的进程的一个副本。新创建的进程称为子进程,原始进程称为父进程。

fork函数的函数原型:

#include <unistd.h>

pid_t fork(void);

⭕fork函数返回值

    父进程中的返回值:
        如果fork函数返回一个大于0的值,表示当前执行的是父进程。这个返回值是子进程的PID(进程ID),可以用来操作子进程。
        如果fork函数返回-1,表示创建子进程失败,通常是因为系统资源不足或权限不够等原因,此时应该处理错误情况。

    子进程中的返回值:
        如果fork函数返回0,表示当前执行的是子进程。可以根据需要在子进程中执行相应的任务逻辑。

根据fork函数的返回值,可以在程序中使用条件语句来区分父进程和子进程的不同逻辑,从而实现不同的处理方式。例如,可以在父进程中等待子进程的完成,或者在子进程中执行某种特定的任务。

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

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

    if (pid > 0) {
        // 父进程逻辑
        printf("This is the parent process. Child's PID: %d\n", pid);
    } else if (pid == 0) {
        // 子进程逻辑
        printf("This is the child process. Parent's PID: %d\n", getppid());
    } else {
        // fork失败
        fprintf(stderr, "Failed to create child process.\n");
        return 1;
    }

    // 父子进程共享的代码
    printf("This message is printed by both parent and child processes.\n");

    return 0;
}


需要注意的是,fork函数的使用可能会导致代码的分支,需要小心处理父子进程之间共享的资源以及避免产生竞争条件,以确保程序的正确性和可靠性。
2. fork函数的写时拷贝

fork函数的写时拷贝(Copy-on-Write,COW)是一种优化策略,用于在创建子进程时避免立即复制父进程的整个地址空间,这种机制可以提高性能和减少内存消耗。

在传统的fork操作中,父进程会创建一个子进程,并且子进程会复制父进程的所有资源,包括内存空间、文件描述符等。这样的完全复制操作非常消耗时间和内存。而使用写时拷贝机制,只有在需要修改共享的内存页时才会进行复制操作,从而节省了系统资源。

具体来说,当调用fork函数创建子进程时,操作系统会执行以下步骤:

    父进程会创建一个与自己拥有相同地址空间的子进程。
    子进程继承了父进程的页表,这意味着它与父进程共享相同的虚拟内存地址空间。
    在初始阶段,父进程和子进程共享所有的物理页面,这些页面被标记为“只读”。
    当父进程或子进程尝试修改共享的内存页时,操作系统会将相应的页面复制到一个新的物理页面,并将其标记为“可写”。
    父进程和子进程现在各自拥有一个独立的物理页面,它们不再共享相同的数据。

通过写时拷贝技术,父进程和子进程共享大部分内存页,只在需要修改共享内存时才进行复制操作。这样可以节省时间和内存,并提高系统性能。例如,在fork之后,如果子进程立即执行exec函数加载了一个新的程序,那么就不需要进行任何复制操作,这是因为子进程并不需要修改父进程的内存数据。
在这里插入图片描述

需要注意的是,写时拷贝只是在逻辑上实现了共享,而不是物理上的共享。父进程和子进程仍然拥有各自独立的虚拟地址空间,它们之间的共享是通过允许读取相同的物理内存来实现的,只有在修改时才会发生内存复制。

总结起来,fork函数的写时拷贝机制使得父进程和子进程在初始阶段共享相同的内存空间,只有在需要修改共享内存时才进行复制操作,从而提高了性能和降低了资源消耗。
二、进程终止
1. 进程退出场景

    代码运行完毕,结果正确:当我们运行一个程序时,通常会期望它在合理的时间内完成指定的任务,并返回正确的结果。如果程序能够正常执行并得到正确的输出,那么我们就称之为结果正确。

    代码运行完毕,结果不正确:然而,在某些情况下,程序可能会返回错误的输出,这时我们就说结果不正确。这可能是由于程序中的逻辑错误、算法错误、数据不一致或格式错误等原因导致的。

    代码异常终止:另外一种情况是当程序因为错误或异常而非正常退出时,我们称之为代码异常终止。在这种情况下,程序可能会抛出异常、崩溃或者停止响应。

2. 进程常见退出方法

    正常终止(可以通过 echo $? 查看进程退出码)
        调用退出函数:在许多编程语言中,都提供了用于结束进程的退出函数,如C/C++的exit()函数和Java的System.exit()方法。这些函数可以接受一个整数作为参数,表示进程的退出状态码,通常使用0表示正常终止。
        返回主函数:在C/C++中,从主函数中返回0也表示进程正常终止。

int main() {
    // 执行完所有任务后,通过返回0表示正常终止进程
    return 0;
}

    1
    2
    3
    4
    5

    异常退出:ctrl + c,信号终止

3. exit函数

exit 函数是一个库函数,它用于正常终止程序并返回到操作系统。通过调用 exit 函数,进程会经历一系列清理操作,例如关闭打开的文件、释放动态分配的内存等,然后将控制权交还给操作系统。这个过程被称为进程的正常退出。

⭕exit 函数的原型:

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

    1
    2

其中,status 参数表示进程的退出状态值。它可以是整数类型,用来传递进程的运行结果或者其他信息。通常情况下,使用 0 表示程序的正常退出,非零值表示程序异常退出或者出错的特定状态。

⭕当调用 exit 函数时,以下操作将在进程退出之前执行:

    调用注册的终止处理程序(通过 atexit 函数注册的函数)。
    关闭标准流(stdin、stdout、stderr)和其他打开的文件。
    刷新缓冲区,确保输出正确地写入文件。
    通知操作系统进程的退出状态码。

调用 exit 函数后,程序将不会返回到 exit 调用之后的代码位置,而是直接返回到操作系统。因此,在调用 exit 函数后的代码将不会被执行。例如:

#include <stdio.h>
#include <stdlib.h>

int main() {
    printf("Before exit\n");
    exit(0);   // 正常终止进程,退出状态码为0
    printf("After exit\n");  // 这行代码不会执行
    return 0;
}

    1
    2
    3
    4
    5
    6
    7
    8
    9

🚨 注意:使用 exit 函数时应确保在终止前完成必要的清理工作,以免资源泄漏或未定义行为。此外,可以通过在命令行或者父进程中获取退出状态码来获取程序的运行结果或其他信息。
4. _exit函数

_exit 函数是一个系统调用,它可以用来立即终止进程的执行。与 exit 函数不同的是, _exit 函数不会调用任何注册的终止处理程序,也不会冲洗输出缓冲区,而是通过直接向内核发送退出状态码来终止进程。因此,使用 _exit 函数可以避免在进程终止时执行一些不必要的或者危险的操作,如清理缓存、关闭文件等。

⭕_exit 函数的原型:

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

    1
    2

其中,status 参数表示进程的退出状态值。它与在 exit 函数中的参数含义相同。

与 exit 函数一样,由 _exit 函数终止的进程将永远不会返回到其调用进程的代码路径处。进程资源(包括打开的文件、未释放的内存等)都将被释放,并通知父进程该进程已经结束。

🚨 注意:由于 _exit 函数是一个系统调用,因此它并没有对库函数进行善后工作,如果你在 _exit 函数之前使用了库(如 iostream),则可能会导致内存泄露或者其他未定义的行为。为了避免这种情况,应该在使用 _exit 函数前显式地调用 std::flush 函数将缓冲区清空,并且进行必要的资源释放工作。
5. exit函数与_exit函数的区别

在这里插入图片描述

    库函数与系统调用:exit 是一个库函数,而 _exit 是一个系统调用。 exit 函数通过调用 _exit 系统调用来终止进程。

    清理操作的执行:exit 函数在终止进程之前执行一系列清理操作,例如关闭文件、刷新缓冲区等。它还会调用注册的终止处理程序(通过 atexit 函数注册的函数)进行额外的清理工作。相比之下,_exit 函数不会执行这些清理操作,直接终止进程。

    缓冲区刷新:exit 函数会自动刷新输出缓冲区,确保所有的输出都被写入文件。而 _exit 函数不会主动刷新缓冲区,可能导致部分输出被丢失。

    返回控制权:exit 函数返回到调用 exit 的代码位置,因此可以在程序中根据退出状态码进行特定操作。相反,_exit 直接将控制权返回给操作系统,不会返回到调用 _exit 的代码位置。

    资源释放:由于 exit 函数会执行清理操作,可以确保打开的文件被正常关闭、动态分配的内存被释放等。而 _exit 函数不会执行这些操作,可能导致资源泄漏。

⭕exit 函数是一个高级的、安全的方式来终止程序。它会执行一系列清理工作并确保输出被正确处理。相比之下,_exit 函数适用于需要立即终止进程且不进行任何额外处理的情况,如遇到致命错误或者需要在子进程中终止时使用。
6. return退出

return是一种更常见的退出进程方法。执行return n;等同于执行exit(n),因为调用main的运行时函数会将main的返回值当做 exit的参数。与 exit 和 _exit 函数不同,return 语句只能在函数内部使用,并且它是一种正常的控制流程操作。

下面是 return 语句的一些关键点:

    函数中止: return 语句的执行将导致函数立即结束并返回到函数的调用者。在函数执行 return 后,没有其他代码会被执行。

    返回值: return 语句可以选择性地返回一个值给调用者。例如,在有返回类型的函数中,如 int 类型,可以使用 return 返回一个整数值。

    返回类型检查: 在函数定义时,需要声明函数的返回类型。如果函数声明为 void 类型,则表示没有任何返回值,此时可以省略 return 语句或使用 return; 来提前结束函数。

    多个返回点: 函数中可以存在多个 return 语句,表示在不同的条件下提前返回。在这种情况下,只有一个 return 语句会被执行,其他的 return 语句都将被忽略。

例如:

#include <stdio.h>

int sum(int a, int b) {
    int result = a + b;
    return result;   // 返回计算结果给调用者
}

void printMessage() {
    printf("Hello, World!\n");
    return;   // 结束函数,没有返回值
}

int main() {
    printf("Before return\n");
    return 0;   // 终止 main 函数并退出程序
    printf("After return\n");  // 这行代码不会执行
}

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17

🚨注意:return 语句只能在函数内部使用,不能用于终止整个程序的执行。要终止整个程序,可以使用 exit 或 _exit 函数。而 return 语句只影响当前函数的执行流程。
三、进程等待
1. 进程等待的概念

进程等待是指一个进程暂停执行,直到某个条件满足或者某个事件发生后再继续执行的操作。在操作系统中,进程等待是一种同步机制,用于实现多个进程之间的协调和互动。

下面是一些常见的情况,需要使用进程等待:

    资源共享:多个进程可能需要同时访问某些共享资源,如数据库、文件、网络连接等。为了避免并发访问导致资源冲突和数据不一致,需要通过进程等待来协调各个进程对资源的使用。

    任务依赖:有些任务的执行可能依赖于其他任务的完成。如果一个进程要执行的任务需要使用其他进程尚未完成的结果,就需要使用进程等待来确保所需的数据已经准备好了。

    同步操作:在多线程或者多进程编程中,有时候需要保证某些操作的顺序性和一致性。例如,在生产者-消费者模型中,消费者进程必须等待生产者进程将数据放入共享缓冲区后才能进行消费。

    并发控制:有时候需要限制同时执行某个代码块的进程数量。例如,资源受限的情况下,只允许有限数量的进程同时执行某个操作,其他进程需要等待。

进程等待的主要目的是确保多个进程之间的协调和同步,以避免资源竞争、数据不一致和并发冲突等问题。通过使用进程等待,可以实现进程之间的合作和资源共享,从而提高系统的可靠性和效率。
2. 进程等待的方法
⭕wait 方法

wait方法:wait方法是在父进程中调用的,用于等待任一子进程退出或终止。它的语法为:

pid_t wait(int *status);

    1

    status指向一个整型变量,用于获取子进程的退出状态信息。
    wait方法会将调用进程挂起,直到任一子进程退出或终止。如果有多个子进程同时退出,它会返回任一子进程的进程ID。
    当子进程结束时,父进程会继续执行,并且通过status参数获取子进程的退出状态。

实战演示:

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

int main() {
    pid_t pid;
    int status;

    pid = fork();

    if (pid < 0) {
        fprintf(stderr, "Fork failed.\n");
        exit(1);
    } else if (pid == 0) {
        // 子进程执行的代码
        printf("This is the child process.\n");
        exit(123);  // 子进程以退出状态123结束
    } else {
        // 父进程执行的代码
        printf("This is the parent process.\n");

        wait(&status);

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

    return 0;
}

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33

在上面的示例中,父进程通过fork()创建了一个子进程。子进程输出一条消息后调用exit(123)退出,并传递了退出状态值为123给父进程。父进程使用wait()等待子进程的退出,并通过WIFEXITED和WEXITSTATUS宏判断子进程是否正常退出,并获取其退出状态值。
⭕waitpid方法

waitpid()函数是一个用于等待指定子进程状态改变的系统调用。它具有比wait()更为灵活的功能,可以指定等待的子进程、等待选项以及获取子进程的退出状态信息。

它的语法为:

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

    1

    pid:表示要等待的子进程的PID。
        pid > 0:等待具有指定PID的子进程。
        pid == 0:等待与调用进程在同一进程组中的所有子进程。
        pid < -1:等待进程组ID等于pid绝对值的任一子进程。
        pid == -1:等待任一子进程,相当于wait()函数的功能。

    status:一个整型指针,用于获取子进程的退出状态信息。(如果不关心子进程的退出状态,可以将该参数设置为NULL)。

    options:参数用于指定等待选项,可以使用多个选项,通过按位或(|)进行组合。常见的选项有:
        WNOHANG:非阻塞模式,即不挂起父进程,如果没有子进程状态发生改变,则立即返回,返回值为0。
        WUNTRACED:包括已经停止但未报告的子进程在内的所有子进程都会被等待。
        WCONTINUED:等待未报告的已停止子进程被继续(通常与WUNTRACED一起使用)。

返回值:

    如果成功等到指定的子进程状态改变,返回该子进程的PID(即pid参数指定的值)。
    如果调用进程没有子进程或出现错误,返回-1,并设置errno来指示具体的错误原因。

🚨注意事项:

    当调用waitpid()函数时,父进程会挂起(阻塞),直到指定的子进程状态发生改变。
    waitpid()函数提供了比wait()更灵活的方式来等待子进程状态改变,并且可以通过指定pid、options参数来满足不同的等待需求。
    如果指定的子进程已经退出,则waitpid()立即返回子进程的PID;如果指定的子进程尚未退出,则根据指定的选项来确定是否挂起等待。
    使用waitpid()函数时,父进程可以同时等待多个子进程的状态改变。
    waitpid()函数可以用于实现非阻塞的等待子进程状态改变的操作,即通过设置WNOHANG选项,在没有可等待的子进程时立即返回。

示例代码:

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

int main() {
    pid_t pid;
    int status;

    pid = fork();

    if (pid < 0) {
        fprintf(stderr, "Fork failed.\n");
        exit(1);
    } else if (pid == 0) {
        // 子进程执行的代码
        printf("This is the child process.\n");
        exit(123);  // 子进程以退出状态123结束
    } else {
        // 父进程执行的代码
        printf("This is the parent process.\n");

        while (waitpid(pid, &status, WNOHANG) == 0) {
            sleep(1);
            printf("Waiting for child process...\n");
        }

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

    return 0;
}

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35

在上面的示例中,父进程通过fork()创建了一个子进程。子进程输出一条消息后调用exit(123)退出,并传递了退出状态值为123给父进程。父进程使用waitpid()和WNOHANG选项进行非阻塞地等待子进程的退出,并通过WIFEXITED和WEXITSTATUS宏判断子进程是否正常退出,并获取其退出状态值。

这两个方法都可以实现父进程对子进程的等待操作,并获取子进程的状态信息。waitpid方法相比wait方法更加灵活,可以选择具体的子进程进行等待,在一些场景下更加有用。
3. 获取子进程的status
1. 获取原理

    wait和waitpid,都有一个status参数,该参数是一个输出型参数,由操作系统填充。
    如果传递NULL,表示不关心子进程的退出状态信息。
    否则,操作系统会根据该参数,将子进程的退出信息反馈给父进程。
    status不能简单的当作整形来看待,可以当作位图来看待,具体细节如下图(只研究status低16比特位)
    在这里插入图片描述

2. WIFEXITED宏 和 WEXITSTATUS宏

WIFEXITED和WEXITSTATUS是用于解析子进程退出状态信息的宏定义。下面是对这两个宏的详细介绍:

    WIFEXITED宏:

int WIFEXITED(int status);

    1

WIFEXITED宏用于判断子进程是否正常退出(非被信号终止),即子进程是否调用了exit或返回了main函数。

    参数status:子进程的退出状态信息,通常是通过wait()或waitpid()函数获取的。

    返回值:若子进程正常退出,则返回非零值(true),否则返回0(false)。

    WEXITSTATUS宏:

int WEXITSTATUS(int status);

    1

WEXITSTATUS宏用于获取子进程的退出状态值(0-255)。只有在WIFEXITED宏为true时才有效。

    参数status:子进程的退出状态信息,通常是通过wait()或waitpid()函数获取的。

    返回值:子进程的退出状态值。

四、进程程序替换
1. 概念

⭕进程程序替换(Process Program Replacement)是指在一个正在运行的进程中将当前执行的程序替换为新的程序的操作。也可以称之为进程映像替换(Process Image Replacement)。通过进程程序替换,可以使一个进程在执行过程中切换到另一个不同的程序,并继续执行。使用进程程序替换时,当前进程的内存空间会被新的程序覆盖,包括代码、数据和堆栈等信息。新程序的代码取代原先进程的代码,新程序的数据取代原先进程的数据,新程序的堆栈取代原先进程的堆栈,从而实现了进程的切换。
2. 替换原理

进程程序替换的原理是通过操作系统提供的相关函数,将当前进程的执行映像(包括代码、数据和堆栈等信息)替换为新的程序的执行映像。具体步骤如下:

    操作系统接收到进程程序替换的请求。
    操作系统在磁盘上找到新程序的可执行文件,并加载到内存中。
    操作系统根据新程序的可执行文件格式,解析出新程序的代码、数据和其他相关信息。
    操作系统为新程序分配新的地址空间,并将新程序的代码、数据和堆栈等信息复制到新的地址空间中。
    操作系统更新进程控制块(Process Control Block,PCB)中的相关信息,如程序计数器(Program Counter)指向新程序的入口地址。
    操作系统修改进程的内存映射表,更新各个段的物理地址映射关系。
    操作系统关闭原程序打开的文件描述符,释放相应的资源。
    操作系统将控制权交还给新程序的入口代码,开始执行新程序。
    新程序开始执行,继续在新的执行映像中进行运算和处理。

需要注意的是,进程程序替换只会替换当前进程的执行映像,不会影响其他进程。替换后的程序会继承原进程的一些状态信息,如文件描述符、信号处理设置等,以确保新程序能够正常运行。


3. 替换函数
⭕exec函数

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

    int execl(const char *path, const char *arg0, ... /* (char *) NULL */);
        替换当前进程的执行映像为指定路径的可执行文件。
        参数 path 是指定可执行文件的路径,参数 arg0 和之后的参数是传递给新程序的命令行参数。
        函数返回值为-1表示替换失败,否则不会返回。

    int execlp(const char *file, const char *arg0, ... /* (char *) NULL */);
        替换当前进程的执行映像为指定名称的可执行文件。文件名会在环境变量 $PATH 指定的目录中查找。
        参数 file 是指定可执行文件的名称,参数 arg0 和之后的参数是传递给新程序的命令行参数。
        函数返回值为-1表示替换失败,否则不会返回。

    int execle(const char *path, const char *arg0, ... /* (char *) NULL, char *const envp[] */);
        替换当前进程的执行映像为指定路径的可执行文件,并可以通过 envp 参数设置新程序的环境变量。
        参数 path 是指定可执行文件的路径,参数 arg0 和之后的参数是传递给新程序的命令行参数,最后的 envp 参数是环境变量数组。
        函数返回值为-1表示替换失败,否则不会返回。

    int execv(const char *path, char *const argv[]);
        替换当前进程的执行映像为指定路径的可执行文件,并传递参数列表给新程序。
        参数 path 是指定可执行文件的路径,参数 argv 是传递给新程序的参数数组,以NULL结尾。
        函数返回值为-1表示替换失败,否则不会返回。

    int execvp(const char *file, char *const argv[]);
        替换当前进程的执行映像为指定名称的可执行文件,并传递参数列表给新程序。文件名会在环境变量 $PATH 指定的目录中查找。
        参数 file 是指定可执行文件的名称,参数 argv 是传递给新程序的参数数组,以NULL结尾。
        函数返回值为-1表示替换失败,否则不会返回。

    int execve(const char *path, char *const argv[], char *const envp[]);
        替换当前进程的执行映像为指定路径的可执行文件,并传递参数列表和环境变量数组给新程序。
        参数 path 是指定可执行文件的路径,参数 argv 是传递给新程序的参数数组,以NULL结尾,envp 是环境变量数组。
        函数返回值为-1表示替换失败,否则不会返回。

这些函数都可以在C/C++等编程语言中使用,并通过设置参数来实现进程程序替换的功能。具体使用哪个函数取决于是否需要指定路径和环境变量以及参数的形式(以列表或数组表示)。
⭕命名理解

    l(list) : 表示参数采用列表
    v(vector) : 参数用数组
    p(path) : 有p自动搜索环境变量PATH
    e(env) : 表示自己维护环境变量

函数名    参数格式    是否带路径    是否使用当前环境变量
execl    列表    不是    是
execlp    列表    是    是
execle    列表    不是    不是,需自己组装环境变量
execv    数组    不是    是
execvp    数组    是    是
execve    数组    不是    不是,需自己组装环境变量
exec函数族 一个完整的例子(图)

在这里插入图片描述
五、总结

    进程创建
        进程创建使用fork函数,可以复制当前进程创建一个新的子进程。fork函数的返回值不同,子进程中返回0,父进程中返回子进程的PID。fork函数利用写时拷贝技术实现了高效的进程创建。

    进程终止
        进程终止可以通过多种方式实现,常见的有调用exit函数和_exit函数、return语句以及异常终止。exit函数是标准库函数,进行一些清理工作后终止进程。_exit函数是系统调用函数,直接终止进程。return语句是从main函数返回,等价于调用exit函数。

    进程等待
        进程等待是指父进程等待子进程执行完毕的过程。可以使用wait和waitpid函数来实现进程等待。wait函数会阻塞父进程,直到子进程退出,返回子进程的PID。waitpid函数可以指定等待的子进程PID,并具有更多的选项。

    进程程序替换
        进程程序替换是指将当前进程的执行映像替换为新的可执行文件。替换函数如exec函数族可以实现进程程序的替换。exec函数会加载新的程序代码和数据,并开始执行新程序。

 
74    2024-01-15 16:40:50