文章目录
- 前言
- 一、重谈进程创建
- 二、进程终止
- 2.1 正常终止的退出码机制
- 2.2 异常终止的信号机制
- 2.3 进程常见的退出方法
- 三、进程等待:避免僵尸进程的关键
- 3.1 进程等待的必要性
- 3.2 进程等待的两个系统调用接口
- 3.2.1 wait()
- 3.2.2 waitpid()
- 区别
- 四、进程程序替换
- 4.1 进程替换原理(单进程)
- 4.2 多进程版程序替换效果——验证程序替换接口
- 4.3 进程替换的接口
- 总结
前言
本文将用最直白的语言带你掌握Linux进程管理的四大核心操作!包含大量代码示例与图解,建议边看边动手实践!
一、重谈进程创建
当我们使用fork()
系统调用创建子进程时,父子进程的执行顺序并非固定,而是由操作系统的进程调度器决定。这种不确定性源于现代操作系统的并发特性
可能输出结果:
父进程PID: 1234
子进程PID: 1235或
子进程PID: 1235
父进程PID: 1234
1.2 调度机制解析
调度策略 | 说明 | 典型系统 |
---|---|---|
完全公平调度(CFS) | 基于虚拟运行时间分配CPU时间片 | Linux默认 |
实时调度 | 优先级驱动 | 嵌入式系统 |
轮转调度 | 均分时间片 | 早期Unix系统 |
二、进程终止
2.1 正常终止的退出码机制
为什么main函数总是return 0 返回,这个0去哪里了?为什么
进程退出场景:
- 代码运行完毕,结果正确
- 代码运行完毕,结果不正确
- 代码异常终止
在程序运行期间,我们通常并不关心意料之中的【程序正常运行且结果正确】的情况,反而那些结果有误、异常退出的情况能够让程序员及时发现问题原因并解决它。
程序中的return 0
表示的是进程的退出码,表示进程的运行结果是否正确
此外,在系统层面上,进程运行时,一般来说是父进程最关心自己的子进程的运行状态,所以退出码是直接向父进程呈现的。
换言之,在子进程正常运行且退出时(返回0),父进程并不关心,更多的是关心子进程运行失败的原因并转交给用户,此时,程序就可以用return返回不同的值,来表示不同的出错原因。
我们可以使用以下指令来查看最近一次运行的进程返回的退出码是多少:
$ echo $? # $?表示最近一个程序运行的退出码
此外,在C语言中有将退出码转化为错误信息的strerror()
函数
//打印所有退出码表示的信息
#include <string.h>int main()
{int i = 0;for(; i<200; i++){printf("%d: %s\n",i,strerror(i));}return 0;
}
此外,在我们平常使用的cd指令、pwd指令等,底层实现也是一段程序,同样会返回错误码信息:
举例: ls 不存在的目录时,返回2错误码
(No such file or directory)
2.2 异常终止的信号机制
在进程运行时,正常的顺序是先检查是否异常,再判断结果信息,只要是程序异常终止了,退出码就没有任何意义了。例如野指针问题或者除0错误等都会触发硬件级的异常问题,操作系统会直接给进程返回某种信号
结束进程(kill)。
【这部分的内容我们会在后面的信号章节详细为大家讲解】
信号 | 值 | 说明 | 触发场景 |
---|---|---|---|
SIGSEGV | 11 | 段错误 | 非法内存访问 |
SIGFPE | 8 | 算术异常 | 除零操作 |
SIGKILL | 9 | 强制终止 | kill -9 |
2.3 进程常见的退出方法
- return 0(在 main 函数中)
在 main 函数中使用 return 0 表示程序正常终止,返回值 0 作为进程的退出状态码。
触发 main 函数的隐式 exit 调用,
执行标准库的清理工作(如刷新 I/O 缓冲区、关闭文件流)。
最终通过系统调用(如 _exit)终止进程。
int main() {printf("Hello"); // 无换行符,但 return 会刷新缓冲区return 0; // 输出 Hello
}
- exit(int status)
标准库函数(stdlib.h),立即终止进程,返回状态码 status
刷新所有标准 I/O 缓冲区(如 printf 未输出的内容)。
关闭所有打开的流(FILE 类型)。
最终调用 _exit 终止进程。
#include <stdlib.h>
void func() {printf("World"); // 无换行符exit(0); // 输出 World
}
- _exit(int status)
系统调用(unistd.h),直接终止进程,返回状态码 status
不执行任何清理:
不刷新 I/O 缓冲区(可能导致数据丢失)。
不调用 atexit 注册的函数。
立即终止进程。
#include <unistd.h>
int main() {printf("Hello"); // 无换行符_exit(0); // 无输出(缓冲区未刷新)
}
方式 | 所属库/系统调用 | 清理操作(I/O 缓冲区、atexit 函数) | 适用场景 |
---|---|---|---|
return 0 | C 标准库 | 是(隐式调用 exit) | main 函数正常退出 |
exit() | C 标准库 | 是 | 任意位置终止并清理资源 |
_exit() | 系统调用 | 否 | 快速终止,避免干扰(如子进程) |
三、进程等待:避免僵尸进程的关键
通过系统调用wait / waitpid
,来对子进程进行状态检测与回收的功能。
3.1 进程等待的必要性
-
避免僵尸进程(Zombie Process)
子进程退出后,若父进程未调用等待函数,子进程的退出状态会残留在内核中,成为僵尸进程(占用 PID,但无法被调度,并且)。
后果:大量僵尸进程会导致 PID 耗尽,系统无法创建新进程。 -
同步父子进程
父进程可能需要等待子进程完成特定任务后再继续执行(如数据处理、文件读写)。 -
获取子进程退出状态
父进程需知道子进程是正常退出(如返回码)、被信号终止,还是其他异常情况。【这里可以通过系统调用来获取不同的值来表示不同的情况,在wait和waitpid接口中存在参数status(输出型参数,由操作系统填充,如果传递NULL,表示不关心子进程的退出状态信息。),status在系统中以整形的方式存在,但不同区间的比特位代表不同的信息(如下图)】
pid_t wait(int *status);
pid_ t waitpid(pid_t pid, int *status, int options);
进程之间具有独立性,因此父进程获取子进程运行状态不能简单地通过访问某个全局变量获取子进程状态因为父进程无法看到该变量(每个进程有自己的独立的进程地址空间),只能通过操作系统调用接口来实现。
关于status参数,它是一个整型指针,用来存储子进程的退出状态。status不能简单的当作整形来看待,可以当作位图来看待,具体细节如下图(只研究status低16比特位),表示含义为:
如果进程正常退出,那么高八位为进程的退出状态码;如果进程被信号所杀(异常中止),那么低七位表示信号码,第八位表示core dump标志(核心转储,是操作系统在程序异常终止时生成的一个文件,记录了程序崩溃时的内存状态、寄存器值、堆栈信息等关键数据。)
exitCode = (status >> 8) & 0xFF; //退出码
exitSignal = status & 0x7F; //退出信号//or 使用系统定义的宏正常退出:
WIFEXITED(status):若为真,表示子进程正常退出。
WEXITSTATUS(status):获取子进程的退出码(即exit(code)中的code)。信号终止:
WIFSIGNALED(status):若为真,表示子进程被信号终止。
WTERMSIG(status):获取导致终止的信号编号(如SIGKILL对应9)。
总的来说,系统为什么要进行进程等待:
- 僵尸进程无法被kill -9信号杀死(无法杀掉一个已经死掉的进程)杀死,需要通过进程等待来杀掉进程。
- 需要通过进程等待获得子进程的退出情况(退出码)
3.2 进程等待的两个系统调用接口
3.2.1 wait()
wait函数的作用是让父进程等待子进程结束。调用wait的时候,父进程会被阻塞,直到有一个子进程结束。然后wait会返回结束的子进程的PID,并且通过参数status来传递子进程的退出状态。
#include <sys/types.h>
#include <sys/wait.h>pid_t wait(int *status);//返回值:成功返回被等待进程pid,失败返回-1。
参数:输出型参数,获取子进程退出状态,不关心则可以设置成为NULL
如果有多个子进程,父进程每次调用wait只能处理一个,所以可能需要循环调用直到所有子进程都被回收,否则可能会有僵尸进程残留。
3.2.2 waitpid()
waitpid函数更灵活。它的参数中可以指定要等待的子进程的PID,或者用-1表示等待任意子进程,类似wait。另外,waitpid还可以设置选项,比如WNOHANG
,这样父进程不会被阻塞,可以立即返回检查是否有子进程结束。这在需要父进程同时处理其他任务的时候很有用,避免阻塞。
pid_ t waitpid(pid_t pid, int *status, int options);
//返回值:当正常返回的时候waitpid返回收集到的子进程的进程ID;如果设置了选项WNOHANG,而调用中waitpid发现没有已退出的子进程可收集,则返回0;如果调用中出错,则返回-1,这时errno会被设置成相应的值以指示错误所在;
//参数:pid:Pid=-1,等待任一个子进程。与wait等效。Pid>0.等待其进程ID与pid相等的子进程。status:WIFEXITED(status): 若为正常终止子进程返回的状态,则为真。(查看进程是否是正常退出)WEXITSTATUS(status): 若WIFEXITED非零,提取子进程退出码。(查看进程的退出码)options:WNOHANG: 若pid指定的子进程没有结束,则waitpid()函数返回0,不予以等待。若正常结束,则返回该子进程的ID。
区别
那wait和waitpid的区别主要在哪儿呢?
参数 | wait | waitpid |
---|---|---|
指定进程 | ❌ 只能等任意 | ✅ 可指定PID |
阻塞控制 | ❌ 强制等待 | ✅ 支持非阻塞 |
状态获取 | ✅ 基础信息 | ✅ 详细信息 |
如果子进程永远在执行,父进程wait会一直在等待子进程退出,在等待期间,父进程不能做任何事情,这种等待叫做阻塞等待 (回忆阻塞状态,scanf会等待硬件资源,这个例子是等待软件资源)
除了可以指定PID和选项之外,waitpid可以更精确地控制要等待哪个子进程,以及是否阻塞。比如,当父进程有多个子进程时,如果使用wait,只能按任意顺序处理结束的子进程,而waitpid可以选择等待特定的子进程,或者以非阻塞方式轮询WNOHANG子进程状态。
举个例子,假设父进程创建了三个子进程,然后想要等待第二个子进程结束,这时候就可以用waitpid,传入第二个子进程的PID作为参数。或者,如果父进程不想阻塞,可以设置WNOHANG选项,这样如果没有子进程结束,waitpid会立即返回0,而不会让父进程挂起。
那如何正确使用这些函数呢?
比如,父进程fork之后,子进程可能执行不同的任务,父进程需要收集它们的退出状态。用wait的话,父进程会阻塞直到有子进程结束,然后处理。而用waitpid的话,可以更灵活,比如循环调用waitpid(-1, &status, WNOHANG)来非阻塞地收集所有已结束的子进程,避免僵尸进程的产生,同时父进程可以做其他事情。
关于非阻塞轮询方式需要注意:
非阻塞轮询方式WNOHANG,可以做自己的事情
- 父进程顺带做自己事情的时候,这个任务不能太重,因为等待子进程退出信息才是主要任务
- 若子进程先于父进程任务退出,那么等待一会儿再回收也是可以的,并不是立即需要回收子进程。
- 通过进程等待,可以保证父进程是最后一个退出的进程,这意味着父进程创建的所有子进程已被回收。
//wait
int status;
pid_t pid = fork();
if (pid == 0) {// 子进程执行任务后退出//while(1){} 若子进程不退出,那么父进程wait阻塞等待exit(42);
} else {wait(&status); // 父进程阻塞等待子进程结束if (WIFEXITED(status)) {printf("子进程退出码: %d\n", WEXITSTATUS(status)); // 输出42}
}//waitpid
int status;
pid_t child_pid = fork();
if (child_pid == 0) {// 子进程任务//while(1){} 可以通过WNOHANG触发非阻塞轮询方式exit(3);
} else {// 父进程非阻塞等待特定子进程while (waitpid(child_pid, &status, WNOHANG) == 0) {printf("子进程未结束,父进程继续工作...\n");sleep(1);}if (WIFEXITED(status)) {printf("子进程退出码: %d\n", WEXITSTATUS(status)); // 输出3}
}
四、进程程序替换
进程程序替换是Linux系统中一个重要的概念,它允许一个进程用另一个程序完全替换当前的执行内容,同时保留进程的基本属性(如PID、文件描述符等)。这一过程通过exec系列函数实现,这些函数能够加载新的程序映像到当前进程中,从而改变进程的行为。调用exec并不创建新进程,所以调用exec前后该进程的pid并未改变。
4.1 进程替换原理(单进程)
之前了解到一个进程执行的过程是,首先系统创建该进程的PCB并分配进程地址空间(虚拟内存),创建映射物理内存的页表。进程先将存储在磁盘上的代码块和数据块加载至物理内存后开始执行,运行到execl函数处,由于ls指令也是存放在磁盘上的,进程替换就非常简单地用ls指令的代码块和数据块替换掉进程的代码块和数据块,再将进程从ls指令的main函数重新执行,就达到了进程切换的目的。
int main()
{printf("before\n");execl("/usr/bin/ls","ls","-a",NULL);printf("after\n");//无法执行after,因为已被替换return 0;
}
4.2 多进程版程序替换效果——验证程序替换接口
int main()
{pid_t id = fork();if(id == 0){// 子进程执行进程替换//sleep(5)printf("before: i am a process,pid: %d, ppid: %d\n",getpid(),getppid());// 要执行的程序 、 怎样执行该程序 、 ...为可变参数列表execl("/usr/bin/ls","ls","-a","-l",NULL);//这类方法的标准写法//execl("/usr/bin/top","top",NULL);//execv("usr/bin/ls",myargv);//execl("./otherExec","otherExec",NULL);printf("after: i am a process,pid: %d, ppid: %d\n",getpid(),getppid());exit(0);//防止子进程继续运行后续代码}//父进程pid_t ret = waitpid(id,NULL,0);if(ret > 0){printf("等待子进程返回成功,father pid: %d, ret id: %d\n",getpid(),ret);}sleep(5);return 0;
}
现象:程序打印before,等待五秒后切换ls进程执行,ls执行完毕瞬间waitpid接收到子进程退出信号,遂执行父进程下的打印操作,等待五秒后程序退出。
我们知道父子进程是独立的两块进程地址空间,且存在写时拷贝技术,所以子进程在替换进程的时候,是不会影响到父进程的。
写时拷贝在修改数据时生效,替换了ls的数据块可以理解,但代码块存放在常量区按理来说不应该会被修改,所以不应该发生写时拷贝,但代码块不发生写时拷贝,岂不是就会影响到父进程的执行?
答:系统层面上写时拷贝不仅发生在数据区,也发生在代码区,用户无法修改代码区的数据,但操作系统需要写入ls代码至父子进程,由于这块代码区是父子共享的且只读,所以仍然发生了写时拷贝。
程序替换有没有创建新的进程?
很明显是没有,这一点在 ps ajx
指令可以看到,并没有额外的进程被创建,只是修改了原PCB等结构体的一些字段。但系统是如何做到替换呢?
exec系统调用的步骤是:加载新程序到内存,替换原有代码段和数据段,调整堆栈,重置PC等寄存器,最后跳转到新程序的入口点(main)。
从进程地址空间角度来看,每个进程由用户地址空间与内核空间构成,在替换时,会保留内核结构,之是将新程序的代码和数据加载到当前进程的用户地址空间(直接覆盖),这一操作只需通过改变文件描述符表就可以做到替换,不创建新的进程是因为内核在执行替换时是复用了原本的内核数据结构(PCB信息),并且只是更改映射关系,重新创建进程开销巨大,这也就是exec与fork函数的区别之一。
CPU如何知道程序的入口地址?
Linux系统下,在编译时会产生一个程序头表,该表中存放着可执行文件的代码段和数据段的加载位置,在执行进程替换后,程序头表也会被替换,但同时,程序计数器PC的值也会被重置头表的入口地址,根据偏移量,PC就能够找到新程序的入口地址了。
多进程替换的特点:
- 替换后,进程的用户空间代码和数据被完全替换,新程序从其main函数开始执行。
- 进程的PID保持不变,但代码段、数据段、堆和栈会被替换。
- 替换成功后,原进程的后续代码不会执行,只有在替换失败时才会继续执行
4.3 进程替换的接口
//库函数 man 3int execl(const char *path, const char *arg, ...);//路径+参数列表,参数逐个传递,以NULL结尾int execlp(const char *file, const char *arg, ...);//文件名+参数列表,自动在PATH中查找int execle(const char *path, const char *arg, ...,char *const envp[]);//可自定义环境变量(通过envp数组传递)int execv(const char *path, char *const argv[]);//路径+参数数组,参数通过数组传递int execvp(const char *file, char *const argv[]);//文件名+参数数组,自动在PATH环境变量中查找可执行文件
exec系列函数:本质是一个加载器
环境变量是什么时候给进程的?
环境变量也是数据,创建子进程的时候环境变量就已经被子进程继承下去了(进程地址空间有一部分为进程环境变量),于是即使不写main函数中的环境变量参数表,系统也会找到进程的环境变量,替换进程后,环境变量不变。
子进程可以新增自己的环境变量信息(putenv
),再往后的子进程可以继承它的环境变量。传递自定义环境变量列表时,原环境变量会被替换。
//系统调用接口
//以上六个库函数接口实现都会调用这一接口int execve(const char *path, char *const argv[], char *const envp[])
总结
本文篇幅较大,设计知识点众多,所以有几个点并没有深入讨论,如核心转储、exel接口函数中的环境变量表如何使用等,这些内容会在后续学习中逐渐补全。
👍 感谢各位大佬观看。如果本文有帮助,请点赞收藏支持~