[Linux内核设计与实现]Linux进程管理


进程描述符及任务结构


进程是Unix操作系统最基本的抽象之一(另一个抽象是文件)。进程是正在执行的程序代码的活标本,不但包括正在执行的代码还包括其他资源。比如打开的文件、挂起的信号、内核内部数据、处理器状态、地址空间及一个或者多个执行线程(thread of executing)、存放全局变量的数据段等。执行线程简称线程,是在进程中活动的对象。Linux线程是一种特殊的进程(共享资源),内核调度的对象是线程而不是进程。进程提供两种虚拟机制:虚拟处理器和虚拟内存。虚拟处理器给进程一种假象,让其认为自己在独享处理器;而虚拟内存让进程在获取和使用内存时觉得自己拥有整个系统的所有内存资源。Linux中进程创建通过fork()系统调用实现。


内核把进程存放在叫做任务队列(task list)的双向循环链表中。链表中的每一项都是类型为task_struct、称为进程描述符(process descriptor)的结构,该结构定义在<linux/sched.h>文件中。进程描述符中包含一个具体进程的所有信息。Linux通过slab分配器(内存分配机制)分配task_struct结构,使得创建进程非常迅速。每个任务有一个thread_info结构,它在内核栈的尾部分配,结构中的task域中存放的是指向该任务实际task_struct的指针。thread_info结构在文件<asm/thread_info.h>中定义。

struct thread_info {
        struct task_struct      *task;          /* main task structure */
        struct exec_domain      *exec_domain;   /* execution domain */
        __u32                   flags;          /* low level flags */
        __u32                   status;         /* thread synchronous flags */
        __u32                   cpu;            /* current CPU */
        int                     preempt_count;  /* 0 => preemptable,
                                                   <0 => BUG */
        mm_segment_t            addr_limit;
        struct restart_block    restart_block;
        void __user             *sysenter_return;
#ifdef CONFIG_X86_32
        unsigned long           previous_esp;   /* ESP of the previous stack in
                                                   case of nested (IRQ) stacks
                                                */
        __u8                    supervisor_stack[0];
#endif
        int                     uaccess_err;
};

内核通过一个唯一的进程标识符(process identification value)或pid来标识每个进程。


进程包括下面五种状态:

  1. TASK_RUNNING(运行)进程是可执行的;它或者正在执行,或者在运行队列中等待执行。这是进程在用户空间中执行唯一可能的状态;也可以应用到内核空间中正在执行的进程
  2. TASK_INTERRUPTIBLE(可中断)进程正在睡眠(或者阻塞),等待某些条件的达成。一旦条件达成,内核就会把进程状态修改为运行。处于此状态的进程也会因为接收到信号而提前被唤醒并投入运行。
  3. TASK_UNINTERRUPTIBLE(不可中断)除了不会接收到信号而被唤醒从而投入运行外,这个状态和可中断状态相同。
  4. TASK_ZOMBLE(僵死)该进程已经结束了,但是其父进程还没有调用wait4系统调用。为了父进程能够获得它的信息,子进程的进程描述符仍然被保留着。一旦父进程调用了wait4,进程描述符就会释放。
  5. TASK_STOPPED(停止)进程停止执行;进程没有投入运行也不能投入运行。通常这种状态发生在接收到SIGSTOP SIGTSTP SIGTTIN SIGTTOU等信号的时候。此外,在调试期间接收到任何信号,都会使进程进入这种状态。


一般程序在用户空间执行,当一个程序执行了系统调用或者触发了某个异常,它就会陷入内核空间。此时,我们称内核“代表进程执行”并处于进程上下文中。除非在此间隙有更高优先级的进程需要执行并有调度器做出了相应的调整,否则在内核退出的时候,程序恢复到用户空间继续执行。Unix系统的进程之间存在一个明显的继承关系,在Linux系统中也是如此。所有的进程都是PID为1的init进程的后代。


进程创建

Unix采用两个步骤来创建进程:fork()和exec()。首先fork()通过拷贝当前进程创建一个子进程。子进程与父进程的区别仅仅在于PID、PPID和某些资源与统计量。exec()函数负责读取可执行文件并将其载入地址空间开始运行。


写时拷贝

Linux的fork()使用写时拷贝(copy-on-write)页实现。写时拷贝是一种可以推迟甚至避免拷贝数据的技术。内核此时并不复制整个进程地址空间,而是让父进程和子进程共享一个拷贝。只有在需要写入的时候,数据才会被复制,从而使各个进程拥有各自的拷贝。也就是说,资源的复制只有在需要写入的时候才进行,在此之前,只是以只读的方式共享。这种技术使地址空间的页的拷贝被推迟到实际发生写入的时候。在页根本不需要写入的情况下,数据就无需复制了。比如fork()之后,子进程立即执行exec()函数。fork()的实际开销就是复制父进程的页表以及给子进程创建唯一的进程描述符。这个优化对提高进程创建很有帮助。


fork()

