进程管理
进程是操作系统的基本概念,本节主要总结Linux内核如何管理进程:进程在内核中如何创建,消亡。
1.进程
进程是处于执行期的程序,但不仅包含可执行的程序代码,还包括其他资源:打开的文件、挂起的信号、内核内部数据、处理器状态、一个或多个具有内存映射的内存地址空间和执行线程以及存放全局变量的数据段等。
线程
执行线程,简称线程,是进程中活动的对象。拥有独立的程序计数器、进程栈和进程寄存器。内核调度的对象是线程而不是进程,在Linux中线程是一种特殊的进程。
2.进程描述符
内核把进程的列表存放在叫做任务队列(task list)的双向循环列表中(列表插入删除复杂度低)。列表的每一项类型都是task_struct
称为进程描述符(process description),进程描述符能够完整的描述一个正在执行的程序。
分配进程描述符
Linux通过slab分配task_struct
结构,在栈底(向下增长的栈)创建一个新的结构struct thread_info
用于存放task_struct
的偏移地址,这样方便定位task_struct
的实际指针。
进程描述符的存放
内核中大部分处理进程的代码都是直接访问task_struct
指针,通过current
宏查找当前正在运行进程的进程描述符。但是像x86寄存器较少,因此只能通过内核栈的尾端创建thread_info
来计算偏移地址查找task_struct
。
进程状态
进程描述符中的state
域描述了进程的当前状态。进程状态处于下列五种状态之一:
- TASK_RUNNING(运行)——进程可执行,处于执行中或者运行队列中等待
- TASK_INTERRUPTIBLE(可中断)——进程正在睡眠(被阻塞),等待某些条件达成。也可以通过接收信号提前被唤醒并随时准备投入运行
- TASK_UNITTERUPTIBLE(不可中断)——对信号不做相应,其余和可中断状态相同,通常用于重要且不能中断的进程
- __TASK_TRACED——被其他进程跟踪的进程,例如通过ptrace对调试程序进行跟踪
- __TASK_STOPPED(停止)——进程停止执行,进程没有投入运行也不能投入运行
设置当前进程状态
内核调整某个进程的状态,可以通过如下代码:
set_task_state(task,state);
或者
task->state = state;
设置当前状态,可以通过set_current_state(state)
或set_task_state(current,state)
进程上下文
一般程序在用户空间执行,一旦程序执行了系统调用或者触发某个异常,它就陷入内核空间(对应第一节内容)。除非在内核空间运行期间有更高优先级的进程需要执行并由调度器做出了相应的调整,否则在内核退出的时候,程序恢复在用户空间继续执行。
系统调用和异常处理程序是对内核明确定义的接口。进程只有通过这些接口才能陷入内核执行,对内核的所有访问必须通过这些接口。
进程家族树
Unix系统的进程之间存在明显的继承关系,Linux也是如此。内核在系统启动最后阶段执行了init
进程,该进程读取系统初始化脚本并执行其他相关程序,最终完成系统启动的整个过程,PID为1,所以所有进程都是init
的后代。因此每个进程标识符都有一个指向父亲的task->parent
指针,和子进程链表&task->children
。
由于任务队列是一个双向循环链表,我们可以通过下面两种方式分别获取前一个和后一个进程:
list_entry(task->tasks.next, struct task_struct, tasks)
和
list_entry(task->tasks.next, struct task_struct, tasks)
3.进程创建
许多操作系统进程创建过程为,首先在新的地址空间创建进程,读入可执行文件,最后执行。而Unix将上述两个步骤分解到两个单独的函数去执行:fork()
和exec()
。
首先,fork()
通过拷贝当前进程创建子进程,子进程与父进程区别仅仅在于PID和PPID和某些资源和统计量。
然后,exec()
负责读取可执行文件并将其载入地址空间运行。
写时拷贝
Linux的fork()
函数进行了一个优化,采用写时拷贝实现。在创建进程阶段,内核并不复制整个地址空间,而是让父进程和子进程共享同一个拷贝。
进程只有在需要写入时,才复制数据,这样将页拷贝推迟到写入阶段,可以使Linux进程快速启动,并且往往进程在fork()
之后会马上exec()
,不会有写入过程(这个优化过程还是相当机智,Linux快启动的灵魂!)
fork()
由前面介绍我们了解了进程需要fork()
拷贝父进程的信息,Linux通过clone()
系统调用实现fork()
,其功能主要通过cope_process()
函数实现:
- 调用
dup_task_struct()
为新进程创建一个内核栈,thread_info结构和task_struct,这些值与父进程完全相同 - 检查并确保创建子进程后,当前用户的进程数没有超过限制
- 区分子进程和父进程,讲进程描述符中许多成员清零或初始化(主要是统计信息),多数数据仍未修改
- 子进程的状态设置为TASK_UNINTERRUPTIBLE,保证其不会被运行
- 调用
copy_flags()
更新进程描述符的flag成员,表明是否拥有超级用户权限的标志PF_SUPERPRIV标志清零,表明进程没有调用exec()
函数的PF_FORKNOEXEC标志被设置。 - 调用
alloc_pid()
为新进程分配一个有效PID - 根据传递给
clone()
的参数标志,cope_process()
拷贝或共享打开的文件、文件系统信息、信号处理函数、进程地址空间和命名空间等。通常对于制定进程的线程,这些资源都是共享;否则,这些资源对每个进程都是不同的,往往需要拷贝到这里。 -
copy_process()
扫尾,并返回一个指向子进程的指针
一般内核会有意让子进程先执行,减小写时拷贝可能的开销。
vfork()
对于vfork()
,其不拷贝父进程的页表项,子进程会作为父进程的一个线程执行,父进程被阻塞,直到子进程退出或者执行exec()
。子进程不能向地址空间写入。
4.线程在Linux中实现
Linux中线程只是共享父进程资源的轻量进程,其创建方式和普通进程类似,只是在调用clone()
时,需要传递一些参数标志位,表明需要共享的资源:
clone(CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND, 0);
而普通的进程为:
clone(SIGHLD, 0);
其中CLONE_VM
——父子进程共享地址空间;CLONE_FS
——共享文件系统信息;CLONE_FILES
——共享打开的文件;CLONE_SIGHAND
——共享信号处理函数和被阻断的信号;
5.进程终结
进程终结一般是自身引起的,它发生在进程调用exit()
系统调用时。当进程接收到它不能处理且不能忽略的信号或者异常时,也可能被动终结。不管什么原因终结,进程终结的大部分工作由do_exit()
完成:
- 将
task_struct
的标志成员设置为PF_EXITING - 调用
del_timer_sync()
删除任意内核定时器。根据返回结果,确保没有定时器在排队,也没有定时器处理程序在运行 - 若BSD的进程记账功能开启的,调用
acct_update_integrals()
来输出记账信息 - 调用
exit_mm()
函数释放进程占用的mm_struct
,若没有其他进程使用,就彻底释放 - 调用
sem_exit()
。若进程排队等候IPC信号,则它离开队列 - 调用
exit_files()
和exit_fs()
分别递减文件描述符、文件系统数据的引用次数,若为0,可以释放 - 接着把存放在
task_struct
的exit_code成员中的任务退出代码设置为由exit()
提供的退出代码,或者去完成其他由内核机制规定的退出动作。退出代码存放在这里供父进程随时检索 - 调用
exit_notify()
向父进程发生信号,给子进程重新找养父,养父为线程组中的其他线程或者init
进程,并设置task_struct
的exit_state
为EXIT_ZOMBIE - 调用
schedule()
切换到新进程
至此进程相关的所有资源都被释放掉了,并处于EXIT_ZOMBIE状态,仅剩内核栈、thread_info结构和task_struct结构用于给父进程提供信息。父进程检索信息后,或者通知内核那是无关信息后,将该内存释放,归还系统使用。