【Linux】进程控制
阿巴阿巴,进程概念结束了,现在该学习如何控制一个进程了
所用系统:CentOS 7.6
[TOC]
1.进程创建
1.1 fork
关于linux下的进程创建其实我们已经接触过了,那便是使用fork
函数来进行操作
1 | pid_t ret = fork(); |
fork的返回值:子进程返回0、父进程返回子进程pid;出错返回-1
小tips,其实
pid_t
就是int
类型!
1 typedef __pid_t pid_t;
在上篇程序地址空间的博客中已经提到,当一个进程调用fork
函数的时候
- 操作系统会给子进程分配一个新的内存块
mm_struct+页表
和内核数据结构task_strcut
给子进程 - 将父进程的部分数据结构拷贝自子进程(写时拷贝)
- 将子进程添加系统进程列表当中
fork
返回,开始调度器调度
简单说来,便是fork
之前只有父进程单独运行。fork之后父子进程的执行流会分别执行,且相互独立
fork之后,是父进程先执行还是子进程先执行依赖于调度器的调度。并非一定是父进程先执行!
需要注意的是,子进程虽然共享父进程的所有代码,但是它只能从fork之后开始执行
这里涉及到了cpu的eip
程序计数器(又称pc指针)这玩意的作用就是保存当前正在执行的指令的下一条指令!
注意,这里说的是CPU执行的指令,并非linux下bash里面的命令
eip程序计数器会把下一个指令拷贝给子进程,子进程就会从该eip所指向的代码处(即fork之后的代码)开始运行
fork啥时候会出错
如果你写一个循环代码一直创建子进程,那么就有可能创建失败!
能够创建的子进程个数依赖于代码的复杂度
1.2 写时拷贝
之前已经提到过写时拷贝的概念,这里再次说明一番
为什么要写时拷贝,创建子进程的时候直接把数据分开不行吗
- 答,这样会存在内存浪费!
一般情况下,父进程创建子进程之后,会出现下面的一些情况
- 父进程的代码和数据,子进程不一定全部都会使用。即便使用、也不一定会进行修改
- 理想状态下,可以把父子进程会修改的内容进行分离,不会修改的部分共享即可。但是这样的实现非常复杂
- 如果fork的时候,就直接分离父子进程的数据,会增加fork运行的时间复杂度和空间复杂度
所以最终linux采用了写时拷贝的方式,只会在需要的时候,拷贝父子需要修改的数据。这样延迟拷贝,变相提高了内存的使用率
2.进程终止
2.1 程序退出码
在之前学习C/C++的时候,我们知道main
函数是一个程序的入口函数,那么你知道main函数内部的返回有何用,又被谁接收了吗?
1 | int main() |
使用echo $?
命令查看环境变量,可以看到我们进程的推出码
1 | int main() |
修改对应的返回值,再次运行程序,可以看到不同的结果
知识点:?
环境变量存放的是上一次运行的程序的退出码
1 | [muxue@bt-7274:~/git/linux/code/22-10-08_进程控制]$ echo $? |
比如这里我们连续两次访问这个环境变量,可以看到第一次的结果是我们自己运行的程序返回的10,第二次的结果是0(echo命令的返回值)
2.1.1 strerror
那么,这个程序退出码有什么含义呢?
这里我们使用for循环打印一下库函数中strerrror
函数内记录的错误码
1 |
|
可以看到,100个错误码被打印了出来
进一步加大循环的次数,能看到C语言中定义的错误码一共是134
个。后续全部打印unknown error
我们设计程序的退出码的时候,可以参照C语言库函数的错误码来进行设置,这样能更好地和库内部进行对接,或用strerror
函数来获取到错误信息
这就是用错误码来实现的异常管理
2.2 程序退出的几种状态
一般情况下,程序有下面的几种退出状态:
- 代码跑完,结果与预期相符
- 代码跑完,结果有问题
- 代码没有跑完,提前出现异常终止,或者被外部关闭
一般情况下,我们不会去在乎一个进程为何会成功;而更在乎一个错误的进程到底哪儿有bug。所以就需要想办法获取到这个进程的错误码
错误码表征了程序退出的信息,交由父进程进行读取
上面我们在bash中能通过echo
读取上一个进程的退出码,那是因为我们自己运行的可执行程序,其父进程就为当前的bash。bash接受了我们进程的退出码,放入到了环境变量中
2.3 终止的常见做法
一般情况下,我们可以在main
函数中return,或者在任何地方使用exit()
来终止程序
这里还需要提及另外一个版本的exit()
,即_exit
最可见的区别便是,exit会刷新缓冲区,而_exit
不会
1 | void test2() |
这里我先调用test2函数,输出的结果是这样的👇
1 | [muxue@bt-7274:~/git/linux/code/22-10-08_进程控制]$ gcc test_strerror.c -o test && ./test |
如果调用的是test3,则会出现下面的情况
1 | [muxue@bt-7274:~/git/linux/code/22-10-08_进程控制]$ gcc test_strerror.c -o test && ./test |
程序什么都没有打印!
缓冲区
这部分是基础IO的知识
linux下有一个输入输出的缓冲区,当我们调用printf
的时候,系统不会立马打印,而是会将待打印的内容先写入缓冲区,直到我们输出\n
或者调用fflush
函数手动刷新缓冲区。
1 | fflush(stdout);//手动刷新缓冲区 |
exit和_exit
在_exit
的man手册中也能看到,该函数会立即干掉这个进程;而exit
还会做一些其他的操作
2.4 终止的时候,内核做了什么?
我们知道,进程=内核结构task/mm_struct等
+进程代码、数据
操作系统可能并不会释放该进程的task_struct/mm_struct
,而是留给下一个进程使用!
要知道,如果想使用一个结构体,就需要对它进行开空间和初始化操作。而在操作系统中,创建、终止进程是一个非常高频的操作。如果总是不断的创建内核结构再释放,其内存利用率就很低,而且拖慢系统运行速度。
这时候系统就会使用内核的数据结构缓冲池,又称slab分派器,来管理这些仍待使用的内核结构。当有新进程出现的时候,更新内核结构的信息,并将其插入到运行队列中
3.进程等待
之前讲过子进程退出,父进程如果不管不顾,就会造成僵尸进程的问题,从而导致内存泄漏等一系列问题
- 另外,僵尸进程一旦出现,即便是
kill -9
也无法杀掉这个进程
所以父进程需要监看子进程的退出状态,并进行相应的操作
父进程通过进程等待的方式回收子进程资源,获取子进程的退出信息
3.1 如何等待
进程等待这里我们需要用到两个函数
1 | pid_t wait(int*status); |
它们的头文件是
1 |
3.2 wait
先来康康第一个,其作用是等待子进程退出,status
是一个输出型参数,子进程退出后,我们可以从中获取到子进程的退出信息
- status是从子进程的
task_struct
中拿出来的,子进程会将自己的退出码写入task_struct
- 如果我们不关心子进程的退出状态,则可以给status传一个NULL空指针
- 若等待失败,则返回-1
1 |
|
嗯,看起来没啥问题,我们成功获取了子进程的pid以及退出码0
那如果我们修改一下子进程中exit
的值呢?
1 | exit(11); |
呀,出问题了,为何状态码变成2816了?
3.2.1 关于status
实际上,输出型参数中status
的值并非是完整的退出状态信息,其分为下面两种情况
所以说,正确访问状态码的方式,是先将status右移8位,再用按位与取出状态码
1 |
|
再来修改一下exit
的值为200,依旧正确!
1 | 子进程退出 |
3.3 waitpid
该函数的原型如下
1 | pid_t waitpid(pid_t pid, int *status, int options); |
- pid:
>0
指定等待子进程pid;-1
等待所有子进程 - status:同wait,为输出型参数
- options:若设置为0,则进行阻塞等待;其余选项见下图
返回值:
- 正常等待,返回子进程的pid
- 如果设置了options,而
waitpid
发现没有已退出的子进程可收集,返回0 - 调用中出错,返回
-1
。此时errno
会被设置成相对应的值来显示错误
1 | wait(): on success, returns the process ID of the terminated child; on error, -1 is returned. |
代码示例
1 | int main() |
3.4 信号终止
目前linux支持的信号如下,在后续信号的章节会单独讲解!
前面提到了,除了正常的终止,status中还可以保存信号终止的信息
这里的core dump
标志是用来干嘛的我们暂且不提(后续信号部分会有讲解)先来试试用kill来干掉子进程!
这里我们要取出的是status
中最低7位的数据,就需要按位与一个二进制末尾是7个1的数字
注意:如果子进程是因为信号退出,那么我们不需要关注退出码,其没有意义!
1 | int main() |
程序最开始的时候,子进程正常创建,父进程等待子进程结束
这里使用kill给子进程发信号,干掉了子进程
1 | [muxue@bt-7274:~/git/c_code]$ kill -9 5952 |
父进程sleep结束后执行waitpid
获取到了子进程的结束信息以及信号9
同时通过之前写的检测脚本
1 | while :; do ps jax | head -1 && ps jax | grep test | grep -v grep;sleep 1; echo "########################"; done |
能看到子进程进入z僵尸状态
父进程回收子进程的过程
换一个kill的信号,父进程也能正确获得其结果
1 | [muxue@bt-7274:~/git/c_code]$ kill -30 7607 |
1 | 我是子进程7607 , ppid:7606 , ret:0 , &ret:0x7ffccf2aeb20 |
除了我们可以手动使用kill 给进程发信号,一些错误也会让进程自己退出。比如信号8就是浮点数错误,可以用除0来复现这个错误
操作系统是怎么知道我们除0了?
- 在CPU内有一个状态寄存器,当cpu进行运算的时候出错了,会更新状态寄存器。操作系统检测到CPU用状态寄存器给他报了个错,就会识别错误类型,并通过信号干掉当前运行的进程
- 我们运行的进程中的软件错误,部分是会在硬件层面上体现的
3.5 库里面提供的宏
自己写按位与多麻烦呀,库里面提供了几个宏供我们使用
WIFEXITED(status)
查看子进程是否是正常退出的,正常退出为真WIFSIGNALED(status)
查看子进程是否为信号终止,信号终止返回真WEXITSTATUS(status)
提取子进程退出码WTERMSIG(status)
提取子进程退出信号
1 | //其余部分代码和上面相同,子进程exit(11) |
下图为子进程正常exit
下图为子进程被kill -9
干掉
3.6 阻塞等待和非阻塞等待
前面的waitpid
函数中的option
参数就和阻塞/非阻塞等待有关
1 | 0 阻塞 |
3.6.1 阻塞等待
当我们调用某些函数的时候,因为条件不就绪,需要我们进行阻塞等待
本质:当前程序自己变成阻塞状态,当一切就绪的时候再被唤醒。
这时候我们等待的不是硬件资源,而是等待子进程运行结束(软件资源)
阻塞等待时,将父进程放入子进程task_struct
中的等待队列。当操作系统检测出子进程退出,就从等待队列中唤醒父进程,阻塞等待成功!
给waitpid
的option
传入0,即为阻塞等待
1 | pid_t st = waitpid(-1,&status,0);//阻塞等待 |
在子进程被信号干掉或者执行完毕退出之前,父进程不会向后执行代码。在用户层面看来,就是一个程序卡住了
3.6.2 非阻塞等待
给
waitpid的
option传入WNOHANG
,即为非阻塞等待
等待期间,父进程可以干其他的事情
1 |
|
这里我们给父进程写了一个死循环,一直等待子进程退出。每一次循环都会调用一次waitpid
的接口,直到成功获取了子进程的退出信息
这种多次调用waitpid
接口的方式又被称为轮询检测
举个具体例子,当我们使用一个聊天软件需要加载图片的时候,父进程(聊天框)可以先显示一个图片的加载图,告诉你图片正在加载(子进程)。而你还是可以正常浏览其他人的发言。等图片加载完毕(子进程退出)之后,父进程就可以把那个临时的加载图替换成获取到的图片本身,这就是一次成功的非阻塞等待
4.进程替换
在之前的fork
中,我们的子进程都是运行的已经预先写好的代码,或者说是继承了父进程的代码继续向后执行。
进程替换
就是让子进程可以执行磁盘里面其他的可执行文件,包括Linux系统的命令、其他语言写的代码py c++ php
等等……
4.1 原理
其实就是让子进程通过调用操作系统的接口,来执行一个已有的可执行程序
- 这个过程中并没有创建新的子进程,本质上还是当前子进程
程序替换的过程
- 将磁盘中的程序加载进入内核结构
- 重新建立页表映射,因为是子进程调用的程序替换,那么就会修改子进程的页表映射
- 效果:子进程代码和父进程彻底分离,子进程执行了一个全新的程序
4.2 如何替换
系统提供了非常多的函数接口,供我们在一个程序中调用系统中其他的可执行程序
要想调用,首先要找到这个程序在那儿,以及要用什么办法执行这个程序(命令行参数)下面以具体的例子来了解一下吧
需要注意的是:我们需要先用
fork
创建子进程,再调用上面这些函数接口来使用其他可执行文件。这些函数接口本身并不会创建新的子进程!
4.3 execl
1 | int execl(const char *path, const char *arg, ...); |
- path是需要运行程序的路径
arg
代表需要执行的程序...
是可变参数,可以传入不定量的参数。这里我们填入的是命令行的参数
说起来学到了这里我才知道原来C语言支持可变参数……
1 |
|
需要注意的是,当我们填入命令行参数的时候,必须要以NULL
作为参数的结尾
我们会发现,调用了其他可执行程序之后,在后面的printf
函数并没有被执行!
这是因为,当我们用这个函数来调用其他可执行程序,本质上已经把当前的代码和数据替换掉了!既然是替换,那么原本的printf("执行结束 %d\n",ret);
肯定也不会执行
返回值问题
那execl
不是有一个int
类型的返回值吗?如果程序替换了之后不会执行后面的代码,那这个返回值还有什么用呢?
查手册可以看到,这个返回值只有出错的时候才会返回-1,同时会更新ERRNO
1 | int main() |
现在我们把执行文件改成usr/bin/
这个错误文件,那么就会调用失败,同时可以看到调用失败的原因是,我们没有权限去执行/usr/bin
1 | int ret = execl("/usr/erqer/","ls","-l",NULL); |
如果改成一个乱七八糟的路径,也会打印出错误结果为“文件或路径不存在”
根据这个特效,我们在执行exec
这些替换函数的时候,其实没有必要去判断返回值。因为这些函数只有出错的时候,才会执行后面的代码!
- 无需判断返回值,直接打印
errno
找出错误原因即可
替换别的代码
之前说过,替换不仅可以替换系统的命令,还可以替换成其他语言的代码
比如下面是一个最简单的py代码
1 | print("我是一个python程序!") |
我们在C中利用execl
来调用这个自己写的python程序。如果你不知道你系统中有没有python,或者不知道它的路径,可以用which
来查看位置
1 | int main() |
可以看到,python程序被成功执行!
子进程替换
了解了替换程序的基本方法了之后,可以先来试试写一个父子进程
这里让父进程进行3.6.2
里面的轮询检测
1 | int add(int a,int b){ |
可以看到,子进程替换了python程序成功了之后,不会执行后面的printf
这里的exit code
也被设置成了1
这里我自己想出来了一个问题,这里的
exit code
和我们python程序里面设置的有没有关系呢?来试试~
1
2 print("我是一个python程序!")
exit(10)#execl父进程接收到的退出码和这里没有关系emm结果没变,说明没有关系
1
2 我是一个python程序!
等待成功, 20333, exit code: 1, exit sig: 0即便我们python程序里面有bug,这里也不会有啥变化
1
2 print("我是一个python程序!")
a =10/0
1
2
3
4
5
6 我是一个python程序!
Traceback (most recent call last):
File "/home/muxue/git/linux/code/22-10-13_exec/test.py", line 3, in <module>
a =10/0
ZeroDivisionError: division by zero
等待成功, 21179, exit code: 1, exit sig: 0
同时我们也可以看到,子进程执行程序替换,是不会影响父进程的(进程具有独立性)
这是因为数据发生了写时拷贝,程序替换的时候可以理解为代码和数据都通过写时拷贝进行了父子的分离(注意分离的是代码和数据,并非父子关系!)
4.4 execv
学会了前面的execl
,再来看看这个
1 | int execv(const char *path, char *const argv[]); |
可以看到这个函数莫得可变参数,而是需要我们用一个指针数组来传入命令行参数!其余都是一样的!
复习一下,const修饰指针有下面两种形式
- 在
*
之前修饰,代表该指针指向对象的内容不能被修改(地址里的内容不能改)- 在
*
之后修饰,代表该指针指向的对象不能被修改(指向的地址不能改)
1 | void testExecv() |
调用成功!
1 | [muxue@bt-7274:~/git/linux/code/22-10-13_exec]$ ./test |
4.5 execlp
1 | int execlp(const char *file, const char *arg, ...); |
注意,这里参数的说明从path
变成了file
这个函数和execl
的区别在于,它会自己去系统环境变量的PATH
里面查找可执行程序
1 | [muxue@bt-7274:~/git]$ echo $PATH |
只有找不到这个程序的时候,才会报错!
1 | void testExeclp() |
结果如下,成功调用
1 | [muxue@bt-7274:~/git/linux/code/22-10-13_exec]$ ./test |
随意指定一个程序,就会报错
1 | int ret = execlp("python3300","python3","test.py",NULL); |
1 | [muxue@bt-7274:~/git/linux/code/22-10-13_exec]$ ./test |
4.6 execvp
1 | int execvp(const char *file, char *const argv[]); |
知道了execv/excel
之间的区别,那么execvp/execlp
之间的区别也就很明显辣!
同样也是只有传参的区别,其他的操作完全一样
1 | void testExecvp() |
1 | [muxue@bt-7274:~/git/linux/code/22-10-13_exec]$ ./test |
4.7 execle/execvpe/execve
这几个函数放在一起了,因为它们的使用方法很相似
1 | int execle(const char *path, const char *arg, |
首先它们的函数名中都有个e,这个e代表的是环境变量,代表我们可以把特定的环境变量传入其中进行处理。它们的环境变量都是在最末尾传的
函数 | 参数 | 说明 |
---|---|---|
execle | 可执行文件的完整路径,命令行参数,环境变量 | 利用可变参数传入命令行参数 |
execve | 可执行文件的完整路径,命令行参数,环境变量 | 利用数组传入命令行参数 |
execvpe | 可执行文件名字,命令行参数,环境变量 | 利用数组传入命令行参数;只需要传入可执行文件的名字,会自动在PATH 里面搜索 |
测试
这里我先用C++写一个打印程序,来打印我们的环境变量
1 |
|
利用g++
将其编译为可执行文件mytest
1 | [muxue@bt-7274:~/git/linux/code/22-10-13_exec]$ ls |
下面开始测试
1 | void testExecve() |
从cpp文件的打印结果可以到,我们传入了完整的环境变量,PATH
成功打印,但是MYPATH
没有打印出来。这是因为环境变量里面没有这个
1 | [muxue@bt-7274:~/git/linux/code/22-10-13_exec]$ echo $MYPATH |
利用export
导入环境变量
1 | [muxue@bt-7274:~/git/linux/code/22-10-13_exec]$ export MYPATH=4321 |
再次测试,可以看到两个环境变量都被打印出来了
自己传入环境变量
上面我们是直接引入外部环境变量
1 | extern char ** environ;//引入外部环境变量 |
我们还可以自己整一个数组来传入环境变量
1 | void testExecve() |
诶,怎么连PATH
都打印不出来了??这不是系统里面有的环境变量吗?
1 | char*const env[]={ |
重新设置传入的环境变量,PATH
才能成功打印
这说明这几个函数的环境变量参数,在传入的时候,是会覆盖掉系统的环境变量的!
关于环境变量参数的问题
实际上,其余不带e
的函数,也是能获取到系统的环境变量的(直接继承父进程BASH的环境变量)
而带e
的函数允许我们单独控制环境变量
- 直接传入
extern char ** environ;
的系统环境变量 - 将特定的环境变量传入
- 临时自定义一部分环境变量
注意PATH和自己的可执行程序
这里只对execvpe
说明一下,如果想用它调用我们自己写的mytest
,那么就需要把mytest放入系统PATH
里面,不然是找不到的!
1 | [muxue@bt-7274:~/git/linux/code/22-10-13_exec]$ ./test |
所以还是用它来调用系统的命令吧
1 | char*const arg_[]={ |
4.8 execve是系统接口
如果你有注意看,应该会发现execve
的man手册是单独拎出来的,左上角的编号也不一样
这是因为,实际上只有execve
是Linux系统提供的接口
而其他函数都是C语言库中对execve
的二次封装,来适应不同的使用场景!
4.9 函数命名总结
- l(list):使用可变参数列表
- v(vector):用数组传参
- p(path):自动在环境变量PATH中搜索
- e(env):表示自己维护环境变量
结语
进程控制章节的内容到这里就基本结束啦,后续有补充的内容会在本博客里面新增!
感谢你看到最后,有啥问题可以在评论区提出哦