1. 进程创建
在这之前我们曾了解过进程创建(详见进程初识(二)),我们在这里对fork函数做一些补充
其实对于父子进程来说,若是有一方试图修改数据时,会向物理内存中申请一份新空间,并将数据拷贝到其中,拷贝完成后将自己对应页表中的只读属性去掉。
2. 进程终止
我们之前都知道,在main函数的最后我们一般都有return 0;这个语句,那么为什么要返回0呢?返回1,2怎么样?这个值返回给了谁?以及为什么要返回这个值呢?
在这里,返回的这个0其实是程序的退出码,来表征进程的运行结果是否正常(0表示success)。对于一个进程来说,当它终止时无外乎三种情况:
1. 代码运行完,运行结果正确
2. 代码运行完,运行结果不正确
3. 代码异常终止
对于前两种情况来说,我们怎么知道运行结果是否正确呢?——可以使用return 返回不同数字,来表示不同的出错原因,这就是进程的退出码。因此,对于代码运行完后判断运行结果是否正确,统一采用进程的退出码来判定。
那么除了代码运行完以外,代码也有可能异常终止,此时代码可能没有跑完,因此在这里进程的退出码毫无意义,即不关心退出码,我们需要关注的是为什么异常?发生了什么异常? 在这个过程中,先确定是否出现异常,未出现就返回退出码,而出现了异常操作系统会发出信号,然后退出。
我们可以用如下方式来验证,
对于上面这段代码我们运行可以发现
在kill指令中我们可以找到与之相对应的信号
我们对一个正常运行的进程使用kill -8 PID有
可以得到相同的结果。
main函数的返回值,本质表示进程运行完成时是否是正确结果,若不是可以用不同数字来表示不同的出错原因,而对于进程来说谁最关心当前进程的情况呢?——父进程,因此main函数的返回值其实是返回给了父进程。而对于退出码,我们可以使用
echo $?
来获取最近一次的退出码,如
而在C语言的库里面有一个将错误码转换为错误信息的函数,即strerror,其手册如下
我们可以使用如下代码,将所有错误信息打印出来
运行有
在之前我们ls一个不存在文件时,有
可以看到,这里返回的是错误码为2的错误信息,我们获取错误码也有
即系统提供的错误码和错误码描述是有对应关系的,当然我们也可以自己设计一份,举个例子
而除了使用main函数以外,我们还可以使用exit函数和_exit函数来退出进程,如exit(0);,它们与return的区别在于
exit函数在任意地方被调用,都表示调用进程直接退出
return 只表示当前函数返回
而exit与_exit之间亦有差距,_exit是系统调用,而exit是用户函数,他会在函数实现的过程中调用_exit,对于下面这个代码
在使用exit时结果为
在使用_exit时结果为
在这里printf函数其实是先把数据写入到缓存区,在合适的时候进程刷新,exit属于用户函数它在实现时,内部应该会调用一些函数进行冲刷缓存区的操作,这之后再调用_exit,而_exit则是系统层面直接将这个进程关闭,因此也不会有冲刷其缓存区的情况。
3. 进程等待
①进程等待是什么?
进程等待就是通过系统调用wait/waitpid,来进行对子进程进行状态检测与回收的功能。
②为什么需要进程等待?
在之前我们曾经提到过僵尸进程的存在,由于进程在变成僵尸进程后无法被杀死,我们需要使用进程等待来解决内存泄漏问题(这个问题必须解决)。此外,一个父进程需要通过进程等待,进而获得子进程的退出情况,这样做是为了知道父进程给子进程布置的任务完成地怎么样(也有可能不关心)。
③进程等待是怎么做的?
在代码方面,父进程通过调用wait/waitpid来解决僵尸进程问题,我们可以查看手册,有
在目前看来,进程等待是必须的,对于wait函数,它是等待任意一个子进程退出,如果子进程一直不退出,父进程默认在wait,调用这个系统调用的时候也就不返回,此时就处于阻塞状态。对于waitpid函数,它共有三个参数,对于第一个参数如果传入的pid>0时,表示等待特定的子进程,如果传入的pid=-1时,表示等待任意一个子进程,第二个参数需要解释一下,它是一个输出型参数(即为了把值带出来),这里的int是被当做几部分来使用的,图解如下
前面我们知道,父进程关心子进程,那么父进程期望获得子进程退出后的哪些信息呢?
1. 首先是子进程代码是否异常?——对于status的0-7位来说,当操作系统没有信号发出的时候默认都是0,因此只有0-7位都是0就认为没有收到信号。
2. 没有异常发生,那么结果对吗?不对是因为什么?——对于status的8-15位来说,程序正常退出时默认为0,即0->success,若是结果不对则从其中读取不同的错误码。
举个例子,status值为256时,低16位为0000 0001 0000 0000此时程序正常退出,退出码为1。我们可以使用以下代码来验证
#include <sys/types.h>
#include <sys/wait.h>
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <string.h>
int main()
{
pid_t id = fork();
if (id < 0)
{
perror("fork\n");
exit(1);
}
else if (id == 0)
{
// child
int cnt = 5;
while (cnt--)
{
printf("this is child, pid:%d, ppid:%d, cnt:%d\n", getpid(), getppid(), cnt);
sleep(1);
}
}
else
{
// father
int cnt = 10;
while (cnt--)
{
printf("this is father, pid:%d, ppid:%d, cnt:%d\n", getpid(), getppid(), cnt);
sleep(1);
}
// pid_t ret = wait(NULL);
int status = 0;
pid_t ret = waitpid(id, &status, 0);
if (ret == id)
{
// 0x7f:0111 1111
printf("wait success, ret:%d, exit sig:%d, exit code:%d\n", ret, status & 0x7F, (status >> 8) & 0xFF);
}
}
return 0;
}
运行有
既然如此,那么进程等待原理是怎么样的呢?
因为操作系统不会相信任何用户,因此他会提供一个接口来让用户访问数据。此外,操作提供提供了两个宏来供我们查看信息
WIFEXITED(status): 若为正常终止子进程返回的状态,则为真。(查看进程是否是正常退出)WEXITSTATUS(status): 若 WIFEXITED 非零,提取子进程退出码。(查看进程的退出码)
之前我们的代码可以修改一部分,即
而对于第三个参数options来说,其一般默认为0,即以阻塞方式等待,除了0以外还可以传入一个参数——WNOHANG(HANG意思是夯住(指的是系统或进程在执行某个任务时变得非常慢或停滞,导致系统或应用程序不再响应),整体代表wait no HANG),即等待的时候不要夯住,举个例子来理解,在一个男生等女友出门的时候,WNOHANG表示的是男生每隔几分钟向女生打一次电话确认女生出门了没有,而阻塞表示的是男生给女生打电话并且说不要挂电话,等你出门了再挂。
④非阻塞轮询
在上面举的例子中,男生打电话询问之后,如果女友未出门(未准备好),也不进入阻塞状态,再加上打电话的间隔时间,就形成了非阻塞+循环的形式,我们将其称为非阻塞轮询。对于非阻塞轮询来说,相对于阻塞最大的优势就是可以在这个等待的期间做一些自己的事情,此时对于返回的ret来说,当等待事件未就绪时就返回0。但是对于一个父进程来说,当它处于非阻塞轮询的状态时,等待子进程退出才是它的主要工作,因此在此时,能做的事只能是一些轻量化工作(如打印日志等)。我们可以定义如下的一些工作列表
#define TASK_NUM 10
typedef void(*task_t)();
task_t tasks[TASK_NUM];
void task1()
{
printf("这是一个执行打印日志的任务, pid: %d\n", getpid());
}
void task2()
{
printf("这是一个执行检测网络健康状态的一个任务, pid: %d\n", getpid());
}
void task3()
{
printf("这是一个进行绘制图形界面的任务, pid: %d\n", getpid());
}
int AddTask(task_t t);
// 任务的管理代码
void InitTask()
{
for(int i = 0; i < TASK_NUM; i++) tasks[i] = NULL;
AddTask(task1);
AddTask(task2);
AddTask(task3);
}
int AddTask(task_t t)
{
int pos = 0;
for(; pos < TASK_NUM; pos++) {
if(!tasks[pos]) break;
}
if(pos == TASK_NUM) return -1;
tasks[pos] = t;
return 0;
}
void DelTask()
{}
void CheckTask()
{}
void UpdateTask()
{}
void ExecuteTask()
{
for(int i = 0; i < TASK_NUM; i++)
{
if(!tasks[i]) continue;
tasks[i]();
}
}
而我们可以在主函数代码中这样调用
int status = 0;
InitTask();
while (1) // 轮询
{
pid_t ret = waitpid(id, &status, WNOHANG); // 非阻塞
if (ret > 0)
{
if (WIFEXITED(status))
{
printf("进程是正常跑完的, 退出码:%d\n", WEXITSTATUS(status));
}
else
{
printf("进程出异常了\n");
}
break;
}
else if (ret < 0)
{
printf("wait failed!\n");
break;
}
else
{
ExecuteTask();
usleep(500000);
}
}
这样封装也带来了非阻塞轮询和执行任务之间的解耦。
4. 进程程序替换
1. 单进程的进程程序替换
我们以下面这段代码为例
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int main()
{
printf("before: this is a process pid: %d, ppid: %d\n", getpid(), getppid());
// 标准进程程序替换接口
execl("usr/bin/ls", "ls", "-a", "-l", NULL);
printf("after: this is a process pid: %d, ppid: %d\n", getpid(), getppid());
return 0;
}
我们编译运行可以看到
在结果中我们可以看到,before的打印成功了,而after的打印未成功,那么这究竟是怎么回事呢?
2. 进程程序替换的原理
在一个正常运行的进程中,各部分对应关系如下,而当遇到exec*函数时,会将exec函数中的一个参数中文件的代码与数据替换当前进程的代码与数据,即
从这个基本原理我们可以看到,整个过程没有创建新的进程,同时也没有修改页表中的对应关系。
3. 多进程的进程程序替换
接下来我们使用多进程版来进行测试,代码如下
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/wait.h>
int main()
{
pid_t id = fork();
if (id == 0) // child
{
printf("before: I am a process,pid: %d, ppid:%d\n",getpid(), getppid());
//这类方法的标准写法
//execl"/usr/bin/ls",“ls","=a","-l",NULL);
execl( "/usr/bin/top", "top", NULL);
printf("after: I am a process,pid: %d, ppid:%d\n", getpid(), getppid());
exit(0);
}
// father
pid_t ret = waitpid(id, NULL, 0);
if (ret > 0) printf("wait success, father pid: %d,ret id: %d\n", getpid(), ret);
return 0;
}
在这里我们让子进程退出后,可以看到
在子进程退出后,父进程仍能对子进程进行进程等待,由此我们可以得出结论——子进程的程序替换不会影响到父进程,那么在这个过程中代码肯定发生了写实拷贝。在程序替换成功后,exec*函数后的代码不会执行,如果替换失败才可能执行后面的代码,所以exec*函数只有失败的返回值而没有成功的返回值。
4. 多进程中验证exec*接口
我们使用man手册查看execl有
这6个程序替换的接口都提供加载器的效果,即在shell中我们输入一条指令,shell会创建一个新的进程并在其中调用exce*函数来加载指令。
①execl
首先,先解释一下我们已经使用过的execl函数,这里的l意为list
execl("/usr/bin/ls", "ls", "-a", "-l", NULL);
我们要想执行一个程序,第一件事应该是什么呢?——先找到程序在哪,因此在这个函数中传入的第一个参数就决定了如何找到这个程序,后面的参数是命令行如何写就如何传参。在找到了程序后,又干什么呢?——根据传入的参数选项,具体执行对应程序。
②execlp
然后,我们介绍一下execlp函数,l我们已经解释过,这里的p意为PATH,execlp会在默认的PATH环境变量中查找指令,我们可以用下面的代码验证
int main()
{
printf("before: I am a process,pid: %d, ppid:%d\n",getpid(), getppid());
execlp( "ls", "ls", "-a", "-l", NULL);
printf("after: I am a process,pid: %d, ppid:%d\n", getpid(), getppid());
return 0;
}
③execle
再然后,我们介绍一下execle函数,l不多说,这里的e意为env,即环境变量,使用方式如下
extern char** environ;
execle("/usr/bin/ls", "ls", "-a", "-l", NULL, environ);
需要注意的是,在这里传入自己所定义的环境变量采取的措施是覆盖而非追加。那么我们如何追加环境变量呢?—— 我们可以调用脚本来将一个程序的环境变量导入到另一个程序中,举个简单的例子
我们可以使用下面的代码来测试,即
int main()
{
printf("before: I am a process,pid: %d, ppid:%d\n",getpid(), getppid());
execl( "/usr/bin/bash", "bash", "shell.sh", NULL);
printf("after: I am a process,pid: %d, ppid:%d\n", getpid(), getppid());
return 0;
}
看到这里我们能提出一个问题——为什么无论是可执行程序还是脚本,都能跨语言调用呢?其实所有语言运行的程序本质上都是进程。回归正题,在了解了execle函数后,我们若是想给子进程传递环境变量如何传呢?
1. 新增:我们可以使用putenv函数来给自己(父进程)添加环境变量
2. 彻底替换:即使用execle函数来直接替换所有的环境变量
既然在这里谈到了我们之前讲过的环境变量,那么我们可以思考一下:环境变量是什么时候传入给进程的呢?——我们要知道,环境变量也是数据,创建子进程的时候,环境变量已经被子进程继承下去了。因此,在程序替换中,环境变量不会被替换的。
④execv
execv函数中的v意为vector,它其实是一个指针数组,我们以如下代码测试
int main()
{
char* const myargv[] = {
"ls",
"-a",
"-l",
NULL
};
printf("before: I am a process,pid: %d, ppid:%d\n",getpid(), getppid());
execv( "/usr/bin/ls", myargv);
printf("after: I am a process,pid: %d, ppid:%d\n", getpid(), getppid());
return 0;
}
运行有
在这个例子中,myargv是一个命令行参数,而ls内部含有main函数,这个main函数会去调用这个命令行参数去执行,这样就能完成指定任务。后面的execvp, execvpe大同小异,这里就不再赘述。
⑤execve
除了上面几个接口外,还有一个execve函数,其文档如下
它与前面六个函数的区别在于execve是系统调用,前面六个函数都是库函数,它们都要调用execve接口。