Linux——进程控制

文章详细阐述了Linux系统中创建进程的fork()函数,包括返回值的意义和内核的操作;探讨了进程终止时的退出码和常见退出方法;介绍了进程等待的重要性以及wait和waitpid函数的使用;最后讨论了进程程序替换的概念,特别是exec函数家族的用途和工作原理。
摘要由CSDN通过智能技术生成

目录

一.创建进程

关于fork()的返回值

父进程调用fork()后Linux操作系统做了 

为什么修该数据时要进行写时拷贝? 

什么情况下会调用fork()?

 二、进程终止

进程退出码

 进程常见退出方法

三、进程等待

为什么要等待进程?

怎么样去获取子进程退出状态(status)?

1.wait方法

2.waitpid方法

阻塞等待与非阻塞等待

那如何实现非阻塞等待?

四、进程程序替换

替换原理

替换函数

其实有六种以exec开头的函数,统称exec函数#include

函数解释

命名理解

 当进行进程程序替换时,有没有创建新的进程?



一.创建进程

        通过前面的学习中我们知道,我们可以使用指令去创建一个进程,这个进程的父进程是bash,也可以代码中调用系统调用函数fork(),创建一个进程。

返回值:

父进程返回子进程pid

子进程返回0 

创建失败返回-1

在系统执行一个进程时,进程代码中有调用fork(),在进程调用fork()时,Linux内核做了以下工作:

1.分配新的内存块和内核数据结构给子进程

2.将父进程部分数据结构内容拷贝至子进程

3.添加子进程到系统进程列表当中

4开始调度器调度

5.fork返回

在fork()后子进程是没有自己的代码的,如果我们不去调用系统调用将进程外的程序加载到内存,让子进程去执行,那么子进程只能从父进程中继承部分代码。此时父子进程的代码和数据是共享的。如果进程不去修该代码中的数据,代码和数据是只读的。

例如以下代码在调用fork( )前的代码是由父进程执行的,调用fork()后代码父子进程共享同一份代码。

 

 调用fork()后,父子进程谁先执行由调度器决定。

关于fork()的返回值:

fork()为什么要给子进程返回0,给父进程返回子进程pid?

一个父进程可以创建多个子进程,而一个子进程只能有一个父进程。因此,对于子进程来说,父进程是不需要被标识的;而对于父进程来说,子进程是需要被标识的,因为父进程创建子进程的目的是让其执行任务的,父进程只有知道了子进程的PID才能很好的对该子进程指派任务。

fork()为什么会有两个返回值?

父进程调用fork()后Linux操作系统做了 

当操作系统为子进程分配新的内存块和内核数据结构给子进程,将父进程部分数据结构内容拷贝至子进程PCB,并将子进程PCB添加到程到系统进程运行列表当中时,子进程已经被操作系统调度了。此时父进程和子进程都要执行return 语句,也就是fork()返回两个值。

写时拷贝通常,父子代码共享,父子再不写入时,数据也是共享的,也就是父子进程的代码和数据通过MMU映射到物理内存的同一块空间。只有当父进程或子进程当任意一方试图写入数据时,才将父进程的数据在内存当中拷贝一份,然后再进行修改。

为什么修该数据时要进行写时拷贝? 

我们知道创建子进程是想让子进程去执行一些任务的,如果父子进程的代码和数据是相同,那么他们执行的任务就是相同的,同一份代码和数据是不会占用两个不同的物理内存的。但如果子进程要去执行不同于父进程的任务,子进程就需要独享资源,需要有不同于父进程的代码和数据,这也是进程独立性的体现。

那操作系统为什么不在创建子进程时就进行数据的拷贝?

子进程不一定会执行和父进程一样的功能,所以子进程不会使用父进程的所有数据,并且在子进程不对数据进行写入的情况下,并不会必要对数据进行拷贝,我们应该按需分配,在需要修改数据的时候再分配,写时拷贝是一种按需申请资源的策略,这样可以高效的使用内存空间。

什么情况下会调用fork()?

1、一个父进程希望复制自己,使父子进程同时执行不同的代码段。例如,父进程等待客户端请求,生成子 进程来处理请求。

2、一个进程要执行一个不同的程序。例如子进程从fork返回后,一个进程被创建出来理论上它是没有自己的代码和数据,它要么从父进程继承部分代码和数据,要么调用exec()重新加载一个程序。

fork调用失败的原因

系统中有太多的进程,资源不足

实际用户的进程数超过了限制 

 二、进程终止

我们创建一个子进程去帮我们运行一个程序完成一个任务,完成情况无非就三情况:

1、子进程运行完毕,执行结果正确

2、子进程运行完毕,执行结果不正确

3、子进程异常

进程退出码

我们以前说代码的执行入口是main(),实际上main()只是用户的代码入口,main()也是被其他函数调用的,在 Visual Studio 中,可以使用调试器来查看谁调用了 main 函数。具体步骤如下:

