目录
- 1. 进程创建
- 1.1 进程创建的方式与过程
- 1.2 写时拷贝
- 1.3 补充知识
- 2. 进程终止
- 2.1 main函数返回值与进程退出码
- 2.2 进程退出码的意义
- 2.3 进程的执行结果与异常信号
- 2.4 进程终止方式:exit与_exit
- 3. 进程等待
- 3.1 进程创建与回收
- 3.2 进程等待与回收的方式
- 3.3 wait与waitpid的使用方式
- 3.3.1 wait接口
- 3.3.2 waitpid接口
- 3.4 非阻塞等待
- 4. 进程替换
- 4.1 什么是进程替换
- 4.2 execl与进程替换的过程
- 4.3 exe*系列接口的使用
- 4.3.1 只包含命令行参数相关
- 4.3.2 环境变量相关接口函数
1. 进程创建
1.1 进程创建的方式与过程
- 在Linux操作系统中,我们使用C语言库函数
fork
来创建进程,fork函数在父进程中会返回创建的子进程pid,在子进程中返回0。- 在前面的学习中,我们已经对创建出的子进程有了一定的了解,我们知道父子进程是共享代码与数据。只有在我们对数据进行更改时,操作系统才会对指定数据部分进行写实拷贝。
- 进程由
内核数据结构 + 代码 + 数据
组成,其中内核数据结构包括,PCB(进程控制块),进程地址空间,页表。在创建进程时,子进程会创建单独的内核数据结构,并将父进程的内核数据结构中的信息拷贝一份。每个进程都拥有自己独立的内核数据结构,进行的独立性由此显示。
1.2 写时拷贝
- 既然对数据无论立即拷贝或是写时拷贝的总时间消耗都是相同的,那为什么要进行步骤繁琐的多次写时拷贝呢?
<1> 如果在创建子进程时将父进程的所有数据进行一次性的拷贝,这样会使得子进程的创建效率变低。
<2> 操作系统要为计算机的运行效率负责,进程在运行时,大概率不会对所有数据都进行修改。那么,在拷贝方式消耗相同的情况下,对大部分只读类型数据不进行拷贝,而只对指定数据进行写时拷贝的方式会大大提高效率。
- 数据拷贝的过程一共分为两步,申请空间与拷贝数据,那么,我们已经要对指定数据进行修改,为什么还要将父进程的原数据拷贝一份而不是只开辟需要的内存空间呢?
<1> 写时拷贝中写的操作,不进行是指对数据的修改,而是增,删,改等一系列操作。也就是说,我们需要在原数据的基础上做做一些调整修改,而不是覆盖式的写入,所以拷贝的步骤是必须的。
- 写时拷贝的底层技术支持:
<1> 我们知道在未触发写时拷贝之前,父子进程是共用数据,也就是说页表中的所建立的映射关系是相同的。
<2> 而只有在需要对数据进行修改时,才会对指定需要修改的数据进行拷贝,在底层上就表现为在物理内存中开辟一块新的内存空间,将数据拷贝一份,然后再于页表中建立全新的映射关系。
<3> 可是,操作系统是如何辨别何时应该进行如上操作,又是如何找到需要进行写时拷贝的指定数据呢?
<4> 接下来,让我们对页表的结构进行进一步的了解:
<5> 页表中,不仅仅存在虚拟地址物理地址的映射关系,还有许多其他的选项,其中有一个选项就是标明我们对相应地址空间内数据的访问权限,而这也正是操作系统控制写时拷贝发生的手段。
- 写时拷贝的底层实现原理:
<1> 在进行子进程创建时,操作系统会将父子进程中所有数据的访问权限都设置为只读,而在此之后,当我们尝试对任意父进程或是子进程中的数据进行修改时,都会因为权限不足而触发问题。
<2> 在操作系统发现问题后,会对问题(缺页中断)进行种类判断,而后进行相应的处理解决,写时拷贝的实现方式正是通过这种问题触发的方式来引起操作系统的注意,从而让操作系统进行写时拷贝与提权。
1.3 补充知识
- 在C语言的学习中,我们知道字符串常量是不能被修改的,给出的解释为字符串存储在常量区,常量的数据不能被修改,若强行修改则会报错。
- 如上给出的解释,只是基于编程语言层面上的概念,而每一个语言上的概念在底层上都有着技术上的支持。
- C语言中内存空间的概念只是操作系统中的进程地址空间,而不是真正的物理内存空间,我们之所以不能进行对字符串常量进行访问与修改,是因为底层上对应的地址空间的访问权限为只读权限。
- 我们知道,C语言中被
const
关键字修饰的变量具有常性,是不能修改的,当我们对被修饰变量进行修改时,会发生报错。
<1> 这类报错是语法上的报错,在程序的编译阶段就会被编译器检测出来,属于语法报错,会对出错的位置进行提示。
<2> 对没有访问权限的数据进行修改时,编译阶段不会进行报错,而是会在我们执行对应生成的可执行程序时,进行报错,属于运行报错。
<3>const
修饰我们不想更改变量的方式,被称之为防御性编程,将错误报警在运行之前,大大优化了调试与纠错的效率。
//运行时报错
char* str = "hello world!";
*str = 'x';
//语法报错
const char* str = "hello world!";
*str = 'x'
- 进程创建失败的原因:
<1> 操作系统内当前的进程太多
<2> 创建进程的数量超过上限(每个用户能够创建的进程数量有限)- 创建子进程的常规用法:
<1> 与父进程执行不同的代码段
<2> 使用进程替换的方式,执行全新的代码
2. 进程终止
2.1 main函数返回值与进程退出码
- 在编写C/C++程序时,我们首先都要写一个main函数,而main函数的返回值我们都统一为0。我们为什么要去定义一个main函数,并且将其的返回值设置为0呢?
- 操作系统在每个进程被执行完成之后,需要对执行完的进程进行回收,这一过程中并不是直接将程序进行销毁的。操作系统需要从回收执行完的进程中获取其的相关执行信息,然后,通过其的执行情况来判断决定后续如何处理。
- 进程使用退出码的方式用来告知操作系统自己的执行情况,而在语言层面上就为main函数的返回值,进程使用不同的退出码用来标识自己不同的执行情况。
- 指令
echo $?
,显示最近一次执行进程的退出码,我们让main函数返回不同的返回值,就会得到不同的退出码。
int main()
{return 3;
}
2.2 进程退出码的意义
- 不同的进程退出码代表着程序不同执行情况,Linux中,
<1> 使用0表示进程执行成功无异常
<2> 用非0表示进程内执行失败,非0数字有多个,可以用来表示不同的失败原因- C语言
string,h
头文件中的库函数strerror
,可以将错误码转化为我们可以理解所对应的错误描述。
#include <stdio.h>
#include <string.h>int main()
{int i = 0;for(i = 1; i < 201; i++){printf("%s\n", strerror(i));}return 0;
}
- Linux操作系统中,错误码有133个,错误码我们既可以使用系统自带的方法,也可以自己定义。
#include <stdio.h>
enum exit_code
{success = 0,open_err,malloc_err,
}const char* get_exit_code(int code)
{switch(code){case success:return "success";case open_err:return "open_err";case malloc_err:return "malloc_err";default:return "unknown_err";}
}int main()
{int i = 0;for(i = 0; i < 5; i++){printf("%s\n", get_exit_code(i));}return 0;
}
- 程序中,只有main函数的返回值为进程退出码,其他函数的退出,仅仅代表此函数调用完毕。
- 操作系统通过获知进程的执行情况来调整之后的行为动作,而进程内部,也需要通过获知各个函数的执行情况来进行后续的动作。
- C语言的库函数,在实现上会将返回结果与退出码压缩,通过返回值的方式表示,函数的返回值只能简单表明函数执行失败而退出,具体退出原因我们无法通过返回值得知。
- C语言头文件
errno.h
中,包含有一个全局变量errno
,这一变量中会记录我们调用库函数执行失败时的退出码。
#include <stdio.h>
#include <errno.h>int main()
{//打开不存在文件FILE* fp = fopen("./log.txt", "r");printf("errno:%d, strerrno:%s\n", errno, strerrno(errno));return 0;
}
2.3 进程的执行结果与异常信号
- 进程的执行结果一共可以被分为3种:
<1> 进程代码执行完成,结果正确
<2> 进程代码执行完成,结果不正确
<3> 进程代码未执行完成,进程出现异常(结果无意义)- 进程的执行完毕而结果并没有符合预期时,进程的退出码就会标识进程执行失败的原因。
- 进程出现的异常的本质为,收到了异常信号,进程收到异常的情况有两种:
<1> 遇到了足以使得自身崩溃的错误而中断执行,因此触发异常信号
<2> 通过kill -[信号选项]
的指令主动为进程发送异常信号
- Linux中的异常信号,如下:
- 异常信号在实现上本质是宏,因此,在使用指令发送对应异常信号时,也可以使用对应异常信号的宏名。
- 触发异常信号,示例:
情况1:代码错误,触发异常信号
#include <stdio.h>
int main()
{int a = 1;//除0错误a /= 0;return 0;
}
情况2:使用指令发送异常信号
指令:
kill -8/SIGFPE [进程pid]
(为进程发送除0异常信号)
指令:kill -11/SIGSEGV [进程pid]
(为进程发送段错误异常信号)
- 综上所述,我们就可以只通过两个数字就表明所有进程的执行情况:
<1> 进程退出码(exit_code)
<2> 进程异常信号(signnumber)
<3> 进程退出码为0代表进程正常执行完成
<4> 进程退出码为非0代表进程执行结果错误
<5> 异常信号为0时,代表进程正常执行没有中断
<6> 而当异常信号为!0时,则代表进程异常中断,此时进程退出码无意义。
signnumber | exit_code |
---|---|
0 | 0 |
0 | !0 |
!0 | 0 |
!0 | !0 |
2.4 进程终止方式:exit与_exit
- 正常情况下,进程终止的方式,只有等待其运行完毕main函数返回,我们无法主动控制其提前终止。那么,我们有没有可以控制进程主动终止的手段呢?
- C语言标准库中提供了
exit
函数,调用此函数就可以在调用处主动终止进程,exit函数的参数就是进程的退出码。即使是在其他函数中调用exit也会直接终止进程,此函数包含在stdlib.h
头文件中。- 除开上述exit函数外,
unistd.h
头文件中包含另一个与其功能类似的函数_exit
,这一函数的使用方式与exit相同,效果也类似,可是,C语言为什么要提供两个功能相同的接口呢,exit与_exit二者真的没有差异吗?- printf函数并不是直接将参数内容直接写入显示器中的,而是会先将需打印内容写入输出缓冲区中,然后再将缓冲区中的内容刷新至显示器中。
情况1:exit调用
#include <stdio.h>
#include <stdlib.h>int main()
{printf("this is a process");exit(3);
}
情况2:_exit调用
#include <stdio.h>
#include <unistd.h>int main()
{printf("this is a process");_exit(3);
}
- 我们分别调用两个exit与_exit两种方式终止进程,从上述结果可以看出,exit会刷新缓冲区中的内容,而_exit不会,这是为什么呢?
<1> exit函数(3号手册)是C语言库函数,而_exit(2号手册)实质上为Linux操作系统的系统调用接口。
<2> 我们想要对计算机进行的一系列操作都要通过操作系统来实现,操作系统是计算机软硬件资源的管理者,操作系统则是通过提供接口的方式让我们进行各种操作。
<3> 因此,我们想要实现进程终止的操作必须要通过操作系统提供的接口来达成,而exit函数终止进程的方式在Linux中,也是通过调用_exit接口来实现的。
<4> 从执行结果也可以得知,这里的缓冲区不是操作系统的缓冲区,而是C语言库几倍的上层缓冲区
- 为什么要通过库函数封装系统调用,而不是直接使用系统调用接口?
<1> 不同的操作系统拥有不同的系统调用接口,如果直接通过直接使用接口的方式编写,代码只能在一个平台上运行。
<2> 系统调用接口的使用方式相较复杂,不便于使用。
<3> C语言通过标准库将各个操作系统的底层系统接口封装,针对不同的操作系统有不同的标准库,这样使得同一份代码只需要在不同平台上更换对应的标准库就可以运行,因此,C语言代码具有了可移植性,C语言正是通过这种库封装的方式屏蔽了底层差异。
- 进程在退出时都做了什么?
<1> 进程在创建时,会在先在内存中创建相应的内核数据结构,而后再将代码与数据加载入内存中。
<2> 进程在退出时,则是与创建时的步骤相反,反过来释放资源。
3. 进程等待
3.1 进程创建与回收
- 我们所创建的一个个子进程都是bash进程的子进程,bash正是通过这种方式是来完成我们要进行的操作,特定的任务。
- 进程在销毁时,会有一个僵尸(Z)状态,即保留自己PCB。之所以存在这样一个状态,是因为,要等待操作系统/父进程对其执行信息进行读取。
- 进程的僵尸状态不会主动结束,因为僵尸状态的进程不能算是活着的进程,
kill -9
命令也无法将其状态终止,必须要被父进程读取后进行回收,此进程才能彻底结束死亡。- bash会主动回收其创建出子进程的僵尸状态,即我们直接正常情况下创建的进程都会被回收,而我们通过进程创建的子进程,不属于bash的子进程。因此,其僵尸状态不会被主动回收,需要我们让其父进程主动等待回收。
3.2 进程等待与回收的方式
- 我们通过自己创建的进程而创建的子进程不属于bash的子进程,因此,不对其进行回收,其就会一直处于僵尸状态,其PCB就会一直存在于内存中,导致内存泄漏。
- 那么,我们应该如何回收我们所创建的子进程,并从中读取信息呢?
- 操作系统提供了如下两个接口:
<1>int wait(int* status)
<2>int waitpid(pid_t pid, int* statue, int option)
- 使用如上两个接口需要包含两个头文件
sys/types.h
与sys/wait.h
- 父子进程谁先执行有调度器决定,但子进程一定比父进程先结束。
3.3 wait与waitpid的使用方式
3.3.1 wait接口
- 在父进程中调用wait接口,父进程执行到此处时,会进行阻塞等待,直至回收到任意一个子进程为止。
- wait接口的返回值,大于0,代表等待成功,小于0,代表等待失败。
- fork与wait成对使用,fork创建的子进程,父进程使用wait回收。
3.3.2 waitpid接口
- 此接口的返回值意义与wait接口一致,大于0代表等待成功,小于0代表等待失败。
- 此接口有三个参数,
pid
,status
,option
<1> 参数pid,为父进程指定等待回收的子进程pid,pid也可以是-1,此时代表回收任意一个子进程都可以。
<2> 参数status,为一个int*类型的输出型参数,其中会存储着回收到的子进程的退出码。
<3> 参数option,为指定父进程进行回收等待的方式,默认为0,即阻塞式等待。
#include <stdio.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>int main()
{pid_t pid = fork();if(pid == 0){exit(1);}int status = 0;waitpid(pid, &status, 0);printf("child exit_code:%d\n", status);return 0;
}
- 为何子进程的退出码是1,而父进程使用waitpid回收得到的却是256呢?
<1> status参数中并不是单纯存储子进程的退出码,其使用一个变量存储了多个退出信息
<2> status一个32个bit大小,其低16为有效数位,次低8位回收子进程的退出码,低7位为子进程的异常信号。
- 通过如下位处理,我们可以分别得到子进程的退出码与异常信号。
int main()
{pid_t pid = fork();if(pid == 0){exit(1);}int status = 0;waitpid(pid, &status, 0);printf("status:%d,child exit_code:%d,signnumber:%d\n", status, (status >> 8) & 0XFF, status & 0X7F);return 0;
}
- Linux下,C语言库中存在着两个宏,其分别可以从status中提取出子进程退出的相关信息
<1>WIFEXITED
,将status参数传递给它,可以得出子进程是否为正常终止,若是,其为真,反之,则为假
<2>WEXITSTATUS
,将waitpid中的status参数传递给它,就可以提取出回收到子进程的退出码
int main()
{pid_t pid = fork();if(pid == 0){int i = 0;//除0异常中断i /= 0;}int status = 0;waitpid(pid, &status, 0);//进程异常中断,退出码无意义if(!WITFEXIED(status)){printf("child exit_code:%d\n", WEXITSTATUS(status));}return 0;
}
- 为什么不使用全局变量来记录子进程的退出信息,而是用相对繁琐的waitpid接口呢?
<1> 进程之间具有独立性,定义的全局变量只属于父进程,进程之间无法直接互相修改数据。
<2> 读取子进程的退出信息本质上为读取子进程的内核数据结构,想要达成此操作只有操作系统拥有权限,我们只能通过操作系统提供的系统调用接口间接达成。
3.4 非阻塞等待
- 我们如上所讲的wait等待回收子进程的方式,都被称之为阻塞等待,即父进程执行至wait处时,会直接进入阻塞状态挂起进入等待队列,在此期间无法进行任何其他行为动作。
- 此种等待方式在一些场景下是效率低下的,与之相对,存在着另一种等待方式,此种等待模式下,父进程可以执行其他与子进程不相关的任务代码,再此期间同时等待着子进程的结束而后回收。
- 非阻塞等待的实现方式与使用方法:
<1> 向waitpid接口的option参数位置,传递WNOHANG
,这样waitpid所使用的等待方式就为非阻塞式等待。(waitnohang,hang宕机)
<2> 非阻塞等待中,不同于阻塞式等待只会单次调用waitpid,当调用一次后得到子进程未执行完毕的结果时,其会接着执行后续代码,而后再次调用查询。此种方式被称为,基于非阻塞的轮询查询方案,在轮询的期间父进程也可以做其他事情。
<3> 每次查询时,子进程未执行完毕时,waitpid返回0,子进程执行完毕时,waitpid返回回收子进程的pid。
- 非阻塞等待期间,父进程也执行其他任务方式:(函数指针,回调机制)
void move()
{printf("is moving\n");
}void walk()
{printf("is walking\n");
}void run()
{printf("is running\n");
}//函数指针
typedef void(*func_t)();
#define NUM 5
func_t task[NUM];//操作表
void init()
{task[0] = move;task[1] = walk;task[2] = run;task[3] = NULL;
}int main()
{pid_t pid = fork();if(pid == 0){int cnt = 10;while(cnt--){sleep(1);}exit(1);}init();while(1){int status = 0;int ret = waitpid(pid, &status, WNOHANG);if(ret){break;}int i = 0;for(i = 0; task[i]; i++){//函数回调task[i]();}}return 0;
}
4. 进程替换
4.1 什么是进程替换
- 在前面的学习中,我们所创建的子进程其数据与代码都继承于父进程,只能通过fork返回值pid分流的方式可以让父子进程执行不同的代码块,那么,有没有一种方式让子进程可以执行不同父进程其他程序的代码呢?
- 接下来,就让我们学习一种新的进程控制方式,进程替换,它通过将其他程序的代码与数据替换入子进程代码段于数据区,来让子进程可以执行其他程序的代码。
- Linux操作系统中,通过
exe*
系列的函数接口来实现进程替换,此类进程替换的函数与接口一共有7个。
4.2 execl与进程替换的过程
- 进程替换的过程并没有创建新的进程,只是将原有进程的信息的进行了修改调整,具体如下:
<1> 重新加载代码
<2> 修改数据
<3> 清空地址空间的堆区与栈区
<4> 在页表上重新建立映射关系
- 进程替换的函数:
int execl(const char* path, const char* arg, ...)
,包含于unistd.h
头文件中
<1> 参数path,可执行程序所在的系统路径(字符串形式)
<2> 可变参数arg,传递如何执行程序的命令行参数(字符串形式)
#include <stdio.h>
#include <unistd.h>int main()
{printf("execl begin\n"); execl("/usr/bin/ls", "ls", "-a", "l", NULL);printf("execl end\n");return 0;
}
exe*
系列函数接口的使用细节:(系统指令也是程序)
<1> 程序一旦替换成功,exe后的代码就不再被执行
<2> exe只有失败才有返回值,成功没有返回值
<3> 替换完成后,不会创建新的进程,但进程会更名
<4> 创建一个进程,是先创建其数据结构,再将程序加载到内存中的,而程序替换的本质工作就是将其他程序的代码数据等从磁盘加载到内存中。(通过系统调用接口实现)
<5> 可变参数arg需要以NULL
结尾。
<6> 参数arg第一个指令参数处也可以进行非标注传参。
execl("/usr/bin/ls", "/usr/bin/ls", "-a", "-l", NULL);
- 使用多进程,即让子进程进行替换的方式去执行代码,会对父进程的代码产生影响吗?
<1> 进程之间具有独立性,在将新的代码加载至内存中进行替换时,父子进程就会发生对代码与数据的写时拷贝。
<2> 多进程版本的进程替换可以使得父子进行执行其他程序的全新代码。
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>int main()
{pid_t pid = fork();if(pid == 0){execl("/usr/bin/ls", "ls", "-a", "-l", NULL);}wait(NULL);return 0;
}
- 进程替换可以替换任意语言的程序,语言的不同在底层没有差异,对于操作系统于CPU来说都是程序。
//C++文件的后缀可以是.cpp/.cc/.cxx
#include <iostream>
using namespace std;int main()
{cout << "hello Linux!" << endl;return 0;
}
4.3 exe*系列接口的使用
exe*
系列的接口与函数一共有7个,其中只有execve
为系统调用接口,其他都为此系统接口的函数封装。它们在功能上没有区别,只是使用方式上有所区别,传参方式不同。
4.3.1 只包含命令行参数相关
- execlp
int execlp(const char* file, const char* arg, ...)
//execlp("ls", "ls", NULL);
//"ls" 与 "ls"不重复,一个是程序名,一个是命令行参数
- 与execl函数唯一不同的是,其不用传递要进行替换程序的系统路径,只需要传递程序名称即可。
- p:PATH,不用告诉系统程序在哪里,只需要告诉程序的名字,当然,也可以指定路径。
- execv
int execv(const char* path, char* const argv[])//示例:
char* const argv = {(char*)"ls",(char*)"-a",(char*)"-l",NULL
};
//字符串字面常量默认为const char*类型,需要进行强转execv("usr/bin/ls", argv);
- 将命令行参数以字符串数组的形式存储传参。
- l:list,列表,参数列表;v:vector,数组
- execvp
int execvp(const char* file, const char* const argv[])
- 此函数与execv函数的唯一区别为,第一参数可以只传递程序名。
4.3.2 环境变量相关接口函数
- execle
int execle(const char* file, char* const arg, ..., char* const envp[])
- 当我们进行进程替换时,可以使用附带传递环境变量的函数
- C语言头文件
stdlib.h
中,存在着一个全局变量environ
,这是一个二级字符指针,其中记录着当前进程的所有环境变量- C语言头文件
unistd.h
中,有一个函数putenv(char* string)
,其可以向当前进程中添加环境变量。- 子进程会继承父进程的所有内核数据结构中的信息,而在进程替换的过程中不会对子进程的命令行参数与环境变量区域做清空,因此,当我们不通过进程替换接口函数传递环境变量时,子进程的环境变量就默认为继承自父进程的环境变量。
- 进程替换不会替换环境变量的数据,只是进行局部性替换,未传环境变量的情况下,子进程可以默认通过地址空间继承的方式获得环境变量。
//process.c
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>//环境变量参数列表也以NULL结尾
char* const envp[] = {(char*)"hello=hellworld",(char*)"PATH=/",NULL
}int main()
{extern char** environ;putenv("haha=hehe");pid_t pid = fork();if(pid == 0){//设置全新的环境变量覆盖//传递父进程的环境变量execle("./test","test", NULL, environ);//使用新创建的环境变量表替换execle("./test", "test", NULL, envp); }wait(NULL);return 0;}//test.c
#include <stdio.h>
int main(int argc, const char* argv[], const char* envp[])
{int i = 0;for(i = 0; argv[i]; i++){printf("%s\n", envp[i]);}return 0;
}
情况1:继承父进程的所有环境变量
情况2:使用新的环境变量替换
- makefile自动化构建工具:一次编译多个程序
.PHONY:all
all:test processtest:test.cgcc -o $@ $^
process:process.cgcc -o $@ $^.PHONY:clean
clean:rm -rf test process
- 当我们向execle函数第三个参数传递我们自定义的环境变量参数列表时,其会将子进程的环境变量替换。
- execvpe
int execvpe(const char* file, char* const argv[], char* const envp[])
- 参数传递:<1> 程序名,<2> 命令行参数表,<3> 环境变量表
- execve(进程替换系统调用接口)
int execve(const char* file, char* const argv[], char* const envp[])