Linux进程管理概述

1. 内核是什么

计算机真正工作的东西是硬件,数据运算需要CPU,数据存储需要硬盘,图形显示需要显卡,音乐播放需要声卡等等,那么如何控制这些硬件?这就是内核的工作,内核就是管理计算机硬件的软件。

2. 进程的概念

进程就是处于执行期的程序,但进程并不仅仅局限于一段可执行程序代码,也就是代码段,通常进程还包括很多其它的资源,像打开的文件,挂起的信号,内核内部数据,处理器状态,一个或多个具有内存映射的内存地址空间及一个或多个执行线程以及用来存放全局变量的数据段等。
现代操作系统的进程提供两种虚拟机制:虚拟处理器和虚拟内存。虚拟处理器是指虽然实际上可能是许多进程分享一个处理器,但是虚拟处理器给进程一个假象让这些进程觉得自己在独享处理器;虚拟内存机制则让进程在分配和管理内存的时候觉得自己拥有整个系统的所有内存资源。
linux支持多进程特性,可以最大化的使用cpu资源;用户可以在同一个cpu上运行多个用户程序。多进程的原理是:时钟中断触发进程调度程序,调度程序分时运行多个进程。这就要求每个进程能够保留现场信息(cpu现场、系统资源、调度信息等)。

3. 进程管理

3.1 进程描述符

进程存放在叫做任务队列(task list)的双向循环链表中。链表中的每一项包含一个具体进程的所有信息,类型为task_struct,称为进程描述符(process descriptor),
task_struct相对较大,在32位机器上,它大约有1.7KB。但如果考虑到该结构内包含了内核管理一个进程所需的所有信息,那么它的大小也算相当小了。每个进程都有一个进程描述符,记录以下重要信息:进程标识符PID、进程占用的内存区域、相关文件的文件描述符、进程环境、信号处理、同步处理等。

3.2 分配进程描述符

Linux通过slab分配器分配task_struct结构,这样能达到对象复用和缓存着色(cache coloring)的目的。在2.6以前的内核中,各个进程的task_struct结构存放在它们内核栈的尾端,这样做是为了让那些像x86那样寄存器较少的硬件体系结构只要通过栈指针就可以计算出它的位置,而避免使用额外的寄存器专门记录。现在则在栈底记录一个thread_info结构体,每个任务的thread_info结构在它的内核栈尾端分配,其实结构体内task域指向该任务实际的task_struct结构。

3.3 进程描述符的存放位置

由于系统需要频繁的获取当前进程描述符的地址,为了提高效率,linux巧妙的实现该功能,使用current宏可以快速得到当前进程地址。硬件体系不同,该宏的实现也不同,它必须针对专门的硬件体系结构做处理。有的硬件体系结构可以拿出一个专门的寄存器来存放指向当前进程task_struct的指针用于加快访问速度。而像x86这样的体系结构,其寄存器并不富余,就只能在内核栈的尾端创建thread_info结构,通过计算偏移间接查找task_struct结构。

3.4 进程状态

task_struct中的state描述进程的当前状态。进程的状态一共有5种,而进程必然处于其中一种状态:
1. TASK_RUNNING(运行)——进程是可执行的,它或者正在执行,或者在运行队列中等待执行。这是进程在用户空间中执行唯一可能的状态;也可以应用到内核空间中正在执行的进程。
2. TASK_INTERRUPTIBLE(可中断)——进程正在睡眠(也就是说它被阻塞)等待某些条件的达成。一旦这些条件达成,内核就会把进程状态设置为运行,处于此状态的进程也会因为接收到信号而提前被唤醒并投入运行。
3. TASK_UNINTERRUPTIBLE(不可中断)——除了不会因为接收到信号而被唤醒从而投入运行外,这个状态与可打断状态相同。这个状态通常在进程必须在等待时不受干扰或等待事件很快就会发生时出现。由于处于此状态的任务对信号不作响应,所以较之可中断状态,使用得较少。
4. TASK_ZOMBIE(僵死)——该进程已经结束了,但是其父进程还没有调用wait4()系统调用。为了父进程能够获知它的消息,子进程的进程描述符仍然被保留着。一旦父进程调用了wait4(),进程描述符就会被释放。
5. TASK_STOPPED(停止)——进程停止执行,进程没有投入运行也不能投入运行。通常这种状态发生在接收到SIGSTOP,SIGTSTP,SIGTTIN,SIGTTOU等信号的时候。此外,在调试期间接收到任何信号,都会使进程进入这种状态。
5种状态的转换关系如图1:
这里写图片描述
图1

3.5 进程家族树

Unix系统的进程之间存在一个明显的继承关系,在Linux系统中也是如此。所有的进程都是PID为1的init进程的后代。内核在系统启动的最后阶段启动init进程。该进程读取系统的初始化脚本并执行相关程序最终完成系统启动的整个过程。
系统中的每个进程必有一个父进程,相应的,每个进程也可以拥有零个或多个子进程。task_struct的real_parent指向父进程、parent指向“养父进程“。父进程是实际创建子进程的进程;而“养父进程”是负责处理子进程消亡的进程,在大多数情况下,父进程就是“养父进程“。但是也有例外,例如,当父进程比子进程提前消亡时,父进程会帮子进程重新寻找一个“养父进程”,通常是进程1。children成员是父进程的子进程链表;sibling成员是子进程的兄弟进程链表。

3.6 进程栈

