Linux内核对进程的实现

这里我们讨论一下Linux内核如何管理每个进程,进程在内核中如何产生,如何创建,最终又如何消亡。我们知道,拥有操作系统的最终目的就是运行用户程序,所以进程管理作为所有操作系统必不或缺的能力也是操作系统的心脏所在,Linux也一样。

1.进程

​ 所谓进程,就是正在处于执行期间的程序。程序就是目标代码存放在存储器中。实际上进程就是正在执行的程序代码的实时的结果,进程执行有很多细节,包括打开的文件,映射的内存地址空间等等,内核需要有效透明的管理所有的细节。

​ 执行线程,也叫线程,是在进程中活动的对象,每个线程都有自己的一个独立的程序计数器,进程栈和一组进程寄存器。内核在调度的时候调度的是线程而不是进程。在现在的系统中,包含多个线程的进程随处可见。

​ 现代操作系统中,进程会提供两种虚拟机制,虚拟处理器和虚拟内存。虽然是很多个进程正在分享一个处理器,但是虚拟处理器会给你一个假象,让这些进程觉得自己在独享一个处理器。而虚拟内存让进程在分配和管理内存的时候觉得自己拥有整个内存资源。值得注意的是(线程之间(同一进程内)可以共享虚拟内存,但每个都拥有各自的处理器);

​ 程序本身是静态的,不是进程,进程是正在执行的程序和他占用资源的总称。是动态的。实际上完全可以让两个进程执行同一个程序。多个进程还可以共享资源,比如打开的we年,地址空间等。

​ 无疑,进程在创建它的时刻开始存活。在Linux 系统中,这通常是调用fork()系统的结果,该系统调用通过复制一个现有进程来创建一个全新的进程。调用fork()的进程称为父进程,新产生的进程称为子进程。在该调用结束时,在返回点这个相同位置上,父进程恢复执行,子进程开始执行。fork()系统调用从内核返回两次:一次回到父进程,另一次回到新产生的子进程。

​ 通常,创建新的进程都是为了立即执行新的、不同的程序,而接着调用exec()这组函数就可以创建新的地址空间,并把新的程序载入其中。在现代 Linux内核中,fork()实际上是由clone()系统调用实现的,后者将在后面讨论。

​ 最终,程序通过exit()系统调用退出执行。这个函数会终结进程并将其占用的资源释放掉。父进程可以通过wait()系统调用查询子进程是否终结,这其实使得进程拥有了等待特定进程执行完毕的能力。进程退出执行后被设置为僵死状态,直到它的父进程调用wait()或waitpid()为止。

2.进程描述符和任务结构

​ 内核将进程列表存放在一个双向循环链表中,这个链表叫做任务队列。链表中的每一项都是一个名为task_struct的结构体。这个结构体称之为进程描述符。进程描述符中包含了一个具体进程的所有信息。这个进程描述符很大,里面的数据能完整的描述一个正在执行的程序,它打开的文件,进程的地址空间,挂起的信号,进程的状态。。。。。

2.1分配进程描述符

​ Linux通过slab分配器分配task_struct结构,这样能达到对象复用和缓存着色(cachecoloring)的目的。—>通过预先分配和重复使用task_struct,可以避免动态分配和释放带来的资源消耗。这样可以让系统的进程创建变得迅速。

​ 在2.6以前的内核中,各个进程的 task_struct存放在它们内核栈的尾端。这样做是为了让那些像x86那样寄存器较少的硬件体系结构只要通过栈指针就能计算出它的位置,而避免使用额外的寄存器专门记录。由于现在用slab分配器动态生成task_struct,所以只需在栈底(对于向下增长的栈来说)或栈顶(对于向上增长的栈来说)创建一个新的结构struct thread_info。

在这里插入图片描述

每个任务的thread_info结构在他的内核栈的尾端分配,结构中的task域存放的是指向该任务的实际的task_Struct指针。—>在应用层叫进程,在内核层进程一般就叫任务。

2.2进程描述符的存放

​ 内核通过一个唯一的进程标识值( process identification value)或PID来标识每个进程。PID是一个数,表示为pid_t隐含类型,实际上就是一个int类型。为了与老版本的Unix和Linux兼容,PID的最大值默认设置为32768 ( short int短整型的最大值),尽管这个值也可以增加到高达400万(这受<linux/threads.h>中所定义PID最大值的限制)。内核把每个进程的PID存放在它们各自的进程描述符中。

​ 这个最大值很重要,因为它实际上就是系统中允许同时存在的进程的最大数目。尽管32768对于一般的桌面系统足够用了,但是大型服务器可能需要更多进程。这个值越小,转一圈就越快,本来数值大的进程比数值小的进程迟运行,但这样一来就破坏了这一原则。如果确实需要的话,可以不考虑与老式系统的兼容,由系统管理员通过修改/proc/sys/kernel/pid_max来提高上限。

​ 在内核中,访问任务通常需要获得指向其task_struct的指针。实际上,内核中大部分处理进程的代码都是直接通过task_struct进行的。因此,通过current宏查找到当前正在运行进程的进程描述符的速度就显得尤为重要。硬件体系结构不同,该宏的实现也不同,它必须针对专门的硬件体系结构做处理。有的硬件体系结构可以拿出一个专门寄存器来存放指向当前进程task_struct的指针,用于加快访问速度。而有些像x86这样的体系结构(其寄存器并不富余),就只能在内核栈的尾端创建thread_info结构,通过计算偏移间接地查找task_struct结构。

2.3进程状态

​ 进程描述符中的state域描述了进程的状态。

​ 系统中的进程都必然处于5种进程状态中的一种,该域的值也必须是这5种状态标志之一。

  • TASK_RUNNING(运行)———>进程是可执行的;它或者正在执行,或者在运行队列中等待执行。这是进程在用户空间中执行的唯一可能的状态;这种状态也可以应用到内核空间中正在执行的进程。

  • TASK_INTERRUPTIBLE(可中断)——>进程正在睡眠(也就是说它被阻塞),等待某些条件的达成。一旦这些条件达成,内核就会把进程状态设置为运行。处于此状态的进程也会因为接收到信号而提前被唤醒并随时准备投入运行。

  • TASK_UNINTERRUPTIBLE(不可中断)———除了就算是接收到信号也不会被唤醒或准备投入运行外,这个状态与可打断状态相同。这个状态通常在进程必须在等待时不受干扰或等待事件很快就会发生时出现。由于处于此状态的任务对信号不做响应,所以较之可中断状态,使用得较少。

  • __TASK_TRACED———>被其他进程跟踪的进程,例如通过ptrace对调试程序进行跟踪。

  • __TASK_STOPPED(停止)———>进程停止执行;进程没有投入运行也不能投入运行。通常这种状态发生在接收到SIGSTOP、SIGTSTP、SIGTTIN、SIGTTOU等信号的时候。此外,在调试期间接收到任何信号,都会使进程进入这种状态。

运行态 可终端的等待态 不可中断的等待态 被跟踪状态 停止态

在这里插入图片描述

2.4设置当前的进程状态

​ 在内核中,经常需要调整某个进程的状态,这时最好使用 set_task_state(task, state) 函数

​ 这个函数可以将指定的进程设置成指定的状态,内部实现等价于 task->state = state

2.5进程上下文

​ 可执行程序的代码是进程的重要组成部分,这些代码从一个可执行文件载入到进程的地址空间中执行。一般程序在用户空间中执行,当进程执行了系统调用或者触发了某个异常,就会进入内核空间。这个时候就称之为,内核代表进程执行,并处于进程上下文中。除非是在这个过程有一个优先级更高的进程需要执行,并且调度器做出了相应的调整,否则内核退出的时候,程序会恢复在用户空间继续执行。

​ 系统调用和异常处理程序是内核明确定义的接口,进程只有通过这些接口才能进入内核空间执行,对内核的所有的访问都必须通过这些接口。

2.6进程家族树

​ Linux系统中,所有的进程都有明显的继承关系。在Linux中,所有的进程都是1号进程的后代。一号进程是init进程,PID号是1,所以叫1号进程,内核在启动的最后阶段会启动1号init进程,该进程会读取系统的初始化脚本,并执行相关的程序,最终完成整个系统启动的过程。

