Linux进程

一、进程的基本概念

定义:进程是 正在执行的程序的一个实例。它不仅是可执行代码(文本段),还包括资源(如内存、打开的文件、处理器状态等)的集合。
进程是操作系统的核心部分,通过创建和合理调度多进程可以让系统各个部分的任务合理协调的运行, 进程的核心重点:
1. 进程在内存中的结构
2. 进程的状态
3. 进程之间的优先级, 如何修改其优先级
4. 创建和控制多个进程
5. 进程之间的通信方式

进程与程序的区别:

        程序:静态的,存储在磁盘上的可执行文件(二进制文件或脚本)。
        进程:动态的,是程序的一次执行活动,有生命周期(创建、运行、终止)。
        进程描述符(Process Descriptor):内核使用一个名为 task_struct 的数据结构来管理进程的所有信息。它是进程在内核中的"身份证"。

进程的组成部分:

PID(进程ID):唯一标识进程的整数。
地址空间:进程独占的虚拟内存空间(代码、数据、堆、栈)。
安全上下文:真实/有效用户ID(UID)和组ID(GID)。
程序计数器:指向下一条要执行的指令。
CPU 寄存器:保存进程的运行上下文。
打开的文件描述符表。
信号处理程序。
进程状态。

进程的生命周期与状态

进程状态:

运行(R - Running/Runnable):正在 CPU 上执行或在就绪队列中等待执行。

可中断睡眠(S - Interruptible Sleep):等待某个事件(如 I/O 完成、信号),可以被信号中断。

不可中断睡眠(D - Uninterruptible Sleep):等待硬件条件(如磁盘 I/O),不能被信号中断。这是防止进程在关键操作时被杀死的一种保护机制。

停止(T - Stopped):进程被暂停(如通过 SIGSTOP 信号),可以通过 SIGCONT 信号继续运行。

僵尸(Z - Zombie):进程已终止,但其退出状态尚未被父进程读取(wait())。它仍然占用着 PID 等少量资源,直到父进程回收。

跟踪(t - Tracing stop):被调试器暂停(如 GDB)。

状态转换:进程在其生命周期中会在上述状态间转换。

二、进程的创建与终止

创建:fork() 系统调用

作用:通过复制当前进程(父进程)来创建一个新进程(子进程)。
核心特性:
        一次调用,两次返回。
        父进程中返回子进程的 PID。
        子进程中返回 0。
        失败则返回 - 1。

为什么pid既可以是父亲的pid又可以是子进程的pid?
表现:在fork执行后,程序就会分支,子进程会复制fork之后的全部代码,父进程和子进程都会执行后续代码且互不干扰,即使变量名一致也不会影响;
底层机制:虚拟内存地址,写时拷贝。

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

int main() {
    pid_t pid;

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

    if (pid < 0) {
        // 错误处理
        fprintf(stderr, "Fork failed\n");
        return 1;
    } else if (pid == 0) {
        // 子进程代码
        printf("This is the child process. PID: %d, Parent PID: %d\n", getpid(), getppid());
    } else {
        // 父进程代码
        printf("This is the parent process. PID: %d, Child PID: %d\n", getpid(), pid);
    }

    return 0;
}

写时复制(Copy-On-Write, COW):现代 Linux 实现 fork() 的优化技术。父子进程初始共享物理内存页,只有当任一进程试图修改某个内存页时,内核才会为该页创建一个副本。这大大提高了创建效率。

变量名在编译之后实际上就是一个地址;
页表:虚拟地址和真实地址之间的映射关系被记录在页表中,每个进程都有自己的页表,且子进程会继承父进程的页表,在发生写时拷贝时,会改变子进程的页表的映射关系,父进程不变
虚拟内存地址:涉及到进程调用的变量,如果需要访问先需要访问虚拟内存,再用虚拟内存查询页表,获取存放变量的实际地址,不能直接访问实际内存。
写时拷贝:在一开始多个独立进程使用不同的虚拟内存通过页表映射到同一个实际地址,单某一个进程试图写入新的数据时,会直接拷贝一份原地址内存放的内容,然后修改页表,使虚拟内存指向新的实际地址,存入新的写入内容。

exec系列的函数如果是带p的, 必须依赖当前进程的环境变量PATH, 因为后续引入的程序需要在PATH里面查找路径,然而我们现在在命令行解释器bash中创建的程序,都是bash的子进程,bash的环境变量PATH里面存放了大量的命令路径(ls, cd , pwd, top), 这是为什么我们无论在哪都可以直接使用这些指令, 但是我们自己创建的指令不可以随处使用, 大多是情况下我们是用./mycmd 表示我们在当前路径下"./"执行mycmd,如果我们把这个mycmd的绝对路径加入bash的PATH环境变量,那么就可以随处调用了,但是会污染指令池.

在上面这个例子中,子进程会默认继承主进程的环境变量,但是也可以用execvpe,引入新的环境变量覆盖子进程的环境变量, 用exec系列的函数是 引入新的外部进程 替代子进程内后续的进程,  这个外部进程会继承原来子进程的环境变量, 除非用了execpe指定新的环境变量,; 我们在bash命令解释行输入的指令ls , cd等,底层都是在fork一个子进程,然后再exec一个进程.

执行:exec() 系列函数

作用:可以随便调用任何其他的进程(任何语言的可执行程序都是一样的)到自己的进程.
原理:  加载一个新的程序到当前进程的地址空间,并开始执行它。exec() 会替换掉当前进程的代码、数据、堆和栈。

常见函数:execl(), execlp(), execv(), execvp(), execle() 等。

exec系列函数是 Linux/Unix 中实现进程程序替换的核心接口,它们的作用是用新程序替换当前进程的代码段、数据段和堆栈,新程序从main函数开始执行。这些函数的差异体现在参数格式是否搜索PATH环境变量是否支持自定义环境变量三个维度,下面逐个详细讲解:

一、先明确:exec系列函数的共性

  1. 替换本质:成功调用后,当前进程的代码 / 数据被新程序覆盖,原进程中exec之后的代码永远不会执行;只有替换失败时,exec才会返回-1(错误码存于errno)。
  2. 进程属性不变:替换后进程的PIDPPID、文件描述符表(默认)、工作目录等内核属性保持不变(没有创建新进程)。
  3. 头文件:均需包含<unistd.h>

