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

进程管理

1. 进程

(1) 进程

  1. 进程就是正在执行的程序代码的实时结果,程序本身并不是进程,进程是处于执行期的程序以及相关的资源的总称。

  2. 通常进程包含打开的文件、挂起的信号、内核内部数据、处理器状态、一个或多个具有内存映射的内存地址空间及一个或多个执行线程,还包括用来存放全局变量的数据段等。

  3. 可能存在多个不同的进程执行同一个程序,并且两个或两个以上并存的进程还可以共享许多诸如打开的文件、地址空间之类的资源。

(2) 线程

  1. 执行线程,简称线程(thread), 是在进程中活动的对象。
  2. 每个线程都拥有一个独立的程序计数器、进程栈和一组进程寄存器。
  3. 内核调度的对象是线程,而不是进程。对Linux而言,线程是一种特殊的进程。

(3) 虚拟机制

进程提供两种虚拟机制:虚拟处理器和虚拟内存。

  1. 虚拟处理器让实际上可能是正在分享一个处理器的许多进程觉得自己在独享处理器。
  2. 虚拟内存让进程在分配和管理内存时觉得自已拥有整个系统的所有内存资源。
  3. 同一进程中的线程之间可以共享虚拟内存,但每个都拥有各自的虚拟处理器。

(4) 进程的创建

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

  2. 接着调用exec()就可以创建新的地址空间,并把新的程序载入其中。

  3. 最终,程序通过exit()系统调用退出执行。这个函数会终结进程并将其占用的资源释放掉。

  4. 父进程可以通过wait4()系统调用查询子进程是否终结,这使得进程拥有了等待特定进程执行完毕的能力。进程退出执行后被设置为僵死状态,直到它的父进程调用wait()waitpid()为止。

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

(1) 进程描述符

内核把进程的列表存放在叫做任务队列的双向循环链表中。链表中的每一项都是类型为task_struct、称为进程描述符的结构,该结构定义在<linux/sched.h>文件中。进程描述符中包含一个具体进程的所有信息,包括:它打开的文件,进程的地址空间,挂起的信号,进程的状态,还有其他更多信息。
在这里插入图片描述
(2) 分配进程描述符

Linux通过slab分配器分配task_struct结构,达到对象复用和缓存着色的目的品(通过预先分配和重复使用task_struct,可以避免动态分配和释放所带来的资源损耗),只需在栈底(对于向下增长的栈来说)或栈顶(对于向上增长的栈来说)创建一个新的结构thread_info。结构中task域中存放的是指向该任务实际task_struct的指针。
在这里插入图片描述
(3) 进程描述符的存放

  1. 内核通过一个唯一的进程标识值或PID来标识每个进程,进程的PID存放在进程描述符中。PID是一个数, 表示为pid_t隐含类型,实际上就是一个int类型。为了与老版本兼容,PID的最大值默认设置为32768(short int的最大值), 这个值也可以增加到高达400万(这受<linux/threads.h>中所定义PID最大值的限制)。这个最大值就是系统中允许同时存在的进程的最大数目。这个值越小,转一圈就越快。需要的话可以通过修改/proc/sys/kernel/pid_max来提高上限。
  2. 内核中大部分处理进程的代码都是直接通过task_struct进行的,通过current宏查找到当前正在运行进程的进程描述符。有的硬件体系结构可以拿出一个专门寄存器来存放指向当前进程task_struct的指针,用于加快访问速度。而像x86这样的体系结构就只能在内核栈的尾端创建thread_info结构,current把栈指针的后13个有效位屏蔽掉,用来计算出thread_info的偏移。该操作通过current_thread_info()来完成的。汇编代码如下:
movl $-8192, %eax  // 假定栈的大小为8KB。当4KB的栈启用时,就要用4096
andl %esp, %eax

​ 最后,current再从thread_infotask域中提取并返回task_struct的地址:

current_thread_info() ->task;

(4) 进程状态

进程描述符中的state域描述了进程的当前状态。系统中的每个进程都必然处于五种进程状态中的一种:

  1. TASK_RUNNING(运行)——正在执行,或者在运行队列中等待执行。这是进程在用户空间中执行的唯一可能的状态;
  2. TASK_INTERRUPTIBLE(可中断)——正在睡眠 (被阻塞),会因为接收到信号而提前被唤醒并随时准备投入运行;
  3. TASK_UNINTERRUPTIBLE(不可中断)——除了就算是接收到信号也不会被唤醒或准备投入运行外,这个状态与可打断状态相同。处于此状态的任务对信号不做响应,通常在进程必须在等待时不受干扰或等待事件很快就会发生时出现;
  4. __TASK_TRACED——被其他进程跟踪的进程,例如通过ptrace对调试程序进行跟踪;
  5. __TASK_STOPPED(停止)——进程停止执行。通常发生在接收到SIGSTOPSIGTSTPSIGTTINSIGTTOU等信号的时候。在调试期间接收到任何信号,都会使进程进入这种状态。
    在这里插入图片描述

(5) 设置当前进程状态

使用set_task_state(task, state)将任务task的状态设置为stateset_current_state(state)set_task_state(current, state)含义是等同的。(在SMP系统中,它会设置内存屏障来强制其他处理器作重新排序)

(6) 进程上下文

  1. 进程执行程序代码时代码从一个可执行文件载入到进程的地址空间执行,一般在用户空间。当执行系统调用或者触发了某个异常,它就陷入了内核空间,称内核“代表进程执行”并处于进程上下文中。在此上下文中current宏是有效的。除非在此间隙有更高优先级的进程需要执行并由调度器做出了相应调整,否则在内核退出的时候,程序恢复在用户空间会继续执行。
  2. 系统调用和异常处理程序是对内核明确定义的接口。进程只有通过这些接口才能陷入内核执行——对内核的所有访问都必须通过这些接口。
  3. 在中断上下文中,系统不代表进程执行,而是执行一个中断处理程序。不会有进程去干扰,所以此时不存在进程上下文。

(7) 进程家族树

  1. 所有的进程都是PID为1的init进程的后代。内核在系统启动的最后阶段启动init进程。该进程读取系统的初始化脚本(initscript)并执行其他的相关程序,最终完成系统启动的整个过程。init进程的进程描述符是作为init_task静态分配的。

  2. 拥有同一个父进程的所有进程被称为兄弟。进程间的关系存放在进程描述符中。每个task_struct都包含一个指向其父进程tast_struct、叫做parent的指针,还包含一个称为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, sibling);
        /* task 现在指向当前的某个子进程 */
}
  1. 对于给定的进程,获取链表中的下一个进程:
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_each_process(task)宏提供了依次访问整个任务队列 的能力。每次访问,任务指针都指向链表中的下一个元素:

struct task_struct *task;

for_each_process(task) {
        /* 它打印出每一个任务的名称和PID*/
        printk("%s[%d]\n",task->comm, task->pid);
}

3. 进程创建

进程创建时先在新的地址空间里创建进程,读入可执行文件,最后开始执行。Unix把上述步骤分解到两个函数中执行:fork()exec()fork()通过拷贝当前进程创建一个子进程。子进程与父进程的区别仅仅在于PID(每个进程唯一)、PPID(父进程的进程号)和某些资源和统计量(例如,挂起的信号,没有必要被继承);exec()函数负责读取可执行文件并将其载入地址空间开始运行。

(1) 写时拷贝

Linux的fork()使用写时拷贝页实现。内核此时并不复制整个进程地址空间,而是让父进程和子进程共享同一个拷贝。资源的复制只有在需要写入的时候才进行,在此之前,只是以只读方式共享。fork()的实际开销就是复制父进程的页表以及给子进程创建唯一的进程描述符。在一般情况下,进程创建后都会马上运行一个可执行的文件,这种优化可以避免拷贝大量根本就不会被使用的数据。

(2) 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. 进程描述符内的许多成员都要被清0或设为初始值。task_struct中的大多数数据都依然未被修改。
  4. 子进程的状态被设置为TASK_UNINTERRUPTIBLE,以保证它不会投入运行。
  5. 调用copy_fags()以更新task_structfags成员。表明进程是否拥有超级用户权限的PF_SUPERPRIV标志被清0。表明进程还没有调用exec()函数的PF_FORKNOEXEC标志被设置。.
  6. 调用alloc_pid()为新进程分配一个有效的PID。
  7. 根据传递给clone()的参数标志,copy_process()拷贝或共享打开的文件、文件系统信息、信号处理函数、进程地址空间和命名空间等。在一般情况下,这些资源会被给定进程的所有线程共享。
  8. 最后,做扫尾工作并返回一个指向子进程的指针。

再回到do_fork()函数,如果copy_process()函数成功返回,新创建的子进程被唤醒并让其投入运行。

(3) vfork()

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

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

4. 线程在Linux中的实现

(1) 创建线程

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

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

上面的代码产生的结果和调用fork()差不多,只是父子俩共享地址空间、文件系统资源、文件描述符和信号处理程序。新建的进程和它的父进程就是线程。

