《Linux内核设计与实现》第三章读书笔记
第三章——进程管理
一、进程
-
进程就是处于执行期的程序(目标码存放在某种存储介质上),不仅局限于一段可执行程序代码,还包含其他资源,如打开的文件、挂起的信号、内核内部数据等。
-
进程提供两种虚拟机制:虚拟处理器和虚拟内存。
-
线程(执行线程)是在进程中活动的对象,拥有独立的程序计数器、进程栈和一组进程寄存器。
注意:内核调度的是线程而不是进程!
-
系统调用通过复制一个现有进程来创建一个全新的进程。调用fork()的进程为父进程,新产生的进程称为子进程。fork()系统调用从内和返回两次:一次回到父进程,一次回到新产生的子进程。
-
调用exec()函数创建新的地址空间,把新的程序载入其中。
-
调用exit()退出程序执行,终结进程并释放占用资源。
-
父进程调用wait()或waitpid(),终止进程僵死状态。
二、进程描述符及任务结构
内核把进程的列表存放在任务队列的双向循环链表中。链表中每一项类型为task_struct,称为进程描述符的结构,定义在<linux/sched.h>文件中。进程描述符中包含一个进程的所有信息。
1. 分配进程描述符
slab分配器动态生成task_struct结构。只需在栈底(向下增长的栈)或栈顶(向上增长的栈)创建一个新的结构struct thread_info:
每个任务的thread_info结构在它的内核栈的尾端分配。结构中task域中存放的是指向该任务实际task_struct的指针。
2. 进程描述符的存放
-
内核通过唯一的进程标识值或PID(int型,默认最大值(系统中允许同时存在的进程的最大数目:32768))来标识进程。
-
current宏:查找当前正在运行进程的进程描述符。
-
current_thread_info()函数(把栈指针的后13个有效位屏蔽掉,计算thread_info的偏移)汇编代码:
movl $-8192, %eax //栈的大小为8KB,若为4KB,用4096 movl %esp, %eax current_thread_info() ->task; // current从thread_info()的task域中提取并返回task_struct的地址
3. 进程状态
进程描述符中的state域描述了进程的当前状态。共有五种进程状态:
-
运行TASK_RUNNING——进程是可执行的;或正在执行或等待执行。这是进程在用户空间中执行的唯一可能的状态。
-
可中断TASK_INTERRUPTIBLE——进程正在睡眠或阻塞,等待某些条件的达成。一旦这些条件达成,内核就会把进程状态设置为运行。出于此状态的进程也会因为接受到信号而提前被唤醒并随时准备投入运行。
-
不可中断TASK_UNINTERRUPTIBLE——通常在进程必须在等待时不受干扰或等待事件很快就会发生时出现。出于此状态的任务对信号不作响应。
-
__TASK_TRACED——被其他进程跟踪的进程,例如通过ptrace对调试程序进行跟踪。
-
停止__TASK_STOPPED——进程停止执行:进程没有投入或不能运行。在接受到SIGSTOP/SIGTSTP/SIGTTOU等信号或在调试期间接受到任何信号时进入此状态。
4. 设置当前进程状态
set_task_state(task,state);
/将任务task的状态设置为state;与set_current_state(state)含义相同;不设置内存屏障来强制其他处理器做重新排序时等价于task->state = state/
5. 进程上下文
可执行程序代码从一个可执行文件载入到进程的地址空间中执行。当一个程序调用执行了系统调用或触发某个异常,就陷入了内核空间——称内核“代表进程执行”并处于进程上下文中。在此上下文中current宏是有效的。
系统调用和异常处理程序是对内核明确定义的接口。进程只有通过这些接口才能陷入内核执行——对内核的所有访问都必须通过这些接口。
6. 进程家族树
(1)所有进程都是PID为1的init进程的后代。内核在系统启动的最后阶段启动init进程。
(2)每个进程必有一个父进程,拥有零个或多个子进程。拥有同一个父进程的所有进程被称为兄弟。
(3)每个task_struct包含一个指向其父进程task_struct(叫做parent)的指针和称为children的子进程链表。对于当前进程,获得其父进程的进程描述符:
struct task_struct *my_parent = current->parent;
(4)init进程的进程描述符是作为init_task静态分配的。任务队列是双向的循环链表,可获取前一个或下一个进程:
list_entry(task->tasks.next, prev/struct task_struct, tasks) //分别通过prev_task(task)和next_task(task)宏实现
(5)for_each_process(task)宏可依次访问整个任务队列。每次访问,任务指针指向链表中下一个元素。
三、进程创建
1. 写时拷贝
- 写时拷贝是一种可以推迟甚至免除拷贝数的技术。内核此时并不复制整个进程地址空间,而是让父进程和子进程共享同一个拷贝。
- 资源的复制只有在需要写入的时候才进行,在此之前只是以只读方式共享。即使地址空间上的页的拷贝被推迟到实际发生写入的时候才进行。
- Fork(0的实际开销就是复制父进程的页表以及给子进程创建唯一的进程描述符,一般情况下进程创建后都会马上运行一个可执行的文件,可避免拷贝大量根本就不会被使用的数据。
2. fork()
Linux通过clone()系统调用实现fork()。
fork()、vfork()和__clone()库函数调用clone(),clone()再调用do_fork()(定义在kernel/fork.c中),do_fork()在调用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标志被清0。表明金城还没有调用exec()函数的PF_FORKNOEXEC标志被设置。
(6) 调用alloc_pid()的为新进程分配一个有效的PID。
(7) 根据传递给clone()的参数标志,copy_process()拷贝或共享打开的文件、文件系统信息、信号处理函数、进程
地址空间和命名空间等。
(8) copy_process()做扫尾工作并返回一个指向子进程的指针。
函数成功返回后,新创建的子进程被唤醒并投入运行。
3. vfork()
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中的实现
1. 创建线程
clone(CLONE_VM | CLONE_FS | CLONE_FILRS | CLONE_SIGHAND, 0);
父子俩共享地址空间、文件系统资源、文件描述符和信号处理程序。
传递给clone()的参数标志决定了新创建进程的行为方式和父子进程之间共享的资源种类。
2.内核线程
-
内核线程和普通进程都可以被调度和被强占,而区别在于内核线程没有独立的地址空间(实际上指向地址空间的mm指针被设置为NULL)。
-
运行ps –ef命令可以看到内核线程。内核线程只能由其他内核线程创建。
从现有内核线程中创建一个新的内核线程:struct task_struct *kthread_creat(int (*threadfn)(void *data), void *data, const char namefmt [], …)
-
新的任务是由kthread内核进程通过clone()系统调用而创建的。新的进程(不可运行状态)将运行threadfn函数,给其传递的参数为data。调用kthread_run()创建一个进程并让它运行起来:
struct task_struct *kthread_run(int (*threadfn)(void *data), void *data, const char namefmt [], …)
五、进程终结
1. 删除进程描述符
-
do_exit()后,系统还保留了它的进程描述符,唯一目的就是向父进程提供信息,当父进程获得已终结的子进程的信息后或者通知内核那是无关信息后,子进程的进程描述符才被释放。
-
wait()族函数都是通过唯一的系统调用wait4()实现——将调用它的进程挂起,直到其中一个子进程退出。
会返回该子进程PID,调用该函数时提供的指针会包含子函数退出时的退出代码。 -
释放进程描述符时,需要调用release_task()。
2. 孤儿进程造成的进退维谷
如果父进程在子进程之前退出,必须有机制来保证子进程能找到一个新的父亲否则这些成为孤儿的进程就会在退出时永远处于僵死状态,白白地耗费内存。前面的部分已经有所暗示于这个问题,解决方法是给子进程在当前线程组内找—个线程作为父亲,如果不行就让init做它们的父进程。
具体:
do_exit() -> exit_notify() -> forgrt_original_parent() -> find_new_reaper()
如果不行就让init做它们的父进程,遍历子进程为它们设置新的父进程,
init进程会例行调用wait()来检查其子进程,清除所有与其相关的僵死进程。