二、逐个拆解:函数原型、参数与示例

一定要注意,如果要使用某个外部程序来替换,必须先寻找这个程序,搞到这个程序的绝对路径,假设我们的myexe在/root/projects路径下,那么就需要把这个路径加入这个子进程的PATH中,不然肯定找不到.子进程的环境变量默认是继承父进程的.

1. execl() —— 「列表参数」+「绝对路径」
  • 命名含义l = list(参数以可变参数列表形式传入)。
  • 函数原型
    int execl(const char *path, const char *arg, ... /* (char *) NULL */);
    
  • 参数解释
    • path:新程序的绝对路径 / 相对路径(必须指定完整路径,除非是当前目录下的程序)。
    • arg:传给新程序的命令行参数(arg[0]通常是程序名本身,本质上就是让新进程通过 argv[0] 知道 “自己被称作什么名字”—— 这是进程感知自身标识的关键方式。),后续参数依次对应argv[1]argv[2]...,最后必须以NULL结尾(标记参数列表结束)
    • argv[0]并非 “命令行参数”,而是程序自身的标识符,而真正的命令行参数从argv[1]开始
  • 示例(执行/bin/ls -l -a):
    // path是绝对路径,参数列表以NULL结尾
    execl("/bin/ls", "ls", "-l", "-a", NULL);
    
  • 特点:参数直观,但需手动写全程序路径,适合路径固定的场景。
2. execlp() —— 「列表参数」+「自动搜 PATH」
  • 命名含义l = list(列表参数),p = path(自动搜索PATH环境变量)。
  • 函数原型
    int execlp(const char *file, const char *arg, ... /* (char *) NULL */);
    
  • 参数解释
    • file:新程序的名字(无需写路径,系统会在PATH环境变量指定的目录中搜索该程序,比如lsps)。
    • arg:与execl()一致(第一个arg为程序名,最后以NULL结尾)。
  • 示例(执行ls -l -a,自动搜PATH):
    // 无需写/bin/ls,系统会在PATH中找ls
    execlp("ls", "ls", "-l", "-a", NULL);
    
  • 特点:无需指定路径,更便捷;若file包含/(如./myprog),则会当作路径处理,不再搜PATH
3. execv() —— 「数组参数」+「绝对路径」
  • 命名含义v = vector(参数以字符串数组形式传入)。
  • 函数原型
    int execv(const char *path, char *const argv[]);
    
  • 参数解释
    • path:与execl()一致(新程序的绝对 / 相对路径)。
    • argv:字符串数组,存放命令行参数(argv[0]为程序名,最后一个元素必须是NULL)。
  • 示例(执行/bin/ls -l -a):
    char *argv[] = {"ls", "-l", "-a", NULL}; // 数组末尾必须NULL
    execv("/bin/ls", argv);
    
    //char *argv[] = {(char*)"ls", (char*)"-l", (char*)"-a", NULL}; 
    /* 
    建议你写上(char*),不然可能会有警报,或者报错
    C 语言标准明确:字符串字面量的类型是 const char[],
    因此它的指针类型是 const char*。理论上,const char* 
    不能直接赋值给 char*(因为会丢失 “只读” 属性)。
    之前的代码里没有加 (char*) 强转,核心原因是:
    不同编译器 / 编译模式对 “字符串字面量赋值给 char*” 的宽容度不同,
    以及 “是否触发警告 / 错误” 取决于编译选项 
    —— 这并非语法上的绝对要求,而是标准与兼容的权衡。
    */
  • 特点:适合参数数量动态变化的场景(比如从用户输入构建参数数组),比列表参数更灵活。
4. execvp() —— 「数组参数」+「自动搜 PATH」
  • 命名含义v = vector(数组参数),p = path(自动搜PATH)。
  • 函数原型
    int execvp(const char *file, char *const argv[]);
    
  • 参数解释
    • file:与execlp()一致(程序名,自动搜PATH)。
    • argv:与execv()一致(参数数组,末尾NULL)。
  • 示例(执行ls -l -a,自动搜PATH):
    putenv("PATH=/bin:/usr/bin:/root/projects");
    char *argv[] = {"myexe", "-l", "-a", NULL};
    execvp("myexe", argv);
    
    
  • 特点:结合了execv()的数组灵活性和execlp()的路径搜索,是最常用的exec函数之一(比如bash执行命令时常用)。
5. execle() —— 「列表参数」+「自定义环境变量」
  • 命名含义l = list(列表参数),e = environment(自定义环境变量)。
  • 函数原型
    int execle(const char *path, const char *arg, 
    ... /*, (char *) NULL, char *const envp[] */);
    
  • 参数解释
    • path/arg:与execl()一致(路径 + 列表参数,最后NULL结尾)。
    • envp:自定义的环境变量数组(每个元素格式为"KEY=VALUE",最后以NULL结尾),会覆盖当前进程继承的环境变量。
  • 示例(执行自定义程序./myprog,并传自定义环境变量):
    char *envp[] = {"MY_ENV=hello", "PATH=/tmp", NULL}; 
    // 自定义环境变量
    // 最后一个参数是envp数组
    execle("./myprog", "myprog", "arg1", NULL, envp);
    
  • 特点:可自定义新程序的环境变量,适合需要隔离环境的场景(普通exec函数会继承父进程的环境变量)。
6. execvpe () —— 「数组参数」+「PATH 搜索」+「自定义环境变量」

        命名含义:v = vector(数组参数),p = path(搜索 PATH 环境变量),e = environment(自定义环境变量)。

        函数原型:int execvpe(const char *file, char *const argv[], char *const envp[]);

        参数解释

  • file:可执行文件的名称或路径(若为名称,会自动搜索 PATH 环境变量;若为路径则直接使用);
  • argv:参数数组(每个元素为命令参数,argv [0] 通常是程序名,最后以 NULL 结尾);
  • envp:自定义的环境变量数组(每个元素格式为 "KEY=VALUE",最后以 NULL 结尾),会覆盖当前进程继承的环境变量。

        示例(执行系统命令 ls,传数组参数并自定义环境变量):

