当前位置: 首页 > news >正文

Linux操作系统从入门到实战(二十四)进程控制

Linux操作系统从入门到实战(二十四)进程控制

  • 前言
  • 一、进程创建
    • 1. fork函数的作用
    • 2. 关于fork返回值的三个关键问题
      • 问题1:为什么子进程返回0,父进程返回子进程的PID?
      • 问题2:为什么一个函数`fork()`会有两个返回值?
      • 问题3:为什么同一个变量pid既等于0,又大于0?
    • 3. fork创建进程的底层操作
    • 4. 写时拷贝(COW)
      • 问题1:为什么不直接拷贝所有数据,而要等到"写时"才拷贝?
      • 问题2:为什么要"拷贝"而不是直接为子进程开辟新空间?
    • 5. 从内核到用户态
  • 二、进程终止
    • 1. 进程终止时,操作系统要做什么?
    • 2. main函数中的return 0,给谁了?
    • 3. 为什么一定要有退出码?
    • 4. 如何查看Linux中的错误码?
    • 5. 进程退出的三种情况与退出码的意义
    • 6. 进程退出的三种方法及对比
    • 7. 库函数与系统调用的输出缓冲区在哪里?
  • 三、进程等待
    • 1. 为什么需要进程等待?
    • 2. 如何实现进程等待?
      • 2.1 wait函数的工作机制
      • 2.2 如何获取子进程的退出信息?—— status参数
    • 3 waitpid函数
      • (1)waitpid的参数解析
      • (2)waitpid的返回值
      • (3)非阻塞等待的优势
    • 4. ls命令处理不存在文件时的父子进程交互


前言

  • 在上一篇博客中,我们已经揭开了进程虚拟地址空间的面纱——它不仅是进程隔离内存资源的“保护罩”,更是操作系统高效管理内存的关键基础
  • 而当我们明确了进程“赖以生存”的地址空间后,下一步必然要聚焦:操作系统究竟如何操控进程的诞生、运行与终结?今天这篇,我们就顺着这个思路,深入拆解进程控制的核心逻辑,带大家读懂操作系统对进程生命周期的完整管控机制。

我的个人主页,欢迎来阅读我的其他文章
https://blog.csdn.net/2402_83322742?spm=1011.2415.3001.5343
我的Linux知识文章专栏
欢迎来阅读指出不足
https://blog.csdn.net/2402_83322742/category_12879535.html?spm=1001.2014.3001.5482


一、进程创建

1. fork函数的作用

  • 在Linux中,fork()函数是进程创建的核心工具——它能让一个已存在的进程(父进程)"复制"出一个新进程(子进程)
  • 从用户视角看,调用fork()后仿佛程序"一分为二",两个进程将执行相同的代码,但拥有各自独立的执行流。

fork()的函数定义如下:

#include <unistd.h>
pid_t fork(void);

这个函数的特殊之处在于:调用一次,返回两次——父进程中返回子进程的PID(进程ID),子进程中返回0,出错时返回-1。

2. 关于fork返回值的三个关键问题

我们先从最直观的疑问入手,理解fork的设计逻辑。

问题1:为什么子进程返回0,父进程返回子进程的PID?

  • 父进程需要管理多个子进程:一个父进程可能创建多个子进程,通过返回子进程的PID,父进程可以精准识别每个子进程(例如通过waitpid()等待特定子进程退出)。
  • 子进程无需识别多个父进程:一个子进程只有一个父进程,若需要获取父进程PID,可通过getppid()函数,因此返回0即可简洁表示"当前是子进程"。

问题2:为什么一个函数fork()会有两个返回值?

fork()的执行过程是"分裂式"的:

  • 调用fork()后,内核会为子进程分配内存和内核数据结构,复制父进程的部分数据,并将子进程加入系统进程列表。
  • fork()返回时,父进程和子进程会同时从fork()调用处继续执行——因此父进程会得到一个返回值(子进程PID),子进程会得到另一个返回值(0),形成"一次调用,两次返回"的现象。

问题3:为什么同一个变量pid既等于0,又大于0?

这是对"进程独立性"的误解。例如在示例代码中:

