目录
什么是进程, 在另一篇博客中,戳链接( ̄︶ ̄)↗https://blog.csdn.net/qq_41071068/article/details/103213364
进程创建
Linux 中我们可以说一个进程就是一个PCB, 即 一个task_struct, 那么创建进程也就是创建PCB, 即是创建task_struct
Linux 中说到进程创建, 就不得不提到 fork()函数. fork()在Lnux下是非常重要的一个函数 .
fork()函数
从已存在进程中创建一个新进程。新进程为子进程,而原进程为父进程
fork()在函数内部会调用clone这个系统调用接口
pid_t fork ()
头文件: unistd.h
fork返回值
fork函数返回值 : (返回值类型为pid_t, 实际等同于int)
- 子进程在执行fork()时返回 0
- 父进程在执行fork()时, fork()创建子进程, 返回子进程的PID (PID是一个大于0的整数)
- 父进程在用fork()创建子进程失败时返回 -1
因为fork运行有多种结果, 所以往往fork之后要根据fork的返回值进行分流(例如用 if 写多个分支), 来看个例子 .
testfork.c 如下
#include<stdio.h>
#include<unistd.h>
int main(){
pid_t pid = fork();
if(pid == -1){
perror("fork error");
}
else if(pid == 0){
printf("子进程\n");
}
else{
printf("父进程\n");
}
return 0;
}
编译执行如下, 可以看到, 当父进程用fork() 创建子进程成功后, 返回了其子进程的pid, 然后继续执行, 直到执行打印语句后子进程才执行, 如下 :
但并不是父进程创建了子进程, 父进程就一定会先执行完,才执行子进程, 也可能是父进程执行到一半, 甚至刚调用fork()创建完子进程后, 就立即转而执行子进程. 这取决于CPU的调度. 比如说下面这段代码 .
#include<stdio.h>
#include<unistd.h>
int main(){
pid_t pid = fork();
if(pid == -1){
perror("fork error");
}
else if(pid == 0){
printf("子进程执行\n子进程pid:%d\n", getpid());
}
else{
printf("父进程开始执行\n");
sleep(5);
printf("父进程执行\n父进程pid:%d\n", getpid());
printf("父进程运行结束\n");
}
return 0;
}
可以看到, 父进程执行到一半开始执行子进程了, 就此次运行结果分析, 由于父进程中sleep()函数, 致使父进程进入睡眠状态
(sleeping)(这种睡眠是可中断的, 当sleep()执行完, 就会中断睡眠, 进入就绪状态(或者说进入运行队列), 等待分配时间片), 子进程
当被创建后, 一直处于就绪状态(一直处于运行队列中), 等待分配时间片, 当父进程睡眠时, 子进程拿到了时间片, 子进程执行 . 当子
进程执行完, 父进程拿到时间片后, 父进程继续运行 .
所以就有, fork创建子进程之前, 父进程独立运行, 创建子进程之后, 谁先运行取决于调度器的调度
进程调用fork, 内核会做出以下操作
- 分配新的内存块和内核数据结构给子进程
- 将父进程部分数据结构内容拷贝至子进程 ( 此时已经创建了子进程的PCB即Linux下的task_struct )
- 添加子进程到系统进程列表当中 ( 即添加子进程PCB)
- fork返回,调度器开始调度
fork写时拷贝
fork 创建子进程采取分时拷贝的策略 .
即, 父进程创建子进程时,但是并没有直接给子进程开辟内存,拷贝数据,而是跟父进程映射到同一位置,但是如果父进
程或子进程有一方想要修改内存中的数据时,那么对于改变的这块内存,需要重新给子进程开辟内存,并且更新页表信息.
这样做, 提高创建子进程的性能, 并且能节省内存 . 如下图 :
图1. 父子进程共享代码与数据 图2. 父子进程代码共享, 数据独立
图里涉及到了虚拟内存与分页式内存管理的内容, 简单说一下, 分页式内存管理可以将一段程序加载到不连续的物理空间上,但是
从虚拟地址空间来看依旧是连续的, 用以解决内存使用率低的问题 .
mm_struct结构体也叫内存描述符,其中记录虚拟内存各个段的起始地址, 结束地址, 通过这种方式描述了进程的虚拟地址空间,
每一个进程都会有唯一的mm_struct结构体, mm_struct记录在task_struct中.
页表: 页表中存储的是虚拟地址和物理地址的映射关系, 即页号到物理块的地址映射. 通过虚拟地址得到页号与页内地址(或者叫页内偏移), 在页表中通过页号找到物理块号. 然后, 物理地址 = 物理块号 x 页面大小 + 页内偏移 就得到了物理地址
来段代码感受一下
#include<stdio.h>
#include<stdlib.h>
#include<unistd.h>
int main(){
pid_t pid = fork();
int data = 0;
if(pid == -1){
perror("fork erro");
}
else if(pid == 0){
printf("子进程执行\n");
data = 10;
printf("data = %d\n", data);
printf("data地址: %p\n", &data);
}
else{
sleep(2);
printf("父进程执行\n");
printf("data = %d\n", data);
printf("data地址: %p\n", &data);
}
return 0;
}
代码中, 先让父进程睡上2秒, 这时会执行子进程, 子进程修改了data的值为10, 但子进程结束后, 父进程继续执行打印出的data
还是0, 两个进程所打印的值不同, 但父子进程中data的地址都是一样的. 我们知道数据不同, 则数据一定存储在不同的物理地址
上, 打印的的变量地址依旧相同, 这是因为取地址&得到的并不是物理地址(在所有有关地址的操作中, 我们只能接触到虚拟地址),
而是虚拟地址, 虚拟地址虽然相同, 但是父子进程有着不同的mm_struct, 即有着不同的页表, 这父子进程的data相同的(虚拟)地址
通过不同的页表映射到不同的物理地址上.
运行结果如下图:
fork失败原因
- 系统进程数达到太多, 达到上限(系统会有一个进程数的限定, 可以修改)
- 内存不足 (fork创建子进程需要创建新的PCB, 写时拷贝可能还会分配新的数据空间)
fork用法
fork()函数当然不是为了创建子进程而创建子进程, 创建子进程目的有两种:
- 一个父进程希望复制自己,使父子进程同时执行不同的代码段.
例如: 父进程等待客户端请求,生成子进程来处理请求。
- 一个进程要执行一个不同的程序 . 例如: 子进程从fork返回后,调用exec函数族(用来做进程替换的一些列函数, 下面再说)替换子进程. 比如, 我们用的Shell就是一个程序, 一般为默认为bash, 我们执行一些非Shell内建命令时, 实际就是一个Shell创建子进程, 再进行进程替换的过程
进程退出
退出场景
- 正常符合预期退出
- 正常不符合预期退出
- 异常退出(执行过程中异常奔溃, 还未执行完)
常见的退出方法
正常退出
- main函数返回 ( return )
- 调用 exit( int status )函数
- 使用 _exit( int status )系统调用接口
可以使用ench $? 来查看进程退出码
异常退出
- 向进程发送信号导致进程异常退出(如 Ctrl+c)
- 代码错误导致进程运行时奔溃异常退出
说明 : 第二种情况是代码错误导致异常终止没什么说的, 就是代码问题
第一种情况是 Unix / Linux 系统中的信号是系统响应某些状况而产生的事件,是进程间通信的一种方式。信号可以由一个进程发
送给另外进程,也可以由核发送给进程 .
信号处理程序
信号处理程序是进程在接收到信号后, 系统对信号的响应. 根据具体信号的涵义, 相应的默认信号处理程序会采取不同的信号处理方式:
- 终止进程运行, 并且产生core dump (核心转储文件)(记录一些错误信息, 方便用户查看)
- 终止进程运行
- 忽略信号,进程继续执行 .
- 暂停进程运行 .
- 如果进程已被暂停,重新调度进程继续执行 .
前两种方式会导致进程异常退出. 实际上,大多数默认信号处理程序都会终止进程的运行。
在进程接收到信号后,如果进程已经绑定自定义的信号处理程序, 进程会在用户态执行自定义的信号处理程序. 如果没有绑定,内
核会执行默认信号程序终止进程运行, 导致进程异常退出 .
例如: kill()函数, 在Shell中执行kill 指令, 在终端用键盘发送信号, 如:Ctrl+c , 都是发送信号来终止进程
_exit()系统调用
void _exit (int status)
头文件 : unistd.h
参数 :status 定义了进程的终止状态,父进程通过wait() 来获取该值(wait() 函数, 用于进程等待, 下面说).
说明 :虽然status是int,占但是仅有低8位可以被父进程所用. 在下面小结进程等待中详细说
功能 : 直接使进程停止运行,清除其使用的内存空间,并销毁其在内核中的各种数据结构
exit()函数
void exit (int status)
头文件 : stdlib.h
参数status 与_exit中同理
exit() 底层封装了 _exit 系统调用, 在底层调用_exit之前, 还做了下面的工作
- 执行用户通过 atexit 或 on_exit定义的清理函数。
- 关闭所有打开的流,所有的缓存数据均被写入 (即刷新缓冲区)
- 调用 _exit()
_exit()和exit()的区别
- 最大的区别是 exit()函数在调用 _exit() 系统调用之前要检查文件的打开情况,把文件缓冲区中的内容写回文件 (即刷新缓冲
区), 然后将控制权交给内核 . _exit() 则是执行后立即返回给内核,而exit()要先执行一些清除操作,
- 调用_exit函数时,会关闭进程所有的文件描述符,清理内存以及其他一些内核清理函数,但不会刷新缓冲区, exit函数是在_exit函数之上的一个封装,其会调用_exit,并在调用之前先刷新缓冲区。
- 补充: exit()函数在调用exit系统之前要检查文件的打开情况,把文件缓冲区的内容写回文件。由于Linux的标准函数库中,由于内存中都有一片缓冲区. 每次读文件时,会连续的读出若干条记录,这样在下次读文件时就可以直接从内存的缓冲区读取. 同样,每次写文件的时候也仅仅是写入内存的缓冲区,等满足了一定的条件(如达到了一定数量或遇到特定字符等),再将缓冲区中的内容一次性写入文件。这种技术大大增加了文件读写的速度,但也给编程代来了一点儿麻烦。比如有一些数据,认为已经写入了文件,实际上因为没有满足特定的条件,它们还只是保存在缓冲区内,这时用_exit()函数直接将进程关闭,缓冲区的数据就会丢失。因此,要想保证数据的完整性,就一定要使用exit()函数。
举栗子
#include<stdio.h>
#include<stdlib.h>
int main(){
printf("hello world!");
exit(0);
return 0;
}
#include<stdio.h>
#include<unistd.h>
int main(){
printf("hello world!");
_exit(0);
}
可以看到, 并没有输出hello world! , 这就_exit()没有刷新缓冲区, 导致在缓冲区的字符串没有打印到显示器就直接刷新, 造成数据丢失 .(注意hello world! 后面不能有\n, 因为\n会刷新输出缓冲区, 影响结果)
return
return是一种更常见的退出进程方法. 执行return n等同于执行exit(n), 因为main中 return n时, 系统会将main的返回值当做 exit()
的参数
进程等待
谁要等待? 等待什么? 为什么要等待 ?
首先要知道进程终止或退出的时候会发生什么, 进程退出时会关闭所有文件描述符, 释放在用户空间分配的内存, 但是PCB却会暂时保留, 里面存着退出状态, 比如一个进程正常退出, PCB里面就放着进程的退出状态(也就是退出码). 如果是异常退出( 前面说到, 肯定是收到信号了), 那么PCB里面存放着导致该进程终止的信号.
然后, 我们知道, 子进程的退出状态讲道理是该由父进程回收( 自己生的孩子, 自己就得负责 ), 也就是说, 父进程必须得等待子进程退出, 接收子进程的退出状态. (即, 父进程要等待, 等待子进程退出, 为了拿到子进程的退出状态)
进程等待的必要性
- 要是父进程先于子进程退出, 那么子进程就成了孤儿进程, 1号进程(孤儿院院长)等待领养, 1号进程要领养, 还得等子进程退出.
- 要是子进程先于父进程退出, 退出时为了保存退出状态 , 子进程一些资源并没有释放, 如果此时父进程没有关注到子进程的退出(也就父进程是没有拿到子进程的退出状态), 子进程伤心欲绝, 就会变成 "僵尸进程",变成一个连kill -9 都杀不死的进程, 因为谁也杀不死一个已经死去的进程 (只有重新来过, 也就是重启机器才能解诀僵尸进程), "僵尸进程" 会造成资源泄露, 占用进程数的问题 .
简单来说, 就是父进程需要知道, 派给子进程的任务完成的如何, 结果对还是不对,或者是否正常退出. 父进程通过进程等待的方式,回收子进程资源,获取子进程退出信息.
说了一大堆, 如何等待 ?
进程等待的方法
-
wait()
头文件 : sys/wait.h
pid_t wait (int* status)
返回值: 成功返回被等待进程PID, 失败则返回 -1. (成功指的是被子进程正常退出, 失败指的是被子程序异常终止)
参数: int* status, 是输出型参数,获取子进程退出状态, 将其退出状态存到status所指向的内存空间, 不关心被子进程退出状态则可以设置status为NULLwait() 是阻塞的, 在wait 返回之前, 阻塞等待子进程退出, 再返回 .
阻塞与非阻塞
- 阻塞: 为了完成一个功能, 若发起调用, 当前不具备完成条件, 则一直等待达到完成条件, 调用才会结束
- 非阻塞: 与阻塞相反, 若发起调用, 当前不具备完成条件, 立即返回
- 最大区别是, 调用是否立即返回
-
waitpid()
pid_ t waitpid (pid_t pid, int *status, int options
参数:
pid :
- pid = -1 :等待任一个子进程
- pid > 0 : 等待其进程ID与pid相等的子进程。
int* status:
- WIFEXITED(status): 若为正常终止子进程返回的状态,则为真。(查看进程是否是正常退出)
- WEXITSTATUS(status): 若WIFEXITED非零,提取子进程退出码。(查看进程的退出码)
后面详细看status和WIFEXITED, WEXITSTATUS这两个宏
options: options = 1时, waitpid是非阻塞的, options = 其他值时 waitpid() 是阻塞的
- WNOHANG: 值为1 的宏
返回值
有三种返回值
=-1 : waitpid()出错
= 0 : 没有等到子进程退出
>0 : 等到子进程退出, 返回子进程PID详细来看:
- 给 options 传WNOHANG 或 1, 此时waitpid()是非阻塞的,
1. 如pid此时指定,即pid>0, 已如指定pid的进程在调用waitpid()时退出, waitpid()返回子进程PID, 如发现指定pid的进程
还没有退出, 不予等待, 直接返回 0.
2. 若pid不指定,即pid = -1,若在waitpid()调用之前已有子进程退出(任意子进程都可以), 则返回该子进程的PID,
若在调用waitpid()之前没有任何子进程退出, 则返回 0- 若给options传入非1的值, 如等不到子进程退出, 会一直阻塞
- 如果调用waitpid()出错, 则返回-1, 这时errno会被设置成相应的值以指示错误所在 .
PS: 当waitpid是waitpid(-1, &status, 非1) 等效 wait (&status)
不关心子进程退出码时 waitpid(-1, NULL, 非1) 等效 wait (NULL)
参数int* status
前面说到. wait/waitpid都可以获取到退出的子进程的退出状态, 那么获取到的状态在哪儿保存呢 ?
这就需要我们事先申请一块内存空间来保存, 即 int status; 用这个int型变量来保存退出状态, 那么wait/waitpid又如何知道, 要将退
出状态给我们事先申请好的这个变量呢? 传入这个变量的指针, 由操作系统填充.
前面说到, 进程在退出时, 会有正常退出和异常退出(终止), 那么一个int 型变量如何保存这两种不同些情况的的退出状态呢 ?
如下图:
可以看到, 在正常退出时, status这个int型变量, 只有低16位用到了
即, 在正常退出时, 低16位中的高8位存的是子进程得退出码, 后8位全为0,
在异常退出时, 底7位存的是进程的异常退出信号值, 第8位是core dump 标志, 即核心转储文件标志, 为了用户方便查看异常终止
的信息, 此时低16位中的高8位未用
所以, 在wait/waitpid结束后, 要先取出dtatus的低7位看子进程是不是异常终止, 如果不是, 再来看退出码, 如下:
if (!(status & 0x7f)) {
printf("子进程退出码为\n", (status >> 8) & 0xff);
}
else {
printf("子进程异常终止\n");
}
这样写很麻烦, 而且还容易出错, 所以系统就将 !(status & 0x7f) 和 (status >> 8) & 0xff 封装成了两个宏
即WIFEXITED 和 WEXITSTATUS , 既上面的代码可以写成如下:
if (WIFEXITED(status)) {
printf("子进程退出码为\n", WEXITSTATUS(status));
}
else {
printf("子进程异常终止\n");
}
举栗子
wait.c
用wait() 模拟父母接孩子的场景, 父母就像wait() 阻塞一样, 不管多久, 都会等着我们, 直到接到我们.
#include<stdio.h>
#include<unistd.h>
#include<sys/wait.h>
#include<stdlib.h>
int main(){
printf("一位爸爸来学校接孩子放学了~\n");
pid_t pid = fork();
if(pid < 0){
perror("fork erro");
exit(-1);
}
else if(pid == 0){
sleep(30);
exit(30);
}
int status = -1;
wait(&status);
if (!(status & 0x7f)) {
printf("%d分钟后接上了孩子\n", (status >> 8) & 0xff);
}
else {
printf("孩子在幼儿园发烧送医院了!\n");
}
return 0;
}
在30秒后, 打印出了30分钟后接上了孩子
如果我们在另一个窗口下用kill -9 杀死子进程, 此时子进程就是异常终止了, 运行如下:
waitpid.c
用waitpid模拟出租车司机在车站等待拉乘客的场景, 出租车司机会一直去询问旅客乘不乘车, 当长的时间拉不到人, 就离开去别的地方拉乘客了
#include<stdio.h>
#include<unistd.h>
#include<sys/wait.h>
#include<stdlib.h>
int main(){
printf("一位出租车司机在车站拉乘客~\n");
pid_t pid = fork();
if(pid < 0){
perror("fork erro");
exit(-1);
}
else if(pid == 0){
sleep(5);
exit(5);
}
int status = -1;
while(waitpid(-1, &status, WNOHANG) == 0){
printf("没有人打车, 继续等...\n");
sleep(1);
}
if(WIFEXITED(status)){
printf("这次等了%d分钟才拉到乘客(子进程退出码)\n", WEXITSTATUS(status));
printf("有人打车了, 出发~\n");
}
else{
printf("没有等到乘客(子进程异常退出)\n");
}
return 0;
}
进程程序替换
为什么要进行进程替换 ?
前面说到, fork()创建子进程, fork创建的子进程要么和父进程执行一样的代码, 要么执行不同的代码分支(通过fork的返回值控制),
但这样还是不够灵活. 假如要有很多的功能已经用别的程序实现好了, 那么就不需要在父进程中控制父子进程执行不同的代码分
支, 让子进程在自己的分支中完成这些功能, 而是可以直接拿一个已有的程序替换掉子进程. 使子进程的代码完全变成所替换程序
的代码. 这样就方便了很多, 而且在子进程需要完成较为复杂的功能或是多项功能时, 分支就显得力不从心了.
所以进程往往要调用exec族中的某一个函数来进行程序替换, 让一个进程来执行另一个程序. 当进程调用一种exec函数时,该进程
的用户空间代码和数据完全被新程序替换,从新程序的启动例程开始执行。调用exec族中的函数并不会创建新进程,所以调用exec
前后被替换进程的iD并未改变。
进程替换的原理
如上图, 进程替换时, 替换的是PCB映射在内存中的代码和数据. 这样, 该进程PID虽然没有变, 但已经物是人非, 已经不是原来的那
个进程了.
如进行进程程序替换? 前面也说到了, exec族函数
替换函数exec族函数
exec族函数共有六个, 功能都是进程程序替换, 但多个不同的函数接口使得使用更加灵活.
其中exceve()是系统调用接口, 其余5个底层都封装了execve().
函数原型如下 :
int execl(const char *path, const char *arg0, ... /*, (char *)0 */);
int execv(const char *path, char *const argv[]);
int execle(const char *path, const char *arg0, ... /*,(char *)0, char *const envp[]*/);
int execve(const char *path, char *const argv[], char *const envp[]);
int execlp(const char *file, const char *arg0, ... /*, (char *)0 */);
int execvp(const char *file, char *const argv[]);
头文件: unistd.h
返回值 :
- 这六个函数返回值相同.
- 当函数调用失败, 返回 -1.
- 当调用成功, 即加载新的程序, 替换后的进程启动开始执行,exec族函数不再返回 .
- 特殊的地方是, 函数调用成功, 不返回
参数:
虽然有六个, 不好记, 但好在有规律. 可以发现, 函数名都是在exce的基础上, 加上l, v, e, p形成新的函数名, 加哪个字母都有各自的
含义 , 如下所示:
- l / v 必须有一个, 也只能有一个, 带l参数格式是列表, 带 v 参数格式是 数组
- p 有p, 会自动搜索环境变量PATH, 则可以不带路径, 只要文件名(但文件必须放在PATH环境变量指定路径). 没有p, 则必须指定文件路径
- e 有e, 则不使用当前环境变量, 需要自己设置环境变量, 没带e, 则使用当前环境变量, 无需设置环境变量
如下表:
函数名 | 参数格式 | 函数是否自带路径(通过PATH) | 是否使用当前环境变量 |
---|---|---|---|
execl | 列表 | 不带, 需要制定文件路径 | 使用 |
execlp | 列表 | 带, 但文件必须的放在指定目录 | 使用 |
execle | 列表 | 不带, 需要制定文件路径 | 不使用, 需要自己设置环境变量 |
execv | 数组 | 不带, 需要制定文件路径 | 使用 |
execvp | 数组 | 带, 但文件必须的放在指定目录 | 使用 |
execve | 数组 | 不带, 需要制定文件路径 | 不使用, 需要自己设置环境变量 |
来看一下这六个函数具体如何使用.
1. execv(参数格式是数组)
#include<stdio.h>
#include<unistd.h>
int main() {
char* arg[] = {"ls","-a","-l","/",NULL };//参数数组;//参数数组
execv("/bin/ls", arg);
printf("hello world!\n");
return 0;
}
可以看到进程被ls程序替换后, 只会执行ls的代码, 并不会再输出 hello world!
如下, 还可以通过main函数的参数传入, 我们用Shell调用ls等命令就是这个原理.
#include<stdio.h>
#include<unistd.h>
#include<stdlib.h>
int main(int argc, char* argv[]){
char* arg[] = {"ls","-a","-l",NULL };//参数数组
pid_t pid = fork();
if(pid == -1){
perror("fork");
}
else if(pid == 0){
printf("替换子进程\n");
execv("/bin/ls", argv);
}
else{
sleep(1);
printf("替换父进程\n");
execv("/bin/ls", arg);
}
printf("hello world!\n");
return 0;
}
2.execl(参数格式是列表)
#include<stdio.h>
#include<unistd.h>
int main(){
execl("/bin/ls", "ls", "/", NULL);
printf("hello world!\n");
return 0;
}
3.execvp / execlp(不带替换程序的路径)
execvp
#include<stdio.h>
#include<unistd.h>
int main(int argc, char* argv[]){
execvp("ls", arg);
printf("hello world!\n");
return 0;
}
execlp
#include<stdio.h>
#include<unistd.h>
int main(){
char* arg[] = {"ls","-a","-l",NULL };//参数数组
execlp("ls", "-a", "-l", NULL);
printf("hello world!\n");
return 0;
}
4.execle / execve(需要自己设置环境变量)
execve
#include<stdio.h>
#include<unistd.h>
#include<stdlib.h>
int main(int argc, char* argv[]){
char* envp[] = {"PATH=/home/test", NULL};
execve("/bin/env", argv, envp);
return 0;
}
execle
#include<stdio.h>
#include<unistd.h>
#include<stdlib.h>
int main(){
char* envp[] = {"PATH=/home/test", NULL};
execle("/bin/env", "", NULL, envp);
return 0;
}
学习完进程的创建和替换后, 就可以利用这些知识, 写一个自己的Shell了
具体代码, 持续更新