第三章 进程管理
3.1进程
1.进程
1)进程就是处于执行期的程序(目标码存放在某种存储介质上),但进程并不仅仅局限于一段可执行程序代码。通常进程还要包含其他资源,像打开的文件,挂起的信号,内核内部数据,处理器状态,一个或多个具有内存映射的内存地址空间及一个或多个执行线程。当然还包括用来存放全局变量的数据段等,实际上,进程就是正在执行的程序代码的实时结果,内核需要有效而又透明地管理所有细节。
2)执行线程,简称线程,是在进程中活动的对象,每个线程都拥有一个独立的程序计数器、进程栈和一组进程寄存器,内核调度的对象是线程,而不是进程,在传统的Linux系统中,一个进程只包含一个线程,但现在的系统中,包含多个线程的多线程程序司空见惯。Linux系统的线程实现非常特别:它对线程和进程并不特别区分,对Linux而言,线程只不过是一种特殊的进程罢了。
3)在现代操作系统中,进程提供两种虚拟机制:虚拟处理器和虚拟内存。有趣的是,注意在线程之间可以共享虚拟内存,但每个都拥有各自的虚拟处理器。
PS.在现代Linux内核中,fork()实际上是由clone()系统调用实现的。
程序通富哦exit()系统调用退出执行,父进程可以通过wait4()系统调用查询子进程是否终结。进程退出执行后被设置为将死状态,知道它的父进程调用wait()或waitpid()为止。
2.线程
执行线程,简称线程,是在进程中活动的对象。内核调度的对象是线程而不是进程。Linux将线程定义为特殊的进程。
3.虚拟处理器和虚拟内存
在现代操作系统中,进程提供两种虚拟机制:虚拟处理器和虚拟内存。
包含在同一个进程中的线程可以共享虚拟内存,但是每个都拥有各自的虚拟处理器。
4.几个函数
fork():创建新进程 exec():创建新的地址空间并把新的程序载入其中 clone():fork实际由clone实现 exit():退出执行 wait4():父进程查询子进程是否终结 wait()、waitpid():程序退出执行后变为僵死状态,调用这两个消灭掉。
3.2 进程描述符及任务结构
内核把进程的列表存放在叫做任务队列的双向循环链表中。链表中的每一项都是进程描述符。
进程描述符中包含的数据能完整地描述一个正在执行的程序,它打开的文件,进程的地址空间、挂起的信号、进程的状态等。
进程描述符的类型为task_struct,里面包含的数据有:
3.2.1 分配进程描述符
Linux通过slab分配器分配task_struct结构——能达到对象复用和缓存着色的目的。
slab分配器——动态生成,只需在栈底或者栈顶创建一个新的结构struct thread_info。
每个任务的thread_info结构在它的内核栈的尾端分配。
结构中task域中存放的是指向该任务实际task_struct的指针。
2.进程描述符的存放
内核通过一个唯一的进程标识值PID来标识每个进程。pid类型为pid_t,实际上就是一个int类型,最大值默认设置为32768,可以通过修改/proc/sys/kernel/pid_max来提高上限。pid存放在各自进程描述符中。
通过current宏查找到当前正在运行进程的进程描述符。x86中,在内核栈的尾端创建thread_info结构,通过计算偏移间接地查找task_struct结构。current通过current_thread_info()把栈指针的后13个有效位屏蔽掉,再从thread_info的task域中提取并返回task_struct的地址。
3.3.3 进程状态
进程描述符中的state域是用来描述进程当前状态的。共有五种状态,标志如下:
TASK_RUNNING(运行):进程是可执行的,或者正在执行,或者在运行队列中等待执行 TASK_INTERRUPTIBLE(可中断):进程正在睡眠/被阻塞 TASK_UNINTERRUPTIBLE(不可中断):睡眠/被阻塞进程不被信号唤醒 TASK_TRACED:被其他进程跟踪的进程 TASK_STOPPED(停止):进程停止执行;进程没有投入运行也不能投入运行。 接收到SIGSTOP、SIGTSTP、SIGTTIN、SIGTTOU等信号时,或者调试时收到任何信号,都可以进入这种状态。
3.2.4 设置当前进程状态
用set_task_state(task,state)函数。
set_task_state(task,state); //将任务task的状态设置为state
set_current_state(state)等价于set_task_state(current,state)
3.2.5 进程上下文
内核“代表进程执行”并处于进程上下文时,上下文中current宏是有效的,除非在此间隙有更高优先级的进程需要执行并由调度器做出了相应调整,否则在内核退出的时候,陈谷恢复在用户空间会继续执行。
程序执行系统调用或者触发异常后,会陷入内核空间,这时候内核代表进程执行,并且处于进程上下文中。
进程对内核的访问必须通过接口:系统调用和异常处理程序。
3.2.6 进程家族树
所有的进程都是pid为1的init进程的后代。
内核在系统启动的最后阶段启动init进程。
系统中的每一个进程必有一个父进程,可以拥有0个或多个子进程,拥有同一个父进程的进程叫做兄弟。
这种关系存放在进程描述符中,parent指针指向父进程task_struct,children是子进程链表。
获得父进程的进程描述符:
struct task_struct *my_parent = current->parent;
访问子进程:
struct task_struct *task; struct list_head *list; list_for_each(list, ¤t->children){ task = list_entry(list, struct task_struct, sibling); /* 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_each_process(task)宏,依次访问整个任务队列,每次访问任务指针都指向链表中的下一个元素。
struct task_struct *task; for_each_process(task){ /* 它打印出每一个任务的名称和PID */ printk("%s[%d] ",task->comm, task->pid); }
3.3 进程创建
- 一般操作系统产生进程的机制:
在新的地址空间创建进程、
读入可执行文件、
执行
- Unix的机制:fork()和exec()。
fork():通过拷贝当前进程创建一个子进程。子进程与父进程的区别仅在于PID,PPID和某些资源和统计量 exec():读取可执行文件并将其载入地址空间开始运行。
3.3.1 写时拷贝
1.写时拷贝是一种可以推迟甚至免除拷贝数据的技术,内核不复制整个进程地址空间,而是让父进程和子进程共享一个拷贝。
2.资源的复制只有在需要写入时才会进行,在此之前以只读方式读取。
3.fork的实际开销就是复制父进程的页表以及给子进程创建唯一的进程描述符。
3.3.2 fork()
Linux通过clone()系统调用实现fork()。
创建进程的大概步骤如下:
fork()、vfork()、__clone()都根据各自需要的参数标志调用clone()。 由clone()去调用do_fork()。 do_fork()调用copy_process()函数,然后让进程开始运行。 返回do_fork()函数,如果copy_process()函数成功返回,新创建的子进程被唤醒并让其投入运行。
调用alloc_pid()为新进程分配一个有效的PID。
一般内核会选择子进程首先执行。但一般不能如愿。这是Linux的轻量级决定的。让子进程会马上调用exec()函数,能够避免写时拷贝的额外开销。
3.3.3vfork()
除了不拷贝父进程的页表项之外,vfork()系统调用和fork()的功能相同。理想情况下不要调用vfork()。
注意:子进程作为父进程的一个单独的线程在它的地址空间里运行 ,父进程被阻塞,直到子进程退出或执行exec()。子进程不能向地址空间写入。
vfork()系统调用的实现是通过向clone()传递一个特殊标志来进行的。
调用copy_process()是,task_struct的vfor_done成员被设置为NULL。 执行do_fork()时,如果给定特定标志,则vfor_done会指向一个特定地址。 子进程先开始执行后,父进程不是马上恢复执行,而是一直等待,知道子进程通过vfor_done指针向它发送信号。 在调用mm_release()时,该函数用于进程退出内存地址空间,并且检查vfor_done是否为空,如果不为空,则会向父进程发送信号。 回到do_fork(),父进程醒来并返回。
3.4线程在Linux中的实现
线程机制是现代编程技术中常用的一种抽象概念,该机制提供了在同一程序内共享内存地址空间运行的一组线程,可以共享打开的文件和其他资源,支持并发程序设计,在多处理器系统上可以保证真正的并行处理。
Linux内核的角度来看并没有线程这个概念,它把所有线程都当做进程来实现,线程仅仅被视为一个与其他进程共享某些资源的进程。
对于Linux来说,线程只是一种进程间共享资源的手段。
3.4.1 创建线程
新建的进程和它的父进程就是流行的所谓线程。
线程的创建和普通进程的创建类似,只不过在调用clone()时需要传递一些参数标志来指明需要共享的资源:
clone(CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND, 0); 共享地址空间、文件系统资源、文件描述符和信号处理程序。
普通的fork:
clone(SIGCHLD, 0);
vfork():
clone(CLONE_VFORK | CLONE_VM | SIGCHLD, 0);
传递给clone()的参数标志决定了新创建进程的行为方式和父子进程之间共享的资源种类。
3.4.2 内核线程
内核线程:独立运行在内核空间的标准进程。内核线程没有独立的地址空间,只在内核空间运行,从来不切换到用户空间,可以被调度和被抢占。
内核线程只能由其他内核线程创建:
kthread_create()通过clone()系统调用创建内核线程后,处于不可运行状态,如果不通过wake_up_process()明确唤醒它,不会主动运行。 创建一个进程并让它运行起来 ,可以调用kthread_run()。 内核线程启动后就一直运行直到调用do_exit()退出,或者内核的其他部分调用kthread_stop()退出,传递给kthread_stop()的参数为kthread_create()函数返回的task_struct结构的地址。
3.5 进程终结
进程终结时,内核必须释放它所占有的资源并告知父进程。
进程终结的原因:一般是来自自身,发生在调用exit()系统调用时。
- 显式的调用
- 隐式的从某个程序的主函数返回
大部分依赖于do_exit()来完成。其中有几个重点:
…… 给子进程重新找养父(线程组中的其他线程或者init进程) 调用schedule()切换到新的进程 ……
进程不可运行并处于EXIT_ZONBIE退出状态,占用的所有内存就是内核栈、thread_info结构和task_struct结构。此时进程存在的唯一目的就是向它的父进程提供信息。
3.5.1 删除进程描述符
释放task_struct结构发生在父进程获得已终结的子进程信息并且通知内核不关注后,需要的系统调用是wait4():
挂起调用它的进程,直到其中的一个子进程退出,此时函数返回该子进程的PID。
释放进程描述符时,需要调用release_task()。
2.孤儿进程
如果父进程在子进程之前退出,必须有机制来保证子进程能找到一个新的父亲,否则这些成为孤儿的进程就会在退出时永远处于僵死状态。
可以用这两种方法解决:
在当前进程组找一个线程作为养父 让init成为它们的父进程。
操作步骤如下:
do_exit()中调用exit_notify() exit_notify()调用forget_original_parent() forget_original_parent()调用find_new_reaper() 遍历所有子进程并为它们设置新的父进程。 调用ptrace_exit_finish()同样进行新的寻父过程,给ptraced的子进程寻找父亲。 init进程调用wait()来检查其子进程,清除所有与其相关的僵死进程。
当一个进程被跟踪时,它的临时父亲设定为调试进程。如果此时它的父进程退出了,系统会为它和它的所有兄弟重新找一个新父亲。可以通过在一个单独的被ptrace跟踪的子进程链表中搜索相关的兄弟进程——用两个相对较小的链表减轻遍历带来的小号。
一旦系统为进程成功地找到和设置了心得父进程,就不会再有出现驻留僵死进程的危险了。init进程会例行调用wait()来检查其子进程,清楚所有与其相关的僵死进程。