Linux通过clone()系统调用实现fork()。这个系统调用通过一系列的参数标志来指明父、子进程所要共享的资源。fork()、 vfork()、 __clone()库函数都根据各自需要的参数标志去调用clone(),然后由clone()去调用do_fork()。do_fork()完成了创建进程的大部分工作,它的定义在kernel/fork.c文件中。该函数调用copy_process()函数,然后让进程开始运行。copy_process()函数完成以下功能:

  1. 调用dup_task_struct()为新进程创建一个内核栈、thread_info机构和task_struct,这些值与当前进程相同,此时,子进程与父进程的进程描述符是完全相同的。
  2. 检查新创建的这个子进程后,当前用户所拥有的进程数目没有超出给它分配的资源的限制。
  3. 现在,子进程开始着手使自己与父进程区别开来。进程描述符内的许多成员都要被清零或者设置为初始指。
  4. 接下来,子进程的状态被设置为TASK_UNINTERRUPTIBLE以保证它不会投入运行。
  5. copy_process()调用copy_flags()以更新task_struct的flags成员。表明进程是否拥有超级用户权限的PF_SUPERPRIV标志被清零。表明进程还没有调用exec()函数的PF_FORKNOEXEC标志被设置。
  6. 调用get_pid()为新进程获取一个有效的PID。
  7. 根据传递给clone()的参数标志,copy_process()函数拷贝或共享打开的文件、文件系统信息、信号处理函数、进程地址空间和命名空间等。在一般情况下,这些资源会被给定进程的所有线程共享;否则,这些资源对每个进程是不同的,因此被拷贝到这里。
  8. 让父进程和子进程平分剩余的时间片。
  9. 最后,copy_process()作扫尾工作并返回一个指向子进程的指针。


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


vfork()

vfork()系统调用和fork()的功能相同,除了不拷贝父进程的页表项。子进程作为父进程的一个单独的线程在它的地址空间里运行,父进程被阻塞,直到子进程退出或者调用exec()。子进程不能向地址空间写入。vfork()系统调用的实现是通过向clone()系统调用传递一个特殊标志来进行。

  1. 在调用copy_process()时,task_struct的vfork_done成员被设置为NULL。
  2. 在执行do_fork()时,如果给定特别标志,则vfork_done会指向一个特殊地址。
  3. 子进程开始执行后,父进程不是马上恢复执行,而是一直等待,直到子进程通过vfork_done指针向它发送信号。
  4. 在调用mm_release()时,该函数用于进程退出内存地址空间,并且检查vfork_done是否为空,如果不为空,则会向父进程发送信号。
  5. 回到do_fork(),父进程醒来并返回。


负责创建进程的函数的层次结构

负责创建进程的函数的层次结构


线程在Linux中的实现

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


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



进程终结

一般来说,进程的析构发生在它调用exit()之后,既可能显示的调用这个系统调用,也可能隐式的从某个程序的主函数返回(其实C语言编译器会在main()函数的返回点后面放置调用exit()的代码)。当进程接受到它既不能处理也不能忽略的信号或者异常时,它还能被动的终结。进程终结一般通过do_exit()来完成。

  1. 首先,将task_struct中的标志成员设置为PF_EXITING。
  2. 其次,调用del_timer_sync()删除任一内核定时器。通过返回的结果,它确保没有定时器在排队,也没有定时器处理程序在运行。
  3. 如果BSD的进程记账功能开启,do_exit()调用acct_process()来输出记账信息。
  4. 然后调用_exit_mm()函数放弃进程占用的mm_struct,如果没有别的进程使用它们,就彻底释放它们。
  5. 接下来调用exit__sem()函数。如果进程排队等候IPC信号,它则离开队列。
  6. 调用_exit_files() _exit_fs() exit_namespace()和exit_sighand(),以分别递减文件描述符、文件系统数据、进程名字空间和信号处理函数的引用计数。如果某些引用计数的数值降为0,那么就代表没有进程在使用这些相应的资源,此时可以释放。
  7. 接着把存放在task_struct的exit_code成员中的任务退出代码设置为exit()提供的代码中,或者去完成任何其他由内核机制规定的退出动作。退出代码存放在这里供父进程随时检索。
  8. 调用exit_notify()想父进程发送信号,将子进程的父进程重新设置为线程组中的其他线程或init进程,并把进程状态设成TASK_ZOMBLE。
  9. 最后,do_exit()调用schedule()函数切换到其他进程。

至此,与进程相关联的所有资源都被释放掉了,进程不可运行并处于TASK_ZOMBLE状态。它占用的资源就是内核桟、thread_info结构和task_struct结构。此时进程存在的惟一目的就是向它的父进程提供信息。


删除进程描述符

在调用了do_exit()之后,尽管线程已经僵死不能运行,但是系统还保留了它的进程描述符。父进程获得已经终结的子进程信息后,或者通知内涵它并不关注那些信息后,子进程的task_struct结构才会被释放。wait()这一族函数都是通过惟一的一个系统调用wait4()实现的。它的标准动作是挂起调用它的进程,直到其中的一个子进程退出,此时函数会返回该子进程的PID。当最终需要释放进程描述符时,release_task()会被调用,完成以下工作:

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

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

接下来,如果这个进程正在被ptrace跟踪,release_task()将跟踪进程的父进程重设为其初始的父进程并将它从ptrace list上删除。

最后,release_task()调用put_task_struct()释放进程内涵桟和thread_info结构所占的页,并释放task_struct所占的slab高速缓存。


至此,进程描述符和所有进程独享的资源就会全部释放掉了。如果父进程在子进程之前退出,系统会将子进程重新寻找一个父进程。这样系统中就不会出现孤儿进程。


实现进程销毁的函数的层次结构
实现进程销毁的函数的层次结构


小结

讨论了进程的一般特性,以及进程与线程之间的关系。然后讨论了Linux如何存放和表示进程(用task_struct和thread_info),如何创建进程(通过clone()和fork()),如何把新的执行映像装入到地址空间(通过exec()系统调用族),如何表示进程的层次关系,父进程又是如何收集其后代的信息(通过wait()系统调用族),以及进程最终如何死亡(强制或自愿的调用exit())。



参考资料:

《Linux内核设计与实现》,第二版

Linux 进程管理剖析

--------------------------------------------------------------------------------

2012-12-24  第一次完成

2013-1-3  增加进程创建与销毁引用图片



  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值