char *argv [] = {"ls", "-l", NULL};// 参数数组
char *envp [] = {"MY_ENV=test", "PATH=/bin:/usr/bin", NULL};// 自定义环境变量
execvpe ("ls", argv, envp); // 因 p 的特性,直接用 "ls" 即可搜索 PATH

        特点:结合了数组参数(v)、自动搜索 PATH(p)、自定义环境变量(e)的特性,无需写可执行文件完整路径,同时能隔离环境,灵活性更高。

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

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

    if (pid < 0) {  // fork 失败
        perror("fork failed");
        exit(EXIT_FAILURE);
    } else if (pid == 0) {  // 子进程
        // 构造参数数组:执行 print_env 程序,无额外参数
        char *const argv[] = {"print_env", NULL};
        
        // 构造自定义环境变量数组(替换原环境变量)
        char *const envp[] = {
            "MYVAR=Hello_execvpe",  // 自定义变量
            "PATH=/bin:/usr/bin:./", // 指定 PATH(确保能找到 print_env)
            "HOME=/tmp/custom_home", // 覆盖 HOME
            NULL                     // 必须以 NULL 结尾
        };

        // 调用 execvpe:支持 PATH 搜索 + 自定义环境变量
        execvpe("print_env", argv, envp);

        // 若执行到此处,说明调用失败
        perror("execvpe failed");
        exit(EXIT_FAILURE);
    } else {  // 父进程
        printf("父进程等待子进程执行...\n");
        wait(NULL);  // 等待子进程结束
        printf("子进程执行完毕\n");
    }

    return 0;
}

三、关键对比与记忆口诀

1. 参数格式差异(l vs v
  • l(list):参数逐个写,用NULL结尾(适合参数固定的场景);
  • v(vector):参数放数组,数组末尾NULL(适合参数动态的场景)。
2. 路径搜索差异(p vs 无p
  • pexeclp/execvp):传程序名,自动搜PATH
  • pexecl/execv/execle):传绝对 / 相对路径,不搜PATH

由于无法保证进程PATH中是否有需要查找的程序路径, 有时需要自己手动添加要找的程序的绝对路径到PATH中, 但是无论是putenv()还是setenv()一旦产生修改就会覆盖那一个环境变量,如PATH,HOST等,所以如果想把一个路径加进去不是个简单事情,我自己写了一个函数可以做到:
 

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

// 封装:追加目录到PATH环境变量(一行调用即可)
void append_path(const char *new_dir) {
    // 1. 获取原有PATH
    const char *old_path = getenv("PATH");
    // 2. 计算新PATH的长度(避免缓冲区不够)
    int new_path_len = (old_path ? strlen(old_path) : 0) + strlen(new_dir) + 2; // +2 是":"和"\0"
    // 3. 动态分配内存(避免局部变量销毁问题)
    char *new_path = malloc(new_path_len);
    if (!new_path) {
        perror("malloc failed");
        exit(EXIT_FAILURE);
    }
    // 4. 拼接原有PATH和新目录
    if (old_path) {
        snprintf(new_path, new_path_len, "%s:%s", old_path, new_dir);
    } else {
        snprintf(new_path, new_path_len, "%s", new_dir);
    }
    // 5. 设置新PATH(putenv会接管new_path,无需手动free,退出前可unsetenv释放)
    putenv(new_path);
}

// 主逻辑:调用封装函数,一行搞定追加PATH
int main() {
    // 只需一行,追加/root/projects/到PATH
    append_path("/root/projects/");

    // 直接调用execvp即可
    char *argv[] = {"myexe", "-l", "-a", NULL};
    execvp("myexe", argv);

    perror("execvp failed");
    exit(EXIT_FAILURE);
}
3. 环境变量差异(e vs 无e

这个很冷门,用得不多

  • eexecle):自定义环境变量;
  • e:继承父进程的环境变量。

