当一个程序被加载到内存中以后,这个程序就变成了一个进程。
此外还可以通过调用fork函数创建子进程,子进程和父进程共享fork之后的代码,可以采用对fork返回值进行判断的办法来让父子进程分别执行后续代码的一部分。
1.一个函数在执行return语句之前就已经完成了这个函数的主要工作,因此fork函数能有两个返回值的原因就是在执行return语句之前,在fork函数内部就已经将子进程创建出来了,return语句被父子进程各执行了一次,所有就有两个返回值。
2.fork给父进程返回子进程的PID是为了方便后续父进程对子进程进行资源回收
3.如果fork函数调用成功,操作系统会给子进程分配内存块并创建对应的内核数据结构(PCB,页表,进程地址空间),fork时子进程获得父进程数据空间、堆和栈的复制,所以变量的地址(当然是虚拟地址)也是一样的。
4.只要是函数就有可能被调用失败,当一个操作系统中的进程太多时,fork函数就会失败。
我们平常在写main函数时总是习惯在最后写一个return 0
,这个返回值其实是main函数退出时的退出码,退出码标定的是一个进程是否正常退出。当我们不关心进程的退出码时就可以直接设置成0,如果关心的话就要设置特定的数字来标定特定的情况。
一个进程退出无非就三种情况:
1.代码跑完了,结果正确(直接返回0)
2.代码跑完了,结果不正确。
此时程序的退出码就可以帮我们标定错误,使用
echo $?
就可以查看最近一个进程的退出码每个退出码都有对应的退出信息,一般用0表示程序正常退出,用非0表示错误,库中给我们提供了134个错误码,可以将其对应的错误信息都打印出来看看:
3.代码没跑完,程序异常了(退出码无意义)
可以使用exit或_exit为一个进程设置退出码,在数据结构阶段我经常看到这样的代码:
int *tmp=(int*)malloc(4*sizeof(int));
if(tmp==NULL)
{
perror("malloc fail\n");
exit(-1);
}
当使用malloc开辟空间失败以后就使用exit函数并将退出码设置成-1用来表示错误
下面通过这样一段代码来看看两者之间的区别:
int main()
{
printf("hello world");
sleep(2);
exit(0);//_exit(0);
return 0;
}
有了前面的基础我们知道缓冲区是行刷新的,没有
\n
虽然printf是先执行,但是也会在程序退出以后才打印语句
首先来看使用exit时的结果:
再来看看使用_exit时的结果:
可以看到两者之间最大的区别就是exit在程序结束时会将缓冲区内的数据刷新出来,但是_exit却不会将缓冲区刷新出来。
那么缓冲区在哪里?
计算机是一个层状结构,我们不能跳过某一层去跳跃式的访问某一层。
exit是一个库函数,而_exit是一个系统调用。
也就是说如果缓冲区在内核当中,那么必须要使用系统调用接口去申请刷新缓冲区。但这里的结果显示,系统调用接口没有刷新缓冲区,库函数却刷新了。因此可以得到一个结论:缓冲区并不在内核空间当中,而是一个用户级的缓冲区。
当子进程退出以后,如果父进程一直不回收子进程的资源,那么子进程就会处于僵尸状态,会造成内存泄漏的问题。
父进程创建一个子进程是为了让它帮我们去执行某一项操作,当子进程将这个操作执行完毕以后会将退出结果保存在PCB中。也就是说当一个进程执行结束以后,它对应的代码和数据可以被释放,但是它的PCB是不能被释放的,要等待父进程读取完退出结果后由父进程来释放。父进程可以通过进程等待(使用系统调用wait/waitpid)的方式来回收子进程对应的资源。
status输出型参数,获取子进程退出码和退出状态,不关心则可以设置成为NULL。
#include
#include
#include
#include
#include
int main()
{
pid_t id=fork();
if(id==0)
{
//子进程
int cnt=5;
while(cnt--)
{
printf("子进程:%d 父进程:%d %d\n",getpid(),getppid(),cnt);
sleep(1);
}
exit(0);//子进程退出
}
sleep(10);//让子进程处于僵尸状态五秒
pid_t ret =wait(NULL);
if(id>0)
{
//父进程
printf("等待成功:%d\n",ret);
}
sleep(3);//回收完保持进程三秒
return 0;
}
这个代码的结果应该是:刚开始有两个运行状态的进程,大概五秒以后子进程结束,但父进程没有去回收,子进程处于僵尸状态,又过来五秒,父进程调用wait系统调用回收子进程,子进程被回收,只剩下父进程,保持三秒后父进程也结束
同样是父进程用于回收子进程的系统调用,但这个系统调用还能顺便拿到子进程退出时的退出码和信号。
对于status不能当作简单的整数来看,可以将其看作一个位图结构只关注它的低16位,其中次低8位中存放的是退出码,低7位中放的是退出信号(和退出码一样0信号表示无异常)
如果在进程运行期间使用kill命令杀掉进程,那么也是相当于被信号所杀。
除了使用status的低十六个比特位以外,还可以通过两个宏来得到子进程退出时的退出码和退出信号。
1.WIFEXITED(status): 若为正常终止子进程返回的状态,则为真。(查看进程是否是正常退出)
2.WEXITSTATUS(status): 若WIFEXITED非零,提取子进程退出码。(查看进程的退出码)
可以看到我是用waitpid的时候第三个参数一直传的是0,这就表示采用的是阻塞式等待。所谓阻塞式等待就是如果子进程没退出,父进程就一直守着子进程直到子进程退出。
马上就要考试了,作为一个聪明但不爱学习的人,我找到了我班上听课最认真的张三同学,希望他能帮助我复习,他答应的很爽快,我觉得有点不好意思于是就提出要请他吃饭。在考试前一天中午我到他家楼下,我打电话给他表示我已经到他家楼下了让他下来和我去吃饭,但是他说他正在看书让我在楼下稍等上十几分钟。我说可以,但是电话不要挂,我们就一直这样打着电话。在他下楼我们挂掉电话之前我什么也干不了,只能一直保持着和他打电话的状态。
这种舔狗式的等待方式就是阻塞式等待,但是父进程一直保持着等待状态,直到子进程运行完毕父进程再去回收子进程的资源。
在非阻塞等待中,父进程会采用轮询的方式检测子进程的状态,如果子进程没有退出,那么父进程就去继续做自己的事,如果在某一次询问中,父进程发现子进程已经结束了,那么父进程就会去回收子进程的资源。
又到了一次考试,我又找到张三帮我复习,在考试的前一天我又到了他家楼下给他打电话,他仍旧表示正在有事让我稍等。有了上次的教训,我这次直接把电话一挂开始玩手机,刷了一会抖音以后我又打了一个电话给张三并询问他好了没,张三说还没好,我又把电话一挂,掏出一本《C和指针》看一看。过一会我又给张三打电话询问他好了吗。
这个我不断给张三打电话询问他好了没的过程,就类似于父进程轮询检查子进程是否执行完毕,如果子进程还在运行,父进程不必一直等待子进程可以继续执行其他代码:
#include
#include
#include
#include
#include
#include
#include
#define NUM 10
typedef void (*func_t)();
func_t handlerTask[NUM];
void task1()
{
printf("handler task1\n");
}
void task2()
{
printf("handler task2\n");
}
void task3()
{
printf("handler task3\n");
}
void loadTask()
{
memset(handlerTask, 0, sizeof(handlerTask));
handlerTask[0] = task1;
handlerTask[1] = task2;
handlerTask[2] = task3;
}
int main()
{
pid_t id = fork();
assert(id != -1);
if (id == 0)
{
int cnt = 10;
while (cnt)
{
printf("这是子进程pid:%d,ppid:%d,cnt:%d\n", getpid(), getppid(), cnt--);
sleep(3);
}
exit(10);
}
loadTask();
int status = 0;
while (1)
{
pid_t ret = waitpid(id, &status, WNOHANG);
//WNOHANG:非阻塞:子进程没有退出,父进程检测之后立即退出
if (ret == 0)
{
//waitpid调用成功&&子进程没退出
//子进程没有退出,我的waitpid没有等待失败,仅仅检测到而来子进程没有退出
printf("wait done,but child is running...parent running other things\n");
for (int i = 0; handlerTask[i] != NULL; i++)
{
handlerTask[i]();//回调
}
}
else if (ret > 0)
{
//waitpid调用成功&&子进程退出
printf("wait success,exit code:%d,sig:%d\n", (status >> 8) & 0xFF, status & 0x7F);
break;
}
else
{
//waitpid调用失败
printf("waitpid call failed\n");
break;
}
sleep(1);
}
return 0;
}
非阻塞式等待并不会占用父进程的全部精力,在等待期间父进程还可以去做其他的事情。
阻塞式等待和非阻塞式等待没有绝对的好坏,只有更适合的应用场景。甚至阻塞式应用的比非阻塞式还要多。
进程等待的本质是父进程检测子进程的退出信息,这个退出信息保存到status中供父进程读取
进程替换就是在这个进程中通过调用exec*
系列的函数,将指定的程序加载到内存中被执行,几乎是所有的后端语言都可以被替换
#include //头文件
//execve的封装
int execl(const char *path, const char *arg, ...);
int execlp(const char *file, const char *arg, ...);
int execle(const char *path, const char *arg, ...,char *const envp[]);
int execv(const char *path, char *const argv[]);
int execvp(const char *file, char *const argv[]);
int execvpe(const char *file, char *const argv[],char *const envp[]);
//系统调用
int execve(const char *filename, char *const argv[],char *const envp[]);
l(list):表示参数通过列表式传参
p(path):表示不用传文件的路径,只需要传文件名就行,它会自动的去环境变量中查找
v(vector):将参数写入数组中,最后统一传递
e(env):环境变量,可以传入自己所写的环境变量
...
表示可变参数列表,也就是说传参的个数是不确定的,但最后要以NULL结尾这些函数只在调用失败时才有返回值(-1),因为如果调用成功,后续的代码都会被替换掉,返回值没有意义
#include
#include
int main()
{
printf("process is running\n");
execl("/usr/bin/ls"/*要执行程序的路径*/,"ls","--color=auto","-a","-l",NULL/*如何执行*/);//一定要用NULL结尾
printf("process is down·····\n");//这句话并不会被打印,因为后续代码和数据已经被execl函数替换了
return 0;
}
但是这样一替换就将整个进程都替换了,所以进程替换一般都是通过创建一个子进程然后让子进程来完成替换的。
可以看到尽管子进程使用了程序替换,但是父进程照样执行不受影响,这是因为有页表和写时拷贝的存在。
父子进程原本共享代码和数据,一旦子进程想修改共享的代码和数据,操作系统就会重新找一块空间并将原数据和代码拷贝一份供子进程修改,这就是写时拷贝(写的时候才拷贝)
进程各自都有独立的进程地址空间,通过页表与物理内存发生映射,所以一旦代码和数据的物理位置发生改变就只要改变页表的映射关系即可。
独立的进程地址空间,独立的代码和数据,保证了进程之间的独立性
各个函数使用示例
int main()
{
char *const argv[] = {"ps", "-ef", NULL};
char *const envp[] = {"PATH=/bin:/usr/bin", "TERM=console", NULL};
execl("/bin/ps", "ps", "-ef", NULL);
// 带p的,可以使用环境变量PATH,无需写全路径
execlp("ps", "ps", "-ef", NULL);
// 带e的,需要自己组装环境变量
execle("ps", "ps", "-ef", NULL, envp);
execv("/bin/ps", argv);
// 带p的,可以使用环境变量PATH,无需写全路径
execvp("ps", argv);
// 带e的,需要自己组装环境变量
execve("/bin/ps", argv, envp);
exit(0);
}
其实这六个函数都是在调用execve
这个系统调用,封装这六个函数是为了满足各种调用场景方便我们使用。
程序替换的本质:用磁盘指定位置上的程序的代码和数据,覆盖进程自身的代码和数据,达到让进程执行指定程序的目的。