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()
被调用后,内核会执行一系列关键操作,确保子进程能独立运行:
- 分配资源:为子进程分配新的内存块(用于存放数据、栈等)和内核数据结构(如进程控制块PCB)。
- 复制数据:将父进程的部分数据结构(如PCB中的进程状态、环境变量等)复制到子进程,但并非所有数据都会立即复制(这涉及后续的写时拷贝机制)。
- 加入调度:将子进程添加到系统进程列表,使其成为调度器的调度对象。
- 返回执行:父进程和子进程分别从
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. 进程终止时,操作系统要做什么?
当一个进程终止时,操作系统的核心任务是回收进程占用的所有资源,确保系统资源不被浪费。从内核数据结构到内存管理,具体操作如下:
-
清理进程控制块(PCB)
PCB是进程的"身份证",包含进程ID、状态、优先级、打开文件列表等关键信息。进程终止后,操作系统会从系统进程列表中移除该PCB,释放其占用的内核内存。 -
撤销页表映射
进程运行时依赖虚拟地址空间与物理内存的映射(通过页表实现)。终止时,操作系统会删除该进程的页表,解除虚拟地址到物理内存的关联,使物理内存块重新回到"空闲"状态,可被其他进程使用。 -
回收内存资源
- 释放进程的用户空间内存(代码段、数据段、堆、栈等),这些内存可能是进程创建时分配的,或通过
malloc
等动态申请的。 - 若进程使用了共享内存、信号量等IPC资源,操作系统会根据资源的"引用计数"决定是否回收(若其他进程仍在使用,则暂时不回收)。
- 释放进程的用户空间内存(代码段、数据段、堆、栈等),这些内存可能是进程创建时分配的,或通过
-
关闭打开的文件
进程打开的文件、管道、网络套接字等,会被操作系统逐一关闭,释放对应的文件描述符,避免文件描述符泄露。 -
通知父进程
操作系统会将子进程的终止状态(退出码或终止信号)告知父进程(通过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退出码对应具体的错误描述。查看方式有两种:
-
通过库函数查询
使用strerror
函数(需包含<string.h>
),传入错误码即可获取对应的描述字符串:#include <stdio.h> #include <string.h> int main() {printf("错误码1的含义:%s\n", strerror(1)); // 输出:Operation not permittedreturn 0; }
-
直接查看头文件或手册
执行man 3 strerror
可查看错误码对应的描述;或直接打开/usr/include/errno.h
,里面定义了EPERM
(1)、ENOENT
(2)等宏与错误码的对应关系。
5. 进程退出的三种情况与退出码的意义
进程退出可分为三类场景,退出码的意义在不同场景下有所不同:
- 代码跑完,结果正确:退出码为0(约定俗成),表示任务成功完成。
- 代码跑完,结果错误:退出码为非0,具体值表示失败原因(如参数错误返回2,文件不存在返回2)。
- 代码没跑完,进程异常终止:此时退出码无意义。进程可能因收到信号(如
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资源,导致无法创建新进程。
除此之外,父进程还需要通过某种方式知晓子进程的执行结果:任务是否完成?结果是否正确?是否因异常终止?
因此,进程等待的核心目的有两个:
- 回收子进程的PCB资源,彻底清除僵尸进程,避免内存泄漏;
- 获取子进程的退出信息(退出码或终止信号),判断任务执行情况。
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参数
wait
的status
参数是一个输出型参数(指针),用于存储子进程的退出状态。它是一个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:与
wait
的status
相同,用于存储退出状态。 -
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进程)的等待机制,具体流程如下:
-
父进程(Shell)创建子进程:
Shell调用fork
创建子进程,子进程通过execvp
加载并执行ls
程序。 -
子进程(ls)执行任务:
ls
程序尝试访问不存在的文件,执行失败,通过exit(n)
退出(n
为非0退出码,如2表示“文件不存在”)。 -
子进程进入僵尸状态:
子进程退出后,其PCB暂时保留(僵尸状态),等待父进程回收。 -
父进程(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
非常感谢您的阅读,喜欢的话记得三连哦 |