一.进程的概念
进程:运行中的程序
一个程序运行起来,有数据以及指令需要被CPU执行处理.根据冯诺依曼体系结构可知:CPU不会直接去硬盘找到程序文件进行执行处理,而是需要先将数据信息加载到内存中,然后CPU从内存中获取数据以及指令进行执行处理,程序运行会被加载到内存中.
CPU分时机制实现CPU轮询处理每一个运行中的程序,而程序运行调度则由操作系统进行管理.
管理思路: 操作系统将每一个程序的运行信息都保存下来,进行调度管理的时候才能知道这个程序上一次运行到了哪里.
操作系统通过对一个程序运行的描述,让一个程序运行起来,这才能称之为运行中的程序.
对于操作系统来说,进程就是PCB,是一个程序运行的动态描述,通过PCB才能实现程序的运行调度管理
task_struct结构体是Linux系统下的PCB(进程控制块).其包含的主要内容有:
- 标识符:描述本进程的唯一标识符,用来区别其它进程
- 状态:描述符进程的状态,退出代码,退出信号等
- 优先级:相对于其它进程的优先级:如果有好几个进程正在执行,就涉及到进程被执行的先后顺序的问题
- 程序计数器:程序中即将被执行的下一条指令的地址
- 内存指针:包括程序代码和进程相关数据(指令)的指针,还有其它进程共享的内存块的指针
- 上下文数据:进程执行时处理器中寄存器中的数据
- I/O状态信息: 包括显示的I/O请求,分配给进程的I/O设备和进程使用的文件列表等
- 记账信息: 包括处理器的时间总和,记账号等等
对于操作系统来说,管理程序的运行就是将程序的运行描述起来,然后组织起来进行管理,描述的运行信息对于操作系统来说就是运行中的程序(进程).
在Linux系统中,线程被称之为轻量级进程,在内核中同样是创建了struct task_struct结构体来描述线程,线程task_struct结构体当中的内存指针指向进程的虚拟地址空间.
Linux系统对进程状态的划分:
- 运行状态 - R: 包含就绪以及运行,也就是说正在运行的,以及拿到时间片就能运行的都称之为运行状态. 操作系统遇到PCB是这个状态就会调度运行
- 可中断休眠状态 - S:可以被打断的休眠状态,通常满足运行条件或者被一些中断打断休眠之后进入运行状态(就如同:一个人睡觉,可以被打醒,也可以满足自然条件后自然醒)
- 不可中断休眠状态 -D:只能通过满足条件自然醒进入运行状态,不会被一些中断打断休眠状态
- 停止 -T:停止与休眠状态不一样(休眠操作系统会去查看进程唤醒条件是否满足,而停止是只能手动唤醒)
- 僵尸状态 -Z:描述的是一个进程退出了,但是进程资源没有完全释放,等待处理的一种状态
特殊进程:
(1).僵尸进程:
概念:子进程先于父进程退出,进程处于僵尸状态,进程退出但是资源没有被完全释放
危害:资源泄漏(可能导致正常的进程运行不起来)
解决方法:进程等待(一直关注子进程,退出了就能直接发现)
进程等待(回收僵尸子进程):
(1).int wait(int* status);
该接口为阻塞接口,处理退出的子进程,当然若是没有子进程退出则会一直等待,直到有子进程退出才会调用返回
status:输出型参数,用于获取退出子进程的返回值
注意:不管子进程是正常退出还是异常退出.只要退出没有被父进程等待处理,就都会变成僵尸进程.
(2).int waitpid(int pid,int* status,int option);
同样是处理退出的子进程.
与wait的不同之处:
- wait等待的是任意一个子进程的退出(wait是一个父进程假设有很多子进程,任意一个退出都会处理调用返回)waitpid可以等待指定的子进程,也可以等待任意一个子进程,通过第一个参数确定(第一个参数pid==-1,则表示等待任意子进程)
- wait是一个阻塞接口(wait如果没有子进程退出,则会一直等待),waitpid可以默认阻塞,也可以设置为非阻塞,通过第三个参数确定(第三个参数option == 0 表示默认阻塞,option == WNOHANG 则表示非阻塞)
- 返回值:成功则返回退出子进程的pid(大于0); 若没有子进程则退出返回0; 若出错则返回-1.
注意:非阻塞操作通常需要循环处理,否则一次处理不成功总不能不处理了,因此需要循环判断是否能进行处理,直到成功返回.
(2).孤儿进程:
概念:父进程先于子进程退出,子进程就会变为孤儿进程
特性:让出终端,进入系统后台运行,并且其父进程成为1号进程(被1号进程领养)
补充:1号进程,在centos7之前,叫做init进程.在centos7之后叫做systemd进程,系统中父进程是1号进程的进程通常都会以d结尾,表示自己在后台默默运行服务.
(3).守护进程(精灵进程)
概念: 孤儿进程是因为父进程异常结束,然后被1号进程init收养
守护进程是创建守护进程时有意把父进程结束,然后被1号进程init收养.
区别:虽然它们都会被init进程收养,但是它们是不一样的进程. 守护进程会随着系统的启动默默在后台运行,周期的完成某些任务或者等待某个事件的发生,直到系统关闭守护进程才会结束.
孤儿进程则不是,会因为完成使命后结束运行.
二.fork函数
在Linux中,fork函数是非常重要的函数.从一个已存在的进程中创建一个新进程.新进程为子进程,而原进程称为父进程.
fork函数原型:
#include <unistd.h>
pid_t fork(void);
//pid_t是宏定义,本质是int类型
进程就是PCB,创建一个新的进程就是创建一个PCB,复制调用fork的这个进程PCB的信息(内存指针,程序计数器,上下文数据)
父子进程的区分: 在父进程中返回子进程的pid,是大于0的.在子进程中返回0.若是返回-1,则表示创建子进程失败. 通俗的理解:其实就相当于链表,进程形成了链表,父进程的fork函数返回的值指向子进程的进程id,因为子进程没有子进程,所以子进程的fork函数返回值为0.
fork函数为什么是一次调用,两次返回呢?
由于在复制时复制了父进程的堆栈段,所以两个进程都停留在fork函数中,等待返回.因此fork函数会返回两次,一次是在父进程中返回,一次是在子进程中返回.这两次的返回值不同.
fork函数的三种返回值:
- 在父进程中返回新创建的子进程的进程ID
- 在子进程中,返回0
- 进程创建出现错误,则返回一个负值
子进程复制父进程,复制了PCB(虚拟地址空间,页表,文件描述符表,内存指针,上下文数据,程序计数器等)所以看起来父子进程除了个别数据(标识符)之外都是一样的,并且父子进程的数据指向的是同一块物理内存,所以看起来父进程有什么子进程就有什么.
但是进程之间要保持独立性(数据独立性),因此当子进程某一块空间中的数据即将发生改变,则为子进程重新开辟物理内存,将数据拷贝过去.
进程独立性:各自有各自的虚拟地址空间,映射各自的数据存储,进程之间没有交叉关系,不会受到其它进程的运行影响,就是为了保证进程的稳定运行.
写时拷贝:子进程创建出来后,与父进程映射访问同一块物理内存.但是当物理内存中的数据即将发生改变时,则重新为子进程开辟空间,拷贝数据过去.
使用写时拷贝的原因:
- 有些数据从来不会发生改变[代码段],重新开辟一块内存将数据拷贝过去,则会造成内存数据冗余
- 一般子进程创建成功后都会进行程序替换
写时拷贝的意义: 提高子进程创建效率,避免内存数据冗余
注意:代码共享,数据独有.
程序替换:
概念:重新加载另一个程序到内存中,然后将现有的pcb的内存指针指向新的程序(更新页表信息),则这个现有的pcb去调度新的程序.
exec函数簇:
#include <unistd.h> extern char** environ; int execl(const char* path,const char* arg,...); //path:带路径的程序文件名称,arg/...表示程序的运行参数,逐个赋予,最终以NULL结尾 int execlp(const char* file,const char* arg,...); //PATH环境变量制定了一些路径,execlp会去PATH环境变量指定的路径下查找程序文件 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 execve(const char* file,char* const argv[],char* const envp[]); l和v的区别:程序运行参数的赋值方式不同 有没有p的区别:新的程序文件名称是否需要带路径 有没有e的区别:是否自定义环境变量
注意:程序替换后,运行完替换的程序后就会退出,原先的程序在程序替换以后的代码都不会被运行.
三.fork函数进程创建的底层实现原理
当进程调用fork后,控制权转移到内核中的fork代码后,内核会做4件事情:
- 分配新的内存块和内核数据结构给子进程
- 将父进程部分数据内容拷贝至子进程
- 添加子进程到系统进程列表当中
- fork返回,开始调度器调度
底层实现更详细过程:
-
调用dup_task_struct() 为新进程创建一个内核栈,thread_intfo结构和task_struct,这些值与当前进程的值相同.此时,子进程和父进程的描述符是完全相同的.
-
检查并确保新创建这个子进程后,当前用户所拥有的进程数目没有超过它分配的资源限制
-
子进程着手使自己与父进程区分开来.进程描述符内的许多成员都要被清0或设置为初始值.那些不是继承而来的进程描述符成员,主要是统计信息.
-
子进程的状态被设置为不可中断休眠状态,以保证它不会投入运行
-
copy_process() 调用copy_flags()以更新task_struct的flags成员. 表明进程是否拥有超级用户权限的PF_SUPERPRIV标志被清0.
-
调用alloc_pid() 为新进程分配一个有效的pid
-
根据传递的clone()的参数标志,copy_process()拷贝或共享 打开的文件,文件系统信息,信号处理函数,进程地址空间和命名空间等.
-
最后,copy_process() 做扫尾工作并返回一个指向子进程的指针
四.vfork函数
函数原型:
pid_t pid = vfork();
同样是创建一个子进程,但是一个进程使用vfork创建子进程之后,vfork的调用并不会立即返回(通常会阻塞父进程),而是让子进程先运行,直到子进程退出或者进行程序替换后才能运行.
特殊之处:父子进程公用父进程的虚拟地址空间
在程序运行中,每调用一个函数,就会有一次函数压栈(函数调用栈).因为父子进程共用虚拟地址空间,使用了同一个栈.则父子进程同时运行就会造成调用栈混乱.因此让子进程先运行,直到子进程退出或者程序替换后有了自己的地址空间(在原有的地址空间中子进程的调用就出栈了)之后才能运行.
注意:vfork创建的子进程,不能在main函数中使用return退出.因为子进程使用return退出的时候释放了所有资源,父进程运行的时候资源是错误的,可能会导致直接报错或者产生死循环.