Linux 进程控制
进程创建
在 Linux 命令行中创建进程,无非就是:
- 要么
路径+可执行程序名称
,其实也是shell
帮你创建了以shell
自己为父进程的子进程 - 要么 在你的代码中使用
fork
函数创建进程,其函数返回值会分别在不同的进程(父子进程)下进行返回,上层直白现象就是会返回两次
返回值:子进程中返回 0,父进程返回子进程 id
(方便父进程管理子进程),出错返回 -1
那创建一个进程究竟做了什么呢?
看过前面内容的老铁应该会发现,除了要创建一个自己的 task_struct
,还要创建其自己的 地址空间(mm_struct
)、页表 等等,总之就是要创建各种 进程对应的内核数据结构,如此进程方能工作呀
所以注意啊: 进程 = 进程对应的内核相关管理数据结构 + 代码和数据
进程调用 fork
,当控制转移到内核中的 fork
代码后,内核做:
- 分配 新的内存块和内核数据结构 给子进程
- 将父进程 部分 数据结构内容拷贝至子进程
- 添加子进程到 系统进程列表 当中
fork
返回,开始 调度器 调度
所以,fork
之前父进程独立执行,fork
之后,父子两个执行流分别执行。注意,fork
之后,谁先执行完全由 调度器 决定
而创建出一个进程是要保持进程独立性的,如何保证呢?
- 首先,子进程拥有自己的一套内核数据结构,不会影响其他任何进程;
- 其次,子进程是父进程直接创建出来的,自然没有属于自己的代码,只能共享父进程的,但我们知道,代码是只读的呀,父进程子进程都是读代码,不修改也就没有互相影响的说法;
- 最后,子进程利用 写时拷贝 的方式完成数据的独立,只要父子进程不修改数据,那就共享;如要修改,那就 写时拷贝
进程终止
进程终止似乎很简单啊,想终止直接终止不就完了?
但我们也要搞清楚 进程终止的相关情况
进程终止是在做什么
我们了解 进程 = 进程对应的内核相关管理数据结构 + 代码和数据 ,咱先不谈终止,请问这么一大堆东西是内核数据结构先被创建,还是代码和数据先被加载啊?
其实是 内核数据结构先被创建,代码和数据后被加载 ,不然先被加载进来的代码和数据谁管理啊?
那 终止进程 呢?所以是不是 先释放进程对应的代码和数据所占的空间,后释放内核数据结构所占的空间 啊,没错,唯一需要注意的是 task_struct
是会被延期处理的,因为有一种状态为 Z
状态(僵尸状态),进程退出时是要维护自己的退出信息的
进程终止的几种情况
- 代码跑完,结果不正确
- 代码跑完,结果正确
那结果正确不正确是什么决定呢?退出码
- 代码未跑完,出现 异常 ,提前退出了,例如:除零异常, OS 直接将进程干掉了(崩溃),原因就是 你的进程做了不该做的事情,一旦出现异常,退出码 将没有意义
退出码
还是以问题引入:
大家在学 C 语言或 C++ 时写 main()
函数是要写返回值的,那为啥都是写 return 0;
呢?不是 1 啊 -1 什么的呢?都是什么意思呢?
这其实是当前进程的 退出码,当 main()
函数退出后,意味着这个进程就将要结束,而要知道,你现在的这个进程是被父进程(Linux 命令行下为 bash
进程)创建出来的,创建出来干嘛?自然是要做和父进程不一样的事情,那你这个进程任务完成的如何是否需要向父进程汇报呢?
当然需要,父进程要来干嘛,又没用,我进程都退出了你能拿我怎么样?其实 父进程为用户服务,是要负责的,与其说父进程要知道子进程任务完成的如何,不如说是用户关心写出来的代码是否过关
所以父进程需要 退出码,而 退出码会携带信息,成功就是 0 ,不成功就是非 0 ;既然不成功,总是要用户知道为啥不成功吧?所以 不同的退出码携带的信息是不一样的,是要将其携带的信息返还给用户让他做下一步决策的,咱来做几个实验验证一下:
先在命令行上运行如下代码:
#include <stdio.h>
#include <unistd.h>
#include <string.h>
int main()
{
printf("It is a process, pid: %d, ppid: %d\n", getpid(), getppid());
return 0;
}
编译为 Test 可执行文件,注意现在的返回值为 0,再运行如下指令:
echo $?
会发现就是 0 ,咱再将返回值改为 100,那结果就是 100
那 echo $?
指令是什么意思呢?就是打印父进程 bash 获取到的,最近一个子进程退出的退出码,而这退出码是保存在 ?
里的,这是一个在 bash
内部里环境变量,使用内建命令 echo
打印自然是轻而易举
继续,咱再运行如下代码:
#include <stdio.h>
#include <unistd.h>
#include <string.h>
int main()
{
int errnum;
for (errnum = 0; errnum <= 100; ++errnum)
{
printf("%d: %s\n", errnum, strerror(errnum));
}
return 100;
}
这段代码就将退出码的内容打印了出来:
内容很长,我只截取了一小段,后面的内容就是退出码对应的信息,以字符串的形式展现给用户的,如果想要直观感受,可以观察如下实验:
[exercise@localhost control]$ ls
makefile Test test.c
[exercise@localhost control]$ echo $?
0
[exercise@localhost control]$ ls lksdjhfiweoirj
ls: cannot access lksdjhfiweoirj: No such file or directory
[exercise@localhost control]$ echo $?
2
[exercise@localhost control]$ echo $?
0
[exercise@localhost control]$
不知道是否观察到第二次的 ls
指令的报错信息和上面打印出的退出码为 2 的信息是否一致;
而 第二次 的 echo $?
指令是打印的 第一次 echo $?
的退出码,第一次的运行当然没有问题,第二次打印结果自然就是 0 啦
自定义
当然啦,咱 也不是非要严格遵守库里的退出码,退出码也可以自定义
使用 enum
枚举错误类型,再使用函数实现自定义的退出码含义,最后使用一个变量承担结果并打印出来就可知晓,这里就不操作了
但是要注意,这种方式的自定义是 不会修改原本进程退出时的退出码的,只是对自己知晓,系统不承认的,也就是说,你的退出码结果只是你自己知晓什么意思,而父进程拿到这个退出码是以库里的含义进行解释的,要注意
异常
为什么出现异常,本质就是:进程收到了 OS 发给进程的信号
即使用信号的方式使进程提前终止;
我们写出来的代码跑起来变成进程后可以被 kill
杀掉,kill -l
查看 kill
的所有选项,要发出信号几就 -几
即可,例如杀灭进程:kill -9 进程pid
就是给进程发送了 9 号信号
而进程出现异常,作为用户我依然是要关心的,此时虽然没有退出码的说法,但看一看退出信号是多少,就可以判断进程异常原因,和 退出码 作用异曲同工
结论:衡量一个进程退出,我们只需要两个数字,一个是退出码,一个是退出信号
这两个数字一定是要让父进程知道,父进程如何知道,那么子进程退出后不会立马是释放 task_struct
数据结构,而是维持一段时间变成 Z
状态,里面会有相关属性表示退出码(exit_code(int)
)和退出信号(exit_signal(int)
),父进程就是需要读取这个
如何终止
main()
函数return
,表示进程终止;- 代码任意位置调用
exit()
函数,等价main()
函数里的return
,其内参数等价为 退出码,会将main
的返回值当做exit
的参数 _exit()
这是一个system call
系统调用
exit() 和 _exit() 的区别
做如下实验:
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int main()
{
printf("Hello Linux!!!");
sleep(3);
exit(3);
}
在命令行上编译运行上述代码,会发现过了 3 秒打印 Hello Linux!!!
,且和命令行提示符一起打印出来,这在本人往期拙作进度条里已经解释过此现象的原因:
[exercise@localhost control]$ ./Test
Hello Linux!!![exercise@localhost control]$ echo $?
3
[exercise@localhost control]$
咱不妨把上面的 exit
函数换为 _exit
函数,验证如下:
[exercise@localhost control]$ ./Test
[exercise@localhost control]$ echo $?
3
[exercise@localhost control]$
会发现打印都不打印了!!
上述代码的 printf
都是执行了的,只是数据暂存在 缓冲区,exit()
会在进程结束时帮忙冲刷缓冲区,而 _exit()
函数则不会,为什么呢?
这是因为 exit()
是 C 库函数,而 _exit()
是一个 系统调用,我们知道 系统调用 的下一层就是 OS 内核 了,所以目前,我们说的缓冲区绝对不在 OS 内核 中,不是 OS 维护的内核缓冲区
而 exit()
就是调用的 _exit()
,但在调用 _exit()
之前,还做了其他工作:
- 执行用户通过
atexit
或on_exit
定义的清理函数 - 关闭所有打开的流,所有的缓存数据均被写入
- 调用
_exit()
进程等待
先说结论:
任何子进程,在退出的情况下,一般都要被父进程等待(孤儿进程除外)
一定要等待吗
我们知道如果子进程一旦长时间成为 僵尸进程 ,将会导致 内存泄漏 问题,所以父进程必须要等待,原因是:
- 通过等待,解决子进程退出的僵尸问题,回收系统资源(一定要考虑的)
- 获取子进程的退出信息,知道子进程因为什么退出(可选的)
如何等待
当然啦,使用 系统调用 wait()
和 waitpid()
方法
wait
作用:等待父进程的任意一个子进程退出 ;如果子进程一直没有退出,那父进程将会一直处于 阻塞等待 的状态;我们知道此时的子进程就相当于 软件资源,而这个等待的过程就相当于父进程在 等待某种软件条件就绪
头文件:
#include <sys/types.h>
#include <sys/wait.h>
函数原型:
pid_t wait(int* status);
返回值:
等待成功则返回被等待的子进程 pid
;失败则返回 -1
参数:
输出型参数,获取子进程退出状态,不关心子进程退出状态则可以设置成为 NULL
waitpid
函数原型:
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
关于 status 参数
而 status
是一个 输出型参数,表示的是 子进程的退出信息;而子进程退出的情况最多只有上面说过的 3 种,那么为了让父进程更有效的了解子进程退出的原因,所以需要的信息一定是进程的 退出码 和 退出信号
那如果 退出码 和 退出信号 是 全局变量 呢?利用 全局变量 让父进程拿到 退出码 和 退出信息 可以吗?
肯定不行!因为父进程根本就看不到,父子进程具有独立性,即使事先定义好两个全局变量,然后使用 fork()
函数创建子进程,当子进程设置好退出码和退出信号后,父进程拿到的依然是原本自己的那一份全局变量,因为子进程要修改变量势必会发生 写时拷贝
所以父进程只能通过 waitpid()
这样的形式获取 退出码 和 退出信号
但又有个小问题,status
顶多只能输出一个参数啊,两个参数不一样怎么输出呢?所以 status
参数是有特定格式的,如下图:
入上图所示: status
为 32 位的 int
型,但只使用 低 16 位 bit
,其中:
- 区间编号
[8, 15]
这 8 位为退出码,范围[0, 255]
足够表示 退出码 - 区间编号
[0, 6]
这 7 位为退出信号,范围[0, 127]
足够表示 退出信号 - 编号第 7 位为
core dump
信号
那我该如何拿到 退出码 和 退出信号 呢?
- 退出码 :
(status >> 8) & 0xFF
即可;- 如果使用 宏:就是上面说的
WIFEXITED(status)
,如果正常退出,就是真,但 此时退出码值为 0 !!!;若为!0
,则为非正常退出,WEXITSTATUS(status)
就可提取查看 非正常退出 的 退出码
- 退出信号 :
status & 0x7F
即可
关于 options 参数
阻塞等待
思考如下场景:
现在有一个父进程,在跑的时候需要创建出一个子进程来完成其他工作,那么为了防止僵尸进程带来的内存泄漏问题,父进程就必须要进行等待,也就是要使用 系统调用 wait()
或 waitpid()
但也很显然,父进程在使用 wait()
或 waitpid()
后一直在进行等待;只要子进程不退出,父进程就会一直等待下去
那父进程就变成了 阻塞状态!!!父进程的 PCB
被链入了子进程的 等待队列 里, 不被 CPU 轮转调用了,居然要等待子进程退出
请问你的父进程不做其他事情吗?就卡在子进程退出后再继续执行下去吗?
如果 waitpid()
参数 options
默认为 0 ,就是在阻塞等待子进程退出
非阻塞等待
如何进行 非阻塞等待 呢?使用上面提到过的 宏:
WNOHANG
如果此时父进程为 非阻塞等待,那么父进程就不会卡死在阻塞状态,而是会继续做自己的事情
如何实现呢?使用 循环的方式轮询子进程的退出情况,子进程没退出,就做自己的事情;退出了,就利用 系统调用 waitpid()
协助 OS 回收子进程的空间
那 waitpid()
返回值将出现以下几种结果:
pid_t < 0
:因为某种原因,导致压根没识别到需要被等待的子进程pid_t > 0
:等待是成功的,系统也成功回收子进程的资源pid_t == 0
:成功检测有需要的等待,但子进程还没有退出,需要下一次重新等待
代码简单演示 waitpid 使用和 使用循环 轮询子进程退出情况
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/wait.h>
int main()
{
// The parent process is running...
// At this time, you need to create a child process to complete some tasks...
pid_t id = fork();
if (id == 0)
{
// child
printf("It is child process, pid: %d, ppid: %d\n", getpid(), getppid());
// Some tasks that the child process needs to complete...
exit(0);
}
// father
while (1)
{
int status = 0;
pid_t ret_id = waitpid(id, &status, WNOHANG);
if (ret_id == 0)
{
printf("The child process has not exited!!!\n");
// Continue the parent process's own business...
}
else if (ret_id > 0)
{
if (WIFEXITED(status))
printf("The child process exits successfully!!! The exit code is %d\n", WEXITSTATUS(status));
else
printf("The child process exits abnormally!!! The exit code is %d\n", WEXITSTATUS(status));
break;
}
else
{
printf("Incorrect wait, please check the syntax and semantics!!!\n");
break;
}
}
return 0;
}
进程程序替换
观察现象
观察以下 C 语言代码,在命令行下编译并运行:
#include <stdio.h>
#include <unistd.h>
int main()
{
printf("testexec ... begin!\n");
execl("/usr/bin/ls", "ls", "-l", "-a", NULL);
printf("testexec ... end!\n");
return 0;
}
上面代码唯一不认识的就是 execl
函数,不急,我们先看现象,结果如下图:
呀,运行的是自己的程序,怎么跑去运行 ls -l
指令呢?还真是,运行 ll
结果的文件也没用颜色标识,那如果把 execl
函数再添加一个参数呢?就像下面这样
execl("/usr/bin/ls", "ls", "-l", "-a", "--color", NULL);
自己去试一试咯
但不管怎么样,我们 原本的进程肯定是被某种力量篡改了,不然为啥连最后的 printf("testexec ... end!\n");
语句都不打印呢?
解释原理
很显然,这是 execl
搞的鬼,我们知道 ls
就是个可执行程序,本质上可能就是个 C 语言程序,不信咱就来看看:
[exercise@localhost replace]$ file /usr/bin/ls
/usr/bin/ls: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked (uses shared libs), for GNU/Linux 2.6.32, BuildID[sha1]=c8ada1f7095f6b2bb7ddc848e088c2d615c3743e, stripped
[exercise@localhost replace]$
那么换句话说,这就是系统中 独立存在的 二进程可执行文件
上面的现象里,我们的进程突然转过去执行 ls
指令就是 execl
的作用:
进程 使用 exec*
系列函数,就可以转 去执行新的程序,而原来的程序也就被替换了
那么 进程程序替换 就是:将新程序的代码和数据 直接覆盖 原本程序的代码和数据,但该进程的内核数据结构本身却不会变化太多,只是要进行相关属性修改罢了,需要的话重新建立页表映射关系罢了
那有没有创建新的进程呢?并没有,如果在调用 execl
函数前后打印当前进程 pid
,其实并没有变,说明这只是 以老进程的壳子去运行新进程的代码
而 新程序代码和数据是要 被加载到内存里 去覆盖老程序的,那如何做到的呢?其实 exec*
系列函数就相当于 Linux 系统里的加载函数,也类似于加载器,所谓底层接口就是他
那新程序从磁盘拷贝到内存里的这个过程,是需要 OS 这个超管参与的,也就可以想象,exec*
系列函数 可能就是系统调用 或者 底层包含系统调用
那么 exec*
系列的函数执行完毕后,后续代码就不再被执行,是因为已经被新程序完全替换
而替换成功与否也不用 execl
函数的返回值判断:如果替换成功, execl
函数后面的代码就不会继续执行;如果继续执行了,那一定是替换失败了
所以上面的过程还真是像 “夺舍” 呢
只能鸡肋替换原进程?
如果现在有需求进行进程程序替换,但还不能影响原本的父进程,怎么办
很显然,父进程先 fork
出一个子进程,让这个子进程被替换即可
那父进程在干啥?当然是要 wait
等待啦,毕竟进程替换不会创建新的进程,而父进程也无所谓等待的是原本被创建的进程还是被替换之后的进程
如下即可简易实现:
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/wait.h>
#include <sys/types.h>
int main()
{
printf("testexec ... begin!\n");
pid_t id = fork();
if (id == 0)
{
// child
sleep(2);
execl("/usr/bin/ls", "ls", "-l", "-a", "--color", NULL);
exit(1);
}
// father
int status = 0;
pid_t rid = waitpid(id, &status, 0); // 这里直接阻塞等待
if (rid > 0)
{
printf("father wait success, child exit code: %d\n", WEXITSTATUS(status));
}
printf("testexec ... end!\n");
return 0;
}
上面代码不用多说,但这是一个非常厉害的思路:
因为原本只创建子进程也只能执行父进程代码的一部分;只程序替换是替换父进程
如果两个合二为一,就可以创建出一个子进程,被替换后去跑一个全新的程序
现在有一个小疑问:父进程创建出子进程,代码在物理内存层面上是共享的,那进程程序替换不会影响父进程吗?
这就是写时拷贝的功劳了,可以直接将父子进程完全分开独立
既如此,也是上升到进程的层面,那么很自然的就可以想到,进程替换只能是替换相同语言代码跑出来的进程吗?
当然不是了,这就是这个函数的强大之处,脚本 shell 语言, python 语言 ,Java 语言等等都可以被替换过来,也就是说,只要跑起来是成为进程的可执行文件,那就可以进行替换
这才是进程替换的真正能力
使用所有的替换方法,认识函数参数的含义
execl
我们刚刚使用的是 execl
接口:
int execl(const char *path, const char *arg, ...);
但 exec*
是一个系列定然不止这一个函数,而 execl
也是认识系列里其他函数的重要途径:
这个 path
表明:要执行的程序,需要指明路径(告诉系统程序的位置);
剩下的除了 arg
,就是 可变参数:在命令行怎么执行,你就怎么传参;这就类似于命令行参数,所以最后一定要传一个 NULL
参数,和 argv
的最后一个元素为 NULL
呼应(本人拙作:Linux 下命令行参数和环境变量)
execv
int execv(const char *path, char* const argv[]);
path 就不解释了,argv
呢?其实也不用解释,就是命令行参数吧?(本人拙作:Linux 下命令行参数和环境变量)
这不过是把 execl
那种一个一个传的可变参数方式变为传一个 数组 过去罢了
在 execl
的方式是这样:
execl("/usr/bin/ls", "ls", "-l", "-a", "--color", NULL);
而 execv
就是需要一个数组:
char* const argv[] = {"ls", "-l", "-a", "--color", NULL};
execv("/usr/bin/ls", argv);
execvp 和 execlp
int execvp(const char *file, char *const argv[]);
这个 execvp
函数末尾的 p
倒是有讲究,就是 PATH
的意思
其实这几个系列的函数,本质都是一样的,拿这个和上面两个比较,主要就是 file
的区别,而 file 是不需要用户传路径给函数的,只需要可执行文件名就行
但系统怎么找到它呢?当然是提到过的 PATH
啦,查找这个程序,系统会自动在 环境变量 PATH
里进行查找
所以使用这个接口函数的条件就是你的可执行文件名得在 环境变量 PATH
里即可
还是上面 ls
的例子:
char* const argv[] = {"ls", "-l", "-a", "--color", NULL};
execvp("ls", argv);
至于 execlp
乃是异曲同工,不再赘述,函数原型如下:
int execlp(const char *file, const char *arg, ...);
execvpe
int execvpe(const char *file, char *const argv[], char *const envp[]);
很熟悉哈,envp
就是环境变量啊,所以 execvpe
最后的 e
就是 环境变量 的意思,可传自定义,也可传系统 bash
的环境变量,如下即可:
extern char** environ; // 系统级 环境变量
char* const argv[] = {"ls", "-l", "-a", "--color", NULL};
execvpe("ls", argv, environ);
系统调用 execve 周边注意
上面介绍的函数都是 man
手册的第 3 节,讲白了就是函数
但在下图里只有 execve
是真正的 系统调用,而其他 exec*
系列函数底层原理都是封装调用的 execve
,而 execve
在 man
手册的第 2 节
int execve(const char *path, char *const argv[], char *const envp[]);
封装这么多底层相同的函数,目的还是为了支持不同的应用场景
这些函数如果 调用成功 则 加载新的程序从启动代码开始执行,不再返回;如果 调用出错 则返回 -1
所以 exec
系列函数只有出错的返回值而没有成功的返回值
而 exec
系列函数的命名规则也导致其参数很好记:
l
(list):表示参数采用列表v
(vector):参数用数组p
(path):有p
自动搜索环境变量PATH
e
(env):表示自己维护环境变量