最近开始对 Linux 进行一次比较深入的学习,对于一个操作系统来说,提供运行程序的能力是其本质,而在 Linux 中,轻量、相应快速的进程管理也是其优良特性之一。我会分两篇文章介绍 Linux 进程。这是第一篇,重点在于 Linux 进程的描述、和生命周期,下一篇将介绍 Linux 下的进程调度。
描述符与任务结构
进程就是处于执行期的程序,而通常还包括部分资源,比如:文件、挂起的信号、内核内部数据、处理器状态、内存空间以及一个或多个执行线程(thread of execution)。因此,可以认为一个进程就是一段可执行程序和计算机资源的集合。
进程的描述
在 Linux 内核中,会有一个被称作任务队列(Task list)的双向循环链表,被用来保存进程的描述(后面会讲到 Linux 线程和进程不做区别,任务队列也会用来保存线程描述)。链表中的每一个元素为 task_struct
类型的结构体(定义在 linux/sched.h
中),task_struct
相对较大,在 32 位系统的机器上,大约有 1.7KB 的大小,该结构体中包含的数据能完整的描述一个正在执行的程序:打开的文件、地址空间、信号、状态等。
Linux 通过 slab 分配器 分配 task_struct
结构。在 2.6 之前,各个进程的 task_struct
存放在他们内核栈的尾端,这样可以使得 x86 等寄存器比较少的硬件体系结构只要通过栈指针就能计算出它的位置,而对于现在比较新的硬件体系上,会专门拿出一个寄存器指向 task_struct
,而只在进程内核栈的尾端保留一个 thread_info
(定义在 asm/thread_info.h
),其记录了执行线程的基础信息,并在 task 域中保留一个指向 task_struct
的指针。
PID
内核通过一个唯一的进程标识值(process identification value,PID)来表示每个进程,PID 是一个宏定义类型 pid_t
,实际上就是一个 short int
(为了保证兼容,采用 short 类型,最大值仅为 32768),对于 PID 最大有多大其实是受 linux/threads.h
中定义的限制,可以通过修改 /proc/sys/kernel/pid_max
来把 PID 增加至高达 400 万。但考虑到进程描述是存储在一个双向循环链表中的,进程越少意味着转一圈越快,进程数量巨大并不意味是一件好事。
进程的状态
在 task_struct
中,state
专门用来描述进程的当前状态,系统中的每个进程必然处于下面五种状态之一:
- TASK_RUNNING(运行)
- TASK_INTERRUPTIBLE(可中断)
- TASK_UNINTERRUPTIBLE(不可中断)
- __TASK_TRACED(被其他进程跟踪)
- __TASK_STOPPED(停止)
TASK_RUNNING 状态下意味着进程是可以被执行的,或许进程正在执行,也可能在运行队列中等待被执行(后面的进程调度会讲到),只有该状态下,进程才有被执行的可能。
TASK_INTERRUPTIBLE 状态意味着进程正在休眠,也许因为被阻塞,或者等待某些条件的达成,一旦这些条件达成,内核就会把进程状态设置为运行,处于此状态的进程也会因为接收到信号被唤醒并随时准备投入运行。
TASK_UNINTERRUPTIBLE 和 TASK_INTERRUPTIBLE 类似,但是不同点在于即使进程收到信号也不会被唤醒或者准备投入运行。该状态下的进程可以处于一个不受打扰的状态,以等待完成某些条件,比如进程创建初期,在没有初始化完成时便是处于该状态。
__TASK_TRACED 意味着被其他进程跟踪,比如通过调试程序进程跟踪等。
__TASK_STOPPED 意味着进程已经停止了执行,比如在收到 SIGSTOP,SIGTSTP,SIGTTIN,SIGTTOU 等信号,此外,在调试期收到任何信号都会进入该状态。更多进程的停止时发生的事情后面会讲到。
上下文与家族树
一般程序在用户空间执行,但当一个程序执行了系统调用或者触发了某个异常,它就会陷入内核空间,此时被称作内核 “代表进程执行” 并处于进程上下文中,一般情况下,在内核退出时,程序恢复到用户空间继续执行,系统调用和异常处理程序是对内核明确定义的接口,进程只有通过这些接口才能陷入内核执行,即对内核的所有访问都必须通过这些接口。在后面会单独拿一篇文章介绍系统调用。
Unix 系统的进程之间存在一个明显的继承关系,在 Linux 系统中也是如此,所有的进程都是 PID 为 1 的 init 进程的后代。而 init 进程是在系统启动的最后阶段启动的第一个进程。该进程负责读取系统的初始化脚本并执行其他的相关程序,以完成系统启动的最后过程。
除了 init 进程,系统的每个进程必有一个父进程,而每个进程也可以拥有零个到多个子进程,进程间呈现的树状关系,使得拥有相同父进程的两个子进程被称为 “兄弟”。进程的关系存放在进程描述符中,每个 task_struct
都包含一个指向其父进程 task_struct
的指针(被称为 parent 指针)。还包含一个称为 children 的子进程链表。也正是拥有这样的数据结构,可以从任意进程的 task_struct
遍历系统中存在的整个家族树。
进程的创建
Unix 系统的进程创建很特别,其他系统都提供了 Spawn 进程机制,首先在新的地址空间里创建进程,读入可执行文件,最好开始执行。这是一种符合直觉的进程创建方式,但是 Unix 采用了与众不同的方式,它把上述过程分成了两步(分成两个独立的函数),fork 和 exec。
首先 fork() 通过拷贝当前进程创建一个子进程,而子进程与父进程的区别仅在于 PID(进程 ID)、PPID(父进程的 PID)、部分资源和统计量(被挂起的信号等没必要继承的资源)。除此之外,新创建的子进程将直接使用父进程的资源(直接将相关指针指向父进程的资源)。而 exec() 函数负责读取可执行文件并将其载入地址空间开始运行。将这两个函数连起来使用可以达到其他系统单独使用 spawn 函数的作用。
Linux 的 fork() 使用写时拷贝(copy-on-wirte)页实现,这就意味着在创建子进程时可以推迟甚至免除拷贝父进程的数据。在 fork 时内核并不复制整个进程地址空间,而是让父进程和子进程共享同一个拷贝,只有到了需要写入的时候,数据才会被复制,从而使得各个进程拥有各自的拷贝。换句话说,资源的复制只会在需要写入的时候进行,在此之前,都是以只读的方式共享。因此 fork 的实际开销只有复制父进程的页表以及给子进程创建唯一的进程描述符。而且 Linux 做了一个巧妙的优化,在进程创建后会马上运行一个可执行文件,即先执行子进程,这样就避免了父进程对内存空间的修改导致触发写时拷贝。
Linux 通过 clone()
系统调用来实现 fork,而 fork()
vfork()
__clone()
函数都通过不同的参数来调用 clone()
系统调用,而 clone()
通过 do_frok()
(定义在 kernel/fork.c
)来完成 fork 绝大部分工作。
do_frok()
首选调用 copy_process
函数来拷贝进程,然后让进程开始运行,而 copy_process
函数即是 Linux 复制进程的具体实现:
copy_process
首先调用dup_task_struck()
为子进程创建内核栈、thread_info
结构和task_struct
,目前这些结构和内容与父进程完全一致。- 检查并确保创建子进程后,用户所拥有的进程数目没有超过分配的资源限额。
- 着手使子进程与父进程区别开来,进程描述符内的不能继承的成员被清零或者初始化。
- 将进程状态设置为 TASK_UNINTERRUPTIBLE 避免被投入运行。
- 调用
copy_flags()
更新task_struct
的标记变量,比如标明进程是否拥有超级用户权限的 PF_SUPERPRIV 被清零,标明进程没有调用 exec() 函数的 PF_FORKNOEXEC 被设置。 - 调用
alloc_pid()
获得一个有效的 PID - 根据
clone()
系统调用获得的参数来复制或者共享打开的文件、文件系统信息、信号处理函数、进程地址空间和命名空间等。 - 最后
copy_process
做些收尾工作并返回一个指向子进程的指针。
这时运行权又回到了 do_frok()
上,do_frok()
会检查 copy_process
返回的指针,并让子进程投入运行,正如上面所说,会优先让子进程投入运行,然后是父进程。至此,一个子进程的创建便完成了。
线程
线程机制是现代编程技术中常用的一种抽象概念,该机制提供了在同一程序内共享内存地址空间运行的同一组线程。这些线程还可以共享如打开的文件等其他资源,但是如上文提到的,从 Linux 内核的角度来看,并没有线程这个概念,这和 Linux 线程的实现有关。
Linux 没为线程准备特别的调度算法或定义特别的数据结构,而是被视为一个与其他进程共享某些资源的进程。每个线程都拥有唯一隶属于自己的 task_struct
,从内核的角度看,它就是一个普通的进程(只是线程和其他的一些进程共享某些资源而已)。
因此,Linux 的线程实现和 Windows 等其他操作系统的实现差距非常大,后者都在内核提供了专门的支持线程的机制(这些系统常把线程称为轻量级进程 lightweight processes)。”轻量级进程“ 这个说法本身就表现了 Linux 与其他系统的差异。其他系统中,相较于重量级进程,线程被抽象成一种耗费较少资源,运行迅速的执行单元,而在 Linux 中,进程本身就足够轻量,而线程只是进程间共享资源的手段而已。
因此,线程的创建也是通过 clone()
系统调用来实现,只不过在调用 clone()
时传递一些参数来标记需要共享的资源。
除了用户空间的线程外,内核经常需要在后台执行一些操作,这些任务一般是通过内核线程(kernel thread)完成。内核线程是独立运行在内核空间的标准进程,内核线程与普通进程的区别在于内核线程没有独立的地址空间(指向地址空间的 mm 指针为 NULL),它们只在内核空间运行,从不切换到用户空间去,而且内核进程和普通进程一样,可以被调度,可以被抢占。
在 Linux 可以通过 ps -ef
查看内核线程,内核线程只能通过内核线程创建,内核是通过从 kthreadd
内核进程中衍生出所有新的内核线程,其接口声明在 linux/kthread.h
中。内核线程启动后就一直运行直到调用 do_exit()
退出,或者由内核其他部分调用 kthread_stop()
退出,线程或者进程的终结,将在下面介绍。
进程的终结
当一个进程(线程)终结时,内核必须释放它所占有的资源并告知其父进程,而这两步便完成了资源释放和删除进程描述符。
释放资源
一般来说,进程的结束是由自身引起的,比如执行 exit()
。但无论是主动或者被动,进程结束时都要靠 do_exit()
函数(定义在 kernel/exit.c
)来 “处理身后事”:
- 将
task_struct
中的标记成员设置为 PF_EXITING - 调用
del_timer_sync()
删除内核定时器,以确保没有定时器在排队,也没有定时器处理程序在运行。 - 如果 BSD 的进程记账程序功能是开启的,则输出记账信息。
- 调用
exit_mm()
来释放进程占用的mm_struct
,如果没有别的进程共享它,便彻底释放。 - 调用
sem_exit()
,如果进程排队等候 IPC 信号,则使它离开队列。 - 调用
exit_file()
和exit_fs()
,以分别递减文件描述符、文件系统数据的引用计数,如果引用计数减低到零,则彻底释放。 - 把存放在
task_struct
的 exit_code 成员设置为exit()
函数提供的退出码。 - 调用
exit_notify()
向父进程发送信号,并给退出进程的子进程寻找养父(养父为线程组中的其他任意进程或者 init 进程),并把退出进程的进程状态设置为 EXIT_ZOMBIE,即僵死状态。 - 调用
schedule()
释放执行权,因为此进程已经设置为僵死状态,所以该进程再也不会被执行,do_exit()
永不会返回。
至此,进程的所有资源就已经全部被释放了,但是为了方便父进程查询退出进程的状态(退出码等),进程的 task_struct
会被保留,直到父进程执行 wait()
函数。
删除进程描述符
wait()
这一族函数都是通过唯一的系统调用 wait4()
实现的,它实现了挂起自己的进程,直到其中一个子进程退出,此时函数会返回该子进程的 PID。此外,调用该函数时提供的指针会包含子函数退出时的退出码。
当最终需要释放进程描述符时,release_task()
会被调用并执行下面的任务:
- 调用
__exit_signal()
,该函数调用_unhash_process()
,而后者又调用detach_pid()
,从 pidhash 上删除该进程,同时也会从任务列表中删除该进程。 _exit_signal()
释放目前僵死进程所使用的所有剩余资源,并进行最终统计和记录。- 如果这个进程是线程组的最后一个进程,并且领头进程已经死掉,那么继续通知僵死的领头进程的父进程。
- 调用
put_task_struct()
释放进程内核栈和thread_info
结构所占的页,并释放task_struct
所占的 slab 高速缓存。
至此,进程描述符和进程独享资源就全部释放掉了。
孤儿进程
如果父进程在子进程退出前退出,必须有机制保证子进程能找到一个新的父亲,否则这些成为孤儿的进程会在退出时永远处于僵死状态。系统会遍历进程所在线程组的其他进程寻找养父,如果线程组没有其他进程,他就会被挂在 init 进程上。
一旦系统为进程成功找到并设置了新的父进程,就不会再有出现驻留僵死进程的风险了,而 init 进程也会例行调用 wait()
来检查其子进程,清除所有与其相关的僵死进程。
总结
进程是一个非常基础和关键的抽象概念,位于每种现代操作系统的核心位置,本文通过介绍进程及其生命周期来描述其基本概念,下一篇会通过介绍进程的调度来描述进程运行时的样子。