【Linux内核】进程管理

时间:2022-07-23
本文章向大家介绍【Linux内核】进程管理,主要内容包括其使用实例、应用技巧、基本知识点总结和需要注意事项,具有一定的参考价值,需要的朋友可以参考一下。

文章目录
  • 简介
  • 执行线程
  • 两种虚拟机制
  • 进程描述符与任务队列
    • PID
    • 进程状态转化图
    • 进程级联
  • 进程创建过程
    • 写时拷贝
    • fork()
  • 线程在Linux中的实现
    • clone参数标志
    • 内核线程
  • 进程终结
    • 删除进程描述符
    • 孤儿进程与孤儿院

简介

进程是Unix操作系统最基本的抽象之一。一个进程就是处于执行期的程序(目标码存放在某种存储介质上)。但进程并不仅仅局限于一段可执行程序代码(Unix称其为代码段(textsection))。通常进程还要包含其他资源,像用来存放全局变量的数据段(text section)、打开的文件、挂起的信号等,当然还包含地址空间及一个 或几个执行线程(threads of execution)。

执行线程

执行线程,简称线程(threads),是在进程中活动的对象。每个线程都拥有一个独立的程序计数器、进程栈和一组进程寄存器。内核调度的对象是线程,而不是进程。在传统的Unix系统中,-一个进程只包含一个线程,但现在的系统大都支持多线程应用程序。稍后你会看到,Linux系统的线程实现非常特别,它对线程和进程并不特别区分。

两种虚拟机制

进程提供两种虚拟机制:虚拟处理器和虚拟内存。虽然实际上可能是许多进程正在分享一个处理器,但虚拟处理器给进程一种假象, 让这些进程觉得自己在独享处理器。而虚拟内存让进程在获取和使用内存时觉得自己拥有整个系统的所有内存资源。线程之间(这里是指包含在同一个进程中的线程)可以共享虚拟内存,但拥有各自的虚拟处理器。

进程描述符与任务队列

内核把进程存放在叫做任务队列(task list)的双向循环链表中。链表中的每一项都是类型为task struct,称为进程描述符( process descriptor)的结构,该结构定义在/include/linux/sched.h文件中。进程描述符中包含一个具体进程的所有信息。

PID

内核通过一个唯一的进程标识值( process identification value)或PID来标识每个进程。PID是一个数,表示为pid _t隐含类型,实际上就是个int类型。为了与老版本的Unix和Linux兼容、PID的最大值默认设置为32767 (short int短整型的最大值)。内核把每个进程的PID存放在它们各自的进程描述符中。

这个最大值很重要,它实际上就是系统中允许同时存在的进程的最大数目。尽管32767对于一般的桌面系统足够用了,但是大型服务器可能需要更多进程。如果确实需要的话,可以不考虑与老式系统的兼容,由系统管理员通过修改/proc/sys/kerne/pid max来提高上限。

在内核中,访问任务通常需要获得指向其task struct指针。实际上,内核中大部分处理进程的代码都是直接通过task_struct进行的。

进程状态转化图

进程级联

Linux进程之间存在一个明显的继承关系。所有的进程都是PID为1的init进程的后代。内核在系统启动的最后阶段启动init进程。该进程读取系统的初始化脚本( initscripts)并执行其他的相关程序,最终完成系统启动的整个过程。

系统中的每个进程必有一个父进程。相应的,每个进程也可以拥有一个或多个子进程。拥有同一个父进程的所有进程被称为兄弟。进程间的关系存放在进程描述符中。每个task_ struct都包含一个指向其父进程tast_ struct,叫做parent的指针,还包含一个称为children的子进程链表。所以,对于当前进程,可以通过下面的代码获得其父进程的进程描述符:

struct task_ struct *task = current->parent;

同样,也可以按以下方式依次访问子进程:

struct task struct *task;
struct list_ head *list;

list_for_each(list, &current->children) {
	task = list entry(list, struct task struct, sibling);
	/* task现在指向当前的某个子进程*/
}

init进程的进程描述符是作为init _task静态分配的。下面的代码可以很好地演示所有进程之间的关系:

struct task_struct *task;

for (task = current; task 1- sinittask; task = task->parent )
	;
/* task 现在指向init */

进程创建过程

