Linux 进程创建与控制详解

目录

[1. 什么是进程?------ 深入理解进程模型](#1. 什么是进程?—— 深入理解进程模型)

[a. 唯一的进程ID (PID)](#a. 唯一的进程ID (PID))

[b. 独立的虚拟地址空间](#b. 独立的虚拟地址空间)

[c. 进程控制块 (PCB - Process Control Block)](#c. 进程控制块 (PCB - Process Control Block))

[d. 文件描述符表](#d. 文件描述符表)

[2. 经典的 fork() - exec() - wait() 模式](#2. 经典的 fork() - exec() - wait() 模式)

[a. fork(): 克隆一个新进程](#a. fork(): 克隆一个新进程)

[b. exec() 家族: 变身为一个新程序](#b. exec() 家族: 变身为一个新程序)

[c. wait() 和 waitpid(): 为子进程"收尸"](#c. wait() 和 waitpid(): 为子进程“收尸”)

[3. 进程的终止: exit() 和 _exit()](#3. 进程的终止: exit() 和 _exit())

总结


1. 什么是进程?------ 深入理解进程模型

在 Linux 中,一个进程(Process)并不仅仅是"一个正在运行的程序",它是一个更为丰富和具体的概念。操作系统为了管理和隔离这些运行中的程序,为每一个进程都创建了一个完整、独立的运行环境。这个环境主要由以下几个部分构成:

a. 唯一的进程ID (PID)

每个进程都有一个独一无二的非负整数标识符,称为 PID 。同时,每个进程(除了系统启动的第一个进程 init)都有一个父进程,其 PID 被称为 PPID (Parent Process ID)。这个父子关系构成了系统中的进程树。

b. 独立的虚拟地址空间

这是进程隔离的核心机制。每个进程都"认为"自己独占了整个系统的内存。这个地址空间通常被划分为几个区域:

  • 文本段 (.text): 存放程序的可执行代码,只读。

  • 数据段 (.data): 存放已初始化的全局变量和静态变量。

  • BSS 段 (.bss): 存放未初始化或初始化为零的全局变量和静态变量。

  • 堆 (Heap): 用于动态内存分配(例如,通过 malloc()),从低地址向高地址增长。

  • 栈 (Stack): 存放局部变量、函数参数和返回地址,从高地址向低地址增长。

由于每个进程都有自己的虚拟地址空间,一个进程的内存操作(如指针访问)无法直接影响到另一个进程,从而保证了系统的稳定性。

c. 进程控制块 (PCB - Process Control Block)

这是操作系统内核中用于描述和管理进程的一个数据结构(在 Linux 中是 task_struct)。它包含了关于进程的所有关键信息,可以看作是进程的"身份证":

  • 进程状态(运行、睡眠、僵尸等)

  • PID 和 PPID

  • CPU 寄存器的值(如程序计数器、栈指针)

  • 虚拟内存映射信息

  • 文件描述符表

  • 用户和组 ID

当内核需要进行进程切换时,它会保存当前进程的 PCB 信息,并加载下一个要运行进程的 PCB 信息。

d. 文件描述符表

每个进程都有一张表,用于记录它打开的文件。表中的每一项是一个整数(文件描述符),指向一个内核中代表打开文件的对象。这张表的前三项通常被预留给:

  • 0: 标准输入 (stdin)

  • 1: 标准输出 (stdout)

  • 2: 标准错误 (stderr)

2. 经典的 fork() - exec() - wait() 模式

这是 UNIX/Linux 系统中创建和管理新任务的基石。几乎所有的程序启动,包括你在 Shell 中执行的每一条命令,都遵循这个模式。

a. fork(): 克隆一个新进程

fork() 是一个非常独特的系统调用,它的作用是创建一个新进程(子进程),这个子进程几乎是调用 fork() 的进程(父进程)的完整副本。

  • 返回值是关键fork() 被调用一次,但会返回两次。

    • 父进程 中,fork() 返回新创建的子进程的 PID

    • 子进程 中,fork() 返回 0

    • 如果创建失败,fork() 在父进程中返回 -1。 通过检查返回值,程序可以确定当前代码是在父进程中执行还是在子进程中执行。

  • 写时复制 (Copy-on-Write, COW)fork() 之后,内核并不会立即复制父进程的整个物理内存空间给子进程,因为这非常低效。相反,父子进程暂时共享相同的物理内存页。只有当其中一个进程尝试写入 某个内存页时,内核才会为该进程复制那个页,让它拥有自己的副本。这种优化策略使得 fork() 的执行速度非常快。

  • 继承了什么?:子进程继承了父进程的虚拟地址空间、文件描述符表、用户和组 ID 等大部分内容。但 PID、PPID 和某些资源(如内存锁)是独有的。

【代码示例:fork() 的使用】

复制代码
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h> // 引入 wait() 所需的头文件

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

    if (pid < 0) {
        // fork 失败
        fprintf(stderr, "Fork Failed\n");
        return 1;
    } else if (pid == 0) {
        // 这里是子进程的代码
        printf("I am the child process.\n");
        printf("My PID is %d, my parent's PID is %d.\n", getpid(), getppid());
        printf("Child process is finishing.\n");
    } else {
        // 这里是父进程的代码
        printf("I am the parent process.\n");
        printf("My PID is %d, I created a child with PID %d.\n", getpid(), pid);
        
        // 等待子进程结束,进行回收,防止产生僵尸进程
        wait(NULL); 
        printf("Parent process finished waiting and is finishing.\n");
    }

    return 0;
}

b. exec() 家族: 变身为一个新程序

fork() 只是创建了父进程的一个副本,如果想让子进程执行一个全新的程序(比如 /bin/ls),就需要 exec() 函数族。

  • 核心作用exec() 会用一个全新的程序镜像替换 当前进程的内存空间(包括代码、数据和堆栈)。一旦调用成功,原有的程序代码就完全被覆盖了,exec() 调用之后的代码将永远不会被执行。

  • 没有新进程exec() 不会 创建新进程。它只是在当前进程(通常是 fork() 出来的子进程)的上下文中加载并运行一个新程序。进程的 PID 保持不变。

  • 函数族exec() 有多个变体,命名规则可以帮助区分它们:

    • l (execl, execlp): 参数以列表(list)形式逐个列出,以 NULL 结尾。

    • v (execv, execvp): 参数以字符串指针数组(vector)形式传递。

    • p (execlp, execvp): 会在系统的 PATH 环境变量指定的目录中搜索要执行的程序。

    • e (execle, execve): 允许你手动传入一个环境变量数组。

【代码示例:结合 fork()execvp()

复制代码
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>

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

    if (pid < 0) {
        fprintf(stderr, "Fork Failed\n");
        return 1;
    } else if (pid == 0) {
        // 子进程
        printf("Child process is about to run 'ls -l'.\n");
        char *args[] = {"ls", "-l", NULL}; // execvp 的参数数组
        execvp(args[0], args);
        
        // 如果 execvp 成功,下面的代码永远不会执行
        perror("execvp failed"); // 如果 execvp 失败,它会返回,我们可以打印错误
        exit(1);
    } else {
        // 父进程
        printf("Parent process is waiting for the child to complete.\n");
        wait(NULL); // 等待子进程结束
        printf("Child process has finished.\n");
    }

    return 0;
}

c. wait()waitpid(): 为子进程"收尸"

进程管理中一个重要环节是资源回收。

  • 僵尸进程 (Zombie Process) :如果一个子进程已经终止,但其父进程还没有调用 wait()waitpid() 来获取它的终止状态,那么这个子进程的进程控制块(PCB)会一直保留在内核中。这个已经死亡但 PCB 仍存在的进程就被称为"僵尸进程"。它不占用内存,但会占用一个 PID,如果大量出现会耗尽系统的 PID 资源。

  • wait():

    • 阻塞父进程,直到它的任何一个子进程终止。

    • 返回终止的子进程的 PID。

    • 通过一个整数指针参数,可以获取子进程的退出状态。

  • waitpid():

    • 功能更强大,是 wait() 的超集。

    • 可以等待一个指定的子进程。

    • 可以通过选项参数(如 WNOHANG)实现非阻塞等待。

通过调用 wait()waitpid(),父进程完成了对子进程的"收尸"(reaping),内核可以安全地释放子进程的 PCB,从而避免了僵尸进程的产生。

3. 进程的终止: exit()_exit()

一个进程可以通过多种方式终止,最常见的是调用 exit() 函数。

  • 退出状态码 :每个进程终止时都会向其父进程返回一个 0 到 255 之间的整数,称为退出状态码。按照惯例,0 表示成功,非 0 表示发生了某种错误。父进程通过 wait() 可以获取这个状态码。

  • exit() vs _exit():

    • exit() 是 C 标准库函数。在终止进程前,它会执行一系列清理工作:

      1. 调用 atexit() 注册的函数。

      2. 刷新并关闭所有标准 I/O 库的流 (stream) (比如 stdout 的缓冲区)。

      3. 最后调用 _exit() 系统调用来终止进程。

    • _exit() 是一个系统调用。它会立即终止进程,不会进行任何清理工作。

为什么需要 _exit()?fork() 之后,子进程中通常建议使用 _exit() 而不是 exit()。因为子进程复制了父进程的 I/O 缓冲区,如果调用 exit(),父子进程可能会重复刷新和关闭相同的流,导致不可预料的行为。而在 exec() 之后,由于整个内存空间都被替换了,这个问题就不存在了。

总结

Linux 的进程创建与控制是一个优雅而强大的模型:

  1. fork() 负责"生",创建一个与父进程几乎一样的子进程。

  2. exec() 负责"养",让子进程变身为一个全新的程序,去执行新的任务。

  3. wait() 负责"葬",父进程在子进程完成后回收其资源,维持系统整洁。

  4. exit() 负责"死",进程完成任务后正常退出,并向父进程报告结果。

理解了这个生命周期,你就掌握了 Linux 多任务编程的基石。

相关推荐
迎風吹頭髮3 小时前
UNIX下C语言编程与实践8-UNIX 静态库原理与创建:ar 命令的使用与静态库调用全流程
服务器·c语言·unix
张红尘3 小时前
龙蜥OS8.10配置repo源使用RPM安装Redis8.2
linux·redis·操作系统
jieyu11193 小时前
入侵检测系统(IDS)和入侵防御系统(IPS)
运维·服务器·系统安全
编程点滴3 小时前
前端项目从 Windows 到 Linux:构建失败的陷阱
linux·前端
小白银子4 小时前
零基础从头教学Linux(Day 43)
linux·运维·服务器·nginx
The star"'4 小时前
Nginx 服务器
运维·服务器·nginx
慌糖4 小时前
自动化接口框架搭建分享-pytest第三部分
运维·自动化·pytest
迎風吹頭髮4 小时前
UNIX下C语言编程与实践12-lint 工具使用指南:C 语言源代码语法与逻辑错误检查实战
服务器·c语言·unix
迎風吹頭髮4 小时前
UNIX下C语言编程与实践11-UNIX 动态库显式调用:dlopen、dlsym、dlerror、dlclose 函数的使用与实例
服务器·c语言·unix