​ 由此可见,系统中的每一个进程都有一个父进程,每个进程也可以有一个或者多个子进程,由此诞生了兄弟进程的概念,也就是有同一个父进程的进程就是兄弟进程。进程之间的关系也会存放在进程描述符中,在进程描述符中,有一个parent指针,指向父进程的task_struct,也就是父进程的文件描述符。还有一个叫children的链表,里面存放的就是子进程。

​ 可以通过以下方式获得当前进程的父进程:

struct task_struct *my_parent = current->parent;

​ 也可以按照以下的方式去访问当前进程的子进程:

struct task_struct *task;
struct list_head *list;
list_for_each(list, &current->children){
	task = list_entry(list, struct task_struct, sibing);  
	/*task 指向某个子进程*/
}

​ init进程的进程描述符是init_task,是静态分配的,固定指定写死的。

​ 事实上,因为有继承关系的存在,可以从任意一个进程出发找到其他任意一个指定的进程,大多数时候,只需要通过简单的重复的方式就可以遍历系统中的所有进程。因为系统中的任务链表是一个双向循环链表。

//获取链表中的下一个进程:
list_entry(task->tasks.next,struct task_struct,tasks)

//获取前一个进程的方法与之相同:
list_entry(task->tasks.prev,struct task_struct, tasks)

//这两个例程分别通过next_task(task)宏和prev_task(task)宏实现。而实际上,for_eachprocess(task)宏提供了依次访问整个任务队列的能力。每次访问,任务指针都指向链表中的下一个元素:
struct task _struct *task ;
for_each_process (task){
    /*它打印出每一个任务的名称和PID*/
    printk ( "%s [%d] \n" , task- >comm,task->pid) ;
}

//特别提醒:
//在一个拥有大量进程的系统中通过重复来遍历所有的进程代价是很大的。因此,如果没有充足的理由(或者别无他法),别这样做。

3.进程的创建

​ 许多其他的系统都提供了产生进程的机制,首先在地址空间里面创建进程,然后读入可执行文件,最后执行。

​ 但是在Linux系统中,创建进程的过程将上面的步骤分解成两个函数中去执行,一个是fork()函数,一个是exec()函数(这里的exec函数指的是 exec 函数族)

​ 首先,fork通过拷贝当前进程创建一个子进程,子进程和父进程的区别仅仅在于PID不同 PPID不同,和某些资源的统计量不同(比如:挂起的信号 没有必要被继承)。然后。exec函数读取可执行文件并将其载入地址空间中开始运行。

3.1写时拷贝

​ 传统的fork()系统调用直接把所有的资源复制给新创建的进程。这种实现过于简单并且效率低下,因为它拷贝的数据也许并不共享,更糟的情况是,如果新进程打算立即执行一个新的映像,那么所有的拷贝都将前功尽弃。—>如果拷贝完成一个新的进程,这个进程根本没有对对数据的写入,那么这些拷贝就是没有意义的。

​ Linux 的fork()使用写时拷贝(copy-on-write) 页实现。写时拷贝是一种可以推迟甚至免除拷贝数据的技术。内核此时并不复制整个进程地址空间,而是让父进程和子进程共享同一个拷贝。

​ 只有在需要写入的时候,数据才会被复制,从而使各个进程拥有各自的拷贝。也就是说,资源的复制只有在需要写入的时候才进行,在此之前,只是以只读方式共享。这种技术使地址空间上的页的拷贝被推迟到实际发生写入的时候才进行。在页根本不会被写入的情况下 (举例来说,fork后立即调用exec) 它们就无须复制了。

​ fork()的实际开销就是复制父进程的页表以及给子进程创建唯一的进程描述符。在一般情况下,进程创建后都会马上运行一个可执行的文件,这种优化可以避免拷贝大量根本就不会被使用的数据(地址空间里常常包含数十兆的数据)。由于Unix 强调进程快速执行的能力,所以这个优化是很重要的。

3.2fork() / vfork() / clone()

​ Linux通过clone()系统调用实现fork()。这个调用通过一系列的参数标志来指明父、子进程需要共享的资源。fork()、vfork()和_clone ()库函数都根据各自需要的参数标志去调用clone(),然后由clone()去调用do_fork()。—>最终都是调用do_fork()函数,只是不同的调用方式,填写的参数不一样,拷贝的方式也不一样。

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

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

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

