第三章 进程管理
主要内容:
- 进程的定义及相关概念
- 内核如何管理进程
- 进程的列举、创建、消亡
- 进程管理是操作系统的心脏
3.1 进程
进程
(1)进程就是出于执行期的程序(目标码存放在某种存储介质上)。进程不局限于一段可执行代码,还包含其他资源(如打开的文件,挂起的信号,处理器状态等等)。
(2)进程定义:处于执行期的程序及相关资源的总称。
(3)内核调度的对象:线程
(4)线程是在进程中活动的对象,每个线程都拥有独立的程序计数器,进程栈,和一组进程寄存器。
(5)Linux线程很特别:对线程和进程不特别区分,线程是特殊的进程。
fork
(6)fork()系统:该
系统调用通过复制一个现有的进程来创建一个全新的进程。
- 父进程:调用fork的进程
- 子进程:新产生的进程
fork调用结束后,在返回相同位置上,父进程恢复执行,子进程开始执行。
(7)fork()系统调用从内核返回两次,一次回到父进程,一次回到子进程。
(8)创建进程的目的:立即执行新的、不同的程序,接着调用
exec()
函数创建新的地址空间,并把新的程序载入其中。
(9)fork()实际上是由clone()系统调用实现的。
(10)退出:通过exit()系统调用退出执行,该函数会终止进程并将占用的资源释放掉。
(11)父进程可以通过wait4()系统调用查看子进程是否终止,使进程拥有了等待特定进程执行完毕的能力。(shell进程中执行应用程序)
(12)进程退出执行后,处于僵死状态,直到它的父进程调用wait()或者waitpid()为止。
(13)别名:进程又称为任务。
3.2
进程描述符及任务结构
操作系统运行时会有很多进程,内核将多个进程存放在一个双向循环链表中,通过PCB来描述一个进程的所有信息,即进程描述符。它是一个结构体,里面
包含了一个进程的所有信息。如进程的状态,打开的文件,父进程等等。
3.2.1 分配进程描述符
Linux通过slab分配器来分配task_struct结构。在栈底或栈顶创建一个struct thread_info结构体。其中包含PCB的指针。
3.2.2 进程描述符的存放
(1) 内核通过PID来唯一的标识每个进程,pid是一个int数,最大值默认为32768(short),可以修改配置文件来调整,pid存放在进程各自的PCB中。
(2) 访问任务需要获取指向其task_struct的指针
(3) 通过current宏可以查看当前正在运行进程的进程描述符
(4) 指向当前进程task_struct的指针存放在:
- 专门的寄存器
- 栈尾的thread_info结构体中
3.2.3进程的状态
进程描述符PCB中的state域描述了进程当前的状态。
每个进程必然处于5种状态之一。
3.2.4 设置当前进程的状态
set_task_state(task,state)函数将指定的进程设置成指定的状态。
3.2.5 进程上下文
(1)可执行文件被载入到进程的地址空间,一般为用户空间。当程序执行了系统调用或者触发了某个异常,就陷入内核空间。此时我们称处于进程上下文中。
(2)
系统调用,异常处理程序 :是对内核明确定义的接口,程序只有通过这些接口才能陷入内核执行,对内核所有访问都必须通过这些接口。
3.2.6 进程家族树
(1)
所有进程都是PID为1的init进程的后代。
(2)内核在系统启动的最后阶段启动
init进程,该进程读取系统的初始化脚本并执行其他相关程序,最终完成系统启动的整个过程。
(3)每个进程必有一个父进程,每个进程也可以拥有0个或对个子进程。拥有同一个父进程的所有进程称为兄弟。进程间的关系存放在进程描述符中。
(4)每一个task_struct都包含一个指向父进程的task_struct,叫做partent的指针。还包含一个称为children的子进程链表。
(5)任务队列是循环双向链表,可以通过任一进程获取任一进程。
3.3 进程创建
(1)其他操作系统:在新地址空间里创建进程,读入可执行文件,最后开始执行。
(2)Unix分开成了两部:fork(),exec();
fork():拷贝当前进程创建一个子进程,子父进程的区别仅在于PID,PPID和某些资源和统计量(如挂起的信号,子进程没必要继承)。
exec():读取可执行文件,并将其载入地址空间,并开始运行。
3.3.1 写时拷贝
(1)传统fork():直接把所有资源复制给新创建的进程,简单但效率低
(2)linux-fork():通过写时拷贝页实现
(3)
写时拷贝:推迟甚至免除拷贝的一种技术,内核此时并不复制整个进程地址空间,而是让父子进程共享一个拷贝。只有在需要的时候,数据才会被复制,在此之前是以只读的方式共享。
(4)Unix强调进程的快速执行能力。
(5)进程创建后往往会马上执行一个可执行程序,这种优化可以避免拷贝大量根本不会被使用的数据,提高了进程的执行效率。
3.3.2 fork()
(1)Linux通过clone()实现fork(),这个调用通过一系列参数标志来指明父子进程需要共享的资源。
(2)fork(),vfork(),__clone()都根据自己需要的参数标志去调用clone(),然后由clone()调用do_fork()。
(3)do_fork()完成创建进程中的大部分工作,在kernel/fork.c中定义。该函数调用copy_process()函数,然后让进程开始运行。
(4)copy_process()函数:
3.3.3 vfork()
3.4 线程在Liuux中的实现
(1)线程和进程一样都是一种抽象概念
(2)线程机制提供了在同一程序内共享内存地址空间运行的一组线程,这些线程可以共享打开的文件和其他资源。
(3)由于线程的存在,在多处理器上可以实现真正的并行处理。
(4)Linux把所有线程都当做普通的进程来看,线程被视为一个与其他进程共享某些资源的进程。每个线程都拥有唯一属于自己的task_struct。只是线程与其他一些进程共享某些资源,如地址空间。
(5)对于Linux来说,线程只是一种进程间共享资源的一种手段。
3.4.1 创建线程
(1)通过clone()来创建进程,线程也一样通过clone(),但是在调用的时候传入一些标志来指明需要共享的资源:
clone(CLONE_VM |CLONE_FS | CLONE_FILES | CLONE_SIGHAND,0);
上面与调用fork()差不多,只是父子俩共享地址空间,文件系统资源,文件描述符和信号处理程序。
对比一下,一个普通fork()的实现是:
Clone(SIGCHLD,0);
(2)传递给clone()的参数标志,决定了新创建进程的行为方式和父子进程之间共享的资源种类。如下表:
3.4.2 内核线程
(1)内核线程:独立运行在内核空间的标准进程
(2)可以被调度和抢占
(3)ps –ef:查看内核线程
(4)内核线程的创建
3.5 内核终结
(1)当一个进程终结时,内核必须释放它所占有的资源,并通知父进程。
(2)最终通过do_exit()来终结进程。其工作为:
3.5.1 删除进程描述符
3.5.2 孤儿进程造成的进退维谷
补充学习:
(1)由进程的创建,来思考
Linux shell中应用程序执行流程?
执行应用程序的方式有很多,从shell中执行是一种常见的情况。交互式shell是一个进程(所有的进程都由pid号为1的init进程fork得到),当在用户在shell中敲入./test执行程序时:
父进程行为:
- shell先fork()出一个子进程(这也是很多文章中说的子shell)
- wait()这个子进程结束
- 当test执行结束后,又回到了shell等待用户输入(如果创建的是所谓的后台进程,shell则不会等待子进程结束,而直接继续往下执行)。
- 所以shell进程的主要工作是复制一个新的进程,并等待它的结束。
子进程行为:
- 调用execve()加载test并开始执行。这是test被执行的关键。
- 子进程通过execve系统调用启动加载器。加载器删除子进程已有的虚拟存储段,并创建一组新的代码、数据、堆、栈段,新的堆和栈被初始化为零。通过将虚拟地址空间中的页映射到可执行文件的页大小组块,新的代码和数据段被初始化为可执行文件的内容,最后将CUP指令寄存器设置成可执行文件入口,启动运行。
execve()是操作系统提供的非常重要的一个系统调用,在很多文章中被称为exec()系统调用(注意和shell内部exec命令不一样),其实在Linux中并没有exec()这个系统调用,exec只是用来描述一组函数,它们都以exec开头,分别是:
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 *constargv[]);
int execvp(const char *file, char *constargv[]);
int execve(const char *path, char *constargv[], char *const envp[]);
库函数exec*都是execve的封装例程。
(2)系统调用
(3)exec()函数
(4)init进程