pid_t pid;
pid = fork(); // 父进程中pid=子进程PID,子进程中pid=0
  • 变量pid在父进程和子进程中是两个独立的变量(位于各自的内存空间)。
  • 父进程中,pid被赋值为子进程的PID(大于0);子进程中,pid被赋值为0。二者并非"同一个变量同时有两个值",而是两个进程中各自的变量值不同。

3. fork创建进程的底层操作

fork()被调用后,内核会执行一系列关键操作,确保子进程能独立运行:

  1. 分配资源:为子进程分配新的内存块(用于存放数据、栈等)和内核数据结构(如进程控制块PCB)。
  2. 复制数据:将父进程的部分数据结构(如PCB中的进程状态、环境变量等)复制到子进程,但并非所有数据都会立即复制(这涉及后续的写时拷贝机制)。
  3. 加入调度:将子进程添加到系统进程列表,使其成为调度器的调度对象。
  4. 返回执行:父进程和子进程分别从fork()调用处继续执行,此时系统中已存在两个独立的进程。

示例现象解析
在用户提供的代码中,子进程没有打印"Before",因为fork()是在"Before"打印之后调用的。子进程从fork()返回后开始执行,因此只执行了"After"部分。

4. 写时拷贝(COW)

在早期的进程创建中内核会直接将父进程的所有内存数据(如数据段、堆、栈)复制给子进程。但这种方式存在明显缺陷,由此引出两个关键问题:

问题1:为什么不直接拷贝所有数据,而要等到"写时"才拷贝?

直接拷贝的问题在于资源浪费

  • 很多场景下,子进程创建后可能只是执行exec()系列函数(加载新程序),此时父进程的数据对其毫无意义,提前拷贝会浪费内存和CPU时间。
  • 即使子进程需要使用父进程数据,也可能只是读取而不修改(如只读变量),此时共享内存即可,无需拷贝。

因此,Linux采用"写时拷贝"优化:

  • fork创建子进程时,父子进程共享同一份物理内存数据(代码段、数据段等),但将这些内存页标记为"只读"。
  • 当子进程或父进程尝试修改共享内存时,CPU会触发"页错误"(Page Fault),内核此时才会为修改方单独拷贝一份内存页,并恢复其可写权限。

这种"按需拷贝"的策略,避免了不必要的内存复制,大幅提高了进程创建效率。

问题2:为什么要"拷贝"而不是直接为子进程开辟新空间?

直接开辟新空间(不拷贝父进程数据)会导致数据不一致
子进程需要继承父进程的执行上下文(如变量值、栈状态等)才能正确运行。例如,父进程在fork()前定义了变量a=10,子进程必须也能看到a=10才能保证逻辑正确。
"拷贝"的本质是确保子进程初始数据与父进程一致,而"写时"延迟则是优化效率的手段——既保证了数据一致性,又避免了冗余操作。

5. 从内核到用户态

写时拷贝的"按需分配"思想,在用户态内存管理中同样存在。例如,当我们用malloc()new申请内存时:

问题:调用malloc(1024)时,操作系统会立即在物理内存中分配1024字节吗?
答案:不会。

  • 此时操作系统仅会为进程分配一段虚拟地址空间(记录在进程的页表中),但不会立即映射到物理内存。
  • 只有当程序首次写入这段内存时(如*ptr = 1),CPU才会触发页错误,操作系统此时才会分配物理内存,并建立虚拟地址到物理地址的映射。

这种机制与写时拷贝一脉相承:推迟资源分配直到真正需要时,最大化利用系统资源(内存、CPU)。对用户而言,这一过程完全透明——程序无需关心物理内存何时分配,只需正常使用虚拟地址即可。

二、进程终止

1. 进程终止时,操作系统要做什么?