Linux的进程创建很特别。 首先,fork(通过拷贝当前进程创建一个子进程。子进程与父进程的区别仅仅在于PID (每个进程唯一)、PPID(父进程的进程号,子进程将其设置为被拷贝进程的PID)和某些资源和统计量(例如挂起的信号,它没有必要被继承)。exec()函数负责读取可执行文件并将其载人地址空间开始运行。把这个两个函数组合起来使用的效果跟其他系统使用的单一函数的效果相似。

写时拷贝

传统的fork()系统调用直接把所有的资源复制给新创建的进程。这种实现过于简单并且效率低下。Linux的fork0使用写时拷贝 (copy on-write) 页实现。写时拷贝是种可以推迟甚至免除拷贝数据的技术。内核此时并不复制整个进程地址空间,而是让父进程和子进程共享同一个拷贝。只有在需要写人的时候,数据才会被复制,从而使各个进程拥有各自的拷贝。也就是说,资源的复制只有在需要写人的时候才进行,在此之前,只是以只读方式共享。这种技术使地址空间上的页的拷贝被推迟到实际发生写人的时候。在页根本不会被写人的情况下,举例来说, fork0后立即调用exec(),它们就无需复制了。fork的实际开销就是复制父进程的页表以及给子进程创建唯一的进程描述符。这种优化可以避免拷贝大量根本就不会被使用的数据(地址空间里常常包含数十兆的数据) 。因为在一般情况下, 进程创建后都会马上运行一个可执行的文件。由于Unix强调进程快速执行的能力,所以这个优化是很重要的。

fork()

Linux通过clone(系统调用实现fork()。这个调用通过系列的参数标志来指明父、 子进程需要共享的资源。fork(), vfork()和__clone()库函数都根据各自需要的参数标志去调用clone()。然后由clone()去调用do_ fork()。

do_ fork完成了创建中的大部分工作,它的定义在kernel/fork.c文件中。该函数调用copy_process()函数,然后让进程开始运行。copy_ precess()函数完成的工作很有意思:

●调用dup_task_struct()为新进程创建一个内核栈、thread__info结构和task._struct, 这些值与当前进程的值相同。此时,子进程和父进程的描述符是完全相同的。

●检查新创建的这个子进程后,当前用户所拥有的进程数目没有超出给他分配的资源的限制。

●现在,子进程着手使自己与父进程区别开来。进程描述符内的许多成员都要被清0或设为初始值。

●接下来,子进程的状态被设置为TASK_ UNINTERRUPTIBLE以保证它不会投入运行。

●调用copy_ flags()以更新task struct的flags成员。表明进程是否拥有超级用户权限的PF_SUPERPRIV标志被清0。表明进程还没有调用exec()函数的PF_FORKNOEXEC标志被设置。

●调用get_ pid()为新进程获取一个有效的PID。

●根据传递给clone()的参数标志,拷贝或共享打开的文件、文件系统信息、信号处理函数、进程地址空间和命名空间等。在一般情况下,这些资源会被给定进程的所有线程共享;否则,这些资源对每个进程是不同的,因此被拷贝到这里。

●让父进程和子进程平分剩余的时间片。

●最后,作扫尾工作并返回一个指向子进程的指针。

再回到do_ fork()函数,如果copy_ process()函数成功返回,新创建的子进程被唤醒并让其投入运行。内核有意选择子进程首先执行。因为一般子进程都会马上调用exec()函数,这样可以避免写时拷贝的额外开销,如果父进程首先执行的话,有可能会开始向地址空间写人。

线程在Linux中的实现

Linux实现线程的机制非常独特。从内核的角度来说,它并没有线程这个概念。Linux把所有的线程都当作进程来实现。内核并没有准备特别的调度算法或是定义特别的数据结构来表征线程。相反,线程仅仅被视为一个使用某些共享资源的进程。每个线程都拥有唯一隶属于自己的task_ struct, 所以在内核中,它看起来就像是一个普通的进程(只是该进程和其他一些进程共享某些资源,如地址空间)。

在其他的系统中,相较于重量级的进程,线程被抽象成一种耗费较少资源,运行迅速的执行单元。而对于Linux来说,它只是一种进程间共享资源的手段(Linux的进程本身就够轻了)。

线程的创建和普通进程的创建类似,只不过在调用clone()的时候需要传递一些参数标志来指明需要共享的资源:

clone参数标志

内核线程

内核经常需要在后台执行一些操作。这种任务可以通过内核线程(kernel thread)完成一独立运行在内核空间的标准进程。内核线程和普通的进程间的区别在于内核线程没有独立的地址空间(实际上它的mm指针被设置为NULL)。它们只在内核空间运行,从来不切换到用户空间。内核进程和普通进程一样,可以被调度,也可以被抢占。

Linux确实会把一些任务交给内核线程去做,像pdflush和ksofirqd这 些任务就是明显的例子。这些线程在系统启动时由另外一些内核线程启动。实际上,内核线程也只能由其他内核线程创建。在现有内核线程中创建一一个新的内核线程的方法如下:

int kernel thread(int (*fn)(void *),void *arg, unsigned long flags);

新的任务也是通过向普通的clone()系统调用传递特定的flags参数而创建的。在上面的函数返回时,父线程退出,并返回一个指向子线程task_ struct的指针。子线程开始运行fn指向的函数,arg是运行时需要用到的参数。一个特殊的clone标志CLONE KERNEL定义了内核线程常用到的参数标志: CLONE FS、CLONE FILES、 CLONE SIGHAND.大部分的内核线程把这个标志传递给它们的flags参数。

进程终结

当一个进程终结时,内核必须释放它所占有的资源并把这一不幸告知其父进程。

一般说来, 进程的析构发生在它调用exit()之后,既可能显式地调用这个系统调用,也可能隐式地从某个程序的主函数返回(其实C语言编译器会在main()函数的返回点后面放置调用exit()的代码)。当进程接受到它既不能处理也不能忽略的信号或异常时,它还可能被动地终结。不管进程是怎么终结的,该任务大部分都要靠do_ exit()来完成,它要做下面这些繁琐的工作:

●将tast struct中的标志成员设置为PF_EXITING,

●如果BSD的进程计账功能是开启的,要调用acctprocess()来输出计账信息。

●调用__cxit mm()丙数放弃进程占用的mm_ struct. 如果没有别的进程使用它们(也就是说,它们没被共享),就彻底释放它们。

●调用sem_ cxit(函数。 如果进程排队等候IPC信号,它则离开队列。

●调用_ cxit_ files()、_ cxit fs()、 exit_ namespace()和exit sighand(), 以分别递减文件描述符、文件系统数据,进程名字空间和信号处理雨数的引用计数。如果其中某些引用计数的数值降为零,那么就代表没有进程在使用相应的资源、此时可以释放。

●把存放在task struct的exit code成员中的任务退出代码置为exit()提供的代码中,或者去完成任何共他由内核机制规定的退出动作。

●调用exit_notify()向父进程发送信号,将子进程的父进程重新设置为线程组中的其他线程或init进程,并把进程状态设成TASK_ZOMBIE.

●最后,调用schedule()切换到其他进程。因为处于TASK_ ZOMBIE状态的进程不会再被调度,所以这是进程所执行的最后一段代码。

●do_exit()的实现在kencl/exit.c文件中可以找到。

至此,与进程相关联的所有资源都被释放掉(假设进程是这些资源的唯一使用者)。进程不可运行(实际上也没有地址空间让它运行)并处于TASK_ZOMBIE状态。它占用的所有资源就是保存thread_info的内核栈和保存tast_struct结构的那小片slab。 此时进程存在的唯一目的就是向它的父进程提供信息。

删除进程描述符

在调用do_exit()后,尽管线程已经僵死不能再运行了,但是系统还保留它的进程描述符。前面说过,这样做可以让系统有办法在子进程终结后仍能获得它的信息。因此,进程终结时所需的清理工作和进程描述符的删除被分开执行。在父进程获得已终结的子进程的信息后,子进程的task_ struct结构才被释放。

wait()这一族函数都是通过唯一,但是很复杂)的一个系统调用wait4()实现的。它的标准动作是挂起调用它的进程,直到其中的一个子进程退出,此时的函数会返回该子进程的PID.此外,调用该函数时提供的指针会包含子函数退出时的退出代码。

当最终需要释放进程描述符时,release_task()会 被调用,用以完成以下工作:

●调用free_uid()来减少该进程拥有者的进程使用计数。Linux用一个单用户高速级存统计和记录每个用户占用的进程数目、文件数目。如果这些数目都将为0,表明这个用户没有使用任何进程和文件,那么这块缓存就可以销毁了。

●调用unhash_process()从pidhash上删除该进程,同时也要从task_list中删除该进程。

●如果这个进程正在被ptrace跟踪,将跟踪进程的父进程重设为其最初的父进程并将它从ptract_list上删除。

●最后,调用put_task_struct()释 放进程内核栈和thread_ info结 构所占的页,并释放tast_struct所占的slab高速缓存。

至此,进程描述符和所有进程独享的资源就全部释放掉了。

孤儿进程与孤儿院

如果父进程在子进程之前退出,必须有机制来保证子进程能找到一个新的父亲,否则的话这些成为孤儿的进程就会在退出时永远处于僵死状态,白白的耗费内存。前面的部分已经有所暗示,对于这个问题,解决方法是给子进程在当前线程组内找一个线程作为父亲,如果不行,就让init做它们的父进程。

一旦系统给进程成功的找到和设置了新的父进程,就不会再有出现驻留僵死进程的危险了。init进程会例行调用wait()来等待其子进程,清除所有与其相关的僵死进程。