注意: 这里的...表示:arg之后,可以传入任意数量的参数(类型需与前面的参数兼容,这里是char*

1. 作用:支持 “参数数量不固定” 的函数设计
execl()需要接收 “程序路径 + 若干命令行参数”,但不同命令的参数数量不同(比如ls -l是 2 个参数,ls -l -a -h是 4 个参数),用...就能灵活适配这种需求,无需为每种参数数量定义不同的函数。

2. 关键规则:必须有 “结束标记”
因为编译器不知道...里传了多少个参数,所以这类函数必须通过约定来标记参数结束。比如exec系列函数要求最后一个参数必须是NULL(空指针),否则函数会因解析参数越界而崩溃。

经典模式:fork() + exec()。Shell 执行命令、服务器创建子进程都是这个模式。

案例:经典模式

// 1. 头文件说明
#include <stdio.h>      // 提供printf等输入输出函数
#include <unistd.h>     // 提供fork、execl、getpid等进程/系统调用函数
#include <stdlib.h>     // 提供exit函数
#include <sys/wait.h>   // 提供wait函数(用于父进程等待子进程)
#include <sys/types.h>  // 提供pid_t类型(进程ID的类型)

int main()
{
    // 打印当前进程的PID,说明“当前程序本身是一个进程”
    printf("我变成了一个进程:%d\n", getpid());

    // 调用fork创建子进程:
    // - 父进程中,fork返回子进程的PID(>0)
    // - 子进程中,fork返回0
    // - 创建失败返回-1(这里未处理失败场景)
    pid_t id = fork();

    // 判断当前进程是否是子进程(子进程中fork返回0)
    if(id == 0)
    {
        // 子进程执行区域:调用execl进行程序替换,执行/usr/bin/ls命令
        // execl参数说明:
        // 第1个参数:要执行的程序的绝对路径(这里是ls的路径)
        // 第2~n个参数:命令行参数(对应新程序的argv数组),最后必须以NULL结尾
        // 【注意】这里的参数有小问题:标准情况下第2个参数应该是程序名(如"ls"),但这里写了"-a"
        // 实际运行时ls仍会执行,但argv[0]会变成"-a"(不影响功能,但不符合规范)
        execl("/usr/bin/ls", "-a", "-l", NULL);  // 程序替换函数:成功则不会执行后续代码
        
        // 只有execl执行失败时,才会走到这行代码(退出子进程)
        exit(0);
    }

    // 父进程执行区域:调用wait等待子进程退出(回收子进程资源,避免僵尸进程)
    // wait(NULL)表示“不关心子进程的退出状态”
    wait(NULL);

    // 子进程执行完成后,父进程继续执行自己的代码,打印多次信息
    printf("我的代码运行中....\n");
    printf("我的代码运行中....\n");
    printf("我的代码运行中....\n");
    printf("我的代码运行中....\n");
    printf("我的代码运行中....\n");
    printf("我的代码运行中....\n");

    return 0;
}

进程终止与进程退出:

一、核心定义

  1. 进程终止(Process Termination)指进程从 “运行 / 就绪 / 阻塞” 状态完全结束生命周期的所有情况,是进程生命周期的最终阶段。它包含主动终止被动终止两类,覆盖进程结束的全部场景。

  2. 进程退出(Process Exit)特指进程主动、正常地结束运行,属于进程终止的子集,通常由进程自身发起(而非外部干预或异常)。

二、关键区别

维度进程退出进程终止
范围进程终止的子集(仅主动正常结束)进程结束的全部情况(包含退出 + 异常终止)
触发方式进程主动发起:1. main()函数返回2. 调用exit()/_exit()3. 线程全部结束(多线程进程)包含主动 + 被动:1. 进程主动退出(同左)2. 外部信号终止(如kill -9Ctrl+C)3. 异常崩溃(如段错误、内存越界触发SIGSEGV
语义强调 “自愿结束”,通常执行完所有逻辑后正常收尾强调 “生命周期结束”,不区分主动 / 被动、正常 / 异常
退出信息必有正常退出码(如exit(0)表示成功)可能是正常退出码,也可能是终止信号(如被SIGKILL终止)

进程的退出信息是进程终止时,内核为其保存的元数据集合,包含进程的退出原因状态码终止信号等关键信息 —— 父进程可通过wait()/waitpid()获取这些信息,从而判断子进程是 “正常结束” 还是 “异常终止”,以及具体原因。

退出信息主要分两类场景:

1. 正常退出的信息:退出码(Exit Code)

如果进程通过exit(n)return n(main 函数)等方式主动终止,退出信息核心是退出码(0~255 的整数):

  • 0:表示进程执行成功(约定俗成);
  • 非 0:表示执行失败(不同数值可自定义含义,比如 1 代表参数错误、2 代表文件不存在)。
  • 退出信息被存放在一个32位int类,它的前16位是没用的,后16位被分成两份:
    包含了退出码(exit()/return)+终止信号(kill)

比如exit(10)的退出信息里,退出码就是 10,在内存中的布局:
|00000000 00000000 | 00001010 | 00000000|
父进程可通过WEXITSTATUS(status)提取。

2. 异常终止的信息:终止信号(Signal)

如果进程被信号终止(比如kill -9、段错误、内存越界),退出信息核心是终止信号的编号

  • 比如被SIGKILL(信号 9)终止,信息里会记录信号 9;
  • SIGSEGV(信号 11,段错误)终止,信息里会记录信号 11。
  • 收到一个默认行为是终止进程的信号(如 SIGKILL, SIGSEGV)。
  • 调用 _exit() 或 _Exit() 系统调用(立即终止,不清理标准 I/O 缓冲区)。

父进程可通过WTERMSIG(status)提取这个信号编号,从而知道进程为何异常崩溃。

退出类型核心信息示例
正常退出退出码(0~255)exit(0) → 退出码 0
异常终止终止信号编号被 kill -9 → 信号 9

进程等待

是指父进程暂停自身执行(或非阻塞轮询),等待子进程结束运行,并回收子进程的资源、获取其退出状态的过程。

简单说:当父进程创建子进程后,若子进程先退出,内核会保留子进程的退出状态(如退出码、终止原因),但不会立即释放所有资源,此时子进程变成 “僵尸进程”;父进程通过wait()waitpid()等系统调用执行 “进程等待”,就能回收僵尸进程的资源,并拿到子进程的退出信息(比如子进程是正常结束还是被信号终止)。

如果父进程不做进程等待,子进程会一直以僵尸状态存在,占用系统 PID 等资源;若父进程先退出,子进程会被 init 进程接管,最终由 init 进程完成等待和回收。
核心目的:回收子进程资源,避免僵尸进程,获取子进程运行结果

父进程通过这两个函数等待子进程结束,并获取退出状态:

一、wait()函数的语法结构

1. 头文件依赖

使用wait()需包含以下头文件:

#include <sys/wait.h>  // 核心头文件(定义函数原型、宏)
#include <sys/types.h> // 定义pid_t类型
2. 函数原型
pid_t wait(int *status);
3. 参数解析
参数名类型含义
statusint*输出参数:用于存储子进程的退出状态(退出码、终止信号等);若传入NULL,表示不关心子进程的退出状态。
4. 返回值
  • 成功:返回被回收的子进程的 PIDpid_t类型);
  • 失败:返回-1(如无待回收的子进程、调用被信号中断等),并设置errno(如ECHILD表示无子进程)。

二、waitpid()函数的语法结构

1. 头文件依赖

wait()相同:

#include <sys/wait.h>
#include <sys/types.h>
2. 函数原型
pid_t waitpid(pid_t pid, int *status, int options);
3. 参数解析

waitpid()的参数更灵活,支持指定子进程、设置阻塞 / 非阻塞模式:

参数名类型取值与含义
pidpid_t指定要等待的子进程,取值分 4 种情况:
1. pid > 0:等待PID 等于 pid的子进程;
2. pid = -1:等待任意一个子进程(等价于wait());
3. pid = 0:等待与父进程同进程组的任意子进程;
4. pid < -1:等待进程组 ID 等于 pid 绝对值的任意子进程。
statusint*wait():输出参数,存储子进程退出状态;传NULL则不获取状态。
optionsint

控制函数行为的选项(可通过 组合多个宏),常用值:
0:阻塞模式(等价于wait());

WNOHANG:非阻塞模式(子进程未退出则立即返回0);

WUNTRACED:等待子进程被暂停(如收到SIGSTOP信号);

WCONTINUED:等待被暂停的子进程恢复运行(如收到SIGCONT 信号)。

4. 返回值

返回值分 3 种情况:

  • 成功回收子进程:返回被回收的子进程 PID
  • 非阻塞模式下子进程未退出:返回0(仅当options=WNOHANG时出现);
  • 失败:返回-1,并设置errno(如ECHILD无对应子进程、EINTR被信号中断)。

二、解析退出状态:核心宏

status参数无法直接读取,需用<sys/wait.h>提供的宏解析,主要分两类场景:

作用
WIFEXITED(status)判断子进程是否正常退出(如exit()/return),是则返回非 0
WEXITSTATUS(status)WIFEXITED为真,获取子进程的退出码(如exit(10)则返回 10)
WIFSIGNALED(status)判断子进程是否被信号终止(如kill -9),是则返回非 0
WTERMSIG(status)WIFSIGNALED为真,获取终止子进程的信号编号(如 9 表示 SIGKILL)
WIFSTOPPED(status)判断子进程是否被信号暂停(如Ctrl+Z),需配合WUNTRACED选项

三、实操示例

场景 1:获取子进程正常退出的退出码
#include <stdio.h>
#include <stdlib.h>
#include <sys/wait.h>
#include <unistd.h>

int main() {
    pid_t pid = fork();
    if (pid == 0) {
        // 子进程:正常退出,退出码设为123
        printf("子进程PID:%d,即将退出\n", getpid());
        exit(123); 
    } else if (pid > 0) {
        int status = 0;
        // 父进程阻塞等待子进程退出
        pid_t rid = waitpid(pid, &status, 0);
        if(rid == pid)//判断返回值是否与子进程pid相等
        {
            int exit_code = ((status>>8)&0xFF);
            int exit_sig = status&0x7F;//0111 1111
            printf("pid: %d, wait success!, status: %d, exit_code: %d, exit_sig: %d\n",\
                    getpid(), status, exit_code, exit_sig);
        }
        /*
        if (WIFEXITED(status)) {
            printf("子进程正常退出,退出码:%d\n", WEXITSTATUS(status));
        }
        */
    }
    return 0;
}

输出

子进程PID:15678,即将退出
pid: 15677, wait success!, status: 31488, exit_code: 123, exit_sig: 0
场景 2:非阻塞循环里 “干别的事”
#include <stdio.h>
#include <sys/wait.h>
#include <unistd.h>
#include <stdlib.h>

// 父进程需要执行的其他任务
void do_other_work() {
    static int count = 0;
    printf("父进程正在处理其他任务:第%d次执行\n", ++count);
    // 实际场景中可以是:读取配置、监听端口、处理文件等
}

int main() {
    pid_t pid = fork();
    if (pid == 0) {
        sleep(3); // 子进程运行3秒
        exit(123);
    } else if (pid > 0) {
        int status;
        pid_t rid;
        
        while (1) {
            rid = waitpid(pid, &status, WNOHANG); // 非阻塞检查
            
            if (rid == pid) { // 子进程退出
                printf("子进程退出,退出码:%d\n", (status>>8)&0xFF);
                break;
            } else if (rid == 0) { // 子进程还在运行
                do_other_work(); // 执行其他任务
                sleep(1); // 延时1秒,避免CPU空转
            } else { // 调用失败
                perror("waitpid error");
                break;
            }
        }
    }
    return 0;
}
场景3: 父进程管理多个子进程
#include <iostream>
#include <vector>
#include <string>
#include <cstdlib>
#include <sys/wait.h>
#include <cstdio>
#include <algorithm>

// 枚举类型:定义程序的返回状态码
enum 
{
    OK,          // 程序正常执行完成的状态码
    USAGE_ERR    // 命令行参数使用错误的状态码
};

// 定义一种函数指针
typedef void (*callback_t)();
// 或C++11后的using方式(更直观)
// using OpFuncPtr = int (*)(int, int);

// 子进程需要执行的任务函数(目前是空实现,实际可填充业务逻辑)
void Task1()
{
    // 这里可以写子进程要执行的具体任务,比如计算、文件处理等
    printf("子进程[%d]正在执行Task任务\n", getpid());
}
void Task2(){}
void Task3(){}
//...

// 此函数用于生成多个相同的子进程
void CreateChildProcess(int num, std::vector<pid_t>* subs, callback_t func)
{
    //循环创建指定数量的子进程
    for(int i = 0;i<num;i++)
    {
        // 调用fork()创建子进程:
        // - 父进程中,fork返回子进程的PID(>0)
        // - 子进程中,fork返回0
        // - 创建失败时,fork返回-1
        pid_t id = fork();
        // 判断当前进程是否是子进程(子进程中fork返回0)
        if(id == 0)
        {
            // 子进程执行区域:
            func();
            // 子进程任务执行完成后,主动退出(避免子进程继续执行后续的父进程逻辑)
            exit(0);
        }
        // 父进程执行区域:将刚创建的子进程PID存入vector容器
        subs->push_back(id);
    }    
}

// 此函数用于生成多个不同的子进程
void CreateDiffChildProcess(int num, std::vector<pid_t>* subs, std::vector<callback_t>& funcs)
{
    //循环创建指定数量的子进程
    for(int i = 0;i < std::min(num, (int)funcs.size());i++)
    {
        // 调用fork()创建子进程:
        // - 父进程中,fork返回子进程的PID(>0)
        // - 子进程中,fork返回0
        // - 创建失败时,fork返回-1
        pid_t id = fork();
        // 判断当前进程是否是子进程(子进程中fork返回0)
        if(id == 0)
        {
            // 子进程执行区域:
            funcs[i]();
            // 子进程任务执行完成后,主动退出(避免子进程继续执行后续的父进程逻辑)
            exit(0);
        }
        // 父进程执行区域:将刚创建的子进程PID存入vector容器
        subs->push_back(id);
    }    
}


//此函数用于等待回收所有的子进程
void WaitAllChild(const std::vector<pid_t> &subs)
{
    // 遍历存储子进程PID的vector
    for(const auto &pid : subs)
    {
        // 定义status变量,用于存储子进程的退出状态
        int status = 0;
        // 调用waitpid等待指定PID的子进程:
        // 参数1:要等待的子进程PID
        // 参数2:存储退出状态的地址
        // 参数3:0表示阻塞等待(父进程暂停,直到该子进程退出)
        pid_t rid = waitpid(pid, &status, 0);
        // 判断waitpid是否成功等待到子进程(成功时返回子进程PID)
        if(rid>0)
        {
            // 打印子进程退出信息:
            // WEXITSTATUS(status):从status中解析出子进程的退出码
            printf("子进程:%d Exit, exit code: %d\n", rid, WEXITSTATUS(status));
        }
    }
}

// 主函数:程序入口,argc是参数个数,argv是参数数组
// 程序运行方式:./myproc [进程数量](比如./myproc 5 表示创建5个子进程)
int main(int argc, char *argv[])
{
    // 1. 命令行参数校验:判断参数个数是否为2(程序名+进程数量)
    if(argc != 2)
    {
        // 参数错误时,提示用户正确用法(argv[0]是程序自身的文件名)
        std::cout << "Usage: " << argv[0] << " process_num" << std::endl;
        // 以“参数使用错误”的状态码退出程序
        exit(USAGE_ERR);
    }

    // 2. 解析命令行参数:将第二个参数(字符串)转为整数,得到要创建的子进程数量
    int num = std::stoi(argv[1]);
    // 存储所有子进程的PID(父进程后续通过这些PID等待子进程)
    std::vector<pid_t> subs;
    // 创建一个任务清单, 可以让子进程调用各种进程
    std::vector<callback_t> funcs;
    funcs.push_back(Task1);
    funcs.push_back(Task2);
    funcs.push_back(Task3);
    
    // 3. 循环创建指定数量的子进程
    CreateChildProcess(num, &subs, Task1);//子进程全部相同
    //CreateDiffChildProcess(num, &subs, funcs);//子进程可以不相同

    // 4. 父进程等待所有子进程退出(回收资源,避免僵尸进程)
    WaitAllChild(subs);

    // 父进程完成所有子进程的等待后,正常退出
    return OK;
}

进程间关系:

父子关系:

每个进程(除了 init 进程)都有一个父进程。
使用 ps -ef 查看 PPID(父进程ID)列。

进程组(Process Group):

一个或多个进程的集合。每个进程组有一个唯一的进程组ID(PGID)。

通常,由 Shell 启动的管道命令(如 ls | grep | wc)属于同一个进程组。

用于信号分发:向一个进程组发送信号,会发送给组内的所有进程。

会话(Session):

一个或多个进程组的集合。每个会话有一个唯一的会话ID(SID)。

一个会话通常与一个控制终端(Controlling Terminal) 相关联。

用户一次登录(通过终端或网络)就形成了一个会话。Shell 通常是该会话的首进程(Session Leader)。

孤儿进程与守护进程

孤儿进程:父进程先于子进程终止,子进程的父进程变为 init 进程(PID 1),由 init 负责回收其资源。
守护进程:一种长期运行的后台服务进程。它脱离控制终端,成为会话首进程和进程组首进程,通常其父进程是 init。创建步骤通常包括 fork() -> 父进程退出 -> setsid() -> 改变工作目录 -> 重设文件掩码 -> 关闭文件描述符。

三、进程间通信(IPC - Inter-Process Communication)

这是进程之间交换数据和同步操作的机制。

1. 管道(Pipe)
无名管道(|):单向通信,只能用于有亲缘关系的进程(如父子进程)。int pipe(int fd[2])。
命名管道(FIFO):通过文件系统中的一个特殊文件进行通信,可用于无亲缘关系的进程。mkfifo 命令或 mkfifo() 函数。

2. System V IPC
消息队列:消息的链表,进程可以从中读取或写入特定类型的消息。
信号量:用于同步进程对共享资源的访问,是一个计数器。
共享内存:最高效的 IPC 方式。多个进程可以将同一块物理内存映射到自己的地址空间。需要与信号量等同步机制配合使用。

3. POSIX IPC
现代版本的 System V IPC,接口更简洁、一致。包括消息队列、信号量、共享内存。

4. 信号(Signal)
一种异步通信机制,用于通知进程某个事件已发生(如 SIGINT(Ctrl+C), SIGTERM, SIGKILL)。
使用 kill() 命令或 kill() 系统调用发送信号。
使用 signal() 或 sigaction() 设置信号处理函数。

5. 网络套接字(Socket)
最强大的 IPC 机制,可以跨网络在不同主机的进程间通信。

三、进程的task_struct

在Linux内核中,会将所有的进程task_struct, 统一放在一张双链表中.
但是有些进程处于运行队列,或者阻塞队列.
以上两个问题并不冲突,因为进程可以同时处于多个队列,进程所处于的链表结构比较特殊.

如果task_struct里面多存几组指针,那么就相当于这个进程结构体存在于多个列表中.

进程调度

目标:在多个可运行进程之间公平、高效地分配 CPU 时间。

四、调度策略与进程优先级:

一、核心概念:

调度策略:

普通(非实时)策略:
SCHED_OTHER(CFS):默认策略,完全公平调度器,保证每个进程公平地获得 CPU。
SCHED_BATCH:适用于非交互性的批处理进程。
SCHED_IDLE:优先级极低,用于后台任务。


实时策略(优先级高于普通进程):
SCHED_FIFO:先进先出,直到被更高优先级进程抢占或自己阻塞。
SCHED_RR:时间片轮转,在相同优先级的实时进程间轮流执行。


优先级与友好度:

进程优先级是内核调度 CPU 资源的核心依据 ——优先级越高,进程获得 CPU 执行的机会越多、响应速度越快,核心目标是优化资源分配(如让游戏、关键服务优先占用 CPU,后台任务不抢占前台资源)。
友好度(Nice Value):范围从 -20(最高优先级,对 CPU 最不友好)到 +19(最低优先级,对 CPU 最友好)。Nice值的每一次修改都是从头再改,不是累积的,第一次加10,第二次-10,优先级为99-10.普通用户只能调低优先级(增加 nice 值)。

1. 静态优先级(Static Priority)

  • 定义:进程创建时确定的基础优先级,默认继承父进程,除非主动修改,否则不会随时间变化。
  • 核心关联值
    • nice值(NI):用户层调控静态优先级的接口,范围 -20(最高)~ 19(最低)(共 40 级)。
    • PRI值(调度优先级):内核实际使用的优先级,与 nice 值对应关系为 PRI = 20 + NI(范围 0~39,0 最高,39 最低)。
    • 例:nice=-20 → PRI=0(最高优先级);nice=19 → PRI=39(最低优先级)。

2. 动态优先级(Dynamic Priority)

  • 定义:内核根据进程状态动态调整的优先级(基于静态优先级),由内核根据友好度和睡眠/运行历史动态计算。目的是优化调度公平性。
  • 调整逻辑
    • 进程长时间占用 CPU → 动态优先级降低(防止 “霸占” CPU);
    • 进程睡眠等待资源(如 I/O、键盘输入)→ 动态优先级提升(醒来后优先执行)。

3. 实时优先级(Real-Time Priority)

  • 定义:专门用于实时进程(如工业控制、游戏关键任务),优先级高于所有普通进程。
  • 范围:0~99(0 最低,99 最高),与普通进程的 PRI 值(0~39)无重叠(实时进程优先级完全碾压普通进程)。

二、底层原理:CPU 调度器如何使用优先级?

Linux 默认使用 CFS 调度器(完全公平调度器) 管理普通进程,核心逻辑:

  1. 为每个进程分配 “权重”:nice 值越低(优先级越高),权重越大;
  2. 权重决定 “CPU 时间片占比”:权重 = 2 的进程,获得的 CPU 时间是权重 = 1 的进程的 2 倍;
  3. 实时进程使用专门的实时调度器(如 SCHED_FIFO、SCHED_RR),一旦就绪,立即抢占普通进程的 CPU。

三、实操:查看与修改进程优先级(CentOS 系统)

1. 查看进程优先级

(1)top 命令(实时查看,直观)
top  # 启动后按键盘 'r' 可直接修改nice值
  • 关键字段:
    • PR:进程的 PRI 值(调度优先级);
    • NI:nice 值(-20~19);
    • RT:标记是否为实时进程(显示 RT 表示实时优先级)。
(2)ps 命令(查看指定进程)
# 查看进程PID、PRI、NI(例:查看PID=1234的进程)
ps -o pid,pri,ni,cmd 1234
  • 输出示例:
      PID  PRI  NI CMD
     1234  19   0 ./game_process  # PRI=19(默认nice=0)
    

2. 修改进程优先级(普通进程)

(1)创建进程时指定 nice 值(nice 命令)
# 格式:nice -n [nice值] 命令
nice -n 5 ./build.sh  # 以nice=5(低优先级)运行编译脚本
nice -n -10 ./game_process  # 以nice=-10(高优先级)运行游戏进程
  • 注意:普通用户只能设置 nice≥0(降低优先级),设置 nice<0(提升优先级)需 root 权限(sudo)。
(2)修改已有进程的 nice 值(renice 命令)
# 格式:renice [新nice值] -p [进程PID]
sudo renice -5 -p 1234  # 将PID=1234的进程nice值改为-5(提升优先级)
renice 10 -p 5678       # 将PID=5678的进程nice值改为10(降低优先级,普通用户可执行)

3. 设置实时进程优先级(chrt 命令,需 root)

实时进程适用于对延迟敏感的场景(如游戏中的输入响应、物理引擎计算):

# 格式:chrt -f [实时优先级0~99] 命令(-f表示SCHED_FIFO调度策略)
sudo chrt -f 50 ./real_time_task  # 以实时优先级50运行任务

# 修改已有进程为实时进程
sudo chrt -f -p 60 1234  # 将PID=1234的进程改为实时优先级60

四、开发场景中的优先级优化建议

1. 游戏开发(UE/C++)

  • 核心进程(如渲染线程、输入处理线程):设置 nice=-5~-10(提升优先级),或实时优先级 30~50(避免卡顿);
  • 后台任务(如资源加载、日志写入):设置 nice=10~15(降低优先级),不抢占前台资源。

2. 后台编译 / 测试

  • 编译脚本(make、cmake):设置 nice=5~10,避免编译时占用过多 CPU,导致系统卡顿。

3. 注意事项(避坑)

  • 不要随意设置 nice=-20(最高优先级)或实时优先级 99,可能导致系统关键服务(如 ssh、内核进程)无法获得 CPU,引发系统卡死;
  • 实时进程一旦进入死循环,会完全霸占 CPU,普通进程无法运行,需谨慎使用;
  • 普通用户无权限修改其他用户进程的优先级,需 sudo 提权。

五、核心总结

  1. 优先级的核心是 “CPU 资源分配权重”,通过 nice值(用户层)和 PRI值(内核层)调控;
  2. 普通进程优先级范围(nice-20~19),实时进程(0~99)优先级更高;
  3. 实操关键:用 top/ps 查看,nice/renice 调控普通进程,chrt 设置实时进程;
  4. 开发中根据任务重要性分配优先级,平衡响应速度和系统稳定性。

五、struct runqueue

struct runqueue 是每个 CPU 核心的运行队列数据结构,负责管理该 CPU 上所有可运行的任务。

主要字段:

struct runqueue {
    /* 基本状态字段 */
    spinlock_t lock;                    // 保护运行队列的自旋锁
    unsigned long nr_running;           // 可运行任务总数
    unsigned long nr_switches;          // 上下文切换计数
    unsigned long nr_uninterruptible;   // 不可中断任务数
    unsigned long expired_timestamp;    // 过期数组创建时间
    
    /* 当前运行任务 */
    struct task_struct *curr;           // 当前运行的任务
    struct task_struct *idle;           // 空闲任务(swapper)
    struct mm_struct *prev_mm;          // 前一个任务的内存描述符
    
    /* 优先级数组管理 */
    struct prio_array *active;          // 指向活跃数组
    struct prio_array *expired;         // 指向过期数组
    struct prio_array arrays[2];        // 两个实际的优先级数组
    
    /* 位图缓存优化 */
    unsigned long bitmap[BITMAP_SIZE];  // 快速位图访问缓存
    
    /* 调度统计 */
    unsigned long long timestamp_last_tick; // 最后时钟中断时间
    unsigned long nr_iowait;            // 等待I/O的任务数
    
    /* 负载相关字段 */
    struct load_weight load;            // 运行队列负载权重
    unsigned long cpu_load;             // CPU负载历史
    unsigned long raw_weighted_load;    // 原始加权负载
    
    /* 多处理器负载均衡 */
    int push_cpu;                       // 任务推送的目标CPU
    struct task_struct *migration_thread; // 迁移线程
    struct list_head migration_queue;   // 迁移任务队列
    
    /* 实时调度相关 */
    unsigned long rt_nr_running;        // 实时任务数
    unsigned long rt_nr_uninterruptible; // 不可中断实时任务数
    struct task_struct *curr_rt;        // 当前运行的实时任务
    
    /* 时钟中断统计 */
    u64 clock;                          // 运行队列时钟
    u64 clock_warps;                    // 时钟偏移计数
    u64 clock_max_delta;                // 最大时钟增量
    
    /* 调试和统计 */
#ifdef CONFIG_SCHEDSTATS
    struct sched_info rq_sched_info;    // 调度统计信息
    unsigned long long rq_cpu_time;     // CPU时间统计
#endif
};

六、struct list_head queue[140]

这是优先级数组中的核心数据结构,用于按优先级组织任务。

#define MAX_PRIO 140

struct prio_array {
    unsigned int nr_active;          // 活跃任务总数
    unsigned long bitmap[BITMAP_SIZE]; // 优先级位图
    struct list_head queue[MAX_PRIO]; // 140个优先级队列
};

七、两者关系详解

层级关系:

struct runqueue (per CPU)
├── active(指针) → prio_array #0
│                   ├── nr_active: 5
│                   ├── bitmap: [0b...1101] (优先级0,2,3有任务)
│                   └── queue[140]:
│                           ├── queue[0]: task_A → task_B → task_C
│                           ├── queue[1]: (empty)
│                           ├── queue[2]: task_D
│                           ├── queue[3]: task_E
│                           └── ... (其余136个队列为空)
├── expired(指针) → prio_array #1
│                   ├── nr_active: 3
│                   ├── bitmap: [0b...0100] (优先级2有任务)
│                   └── queue[140]:
│                           ├── queue[2]: task_X → task_Y → task_Z
│                           └── ... (其余为空)
└── arrays[2] (实际存储)

工作流程:

在O(1)调度器中,每个CPU都有一个自己的运行队列(struct runqueue),其中包含两个优先级数组(struct prio_array):一个活跃数组(active)和一个过期数组(expired)。每个优先级数组中有一个位图(bitmap)和一个链表数组(queue),链表数组有140个元素,对应0到139的优先级(0最高,139最低)。

处理过程:

  1. 调度器选择下一个任务时,首先查看活跃数组(active)的位图,使用sched_find_first_bit()函数找到第一个被设置的位,即最高优先级中有任务存在的优先级。然后,从该优先级对应的链表中取出第一个任务来运行。

  2. 当任务运行一段时间后,它会用尽自己的时间片(time slice)。然后,调度器会将该任务从活跃数组中移除,并重新计算其优先级和时间片,然后将其加入到过期数组(expired)的对应优先级链表中。

  3. 如果活跃数组中没有任务了(即所有任务都已经运行并转移到过期数组),那么调度器会交换活跃数组和过期数组的角色。也就是说,原来的过期数组变成新的活跃数组,而原来的活跃数组(现在为空)变成新的过期数组。

  4. 这样,调度器可以保证在O(1)时间内选择下一个任务,因为位图操作和从链表头取任务都是常数时间操作。

// 任务添加到运行队列
void enqueue_task(struct task_struct *p, struct prio_array *array)
{
    int prio = p->prio;  // 获取任务优先级(0-139)
    list_add_tail(&p->run_list, &array->queue[prio]);
    __set_bit(prio, array->bitmap);
    array->nr_active++;
}

// 从运行队列选择下一个任务
struct task_struct *pick_next_task(struct runqueue *rq)
{
    struct prio_array *array = rq->active;
    int idx = sched_find_first_bit(array->bitmap);
    
    if (idx >= MAX_PRIO)
        return NULL;
        
    // 从对应优先级队列中获取第一个任务
    return list_entry(array->queue[idx].next, 
                     struct task_struct, run_list);
}

关键特点

struct runqueue 特点:

  • 每CPU独立:每个CPU核心有自己的运行队列

  • 负载隔离:避免多CPU间的锁竞争

  • 状态管理:维护CPU相关的调度状态

  • 性能优化:通过本地缓存减少全局锁竞争

struct list_head queue[140] 特点:

  • 优先级分组:140个优先级级别(0-139,0为最高)

  • O(1)调度:通过位图快速找到最高优先级任务

  • 链表管理:每个优先级使用双向链表管理任务

  • 公平性:同一优先级任务按FIFO顺序调度

八、实际调度示例

// 简化的调度器核心逻辑
asmlinkage void schedule(void)
{
    struct task_struct *prev, *next;
    struct runqueue *rq;
    
    // 获取当前CPU的运行队列
    rq = this_rq();
    prev = current;
    
    // 从活跃数组选择下一个任务
    next = pick_next_task(rq);
    
    if (likely(prev != next)) {
        // 执行上下文切换
        rq->curr = next;
        context_switch(prev, next);
    }
    
    // 如果活跃数组为空,交换活跃和过期数组
    if (unlikely(!rq->active->nr_active)) {
        struct prio_array *array = rq->active;
        rq->active = rq->expired;
        rq->expired = array;
    }
}

九、现代内核的变化

在较新的 Linux 内核中(CFS调度器),这种结构有所变化:

  • CFS 红黑树:替代了固定的140个优先级队列

  • 虚拟运行时间:基于 vruntime 进行任务排序

  • 更灵活的调度:支持更复杂的调度策略

但理解传统的 O(1) 调度器结构对于掌握调度原理仍然非常重要,因为它揭示了多级优先级队列和位图优化的核心思想。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值