当一个进程终止时,操作系统的核心任务是回收进程占用的所有资源,确保系统资源不被浪费。从内核数据结构到内存管理,具体操作如下:

  1. 清理进程控制块(PCB)
    PCB是进程的"身份证",包含进程ID、状态、优先级、打开文件列表等关键信息。进程终止后,操作系统会从系统进程列表中移除该PCB,释放其占用的内核内存。

  2. 撤销页表映射
    进程运行时依赖虚拟地址空间与物理内存的映射(通过页表实现)。终止时,操作系统会删除该进程的页表,解除虚拟地址到物理内存的关联,使物理内存块重新回到"空闲"状态,可被其他进程使用。

  3. 回收内存资源

    • 释放进程的用户空间内存(代码段、数据段、堆、栈等),这些内存可能是进程创建时分配的,或通过malloc等动态申请的。
    • 若进程使用了共享内存、信号量等IPC资源,操作系统会根据资源的"引用计数"决定是否回收(若其他进程仍在使用,则暂时不回收)。
  4. 关闭打开的文件
    进程打开的文件、管道、网络套接字等,会被操作系统逐一关闭,释放对应的文件描述符,避免文件描述符泄露。

  5. 通知父进程
    操作系统会将子进程的终止状态(退出码或终止信号)告知父进程(通过wait/waitpid等接口),确保父进程能及时处理子进程的"后事"。

2. main函数中的return 0,给谁了?

main函数中return 0,本质是将一个"退出码"返回给操作系统。操作系统会将这个退出码暂存起来,供需要的进程(通常是父进程)查询。

  • 对于用户来说,可通过Shell命令echo $?查看最近一个前台进程的退出码。例如:

    ./a.out  # 运行程序
    echo $?  # 输出0(若main函数return 0)
    

    这里的$?就是Shell维护的一个变量,存储着最近进程的退出码。

  • 对于父进程来说,可通过waitpid等系统调用获取子进程的退出码,从而判断子进程的执行结果。

3. 为什么一定要有退出码?

退出码是进程间传递"执行结果"的简单机制,核心作用是让父进程知晓子进程的任务完成情况

  • 0通常表示"成功":子进程按预期完成任务(如命令执行成功、程序运行无错误)。
  • 非0值表示"失败":不同的非0值可代表不同的失败原因(如1表示通用错误,2表示用法错误,127表示命令未找到等)。

例如,编译程序时若源码有语法错误,编译器会返回非0退出码,Shell捕获后便知编译失败;若返回0,则表示编译成功。这种机制让进程间无需复杂通信,就能传递关键结果信息。

4. 如何查看Linux中的错误码?

Linux的错误码定义在/usr/include/errno.h头文件中,每个非0退出码对应具体的错误描述。查看方式有两种:

  1. 通过库函数查询
    使用strerror函数(需包含<string.h>),传入错误码即可获取对应的描述字符串:

    #include <stdio.h>
    #include <string.h>
    int main() {printf("错误码1的含义:%s\n", strerror(1));  // 输出:Operation not permittedreturn 0;
    }
    
  2. 直接查看头文件或手册
    执行man 3 strerror可查看错误码对应的描述;或直接打开/usr/include/errno.h,里面定义了EPERM(1)、ENOENT(2)等宏与错误码的对应关系。

5. 进程退出的三种情况与退出码的意义

进程退出可分为三类场景,退出码的意义在不同场景下有所不同:

  1. 代码跑完,结果正确:退出码为0(约定俗成),表示任务成功完成。
  2. 代码跑完,结果错误:退出码为非0,具体值表示失败原因(如参数错误返回2,文件不存在返回2)。
  3. 代码没跑完,进程异常终止:此时退出码无意义。进程可能因收到信号(如SIGSEGV段错误、SIGKILL强制终止)而被操作系统杀死,终止原因由信号决定,而非退出码。

6. 进程退出的三种方法及对比

进程退出需通过特定接口触发,核心是最终调用系统调用让操作系统完成资源回收。常见方法有三种:

方法本质特点
return n(main中)函数返回仅在main函数中有效,会隐式调用exit(n),将n作为退出码。
exit(n)标准库函数会执行:1. 刷新用户空间的I/O缓冲区;2. 调用_exit(n)完成真正的终止。
_exit(n)系统调用直接通知操作系统终止进程,不刷新缓冲区,立即释放资源。

示例对比

#include <stdio.h>
#include <stdlib.h>  // exit
#include <unistd.h>  // _exitint main() {printf("Hello");  // 输出到用户缓冲区(未刷新)// return 0;  // 等价于 exit(0),会刷新缓冲区,输出"Hello"// exit(0);   // 刷新缓冲区,输出"Hello"// _exit(0);  // 不刷新缓冲区,无输出
}

7. 库函数与系统调用的输出缓冲区在哪里?

