一、fork函数
在linux中,父进程通过fork函数创建子进程,子进程返回0,父进程返回子进程的pid,出现错误返回-1。
当运行fork函数时,OS会为子进程创建task_struct、mm_struct(进程地址空间)、页表,子进程继承父进程的代码,数据通过写时拷贝的方式进行共享和独立。因此父子进程互相不影响,做到相互独立。
而在fork之前父进程独自运行,fork之后父子进程分别执行,因此父子进程都会有一个返回值,当父进程先return后,子进程再return,此时就发生写时拷贝(注意:父子进程的先后执行顺序取决于调度器)。
如上图,二号进程被执行两次,就是因为fork函数中,父进程会执行第二次printf代码,而子进程也会执行第二次printf代码。
那么子进程如何继承父进程的代码呢???或者说子进程是全部继承父进程的代码吗???
一般来说,父子进程共享fork之后的代码,子进程只能执行fork之后的代码。在CPU中有程序计数器eip,其作用是用来保存正在执行指令的下一条指令,当进行fork时,eip会拷贝给子进程,子进程就会从eip指向的位置开始执行,eip还可以改成main函数入口,此时子进程就会从main函数开始执行。
写时拷贝
在不写入数据时,父子进程代码、数据共享;当任意一个进程写入数据时,就以写时拷贝的方式进行。
如上图,在未修改数据之前,父子进程共享数据块、代码块,当父子进程任意一方要修改数据时,就会触发写时拷贝,OS为要修改数据的一方开辟一块空间并将原数据拷贝到这块空间中,并为他生成新的页表。
为什么不在fork时把父进程数据全部给子进程呢
- 父进程的数据子进程并不是全部需要的,可能只是只读操作,这会产生空间浪费;
- 值拷贝需要修改的数据,其他数据共享,这种层面技术角度难以实现;
- fork函数直接全部拷贝给子进程,会增减fork的成本,消耗空间时间。
写时拷贝优点在于:只拷贝修改的数据,也就是用最小的成本实现,这是一种延迟拷贝策略,只有当真正需要的时候(进程需要立刻使用,如果进程不是立刻使用,OS会分配给需要的进程)才会开辟空间,拷贝数据,这也就变相的提高了内存使用效率。
二、进程终止
在c/cpp代码中,main函数是入口,而代码中的return 0是什么意思???
- return 0是return给谁???
- return 其他数值可以吗???
在代码执行完毕后,结果正确则返回0,不正确返回非0,而各个非0的数字有代表退出码,不同的退出码代表不同的进程退出信息,方便快速定位程序的问题。
- 在main函数中,return代表进程退出;在其他函数中代表返回值,该函数调用结束;
- 在代码中的任意位置使用exit表示进程退出。
如上图,使用echo $? 打印最近一次的退出码,第一次打印111,代表主函数的返回信息,而第二次打印0,代表echo进程成功执行,返回0。
内核在进程终止时的作用
进程是由内核结构和进程代码、数据构成,内核结构也就是task_struct和mm_struct,而在进程终止时OS可能并不会将内核的数据结构释放。创建一个进程主要分为开辟空间,初始化进程数据两步。而linux会维护一张废弃的数据结构链表,当一个进程被释放(结构被释放,空间没有被释放)时,其不需要的数据就会被维护在这个数据结构链表中,当这个进程再次被创建的时候,OS就会从数据结构链表中找到该进程对应的task_struct和mm_struct,只需要改进成的数据进行初始化即可
三、进程等待
- 如果子进程退出时,父进程不做处理就可能造成僵尸进程,最终造成内存泄漏问题;
- 父进程无法拿到子进程的返回值,且僵尸进程无法被杀死。
因此父进程通过进程等待的方式回收子进程的资源并获取子进程的退出信息。
通过wait证明进程等待
用wait的方式回收子进程,让子进程从Z状态进入X状态;
- pid_t wait ( int *status ) ; pid_t>0表示等待成功,否则失败;它可以等待任意一个退出的子进程;
- pid_t waitpid ( pid_t pid , int *status , int options) ; 参数pid代表要等待的进程pid,-1表示等待任意进程;options是0表示阻塞等待;
- status是一个输出型参数,通过调用该函数从函数内部拿到特定的数据;若不关心子进程退出状态可设为NULL :例如当子进程代码执行完毕返回退出信息到子进程的task_struct时,该函数从子进程的task_struct内部拿到子进程的退出码。
详解status
在waitpid(pid_t pid , int *status , int options)函数方法中,options默认是0,叫做阻塞等待,
如上图,在代码中子进程经过count 5次循环后break,exit的退出码是99,在父进程代码中,可以直接通过status获得子进程的退出码,而status的次八位表示子进程的退出状态,status的低七位表示异常退出,因为这个进程收到某个特定的信号。
如上图,status的低七位表示子进程收到的某个特定信息。当一个进程出现异常时,只关心退出信号,退出码毫无意义。
- WIFEXITED(status): 查看进程是否正常返回,子进程正常结束返回时,则为真。
- WEXITSTATUS(status):查看进程的退出码,若WIFEXITED非零,直接拿到子进程退出码。
四、进程程序替换
子进程执行的是父进程的代码块,如何让创建出来的子进程执行完全不一样的、全新的程序呢???此时就需要用到进程程序替换,由OS完后该工作。
一般在服务器设计时需要子进程干两件事情:
- 让子进程执行父进程的代码块;
- 让子进程执行磁盘中全新的程序(shell,让客户端执行对应的程序)。
程序替换原理就是:将磁盘中的程序加载到内存中并重新建立要执行程序的页表映射,此时子进程就不再共享父进程的数据及代码而是执行该程序对应的数据及代码。
如何实现进程程序替换?
执行一个全新的程序,需要解决什么问题???
- 先确定程序的位置;
- 程序可携带选项执行(明确程序如何执行即要不要带选项);
以int execl(const char *path , const char *arg, ...); 为例
第一个参数表示上述问题一;
第二个参数表示可携带的选项(就是命令行的写法(ls -a -l))最后必须是NULL,表示【如何执行程序】的参数传递完毕。
执行上述代码后发现,最后一个printf并没有执行,这是因为一旦execl将程序替换成功后,就会将后面的代码全部替换(execl之后的原先代码就不再存在),因此不执行。
那么该程序替换函数是否需要返回值呢???
当execl替换成功后,即使由返回值int ret = execl(......),此时也没有机会再执行后续代码,因为后面的代码已经被替换了。但是如果程序替换失败,就不会执行execl(......)代码,而是通过返回值来判断失败原因。
进程创建与进程替换
如上图,在子进程内部进行程序替换,如果替换失败则exit(1),此时代码执行结果显示:子进程程序替换成功,父进程等待成功,说明父子进程各自执行且没有受到影响。子进程进程程序替换,不会影响父进程(进程之间的独立性)。
那么是如何做到的呢???
在数据层面是写时拷贝,但是当子进程要进城execl时,OS会识别到紫禁城的execl操作并且为了不让父进程代码受到影响就会将代码也进行写时拷贝,因此就可以理解为数据和代码都发生了写时拷贝。
带有path变量的函数中可以不写全路径,带有env变量的函数中需要自己组装环境变量。
函数名称 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[]); ![]()
如上图,对于函数带有p时,此时只需程序名即可,不需要完整的路径,只需要将程序名输入即可。对于函数中涉及到数组,需要将程序的携带选项放入到数组中即可(这里的path和file可视为一样)。
c程序调用cpp程序
如上图,在ALL依赖关系与方法中,ALL依赖mycmd与myexec两个方法,因此make ALL会依次生成mycmd与myexec两个程序。
在myexec.c文件中的execl函数会在对应的路径中搜索"mycmd",找到后并执行。
自定义环境变量的传递
如上图,当没有定义环境变量MYPATH时,getenv获取不到该环境变量,因此程序在第8行时崩溃。
如上图,在.c文件中定义一个环境变量传递给子进程可以发现:.cpp在执行到第6行时,getenv崩溃,.cpp其他代码就无法执行下去,因此无法将PATH显示出来。
如上图,当把PATH注释掉后发现,程序可以正常执行。
对比一下就可以发现,一旦自己传进来一个环境变量之后,.cpp中的后续代码就无法显示,
一旦将环境变量添加给子进程,该变量就会将其他环境变量覆盖。
如上图,在.c文件中设置环境变量的指针声明,此时运行结果就不会报错,在使用export声明环境变量即可: