《内核设计与实现》读书笔记(三)- 进程管理

进程是所有操作系统的核心概念,同样在linux上也不例外。

主要内容:

  • 进程和线程
  • 进程的创建
  • 进程的终止

1. 进程和线程

1.1 进程

进程是处于执行期的程序以及相关的资源的总称。

线程是进程中活动的对象。内核调度的对象是线程,而不是进程。

进程和线程的管理操作(比如创建和销毁)都是由内核来实现的。

Linux中的进程于Windows相比是很轻量级的,而且不严格区分进程和线程,线程不过是一种特殊的进程。

所以下面只讨论进程,只有当线程与进程存在不一样的地方时才提一下线程。

 

进程提供2中虚拟机制:虚拟处理器和虚拟内存

每个进程有独立的虚拟处理器和虚拟内存,

每个线程有独立的虚拟处理器,同一个进程内的线程有可能会共享虚拟内存。

1.2 进程描述符和任务结构

内核把进程的列表存放在叫做任务队列(task list)的双向循环链表中。链表中的每一项都是类型为task_struct称为进程描述符(process descriptor)的结构中(include/linux/sched.h)

进程描述符中包含的数据能完整地描述一个正在执行的程序:它打开的文件,进程的地址空间,挂起的信息,进程的状态,还有其它很多信息。

内核通过一个唯一的进程标识值或PID来标识每个进程。

在内核中,访问任务通常需要获得指向其task_struct的指针。因为,内核中大部分处理进程的代码都是直接通过task_struct进行的。因此,通过current宏查找到当前正在运行进程的进程描述符的速度尤为重要。

1.3 进程状态

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

TASK_RUNNING:运行

TASK_INTERRUPTIBLE:可中断的

TASK_UNINTERRUPTIBLE:不可中断的

TASK_TRACED:被其他进程跟踪的进程

TASK_STOPPED:停止

1.3.1 进程状态转换

1.3.2 设置当前进程状态

内核经常要调整某个进程状态。这时最好使用set_task_state(task, state)函数:

set_task_state(task, state);  /* 将任务task的状态设置为state */

1.4 进程上下文

可执行程序代码是进程中的重要组成部分。这些代码从一个可执行文件载入到进程的地址空间执行。

一般程序在用户空间执行,但当一个程序执行了系统调用或处罚了某个异常,它就陷入了内核空间。这时,我们称内核“代表进程执行”并处于进程上下文中。

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

1.5 进程家族树

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

系统中的每个进程必有一个父进程,相应的,每个进程也可以拥有另个或多个子进程。拥有同一个父进程的所有进程称为兄弟。

进程间的关系存放在进程描述符中。每个task_struct都包含一个指向其父进程task_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现在指向当前的某个子进程*/

        };

init进程的进程描述符是作为init_task静态分配的。

2. 进程的创建

Linux系统的进程的创建于其他操作系统的进程创建(spawn机制)不同,Linux系统的进程创建分两个步骤执行:fork()和exec()

fork:通过拷贝当前进程创建一个子进程

exec:读取可执行文件并将其载入地址空间开始运行

2.1 写时拷贝

传统的fork()系统调用直接把所有的资源复制给新建的进程,这种实现效率低,因为它拷贝的数据也许并不共享。

为了提高效率,Linux的fork()使用写时拷贝(copy-on-write),只有在需要写入的时候,数据才会被复制,即资源的复制只有在需要写入的时候才进行,在此之前只是以只读方式共享。

fork()的实际开销就是复制父进程的页表以及给子进程创建唯一的进程描述符。

2.2 fork()

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

do_fork()完成创建中的大部分工作。该函数调用copy_process()函数。

 

copy_process()创建的流程:

 

  1. 调用dup_task_struct()为新进程分配内核栈、thread_info结构和task_struct等,其中的内容与父进程相同(描述符完全相同)。
  2. 检查并确保新建的子进程(进程数目是否超出上限等)
  3. 清理新进程的信息(比如PID置0等),使之与父进程区别开。
  4. 新进程状态置为 TASK_UNINTERRUPTIBLE
  5. 调用copy_flags()以更新task_struct的flags成员。
  6. 调用alloc_pid()为新进程分配一个有效的PID
  7. 根据clone()的参数标志,拷贝或共享相应的信息
  8. 做一些扫尾工作并返回一个指向子进程的指针

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

2.3 线程在Linux中的实现

线程机制提供了同一程序内共享内存的地址空间运行的一组线程。线程机制支持并发程序设计技术。

Linux实现线程机制是把所有的线程都当做进程来实现。

2.3.1 创建线程

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

比如,通过一个普通的fork来创建进程,相当于:clone(SIGCHLD, 0)

创建一个和父进程共享地址空间,文件系统资源,文件描述符和信号处理程序的进程,即一个线程:clone(CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND, 0)

2.3.2 内核线程

内核经常需要在后台执行一些操作,这种任务可以通过内核线程(kernel thread)完成。

在内核中创建的内核线程与普通的进程之间还有个主要区别在于:内核线程没有独立的地址空间,它们只能在内核空间运行。

而内核进程和普通进程一样,可以被调度,也可以被抢占。

3. 进程终止

进程的终止分为两部分来完成:进程终止时所需的清理工作(由do_exit()完成)和进程描述符的删除 

3.1 进程终止时的清理工作

大部分都要靠do_exit()来完成,其步骤是:

  1. 设置task_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_struct的exit_code成员中的任务退出码置为由exit()提供的退出代码,或去完成任何其它由内核机制规定的退出动作
  8. 调用exit_notify()向父进程发送信号,并把自己的状态设为EXIT_ZOMBIE
  9. 调用schedule()切换到新进程继续执行

子进程进入EXIT_ZOMBIE之后,虽然永远不会被调度,关联的资源也释放掉了,但是它本身占用的内存还没有释放,
比如创建时分配的内核栈,thread_info和task_struct结构等。这些由父进程来释放

3.2 进程描述符的删除

在调用了do_exit()后,子进程已经僵死不能再运行了,但系统还保留了它的进程描述符。这样是为了让系统有办法在子进程结束后仍能获得它的信息。

释放进程描述符时,release_task()会被调用(由父进程调用),release_task()函数完成以下工作:

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

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

 

从上面的步骤可以看出,必须要确保每个子进程都有父进程,否则这些孤儿进程会在退出时永远处于僵死状态,浪费内存,如果父进程在子进程结束之前就已经结束了会怎么样呢?

子进程在调用exit_notify()时已经考虑到了这点。

如果子进程的父进程已经退出了,那么子进程在退出时,exit_notify()函数会先调用forget_original_parent(),然后再调用find_new_reaper()来寻找新的父进程。

find_new_reaper()函数先在当前线程组中找一个线程作为父亲,如果找不到,就让init做父进程。(init进程是在linux启动时就一直存在的)

posted @ 2017-02-14 10:56  大海中的一粒沙  阅读(311)  评论(0编辑  收藏  举报