3 子进程着手使自己与父进程区别开来。进程描述符内的许多成员都要被清О或设为初始值。那些不是继承而来的进程描述符成员,主要是统计信息。task_struct中的大多数数据都依然未被修改。

4 子进程的状态被设置为TASK_UNINTERRUPTIBLE,以保证它不会投入运行。

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

6 调用alloc _pid()为新进程分配一个有效的PID。

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

8 最后 copy_process()做扫尾工作并返回一个指向子进程的指针。

再回到do_fork函数,如果copy_process函数成功返回,新创建的子进程被唤醒,并让其投入运行。内核有意选择子进程首先执行,因为一般子进程都会马上调用exec函数,这样可以避免写实拷贝的额外开销。—>但事实上,虽然内核希望子进程首先运行,但调度器并非每次都能得偿所愿。 如果是父进程首先执行的话,有可能会开始向地址空间中写入。

​ 除了不拷贝父进程的页表项外,vfork()系统调用和fork()的功能相同。子进程作为父进程的一个单独的线程在它的地址空间里运行,父进程被阻塞,直到子进程退出或执行exec()。子进程不能向地址空间写入。在过去的3BSD时期,这个优化是很有意义的,那时并未使用写时拷贝页来实现 fork()。现在由于在执行fork()时引入了写时拷贝页并且明确了子进程先执行,vfork()的好处就仅限于不拷贝父进程的页表项了。如果Linux将来fork()有了写时拷贝页表项,那么vfork()就彻底没用了。另外由于vfork()语意非常微妙(试想,如果exec()调用失败会发生什么),所以理想情况下,系统最好不要调用vfork(),内核也不用实现它。完全可以把vfork实现成一个普普通通的fork()——实际上,Linux 2.2以前都是这么做的。

​ vfork()系统调用的实现是通过向clone()系统调用传递一个特殊标志来进行的。

1 在调用copy_process()时,task_struct的vfor_done成员被设置为NULL。

2 在执行do_fork()时,如果给定特别标志,则vfork_done 会指向一个特定地址。

3 子进程先开始执行后,父进程不是马上恢复执行,而是一直等待,直到子进程通过vfork_done指针向它发送信号。

4 在调用mm_release()时,该函数用于进程退出内存地址空间,并且检查vfork_done是否为空,如果不为空,则会向父进程发送信号。

5 回到do_fork(),父进程醒来并返回。

如果一切执行顺利,子进程在新的地址空间里运行而父进程也恢复了在原地址空间的运行。这样,开销确实降低了,不过它的实现并不是优良的。

4.线程在Linux中的实现

​ 线程的机制提供了在同一程序内共享内存地址空间运行的一组线程,这些线程还能共享打开的文件和其他的资源,线程的机制支持并发程序设计,在多处理器系统中,线程也能保证真正的并行处理。

​ 在Linux中,其实本质上并没有线程这个概念,Linux上把所有的线程都当做进程来实现,内核并没有准备特别的调度算法或者是定义特别的数据结构来表征线程。相反,线程仅仅被视为一个与其他进程共享某些资源的进程。每个线程都拥有唯一隶属于自己的task_struct。所以在内核中,他看起来就想是一个普通的进程(只是线程和其他的一些进程共享资源,比如地址空间)

​ Linux中线程的实现机制和Windows操作系统或者是其他的操作系统实现的差异非常大,其他的操作系统都在内核中提供了专门支持线程的机制(这些系统常常把线程称作是轻量级的进程),而轻量级的进程这种叫法本省就概括了Linux在线程实现机制和其他的系统中的差别。在其他系统中,相较于重量级进程,线程被抽象成一种耗费资源较少,可以迅速执行的单元。而对于Linux来说,它只是一种进程间共享资源的手段,因为Linux进程本身就够轻量级的了。

​ 举个例子来说,假如我们有一个包含四个线程的进程,在提供专门线程支持的系统中,通常会有一个包含指向四个不同线程的指针的进程描述符。该描述符负责描述像地址空间、打开的文件这样的共享资源。线程本身再去描述它独占的资源。相反,Linux仅仅创建四个进程并分配四个普通的task_sturct结构。建立这四个进程时指定他们共享某些资源,这是相当高雅的做法。

4.1创建线程