linux系统为每个用户进程分配了两个栈:用户栈和内核栈。当一个进程在用户空间执行时,系统使用用户栈;当在内核空间执行时,系统使用内核栈。由于内核栈地址空间的限制,内核栈不会分配很大的空间。此外,内核进程只有内核栈,没有用户栈。
当进程从用户空间陷入到内核空间时,首先,操作系统在内核栈中记录用户栈的当前位置,然后将栈寄存器指向内核栈;内核空间的程序执行完毕后,操作系统根据内核栈中记录的用户栈位置,重新将栈寄存器指向用户栈。由于每次从内核空间中返回时,内核栈肯定已经使用完毕,所以从用户栈切换到内核栈时,只需要简单的将栈寄存器指向内核栈顶即可,不需要做什么特殊处理。

3.7 进程创建

3.7.1 概述

Unix的进程创建很特别。许多其他的操作系统都提供了产生进程的机制,首先在新的地址空间里创建进程,读入可执行文件,最后开始执行。Unix采用了与众不同的实现方法,它把上述步骤分解到两个单独的函数中去执行:fork()和exec()。首先fork()通过拷贝当前进程创建一个子进程。exec()函数负责读取可执行文件并将其载入地址空间开始运行。

3.7.2 写时拷贝

传统的fork()系统调用直接把所有的资源复制给新创建的进程。这种实现过于简单并且效率低下,因为它拷贝的数据也许并不共享,更糟的情况是,如果新进程打算立即执行一个新的映像,那么所有的拷贝都将前功尽弃。
Linux的fork()使用写时拷贝(copy-on-write)页实现。写时拷贝是一种可以推迟甚至免除拷贝数据的技术。内核此时并不复制整个进程地址空间,而是让父进程和子进程共享一个拷贝,只有在需要的时候,数据才会被复制,从而使各个进程拥有各自的拷贝。也就是说,资源的复制只有在需要写入的时候才进行,在此之前,只是以只读方式共享。这种技术使地址空间上的页的拷贝被推迟到实际发生写入的时候才进行,在页根本不会被写入的情况下他们就无需复制了。
fork()的实际开销就是复制父进程的页表以及给子进程创建唯一的进程描述符。在一般情况下,进程创建后都会马上运行一个可执行文件,这种优化可以避免拷贝大量根本就不会被使用的数据。

3.7.3 fork()和vfork()

fork()和vfork()都能创建进程,这两个函数最后都会去调用clone(),然后由clone()去调用do_fork()。do_fork()首先调用copy_process()创建新进程,然后调用wake_up_new_task()将进程放入运行队列中并启动新进程。
copy_process()主要完成以下工作:
1、参数标志检测,处理存在冲突或者依赖关系的参数标志组合;
2、不能为init进程创建兄弟进程,防止这些兄弟进程在退出时变为僵尸进程(进程0不会处理僵尸进程)。如果当前进程为init,则不能指定CLONE_PARENT参数标志;
3、调用dup_task_struct()为新进程创建一个task_struct结构、一个thread_info结构和内核栈(内核栈的末端就是thread_info结构,所以这两者共享了页结构),此时父子进程的描述符、内核栈内容完全一致(除了thread_info相关的内容);在内核栈与thread_info之间写入“内核栈防越界魔数”STACK_END_MAGIC(0x57AC6E9D);
4、判断当前用户所拥有的进程数是否超出最大值;
5、调用copy_flags()设置新进程的flags成员,表明进程是否拥有超级用户权限的PF_SUPERPRIV标志被清0,表明进程还没有调用exec()函数的PF_FORKNOEXEC标志被设置;
6、初始化新进程的孩子链表和兄弟链表指针指向新进程自己(即无孩子和兄弟进程);
7、设置新进程的时间为0;设置进程创建时间为当前系统时间;
8、进行调度程序相关的设置,设置运行状态为TASK_RUNNING,继承父进程的优先级值;
9、根据参数标志拷贝或共享文件系统、信号量、信号处理函数、打开的文件、地址空间、命名空间和cpu寄存器组等资源;默认情况下,子进程拷贝父进程的资源;
10、调用alloc_pid()为新进程分配一个有效的PID;
11、根据参数标志,设置子进程与父进程的关系以及与进程组的关系;
12、返回新进程的进程描述符。
总而言之,fork()的作用就是“复制”一份父进程及其资源,搭建好了一个进程运行时所需的必要环境;当调用exec()函数族时,才会将进程地址空间的内容替换为子进程的可执行文件。
除了不拷贝父进程的页表项之外,vfork()系统调用和fork()的功能相同。子进程作为父进程的一个单独的线程在它的地址空间里运行,父进程被阻塞,直到子进程退出或执行exec()。子进程不能向地址空间写入。在过去的3BSD时期,这个优化是很有意义的,那时没有写时拷贝,现在由于fork()引入了写时拷贝页并且明确了子进程先执行,vfork()的好处就仅限于不拷贝父进程的页表项了。如果以后fork()实现了写时拷贝页表项,vfork()就完全没用了。

3.8 进程终止

进程在运行结束,或接受到它既不能处理也不能忽略的信号,或异常时,都会被终结。此时,依靠do_exit()(在kernel/exit.c文件中)把与进程相关联的所有资源都被释放掉(假设进程是这些资源的唯一使用者)。至此,与进程相关的所有资源都被释放掉了。进程不可运行(实际上也没有地址空间让它运行)并处于TASK_ZOMBIE状态。它占用的所有资源就是内核栈、thread_info和task_struct。此时进程存在的唯一目的就是想它的父进程提供信息。在父进程获得已终结的子进程的信息后,或者通知内核它并不关注那些信息后,子进程持有的task_struct等剩余内存才被释放。
如果父进程在子进程之前退出,必须有机制保证子进程能找到一个新的父类,否则的话这些成为孤儿的进程就会在退出时永远处于僵死状态,白白的耗费内存。解决方法是给子进程在当前线程组内找一个线程作为父亲,如果不行,就让init做它们的父进程。

posted @ 2016-08-24 16:40  陆小呆  阅读(195)  评论(0编辑  收藏  举报