库函数(如printf)和系统调用(如write)的缓冲区位置不同,核心区别在于:

  • 库函数的缓冲区:位于用户空间(进程自己的内存中)。例如printf会先将数据写入用户缓冲区,满足刷新条件(如遇到\n、缓冲区满、进程调用exit)时,才会调用write系统调用将数据写入内核缓冲区。

  • 系统调用的缓冲区:位于内核空间write直接将数据写入内核维护的缓冲区,由操作系统决定何时真正写入硬件(如磁盘、显示器)。

关键结论:库函数的缓冲区不在操作系统内部,而是属于进程的用户空间内存;当进程通过_exit终止时,用户缓冲区未被刷新,数据会丢失。

三、进程等待

1. 为什么需要进程等待?

我们已经知道,子进程退出后若父进程“不管不顾”,会产生僵尸进程(Z状态)。这种状态的进程虽然已经停止运行,但其进程控制块(PCB)仍占用内核内存,导致资源泄漏。更严重的是,僵尸进程无法被kill -9清除(因为它已经“死亡”),若大量积累会耗尽系统的进程ID资源,导致无法创建新进程。

除此之外,父进程还需要通过某种方式知晓子进程的执行结果:任务是否完成?结果是否正确?是否因异常终止?

因此,进程等待的核心目的有两个

  1. 回收子进程的PCB资源,彻底清除僵尸进程,避免内存泄漏;
  2. 获取子进程的退出信息(退出码或终止信号),判断任务执行情况。

2. 如何实现进程等待?

Linux提供了wait系列系统调用来实现进程等待,最基础的是wait函数:

#include <sys/types.h>
#include <sys/wait.h>pid_t wait(int *status);

2.1 wait函数的工作机制

  • 等待对象:父进程调用wait后,会阻塞等待任意一个子进程退出(若有多个子进程)。
  • 阻塞行为:若此时没有子进程退出,父进程会进入阻塞状态(暂停运行),直到有子进程退出才被唤醒。
  • 资源回收:当子进程退出后,wait会自动回收该子进程的PCB,彻底清除僵尸状态。
  • 返回值:成功时返回退出的子进程PID;失败时(如无可用子进程)返回-1。

2.2 如何获取子进程的退出信息?—— status参数

waitstatus参数是一个输出型参数(指针),用于存储子进程的退出状态。它是一个32位整数,内核会按如下规则填充:

  • 高8位:存储子进程的退出码(正常退出时有效,如return 0中的0)。
  • 低7位:存储子进程的终止信号(异常终止时有效,如段错误信号SIGSEGV)。

可以通过系统提供的宏解析status

  • WIFEXITED(status):判断子进程是否正常退出(非信号终止),若为真则可用WEXITSTATUS(status)获取退出码。
  • WIFSIGNALED(status):判断子进程是否被信号终止,若为真则可用WTERMSIG(status)获取终止信号值。

示例代码:

#include <stdio.h>
#include <sys/wait.h>
#include <stdlib.h>
#include <unistd.h>int main() {pid_t pid = fork();if (pid == 0) {// 子进程:正常退出,退出码为3exit(3);} else if (pid > 0) {int status;pid_t ret = wait(&status);  // 父进程等待子进程if (ret > 0) {if (WIFEXITED(status)) {printf("子进程%d正常退出,退出码:%d\n", ret, WEXITSTATUS(status));}}}return 0;
}
// 输出:子进程xxx正常退出,退出码:3

3 waitpid函数

wait函数只能等待“任意一个子进程”,若需要指定等待某个子进程,或希望非阻塞等待,可使用waitpid

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

(1)waitpid的参数解析

  • pid:指定等待的子进程PID:

    • pid > 0:等待PID为pid的子进程;
    • pid = -1:等待任意子进程(与wait功能相同);
    • pid = 0:等待与父进程同组的任意子进程;
    • pid < -1:等待组ID为|pid|的任意子进程。
  • status:与waitstatus相同,用于存储退出状态。

  • options:控制等待行为:

    • 0:默认阻塞等待(与wait一致);
    • WNOHANG:非阻塞等待——若指定的子进程未退出,waitpid立即返回0,不阻塞。

(2)waitpid的返回值

  • 成功:若子进程已退出,返回该子进程的PID;若使用WNOHANG且子进程未退出,返回0。
  • 失败:返回-1(如无指定的子进程)。