​ 线程的创建和进程的创建是非常相似的,只不过在调用clone函数的时候需要传递一些参数标志来指明需要共享的资源。

clone(CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND, 0);

​ 上面的代码产生的结果和调用fork差不多,知识父子两共享地址空间,文件系统资源,文件描述符和信号处理程序。换个说法就是,新建的进程和他的父进程就是所谓的线程。

4.2内核线程

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

5.进程的终结

​ 当一个进程终结的时候,内核必须释放它所占有的资源,并且将进程被终结的消息告知它的父进程。

​ 一般来说进程的终结是自身引起的,当发生进程调用exit()的时候,既可以显示的调用这个这个系统调用,也可能隐式的从某个程序的主函数返回(C语言的编译器,会在函数的返回点后面放置调用exit的代码),当进程接收到他既不能捕获也不能忽略的信号或者异常的时候,他也有可能被动的终结,不管这个进程是怎么终结的,他最终都是要靠do_exit完成的:

1)将tast_struct中的标志成员设置为PF_EXITING。
2)调用del_timer_sync()删除任一内核定时器。根据返回的结果,它确保没有定时器在排队,也没有定时器处理程序在运行。
3)如果BSD的进程记账功能是开启的,do_exit()调用acct_update_integrals()来输出记账信息。
4)然后调用exit_mm()函数释放进程占用的mm_struct,如果没有别的进程使用它们(也就是说,这个地址空间没有被共享),就彻底释放它们。
5)接下来调用sem _exit()函数。如果进程排队等候IPC信号,它则离开队列。
6)调用exit_iles()和 exit_fs(),以分别递减文件描述符、文件系统数据的引用计数。如果其中某个引用计数的数值降为零,那么就代表没有进程在使用相应的资源,此时可以释放。
7)接着把存放在 task_struct的exit_code成员中的任务退出代码置为由exit()提供的退出代码,或者去完成任何其他由内核机制规定的退出动作。退出代码存放在这里供父进程随时检索。
8)调用exit_notify)向父进程发送信号,给子进程重新找养父,养父为线程组中的其他线程或者为init进程,并把进程状态(存放在task_struct结构的exit_state中)设成EXIT_ZOMBIE。
9)do_exit(调用schedule()切换到新的进程。因为处于EXIT_ZOMBIE状态的进程不会再被调度

5.1删除进程描述符

​ 再调用了do_exit之后,尽管进程已经不能再被运行了,但是系统依然保留了他的进程描述符。这样可以让系统有办法在子进程终结后任然能够获得他的信息,因此,进程终结是所需的清理工作和进程描述符的删除是被分开执行的。在父进程获得已终结的子进程的信息后,或者通知内核它并不关注那些信息之后,子进程的进程描述符task_struct才会被释放。

	wait()这一族函数都是通过唯一(但是很复杂)的一个系统调用wait4()来实现的。它的标准动作是挂起调用它的进程,直到其中的一个子进程退出,此时函数会返回该子进程的PID。此外,调用该函数时提供的指针会包含子函数退出时的退出代码。
	
	当最终需要释放进程描述符时,release_task()会被调用,用以完成以下工作:
	
1)它调用_exit_signal(),该函数调用_unhash_process(),后者又调用detach_pid()从pidhash上删除该进程,同时也要从任务列表中删除该进程。
2)_exit_signal()释放目前僵死进程所使用的所有剩余资源,并进行最终统计和记录。
3)如果这个进程是线程组最后一个进程,并且领头进程已经死掉,那么release_task()就要通知僵死的领头进程的父进程。
4) release_task()调用put_task_struct()释放进程内核栈和 thread_info结构所占的页,并释放tast_struct所占的slab 高速缓存。

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

5.2孤儿进程

如果父进程在子进程之前退出,必须有机制来保证子进程能找到一个新的父亲,否则这些成为孤儿的进程就会在退出的时候永远处于僵死状态,白白的耗费内存。解决这个问题的办法就死在当前线程组中找一个线程作为父亲,如果不行就让init进程做他们的父亲。

​ 在do_exit中会调用exit_notify 该函数会调用forget_original_parent,这个函数会调用find_new_reaper来执行寻父过程,这个函数中的代码试图找到进程所在的组内的其他进程,如果组内没有其他的进程,就找到并返回init进程。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值