本文的主要内容:一个进程从生到死的过程。
一、任务队列和 task_struct 任务描述符
Linux的“任务队列”是一个双向链表,链表中每一项为进程描述符task_struct
,它包含了一个正在执行的程序的完整信息:它打开的文件、进程的地址空间、挂起的信号、进程的状态等等。
- Linux 通过 slab 分配器分配 task_struct,并实现“对象复用”和“缓存着色”(“着色”是为了能重复使用 task_struct,类似于线程池中复用线程的设计)。
- “进程内核栈”并不直接管理 task_struct 结构体,而是将其指针放到一个
struct thread_info
这个新结构体中。查找时使用 current 宏先找到thread_info
,就能找到task_struct
指针从而确定当前正在执行的进程。 - 每个进程都有自己唯一的PID(Process Identification Value)。它的最大值决定了系统中允许同时存在的最大进程数,一般为
short int
的最大值 32768(位于<linux/threads.h>
,可以通过/proc/sys/kernel/pid_max
来修改)。 - 通过
set_task_state(task, state)
来设置进程的状态。
(一)进程上下文
用户进程执行系统调用(或中断异常)而“陷入内核空间”,此时内核代表进程执行,也就是处于“进程上下文”中。因此“进程上下文”包括两部分资源:
- 用户空间:虚拟内存空间(包括栈、堆、全局区、代码区等资源)。“用户空间资源的切换”本质上是“页表的切换”,这也是造成线程和进程之间性能的差异的关键。
- 内核空间:堆栈、寄存器等资源。“内核空间资源的切换”本质上是维护新的 PCB 控制块和程序计数器等资源。
“中断上下文”中,系统不代表进程执行,而是执行一个中断处理程序。不会有进程干扰这些中断处理程序,所以此时不存在进程上下文。
(二)进程家族树
Linux系统的进程之间存在一个明显的继承关系。
- 0号进程:
pid=0
的内核调度进程,在系统启动后,它负责 CPU 调度和其他低级任务。
这个进程通常是内核的一部分,而不是一个常规的用户空间进程,因此零号进程不能被杀死(kill)。
- 1号进程:用户空间的第一个进程,也是
init
或systemd
进程。由内核在系统启动的最后阶段启动init进程,负责初始化系统的其他部分、启动所有其他用户空间的进程。是用户空间所有孤立进程的父进程。
init 进程的描述符是单独静态分配的
init_task
,可以通过task ?= &init_task
判断当前指针是否执行 init 进程。
用户空间所有进程都是1号进程的后代。每个进程也可以拥有多个子进程。task_stuct
中有struct task_struct *parent
指向父进程;还有一个 children
子进程链表。
二、进程创建:fork调用
内核是如何fork出一个进程的
从源码的角度理解操作系统进程
Linux进程创建分解为 fork 和 exec 两步。fork 拷贝当前进程创建一个子进程,exec 读入可执行文件并载入地址空间开始运行(实际上还得调度器说了算)。
fork 的工作流程
fork, vfork, __clone
的底层都是 clone 系统调用,唯一的区分是传入的参数不同,从而执行不同的操作。
clone 会调用 do_fork
,再调用 copy_process
完成:
dup_task_struct
拷贝当前进程,包括内核栈、thread_info 和 task_struct,此时父子进程是完全相同的。- 区分父子进程。将子进程 task_struct 内的统计信息清零(比如挂起的信号就没必要继承)
- 子进程的状态设为
task_uninterruptible
。 - 更新子进程的 flags 标志。比如超级权限
PF_SUPERPRIV
、是否调用了 exec 函数PF_FORKNOEXEC
。 - 调用
alloc_pid
为子进程分配新的 PID,PPID 为父进程。 - 资源处理。根据传入的参数不同,拷贝或共享打开的文件、文件系统信息、信号处理函数、进程地址空间等。
- 完成扫尾工作,返回一个指向子进程的指针。
do_fork
收到copy_process
完成后,会唤醒子进程并优先让其运行。这是出于“写时拷贝策略”的考虑,如果父进程先执行可能会向地址空间写入数据,导致还需要另外的拷贝。
COW:只有在需要写入(修改数据)时,才会拷贝,使各自进程拥有自己的拷贝,否则就以只读方式共享。最大的优势就是 fork 的实际开销就是复制父进程的页表、给子进程创建唯一的进程描述符,确保进程能快速创建、执行。
vfork 最大的不同是:父进程会被阻塞,直到收到 vfork_done
信号(子进程退出或执行 exec)。这是个很老的设计了,因为原来没有COW策略,通过这种设计可以让子进程优先运行。并且 vfork 需要等待信号,如果等不到(exec 调用失败)那么父进程会被一直阻塞。
三、线程
线程在Linux中就是个共享某些资源的进程。因此线程在创建时,传入 clone 函数的参数指定了共享资源,比如clone_vm,clone_fs,clone_files,clone_sighand
等。
线程也是有数量限制的:因为每个线程都要占据堆栈空间,而物理内存不是无限的。可以使用ulimit -n
来查看。
内核线程为什么没有独立的地址空间?
内核线程是独立运行在内核空间的标准进程,并且它没有独立的地址空间(指向地址空间的 mm 指针被设置为 NULL)。
- 性能考虑:拥有独立地址空间意味着上下文切换(context switch)时需要更改页表和刷新 TLB(Translation Lookaside Buffer),这是一个相对耗时的操作。
- 简化设计:内核线程主要用于内核级任务,这些任务通常不需要访问用户空间数据,也不需要保护以防止其他进程或线程的干扰。因此,没有必要给它们分配独立的地址空间。
- 资源共享:内核线程需要访问全局内核数据结构,这些结构存在于内核地址空间中。如果内核线程有自己独立的地址空间,那么访问这些全局数据结构将变得更加复杂和耗时。
- 内核简洁和一致性:不使用独立的地址空间可以减少内核的复杂性,同时也更容易保证代码的一致性和可维护性。
- 目的和作用不同:用户空间进程和内核线程的目的和作用不同。用户空间进程用于执行用户级代码,而内核线程用于执行内核级任务。后者通常不需要像前者那样的隔离和保护。
- 内核状态共享:内核线程通常需要共享某种状态或资源,如文件描述符、内核参数等。如果每个内核线程都有自己的地址空间,这种共享将变得更加复杂。
它们只在内核空间运行,从不切换到用户空间中去。它们的父进程是 kthread
内核线程,也是通过 clone 系统调用完成创建。
四、进程终结
调用 exit()
结束进程,它的底层是 do_exit()
调用。过程如下:
- 设置
PF_EXITING
标志、删除内核定时器。 - 调用
exit_mm()
释放进程占用的 mm_struct。 - 释放资源。
exit_files(), exit_fs()
递减文件描述符、系统数据的引用计数(为零则可以释放)。 - 执行
exit_notify
通知父进程,寻找养父(线程组的其他线程或init进程),并设置状态为EXIT_ZOMBIE
,代表其用不被调用。此时它也就剩下:内核栈、thread_info 和 task_struct 信息了,这是给父进程清理用的。 - 调用
schedule
切换到新进程,do_exit
函数永不返回。
最后,父进程调用wait()
清理残余的(处于EXIT_ZOMBIE状态)进程描述符。
孤儿进程 & 僵尸进程
如果父进程先于子进程退出,那么这些子进程就变成了“孤儿进程”,如果没有新的“养父”进程来回收它们,这些资源就会一直占用,导致资源泄漏。
在子进程退出后,内核仍会保留子进程的一些信息(如退出状态)供父进程查询。如果父进程不调用 wait() 来获取这些信息,那么这些进程会变成“僵尸进程”。
统一将孤儿进程的父进程设置为 init 进程的优势:
- 可以简化进程管理的逻辑。init 进程(或者 systemd)被设计为能够正确处理这些孤儿进程,包括回收它们的资源和处理它们的退出状态。
- 确保进程状态的一致性。即便父进程退出了也不影响子进程的运行状态。
- 通过自动处理这些孤儿进程,可以提高系统的健壮性,即使在不太理想的情况下(例如,某些进程意外退出)。