【寻找Linux的奥秘】第八章:进程控制
请君浏览
- 前言
- 1. 进程创建
- 1.1 fork函数
- 1.2 写时拷贝
- 2. 进程终止
- 2.1 常见退出方法
- 2.2 退出码
- 2.3 exit、_exit、return
- 3. 进程等待
- 3.1 等待的必要性
- 3.2 等待的方法
- 3.2.1 wait方法
- 3.2.2 waitpid方法
- 3.3 获得子进程status
- 4. 进程替换
- 4.1 替换原理
- 4.2 替换函数
- 尾声
前言
本专题将基于Linux操作系统来带领大家学习操作系统方面的知识以及学习使用Linux操作系统。前面我们认识了进程,也了解环境变量和虚拟地址空间的概念,那么本章让我们更深入地去了解进程。本章我们要学习的是——进程控制
1. 进程创建
1.1 fork函数
在前面我们认识进程的时候已经了解了如何通过fork
函数去创建一个进程,那么这里就简单的回顾一下。
在Linux中fork
函数是一个⾮常重要的函数,它是一个系统调用:
它的作用就是从已存在的进程中创建⼀个新进程。新进程为⼦进程,⽽原进程为⽗进程。
fork
函数会有两个返回值,它会给父进程返回子进程的pid
,给子进程返回0
;如果进程创建失败,就会给父进程返回-1
。
fork
调用失败的原因:
- 系统对每个用户能创建的最大进程数有限制。
- 整个系统的最大进程数量已达上限。
- 当前进程打开了太多文件或其他内核资源,新进程创建时无法复制资源描述符表(存在于 PCB 中,用于管理进程打开的文件)。
原因前面我们也讲过了,当进程调用fork
时,也就是程序在执行内核中的fork
代码时,在函数中会依次进行下列操作:
- 分配新的内存块和内核数据结构给⼦进程。
- 将⽗进程部分数据结构内容拷⻉⾄⼦进程。
- 添加⼦进程到系统进程列表当中。
- fork返回,开始调度器调度。
也就是说,在fork函数还没有执行完时,子进程就已经被创建出来了,并且和父进程拥有相同的代码和数据,所以父进程和子进程都会有自己的返回值在fork
中。所以看似fork
函数有两个返回值是我们肉眼所看到认为的,实际上是有两个进程在执行,它们有各自的返回值。
当⼀个进程调⽤fork
函数之后,就有两个⼆进制代码相同的进程。⽽且它们都运⾏到相同的地⽅。但之后每个进程都将可以开始它们⾃⼰的旅程(根据pid
进行分流)。下面让我们用一个示例来感受一下:
#include<unistd.h>int main( void )
{pid_t pid;printf("Before: pid is %d\n", getpid());if ( (pid=fork()) == -1 ){perror("fork()");exit(1);} printf("After:pid is %d, fork return %d\n", getpid(), pid);return 0;
}
运行程序我们得到如下的结果:
可以看到这里有三行输出,一行before
,两行after
,可以很清晰的看到前两行是我们父进程执行的结果,最后一行是子进程执行的结果。这也验证了我们上面所说。
所以,执行fork
之前⽗进程独⽴执⾏;执行fork
之后,⽗⼦两个执⾏流分别执⾏。注意,fork
之后,谁先执⾏完全由调度器决定(也就是通过调度算法添加到调度队列)。
fork
的常规用法:
- ⼀个⽗进程希望复制⾃⼰,使⽗⼦进程同时执⾏不同的代码段。例如,⽗进程等待客⼾端请求,⽣成⼦进程来处理请求。
- ⼀个进程要执⾏⼀个不同的程序。例如⼦进程从
fork
返回后,调⽤exec
函数(也就是进程替换,下面讲)。
1.2 写时拷贝
前面我们说过,子进程被创建之后不仅和父进程共享代码,在数据被修改之前也是共享的。但由于要保持进程之间的独立性,当任意⼀⽅试图写⼊数据,OS就会重新开辟一块空间,将数据拷贝一份,同时将页表上相对应的物理地址进行更改,这样父子进程就可以随意修改自己的数据了,这种方式称作写时拷贝。
写时拷⻉是⼀种延时申请技术,可以提⾼整机内存的使⽤率。写时拷贝的好处:
- 节省内存:在多个对象共享同一数据时,不立即复制数据,只有在其中一个修改数据时才进行复制,减少了不必要的内存使用。
- 提高性能:初始化时无需复制数据,减少了 CPU 和内存的开销,提高了系统响应速度。
- 支持并发:多个进程或线程可以并发读取同一份数据而无需加锁,直到某个写入时才分离,减少同步操作的复杂性。(当前了解即可)
正是有了写时拷⻉技术的存在,所以⽗⼦进程才得以彻底分离,完成了进程独⽴性的技术保证。
2. 进程终止
进程终止(Process Termination)是指一个进程的生命周期结束,它释放所占用的资源、退出内核调度,可能还会把信息传递给父进程。它的本质是释放系统资源。
2.1 常见退出方法
一个进程终止一般有三种情况:
- 代码运行完毕,结果正确。
- 代码运行完毕,结果错误。
- 代码异常终止。
那么当代码正常执行完后,我们该如何判断结果是否是正确的呢?
其实在我们学习C/C++的时候,我们知道main
函数的结束就是return
语句,return
返回的值我们称之为退出码,所以main函数的返回值通常表明程序的执行情况,当代码正常执行并且结果正确,我们一般将返回值设置为0
,结果不正确时会返回!0
,这里可以有很多的值,不同的值表明不同的出错原因。当代码异常终止时退出码就没有意义了。
程序一旦异常终止,一般是进程收到了信号。(对于信号后面会进行详细讲解)
常见的进程终止方式:
-
正常终止(自愿退出):代码正常运行结束
方法 描述 return
从main()
返回主函数返回时进程自动终止 exit(int status)
标准库函数,终止进程并返回状态码 status
给父进程_exit(int status)
系统调用,立即终止,不刷新缓冲区(更底层) -
异常终止(出错或被强制杀死):代码没有正常运行结束
方式 描述 错误引起的崩溃 如段错误(Segmentation Fault) 被信号杀死 如 SIGKILL
,SIGTERM
,SIGINT
使用 abort()
强制终止进程(通常用于调试/错误处理)
main
:main
函数结束,就表示进程结束;对于其他函数的结束则只表示函数调用完成,并且返回到上一级函数。exit
:在任何地方调用exit,都表示进程结束,包括在main
中调用其他函数时。并返回给父进程子进程的退出码
2.2 退出码
进程的退出码也是进程的一种属性,所以它也存放在进程的task_struct
(PCB)上。
在Linux中,我们可以通过echo $?
命令来查看最近一个程序(进程)退出时的退出码。我们运行一下上面举例子时使用的代码,然后使用echo
查看:
我们将上述代码的return结果该成10
再来看一看结果:
可以发现,对于我们自己写的代码,我们可以自己定义它的返回值,并给每一个返回值一个特定的含义,可以通过不同的返回值去知晓程序运行的具体结果。那么对于shell中的命令是否有对应的退出码呢?我们知道执行这些命令时它们也是进程,下面让我们来看一看:
可以发现,当我们的指令正常执行后,退出码就是0
,表示执行成功,没有错误。当我们第二次执行ls时我们去寻找一个不存在的目录时,我们发现它的退出码是2
,我们知道对于!0
的退出码代表了执行错误,那么退出码2
在Linux中代表了什么含义呢?
虽然程序可以自定义 !0
的退出码,Linux 也定义了一些约定俗成的退出码含义。我们之前在C语言中可以使用strerror
函数来查看退出码对应的原因是什么,那么让我们来看一看Linux中有多少个退出码以及它们对应的原因:
#include<stdio.h>
#include<string.h>int main()
{int i = 0;//先假设Linux中C标准库有150个退出码for(i; i < 150; i++){printf("%d->%s\n", i, strerror(i));}return 0;
}
运行结果:
可以看到,在Linux中C标准库一共给出了134个退出码及其含义,我们也看到了退出码2
所代表的含义是没有该文件或文件夹,刚好也就是我们执行ls
时shell给我们的报错。
Linux中Shell的主要退出码:
- 退出码 0 表⽰命令执⾏⽆误,这是完成命令的理想状态。
- 退出码 1 我们也可以将其解释为 “不被允许的操作”。例如在没有
sudo
权限的情况下使⽤yum
;再例如除以 0 等操作也会返回错误码 1 。- 130 ( SIGINT 或 ^C )和 143 ( SIGTERM )等终⽌信号是⾮常典型的,它们属于 128+n 信号,其中 n 代表终⽌码。
- 可以使⽤
strerror
函数来获取退出码对应的描述。
2.3 exit、_exit、return
exit
函数是库函数,_exit
函数是系统调用。exit
在执行时也会调用_exit
,但在调用_exit
之前还会做其他工作:
- 执⾏⽤⼾通过
atexit
或on_exit
定义的清理函数。 - 关闭所有打开的流,所有的缓存数据均被写⼊。
- 调⽤_exit。
这是什么意思呢?在前面我们稍微了解了一下缓冲区,知道我们如果想要在显示器上打印一些信息是会先存放在缓冲区中的,我们可以手动刷新缓冲区,例如fflush()
函数刷新或者\n
,也可以等待进程结束自动刷新缓冲区。也就是说当我们调用exit
结束进程时,也会把刷新缓冲区;而直接调用_exit
时,缓冲区中的内容并不会被刷新,我们可以通过下面的代码示例来感受一下:
#include<stdio.h>
#include<stdlib.h>int main()
{printf("hello");exit(0);
}
运行结果:
#include<stdio.h>
#include<unistd.h>int main()
{printf("hello");_exit(0);
}
运行结果:
return
是⼀种更常⻅的退出进程⽅法。在main
函数中执⾏return n
等同于执⾏exit(n)
,当 main()
函数正常执行并使用 return
语句时,编译器会将该 return
转换成对 exit()
函数的调用,并返回 return
中的状态码
3. 进程等待
3.1 等待的必要性
前面我们说过,当子进程退出时,父进程如果不管不顾,子进程就会进入僵尸状态,从而造成内存泄漏。
另外,一旦进程变成僵尸状态,就会变得刀枪不入,我们各种终止进程的操作都没有作用,哪怕使用
kill -9
命令也无能为力,因为谁也没有办法杀死一个已经死去的进程。
我们知道父进程创建子进程肯定会让子进程执行特定的任务,子进程任务执行的结果如何父进程需要知道,例如⼦进程运⾏完成,结果对还是不对,或者是否正常退出。
所以父进程会通过进程等待的方式,去回收子进程的资源,同时可以获取子进程的退出信息。
3.2 等待的方法
3.2.1 wait方法
wait
函数也是一个系统调用,用于父进程等待子进程状态的变化(如终止):
wait
的功能:
- 父进程调用
wait
后会阻塞,直到某个子进程终止(或状态改变)。 - 返回子进程的 PID,并通过
status
参数获取子进程的退出状态。 - 如果没有子进程,立即返回
-1
并设置errno
为ECHILD
(表示“没有子进程”(No child processes))。
wait
的参数是输出型参数,也就是说我们通过传递一个指针来获取子进程的退出状态,如果不关心则可以设置为NULL
。errno
是一个全局变量,存储最近一次系统调用或库函数的错误代码,所以在C语言中我们通常使用strerror(errno)
来打印错误信息。
如果父进程使用wait
函数等待子进程,但是此时子进程还没有结束,那么父进程会阻塞在wait
调用处(和使用scanf
时等待我们输入一样)。父进程如果有多个子进程,在父进程调用 wait
函数时,会等待最先终止的子进程,并且返回第一个完成状态变化的子进程的 PID。
下面让我们看一看wait
的使用:
#include<stdio.h>
#include<unistd.h>
#include<stdlib.h>
#include<sys/types.h>
#include<sys/wait.h>int main()
{int status;pid_t pid = fork();if(pid < 0){exit(-1);printf("进程创建失败\n");}else if(pid == 0){printf("I am child, pid is %d, ppid is %d\n", getpid(), getppid());//子进程打印完后我们直接让子进程退出,退出码为1exit(1);}else{printf("I am parent, pid is %d\n", getpid());sleep(1);pid_t rid = wait(&status); if(rid == pid){printf("wait success, rid: %d, status: %d\n", rid, status);}}return 0;
}
运行结果:
可以看到,我们的wait
函数也是成功的等待到了子进程,至于退出码我们设置的是1
,那么为什么打印出来的结果是256
,我们下面再说。
3.2.2 waitpid方法
我们知道,wait
函数只能等待最先结束的子进程,那么如果一个父进程有多个子进程,父进程该如何去等待特定的子进程呢?这时我们就可以使用waitpid
函数:
如果你需要父进程等待特定子进程(而不是任意一个最先终止的子进程),可以使用 waitpid
函数,而不是 wait
函数。waitpid
允许指定子进程的 PID,从而精确控制等待的目标。
可以看到waitpid
函数有三个参数:
pid:指定要等待的进程:
pid > 0
:等待指定的子进程的pid
。pid == -1
:等待任意子进程(行为类似wait
)。pid == 0
:等待同一进程组中的任意子进程。pid < -1
:等待进程组ID为|pid|
的任意子进程。
status:存储子进程的退出状态。
options:控制等待行为:
0
:阻塞等待(和wait相同)。WNOHANG
:非阻塞等待,若pid
指定的⼦进程没有结束,则waitpid()
函数返回0
,不予以等待。若正常结束,则返回该⼦进程的pid
。
对于非阻塞等待,如果目标子进程尚未终止,
waitpid
返回 0,父进程可以继续其他工作。通常采用循环检查,直到目标子进程终止,这种方式叫做非阻塞轮询。如何使父进程进行其他工作呢?我们可以采用函数调用的方式
waitpid
的返回值:当正常返回的时候waitpid
返回收集到的⼦进程的pid
;如果设置了选项WNOHANG
,⽽调⽤中waitpid
发现没有已退出的⼦进程可收集,则返回0;如果调⽤中出错,则返回-1
,这时errno
会被设置成相应的值以指⽰错误所在(如 ECHILD
表示无子进程,EINTR
表示被信号中断)。
下面让我们看一看waitpid
的使用:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
#include <errno.h>
#include <string.h>int main() {pid_t pids[3];int i;// 创建三个子进程for (i = 0; i < 3; i++) {pids[i] = fork();if (pids[i] < 0) {perror("fork failed");return 1;} else if (pids[i] == 0) {// 子进程printf("Child %d (PID: %d)", i, getpid());exit(i + 100); // 退出码为 100, 101, 102}}sleep(2);// 父进程等待特定子进程(例如,第二个子进程,索引 1)int status;pid_t target_pid = pids[1]; // 等待第二个子进程printf("Waiting for Child 1 (PID: %d)\n", target_pid);pid_t result = waitpid(target_pid, &status, 0); // 阻塞等待特定 PIDif (result == -1) {if (errno == ECHILD) {printf("No such child process: %s\n", strerror(errno));} else {perror("waitpid failed");}return 1;}if (WIFEXITED(status)) {printf("Child (PID: %d) exited with status %d\n", result, WEXITSTATUS(status));}return 0;
}
运行结果:
3.3 获得子进程status
我们知道在wait
和waitpid
中都有⼀个int *status
的参数,该参数是⼀个输出型参数,由操作系统填充。如果传递NULL,则表示不关心子进程的退出状态信息,否则OS会根据该参数将子进程的退出信息反馈给父进程。
在上面的代码中我们发现获取到了子进程的退出信息后status
的值与我们的预期不同,那么这是为什么呢?status不能简单的当作整形来看待,我们要把它当作位图来看待(只研究status低16位):
也就是说当我们的程序正常终止后,退出状态,也就是退出码保存在status的第十六位的高八位中;若是异常终止,status会保存异常时对应的信号编号,保存在status的低七位中,这时退出码就没有了意义。
在C语言的标准库中,有专门的宏用来转换status,来获得我们想要的信息:
WIFEXITED(status)
: 若为正常终⽌⼦进程返回的状态,则为真。(查看进程是否是正常退出)WEXITSTATUS(status)
: 若WIFEXITED⾮零,提取⼦进程退出码。(查看进程的退出码)
所以父进程为什么要进行等待?最重要的就是回收子进程资源,避免内存泄漏;同时也可以去获取子进程的退出信息,这也是僵尸状态存在的意义。
4. 进程替换
当我们使用fork
函数创建了子进程后,⽗⼦进程各⾃执⾏⽗进程代码的⼀部分,如果⼦进程就想执⾏⼀个全新的程序呢?这时就需要通过程序替换来完成这个功能!
程序替换是通过特定的接⼝,将磁盘上的⼀个全新的程序(代码和数据)加载到调⽤进程的地址空间中!
4.1 替换原理
在Linux系统中,进程替换主要通过exec
系列函数实现。⽤fork
创建⼦进程后执⾏的是和⽗进程相同的程序(但有可能执⾏不同的代码分⽀),子进程往往要调⽤exec
系列函数以执⾏另⼀个程序。当进程调⽤exec
系列函数时,该进程的物理内存空间的代码和数据完全被新程序替换,从新程序的启动例程开始执⾏。调⽤exec
系列函数并不创建新进程,所以调⽤exec
系列函数前后该进程的pid
并未改变。
一旦程序替换成功,子进程就去执行新的代码了,原始代码的后半部分已经不存在了,所以对于exec
系列函数来说,只有失败的返回值,没有成功的返回值。只要有返回值,就代表着替换失败。
4.2 替换函数
exec系列函数有六种,它们都以exec开头,所以统称为exec
函数:
这些函数原型看起来很容易混,但只要掌握了规律就很好记。
l(list)
: 表⽰参数采⽤列表的形式传。v(vector)
: 表示参数采⽤数组的形式传。p(path)
: 表示在PATH环境变量中搜索可执行文件e(env)
: 表⽰可以指定环境变量
事实上,只有execve
是真正的系统调⽤,其它五个函数最终都调⽤execve
,所以execve
在man⼿册 第2节,其它函数在man⼿册第3节。
这些函数之间的关系如下图所⽰:
下面让我们来看一看这些exec函数的示例:
-
int execl(const char *path, const char *arg, ...);
:#include <unistd.h> #include <stdio.h> #include <stdlib.h> #include <sys/wait.h>int main() {pid_t pid = fork();if (pid == 0) {// 子进程:替换为ls命令execl("/bin/ls", "ls", "-l", "/home", NULL);// 如果execl成功,下面的代码不会执行perror("execl failed");exit(1);} else if (pid > 0) {// 父进程:等待子进程结束wait(NULL);printf("Child process finished\n");} else {perror("fork failed");return 1;}return 0; }
对于
execl
函数,第一个参数的含义是我们要执行谁,剩下的参数的含义是我们像怎么去执行它,这也就对应了我们在命令行上执行命令的操作,命令的名称以及相应的选项。使用带l
的exec函数时,传参时我们最后需要以NULL
为结尾,代表我们的参数传递完毕。我们在命令行上是如何使用命令的,就如何去传参数。下面让我们来看一下运行结果:
-
int execv(const char *path, char *const argv[]);
:#include <unistd.h> #include <stdio.h> #include <stdlib.h> #include <sys/wait.h>int main() {pid_t pid = fork();if (pid == 0) {// 子进程:使用数组传递参数char *argv[] = {"ls", "-l", NULL};execv("/bin/ls", argv);perror("execv failed");exit(1);} else if (pid > 0) {wait(NULL);printf("Child process finished\n");} else {perror("fork failed");return 1;}return 0; }
对于带v的exec函数,我们只需将传递选项的参数通过一个指针数组来提供即可。下面来看运行结果:
-
int execlp(const char *file, const char *arg, ...);
int execvp(const char *file, char *const argv[]);
#include <unistd.h> #include <stdio.h> #include <stdio.h> #include <sys/wait.h>int main() {pid_t pid = fork();if (pid == 0) {// 不需要完整路径,在PATH中搜索execlp("ls", "ls", "-l", NULL);perror("execlp failed");exit(1);} else if (pid > 0) {wait(NULL);printf("Child process finished\n");} else {perror("fork failed");return 1;}return 0; }
对于带p的exec函数,我们第一个参数只需要传入需要执行的程序名即可,不用带路径,它会自动在PATH环境变量中搜索。
-
int execle(const char *path, const char *arg, ...,char *const envp[]);
int execve(const char *path, char *const argv[], char *const envp[]);
#include <unistd.h> #include <stdio.h> #include <stdlib.h> #include <sys/wait.h>int main() {pid_t pid = fork();if (pid == 0) {// 子进程:传递环境变量表char *argv[] = {"ls", "-l", NULL};char *env[] = {(char *const)"MYVAL=123456",NULL};execve("/bin/ls", argv, env);perror("execv failed");exit(1);} else if (pid > 0) {wait(NULL);printf("Child process finished\n");} else {perror("fork failed");return 1;}return 0; }
对与带e的
exec
函数,我们需要给它传入环境变量表(其他的都是使用的继承父进程的环境变量表)。我们如果要传入环境变量,那么我们的目标子进程就会将之前从父进程继承的环境变量表替换成传入的新的环境变量表。很显然这并不符合我们的预期,因为我们通常是对于子进程新增一些环境变量,而不是覆盖,那么该怎么做呢?我们可以通过putenv
函数来导入新的环境变量
所以一般来说我们并不经常使用带e的exec
函数,如果想要新增环境变量,可以直接在父进程中使用putenv
导入即可,这样父进程在创建子进程时子进程会复制一份。如果一定要使用我们可以在导入后传入envrion
指针。
对于exec系列函数,它们只会替换进程的代码和数据,而PID、PPID、进程组ID、会话ID等都不会改变。进程替换是Linux系统中创建新进程的重要机制,通常与fork()
结合使用,形成了经典的"fork + exec"模式。
尾声
本章讲解就到此结束了,若有纰漏或不足之处欢迎大家在评论区留言或者私信,同时也欢迎各位一起探讨学习。感谢您的观看!
我的博客即将同步至腾讯云开发者社区,邀请大家一同入驻:https://cloud.tencent.com/developer/support-plan?invite_code=4p7uctlc94x