进程创建
以fork()为例,从已存在的进程中创建一个新进程,新进程为子进程,原进程为父进程。
#include<unistd.h>
pid_t fork(void);
返回值:自进程中返回0,父进程返回子进程id,出错返回-1
- 进程调用fork,当控制转移到内核中的fork代码后,内核做:
- 分配新内存块和内核数据结构给子进程。
- 将父进程部分数据结构内容拷贝到子进程中。
- 添加子进程到系统进程列表中。
- fork返回,开始调度器调度。
进程 = 内核数据结构(OS维护的) +进程代码和数据(一般是磁盘给的)。
创建子进程,给子进程分配对应的内核结构,必须子进程独有,以确保进程的独立性。理论上,子进程也要有自己的代码和数据。可一般而言,没有加载数据的过程,即子进程没有自己的代码和数据。所以,子进程只能“使用”父进程的代码和数据。
代码:都是不可写的,只能读取,所以父子共享没有问题。
数据:可能被修改,所以必须分离。
所以针对数据的安全问题,
可以直接把父进程的数据拷贝一份给子进程用(写时拷贝)。
但是,拷贝给子进程的数据,子进程可能用不到,即便用到了也可能只是浅浅的读一下。
依着编译器编译程序的时候,都扣扣搜搜的,知道节省空间的样子。
所以,只会将 将来会被父进程或子进程写入的数据拷贝一份。
但一般而言,os也不知道那些空间可能会被写入。且提前拷贝了也不一定会立刻使用。
所以os选择了,写时拷贝技术,来进行父子进程的数据分离。
os为何要选择写时拷贝技术?
1.用的时候再分配,是高效使用内存的一种表现。
2.os无法在代码执行前预知哪些空间会被访问。
fork之后,父子进程代码共享,其共享是after共享,还是所有的都共享?
所有的共享。
1.代码汇编完之后,会有很多行代码,每行代码加载到内存后,都会有各自对应的地址。
2.因为进程随时可能被中断(可能并没有被执行完),下次回来,还必须从之前中断的位置继续运行。
所以就要求cpu必须随时记录当前进程执行的位置,所以,cpu内有对应的寄存器数据(EIP,也称pc指针(point code)、程序计数器)。
3.寄存器在cpu内只有一份,但寄存器内的数据可以有多份。父子进程各自调度,各自会修改EIP,但无伤大雅,因为子进程已经认为自己的EIP起始值就是fork之后的代码。
进程终止
进程终止时,操作系统的动作:
释放进程申请的相关内核数据结构和对应的数据和代码。
本质就是释放系统资源(内存、cpu资源……)。
进程退出的场景:
1.代码运行完毕,结果正确。
2.代码运行完毕,结果不正确。
3.代码异常之中。
main函数的返回值意义是啥?
返回值是进程退出嘛。
平常main()中的return 0是啥意思?为什么总是0?
0表示sucess。返回非0,就意味着运行的结果不正确。
ehco $?:用来获取最近一个进程,执行完毕的退出码。
echo $?本身也是一个进程,所以第二个ehco $获取的是第一个ehco $的进程退出码。第三个echo $?也同理。所以第二、三个退出码是0.
以求1到100的和为例,通过查询进程退出码检测进程是否运行正确
同时,非0值有很多个,不同的非0值,可以表示不同给的错误原因。
这样子,程序运行结束后,结果不正确时,方便定位错误的原因细节。
如下图,打印了前十个退出码:
这些退出码是系统提供给我们使用的。当然,也可以自己定义退出码使用。
进程常见的退出方式:exit
#include<stdlib.h>
void exit(int status);
终止普通进程
如下图,exit与return不同。
return在普通函数通常代表调用结束,在main函数中代表进程退出。
exit在代码的任何地方调用,都表示直接终止进程。
且return是语句,exit是函数。
进程常见的退出方式:_exit
#include<unistd.h>
void _exit(int status);
|
|
printf中没有\n时,数据没有被立即刷新出来,而是存在于相关的缓冲区。exit终止程序时,会将缓冲区的数据刷新出来再终止。
|
|
进程等待
若子进程退出,父进程不管不顾,可能会造成僵尸进程问题,进而导致内存泄漏问题。
且进程一旦变成僵尸状态,那么kill -9也无法杀死这个进程。
同时父进程是需要知道子进程的运行状况的。
所以父进程通过进程等待的方式,回收子进程资源,获取子进程退出信息。
如上图,子进程
进程等待方法:wait方法
#include<sys/types.h>
#include<sys/wait.h>
pid_t wait(int *status);
返回值:成功返回被等待进程id,失败返回-1。
参数:输出型参数,获取子进程退出状态,不关心的话可以设置成NULL。
使用wait方法让父进程阻塞等待:
父进程执行完毕后,回收子进程资源:
5s~7s这个时间段,子进程变成了僵尸进程。待7s后,父进程执行完毕后,回收子进程。
进程等待方法:waitpid方法
#include<sys/types.h>
#include<sys/wait.h>
pid_t waitpid(pid_t pid, int*status, int options);
返回值:
(1)当正常返回的时候,waitpid返回收集到的子进程id。
(2)如果设置了选项WNOHANG,而调用中waitpid发现没有已退出的子进程可收集,则返回0。
(3)如果调用中出错,则返回-1,这时error会被设置成相应的值以表示错误所在。
参数:
pid:
pid=-1,等待任一个子进程,与wait等效。
pid>0,等待进程ID与pid相等的子进程
status:
WIFEXITED(status):若为正常终止子进程范返回的状态则为真。(查看进程是否正常退出)
WEXITSTATUS(stauts):若WIFEXITED非零,提取子进程退出码。(查看进程退出码)
options:
默认为0,表示阻塞等待。
获取子进程status
wait和waitpid,都有一个status参数,该参数是一个输出型参数,由操作系统填充。
如果传递NULL,表示不关心子进程的退出状态。
否则,操作系统会根据该参数,将子进程的退出信息反馈给父进程。
status可以当做位图来看待
status并不是按照整数来整体使用的。
父进程通过wait/waitpid函数可以拿到子进程的退出结果,可以用一个全局变量代替wait/waitpid函数嘛?
进程具有独立性,数据会发生写时拷贝后,父进程无法拿到。且就算可以,信号的代替方案也有待商榷。
进程具有独立性,进程退出码、收到的信号,也是子进程的数据,父进程怎么拿到的?
一个进程死亡之后,其数据、资源可以释放了,但是至少要保留其PCB信息。而twait/waitpid函数的工作本质其实是,读取task_struct结构中保留的进程退出时的退出结果信息。
task_struct是内核数据对象,wait/waitpid函数有资格调用嘛?
有。wait/waitpid算系统调用。
使用系统提供的宏,获取退出码
waitpid方法的参数options
options默认为0,代表阻塞等待。
WNOHANG选项,代表父进程非阻塞等待。
WNOHANG是一个被系统定义的宏值。
非阻塞等待:
父进程通过waitpid来进行等待,如果子进程没有退出,立马返回waitpid这个系统调用。
属于操作系统的内核中的waitpid实现的伪代码:
检测子系统退出状态,查看task_struct中子进程的运行信息
waitpid(child_id, status, options)
{
if (status == 退出)
{
return child_pid;
//waitpid是父进程调用的,
//通过 status |= child->sig_number 、 status |= ((child->exit)>>8)
//父进程可以直接拿到子进程的退出码和受到的信号
}
else if (status == 没退出)
{
if (options == 0)
{
//为0,默认挂起状态。
//拿父进程的pcb,father_pcb将其挂入等待队列中。
//进程阻塞的本质,是进程阻塞在系统函数内部。
//当运行条件满足时,父进程被唤醒,EIP寄存器中留存的是地址,只想的是是if(options==0)这行代码,所以从这行代码向后继续指向。
}
else if (options == WNOHONG)
{
return 0;//不阻塞进程
}
return 0;
}
else
{
//出错、其他原因……
return -1;
}
}
···
进程程序替换
· forl的常规用法:
- 父进程希望复制自己,是父子进程同时执行不同的代码段。例如:父进程等待客户端请求,生成子进程来处理请求
- 子进程要执行一个不同的程序。
程序替换,是通过特定的接口,加载到磁盘上的一个权限的程序(代码和数据),加载到调用进程的地址空间中。让子进程执行一个其他 程序。
替换原理
用fork创建子进程后,子进程执行的是和父进程相同的程序(有可能执行不同给的代码分支)。子进程往往要用exec函数以执行另一个程序。当进程调用一种exec函数时,该进程的用户空间代码和数据完全被新程序替换,从新进程的启动例程开始执行。
调用exec并不创建新进程,所以调用exec函数前后该进程的id并未改变。
程序替换,是通过特定的接口,加载到磁盘上的一个权限的程序(代码和数据),加载到调用进程的地址空间中。
进程替换,有没有创建新的子进程?
没有。进程早就存在了,进程替换也只是改变映射关系,并不影响进程PCB的优先状态,自然没有创建新的子进程。
操作系统是如何将程序放入(加载)到内存的?
exec函数,就是如何加载程序到内存的函数。
替换函数
有六种以exec开头的函数,统称exec函数:
#include<unistd.h>
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, const char *const argv[]);
int execvp(const char *file, char *const argv[]);
//man手册里只有这六个,但是官方手册有七个。
execl()
- 参数:
- const char *path:上文有提及到:exec函数,就是如何加载程序到内存的函数。加载程序自然需要其地址,所以这个path是带路径+目标文件名。
- …(三个点):可变参数列表,代表可以传入多个不定参数。
为什么不打印最后一句printf?
execl是程序替换,调用该函数成功之后,会将当前进程的所有的代码和数据都进行替换,包括已经执行的和没有执行的。
所以一旦调用成功,后续的所有代码,全部不会执行。
execl()只有当他出错了,才会有返回值:-1。
execl为什么成功调用没有返回值?
进程替换时,execl函数本身也属于要被替换掉的代码。所以execl函数成功后,根本不需要返回值。
为什么要创建子进程?
为了不影响父进程,让父进程聚焦在读取数据、解析数据,指派进程执行代码的功能。
execv
参数:
char *const argv[]:是一个指针数组。
以“ls -a -l”为例,就是将其拆开,将“ls”、“-a”、“-l”分别装入数组。
正因为是一个指针数组,所以一定要以NULL结尾。。
execlp
参数:
const char *file:这里只需要给执行程序的名称,系统会自动在环境变量path中进行查找
execvp
execle
相较于execl,execle的参数多了const char *envp[],这个参数接受的是环境变量,也是一个指针数组。
获取环境变量
#include<stdlib.h>
char *getenv(const char *name);
环境变量具有全局属性,可以被子进程继承下去。
如何指向自己写的执行程序?
上面的ls例子,是系统提供的。
下面以用exec.c调用自己写的mycmd为例。
命名规律
综上,不难发现,exec*的功能就是加载器的底层接口。 整理下命名规律
- l(list):表示参数采用列表
- v(vector):参数用数组
- p(path):有p自动搜索环境变量PATH
- e(env):表示自己维护环境变量
函数名 | 参数格式 | 是否带路径 | 是否使用当前环境变量 |
---|---|---|---|
execl | 列表 | 不是 | 是 |
execlp | 列表 | 是 | 是 |
execle | 列表 | 不是 | 不是,得自己组装环境变量 |
execv | 数组 | 不是 | 是 |
execvp | 数组 | 是 | 是 |
execve | 数组 | 不是 | 不是,得自己组装环境变量 |