(3)非阻塞等待的优势

非阻塞等待允许父进程在等待子进程的同时做其他事情,避免长时间阻塞。例如:

// 父进程非阻塞等待子进程
while (1) {pid_t ret = waitpid(child_pid, &status, WNOHANG);if (ret == 0) {// 子进程未退出,父进程可做其他工作printf("子进程未退出,继续等待...\n");sleep(1);} else if (ret > 0) {// 子进程已退出,处理退出信息printf("子进程%d已退出\n", ret);break;} else {// 等待失败break;}
}

4. ls命令处理不存在文件时的父子进程交互

当我们在Shell中执行ls 不存在的文件时,背后涉及父进程(Shell)与子进程(ls进程)的等待机制,具体流程如下:

  1. 父进程(Shell)创建子进程
    Shell调用fork创建子进程,子进程通过execvp加载并执行ls程序。

  2. 子进程(ls)执行任务
    ls程序尝试访问不存在的文件,执行失败,通过exit(n)退出(n为非0退出码,如2表示“文件不存在”)。

  3. 子进程进入僵尸状态
    子进程退出后,其PCB暂时保留(僵尸状态),等待父进程回收。

  4. 父进程(Shell)等待并处理结果
    Shell调用waitpid等待子进程退出,获取其退出码(非0),然后在终端显示错误信息(如“ls: 无法访问’不存在的文件’: 没有那个文件或目录”),同时回收子进程PCB,清除僵尸状态。

整个过程中,进程等待确保了子进程的资源被回收,同时让父进程(Shell)能根据子进程的退出码反馈执行结果,这正是进程等待机制的实际应用。


以上就是这篇博客的全部内容,下一篇我们将继续探索Linux的更多精彩内容。

我的个人主页
欢迎来阅读我的其他文章
https://blog.csdn.net/2402_83322742?spm=1011.2415.3001.5343
我的Linux知识文章专栏
欢迎来阅读指出不足
https://blog.csdn.net/2402_83322742/category_12879535.html?spm=1001.2014.3001.5482

非常感谢您的阅读,喜欢的话记得三连哦

在这里插入图片描述

http://www.xdnf.cn/news/1446571.html

相关文章:

  • PixPin截图工具完全体下载|解决Snipaste无法长截图问题+比QQ截图更专业+无广告绿色版支持Win7-Win11全系统兼容
  • AssetStudio解包Unity游戏资源
  • 如何从PDF中高效提取表格数据
  • 什么是端到端保护?天硕工业级 SSD 固态硬盘怎么做?
  • ansible中配置并行以及包含和导入
  • burpsuite攻防实验室-JWT漏洞
  • 【机器学习学习笔记】线性回归实现与应用
  • Shell-AWK详解
  • 单片机day2
  • Chapter1—设计模式基础
  • 线性代数基础 | 基底 / 矩阵 / 行列式 / 秩 / 线性方程组
  • 在线性代数里聊聊word embedding
  • Java:跨越时代的编程语言,持续赋能数字化转型
  • java面试:可以讲解一下mysql的索引吗
  • 「数据获取」《吉林企业统计年鉴(2004)》(获取方式看绑定的资源)
  • 基于区块链的商品信息追溯平台(源码+论文+部署+安装)
  • 关于linux软件编程15——数据库编程sqlite3
  • wpf之Border
  • 小程序 NFC 技术IsoDep协议
  • iBeLink BM S1 Max 12T矿机评测:Sia算法、高效算力与优化设计解析
  • AI 重塑就业市场:哪些职业会被替代?又有哪些新岗位正在崛起?
  • 文件处理三大利器之三:awk
  • 3大主流语言web框架写hello world
  • 接口测试之Mock测试方法详解
  • 使用spring-boot-starter-validation常用注释优雅判断类型
  • 小迪安全v2023学习笔记(七十六讲)—— Fuzz模糊测试口令爆破目录爆破参数爆破Payload爆破
  • uniapp 开发上架 iOS App全流程
  • uni-app iOS 文件管理与 itools 配合实战,多工具协作的完整流程
  • 如何选择适合企业的海外智能客服系统:6 大核心维度 + 实战选型指南
  • 集成运算放大器的作用、选型和测量指南-超简单解读