在 Visual Studio 中打开项目。
单击“调试”菜单,选择“新建调试会话”。
在弹出的对话框中,选择“本机调试器”。
单击“启动”按钮开始调试。
程序开始运行后,单击“调试”菜单,选择“窗口”,再选择“调用堆栈”。
在调用堆栈窗口中,可以看到 main 函数被调用的路径和调用者的信息。
 我们的调用main函数的_tmainCRTStartup函数也是被其他函数调用的。

既然main()是被被操作系统调用的,那么main()在执行完程序代码时,要告诉操作系统它执行的情况,这里我们就可以知道以前我们在写程序时为什么main()和return语句是成对出现了,reutrn表示返回,即返回一个数字,这个数字就是main()函数的返回值,用于告诉用户或操作系统程序的执行情况。我们可以通过输入命令echo $? 查看程序的退出码,从而获取程序执行情况。

 进程常见退出方法

1. 从main返回,执行return语句。

2. 调用exit( ),从代码的任意地方退出进程。

3. _exit()

 参数:status 定义了进程的终止状态,父进程通过wait来获取该值。

 return、exit()和_exit()的联系和区别:

return 语句是将执行状态返回给上以级主调函数不会退出程序。而如果是main()中的return 将执行状态返回给_tmainCRTStartup,_tmainCRTStartup是操作系统中的函数,就是说main()执行return 语句时表示用户的代码程序跑完了。

exit(int status)和_exit(int status)为退出程序函数,括号内的参数都将返回给操作系统;

exit()和_exit() 

exit()

用户调用exit(),在操作系统关闭进程前。

缓存数据均被写入,冲刷缓冲区数据。

关闭所有打开的流(如文件)。

例如,以下代码中exit终止进程前会将缓冲区当中的数据输出。
 

_exit()

 调用_exit()终止进程,会直接终止进程。

 

通过学习exit()和_exit()我们可以初步知道缓冲区不在操作系统内部。exit()封装了_exit,exit函数会执行用户定义的清理函数、冲刷缓冲,关闭流等操作,然后再终止进程,而_exit函数会直接终止进程,不会执行用户定义的清理函数、冲刷缓冲,关闭流等操作,exit()封装了_exit,在日常使用中我们推荐使用exit()。

三、进程等待
 

那么谁等谁? 

父进程等子进程。

为什么要等待进程?

之前讲过僵尸进程,子进程退出,父进程如果不管不顾,就可能造成‘僵尸进程’的问题,进而造成内存泄漏。

另外,进程一旦变成僵尸状态,那就刀枪不入,“杀人不眨眼”的kill -9 也无能为力,因为谁也没有办法杀死一个已经死去的进程。

最后,父进程派给子进程的任务完成的如何,我们需要知道。如,子进程运行完成,结果对还是不对, 或者是否正常退出。

父进程通过进程等待的方式,回收子进程资源,获取子进程退出信息。

怎么样去获取子进程退出状态(status)?

父进程通过调用wait()和waitpid()去等待子进程,从而获取子进程的status,wait()和waitpid()都有一个输出型参数status,如果对status参数传入NULL,表示不关心子进程的退出状态信息。否则,操作系统会通过该参数,将子进程的退出信息反馈给父进程。status是一个整型变量,但status不能简单的当作整型来看待,而是以位图,status的不同比特位所代表的子进程退出不同信息,具体细节如下图(只研究status低16比特 位):

在status的低16比特位当中,高8位表示进程的退出状态,即退出码。进程若是被信号所杀,则低7位表示终止信号,而第8位比特位是core dump标志。


 

exit_Code = (status >> 8) & 0xFF; //子进程退出码
exit_Signal = status & 0x7F;      //子进程退出信号

 父进程如何获取子进程的退出码和退出信号?

需要注意的是,当一个子进程进程非正常退出时,说明该进程是被信号所杀,那么该子进程进程的退出码也就没有意义了,也就是执行结果没有参考价值。

进程等待的方法:

1.wait方法

#include<sys/tpyes.h>

#include<sys/wait.h>

pid_t wait(int*status);

返回值:

成功返回被等待进程pid,失败返回-1。

参数: 输出型参数,获取子进程退出状态,不关心则可以设置成为NULL

 我们可以看到,当子进程退出后,父进程读取了子进程的退出信息,并将子进程PCB释放,子进程也就不会变成僵尸进程Z状态了。

2.waitpid方法

pid_ t waitpid(pid_t pid, int *status, int options);

返回值:

当正常返回的时候waitpid返回收集到的子进程的进程ID;

如果设置了选项WNOHANG,而调用中waitpid发现没有已退出的子进程可收集,则返回0;

如果调用中出错,则返回-1,这时errno会被设置成相应的值以指示错误所在;

参数:

pid:

pid=-1,等待任一个子进程。与wait等效。

pid>0,等待其进程ID与pid相等的子进程。

status:

WIFEXITED(status): 若为正常终止子进程返回的状态,则为真。(查看进程是否是正常退出)

WEXITSTATUS(status): 若WIFEXITED非零,提取子进程退出码。(查看进程的退出码)

options:

WNOHANG: 若pid指定的子进程没有结束,则waitpid()函数返回0,不予以等待。若正常结束,则返回该子进程的ID。 

如果子进程已经退出,调用wait/waitpid时,wait/waitpid会立即返回,并且释放资源,获得子进程退 出信息。 如果在任意时刻调用wait/waitpid,子进程存在且正常运行,则进程可能阻塞。 如果不存在该子进程,则立即出错返回。 

阻塞等待与非阻塞等待

在上面的例子中,在子进程没有退出时,父进程会一直等待子进程的退出,在等待期间,父进程没有执行其他指令,这种等待叫做阻塞等待。

在实际的开发中我们可以在子进程退出前,让父进程去执行其他指令完成任务,当子进程退出时再读取子进程的退出信息,即非阻塞等待。

那如何实现非阻塞等待?

我们只需要向waitpid()的第三个参数potions传入WNOHANG,若等待的子进程没有结束没有返回退出码,那么waitpid()将直接返回0,不予以等待。如果waitpid()返回该子进程的pid,则表示子进程结束。

 我们在查看进程状态时可以发现子进程返回了而父进程在执行其他任务时,子进程会保持僵尸状态,当父进程等待成功时,子进程PCB就被释放了,查看不到相应进程的信息。

四、进程程序替换

替换原理

用fork创建子进程后执行的是和父进程相同的程序(但有可能执行不同的代码分支),子进程往往要调用一种exec函数以执行另一个程序。当进程调用一种exec函数时,该进程的用户空间代码和数据完全被新程序替换,从新程序的启动。例程开始执行。调用exec并不创建新进程,所以调用exec前后该进程的id并未改变。

替换函数

其实有六种以exec开头的函数,统称exec函数
#include<unistd.h>

1.

int execl(const char *path, const char *arg, ...);

第一个参数是要执行程序的路径,第二个参数是可变参数列表,表示如何执行这个程序,并以NULL结尾。

例如,要执行的是ls程序。

execl("/usr/bin/ls", "ls", "-a", "-i", "-l", NULL);

2.

int execlp(const char *file, const char *arg, ...);


 第一个参数是要执行程序的名字,第二个参数是可变参数列表,表示如何执行这个程序,并以NULL结尾。

例如,要执行的是ls程序。

execlp("ls", "ls", "-a", "-i", "-l", NULL);

3.

int execle(const char *path, const char *arg, ...,char *const envp[]);

第一个参数是要执行程序的路径,第二个参数是可变参数列表,表示你要如何执行这个程序,并以NULL结尾,第三个参数是自己设置的环境变量。你设置了环境变量,在程序内部就可以使用该环境变量。

char* myenvp[] = { "MYVAL=2021", NULL };
execle("./selfcmd", "selfcmd", NULL, selfenvp);

4.

int execv(const char *path, char *const argv[]);

第一个参数是要执行程序的路径,第二个参数是一个指针数组,数组当中的内容表示你要如何执行这个程序,数组以NULL结尾。

例如,执行的是ls程序。

char* selfargv[] = { "ls", "-a", "-i", "-l", NULL };
execv("/usr/bin/ls", selfargv);


 

5.

int execvp(const char *file, char *const argv[]);

第一个参数是要执行程序的名字,第二个参数是一个指针数组,数组当中的内容表示你要如何执行这个程序,数组以NULL结尾。
 

例如,执行的是ls程序。
 

char* selfargv[] = { "ls", "-a", "-i", "-l", NULL };
execvp("ls", selfargv);

6.

 int execve(const char *path, char *const argv[], char *const envp[]);

第一个参数是要执行程序的路径,第二个参数是一个指针数组,数组当中的内容表示你要如何执行这个程序,数组以NULL结尾,第三个参数是你自己设置的环境变量。

例如,你设置了MYVAL环境变量,在selfcmd程序内部就可以使用该环境变量。

char* selfargv[] = { "mycmd", NULL };
char* selfenvp[] = { "MYVAL=2021", NULL };
execve("./selfcmd", selfargv, selfenvp);

函数解释

这些函数如果调用成功则加载新的程序从启动代码开始执行,不再返回。

如果调用出错则返回-1

所以exec函数只有出错的返回值而没有成功的返回值。

命名理解

这些函数原型看起来很容易混,但只要掌握了规律就很好记。

l(list) : 表示参数采用列表

v(vector) : 参数用数组

p(path) : 有p自动搜索环境变量PATH

e(env) : 表示自己维护环境变量

        事实上,只有execve是真正的系统调用,其它五个函数最终都调用 execve,所以execve在man手册 第2节,其它函数在 man手册第3节。这些函数之间的关系如下图所示。 下图exec函数族 一个完整的例子: 

 
当进行进程程序替换时,有没有创建新的进程?

进程程序替换之后,该进程对应的PCB、进程地址空间以及页表等数据结构都没有发生改变,只是进程在物理内存当中的数据和代码发生了改变,所以并没有创建新的进程,而且进程程序替换前后该进程的pid并没有改变。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值