对比一下,一个普通的fork()的实现是:

clone(SIGCHLD,0);

而vfork()的实现是:

clone(CLONE_VFORK | CLONE_VM | SIGCHLD, 0);

传递给clone()的参数标志决定了新创建进程的行为方式和父子进程之间共享的资源种类。表3-1列举了这些参数标志以及它们的作用,这些是在<linux/sched.h>中定义的。
在这里插入图片描述
(2) 内核线程

  1. 内核通过内核线程(独立运行在内核空间的标准进程)在后台执行一些操作。内核线程和普通的进程间的区别在于内核线程没有独立的地址空间(实际上指向地址空间的mm指针被设置为NULL)。它们只在内核空间运行,不切换到用户空间去。内核进程可以被调度,也可以被抢占。

  2. 运行ps -ef命令可以看到很多内核线程,这些线程在系统启动时由另外一些内核线程创建。内核线程只能由其他内核线程创建,通过从kthreadd内核进程中衍生出所有新的内核线程来自动处理。在<linux/kthread.h>中申明有接口,从现有内核线程中创建一个新的内核线程的方法如下:

struct task_struct *kthread_create(int (*threadfn)(void *data),
                   void *data,
                   const char namefmt[], ...)

​ 新的任务是由kthread内核进程通过clone()系统调用而创建的。新的进程将运行threadfn函数。进程被命名为namefmtnamefmt接受可变参数列表类似于printf()的格式化参数。新创建的进程需要调用wake_up_process()唤醒后才可以运行。

​ 可以通过调用kthread_run()创建一个进程并运行:

struct task_struct *kthread_run(int (*threadfn)(void *data),
                   void *data,
                   const char namefmt[], ...)

kthread_run是以宏实现的:

#define kthread_run(threadfn, data, namefmt, ...)					   \
({																	   \
        struct task_struct *__k;									   \
        	= kthread_create(threadfn, data, namefmt, ## __VA_ARGS__); \
        if (!IS_ERR(__k))											   \
        	wake_up_process(__k);								   	   \
        __k;														   \
})
  1. 内核线程启动后就一直运行直到调用do_exit()退出,或者内核的其他部分调用kthread_stop()退出,传递给kthread_stop()的参数为kthread_create()函数返回的task_struct结构的地址。

5. 进程终结

(1) 释放进程的内存

一个进程终结时,内核必须释放它所占有的资源并告知其父进程。一般进程的析构发生在进程调用exit()系统调用时。当进程接受到它既不能处理也不能忽略的信号或异常时,也可能被动地终结。进程的终结大部分靠do_exit()(定义于kernel/exit.c)来完成:

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

至此,进程不可运行(实际上也没有地址空间科运行)并处于EXIT_ZOMBIE退出状态。进程向它的父进程提供信息,父进程检索到信息后,由进程所持有的剩余内存被释放。

(2) 删除进程描述符

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

在调用do_exit()之后,尽管线程已经僵死但是系统还保留了它的进程描述符,释放进程描述符时,release_task()会被调用:

  1. 调用exit_signal(),该函数调用_unhash_process(),后者又调用detach_pid()pidhash上和任务列表中删除该进程。
  2. _exit_signal()释放目前僵死进程所使用的所有剩余资源,并进行最终统计和记录。
  3. 如果这个进程是线程组最后一个进程,并且领头进程已经死掉,那么通知僵死的领头进程的父进程。
  4. 调用put_task_struct()释放进程内核栈和thread_info结构所占的页,并释放tast_struct所占的slab高速缓存。

(3) 孤儿进程寻找养父进程

如果父进程在子进程之前退出,需要给子进程在当前线程组内找一个线程作为父亲,如果不行,就让init做它们的父进程。在一个单独的被ptrace跟踪的子进程链表中搜索相关的兄弟进程——用子进程链表和ptrace子进程链表减轻遍历带来的消耗。当一个进程被跟踪时,它的临时父亲设定为调试进程。此时如果它的父进程退出了,系统会为它和它的所有兄弟重新找一个父进程:

do_exit()中会调用exit_notify(),该函数会调用forget_original_parent(),后者会调用find_new_reaper()来执行寻父过程。给子进程找到合适的养父进程后,只需要遍历所有子进程并为它们设置新的父进程,然后调用ptrace_exit_finish()ptraced的子进程寻找父亲。成功地找到和设置了新的父进程后,init进程会例行调用wait()来检查其子进程,清除所有与其相关的僵死进程。

注:本文摘自《Linux内核设计与实现(第三版)》
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值