本篇是《Linux内核学习笔记》系列的第一篇。
进程是处于执行期的程序及其相关的资源的总称,它的另一个名字叫任务(task)。在现代Linux系统中,一个进程可包含多个线程,它是在进程中活动的对象,每个线程都有独立的PC、栈和PSW。
注意,Linux中内核调度的对象是线程!
1. 进程描述符
1.1. 进程描述符的表示和存放
进程描述符task_struct完整地描述了进程的所有信息,它由slab分配器分配产生,并存放在叫任务队列(task list)的双向循环链表中。
在Linux 2.6中,x86体系下,进程内核栈的尾端会分配一个thread_info结构,其中包含着该进程描述符的指针。该结构的好处在于:
task_struct结构是动态生成的,无法直接放在内核栈尾端,所以thread_info某种程度上算是一层wrapper;- 由于
thread_info和内核栈相对位置固定,所以很容易使用优化的汇编代码,通过前者的指针来定位后者,反之亦然。
然而在Linux 5.15中,task_struct和thread_info的包含关系反过来了,内核栈的指针也包含在了进程描述符中。

在ARM、MIPS等体系中, thread_info 中仍包含着进程描述符的指针,而x86体系的thread_info可以说是改得亲妈都不认识(划去

不过它的地址和包含它的进程描述符的地址是相同的,这是宏current_thread_info()的要求。这非常棒,因为不需要通过thread_info的成员指针就能访问进程描述符了。当然,由于访问进程描述符是很重要的操作,不缺寄存器的体系结构(像PowerPC)会直接把当前进程描述符的地址存到一个寄存器里。
这里给出宏current_thread_info()的实现,它在形式上是统一的,返回一个thread_info结构:
// In include/linux/thread_info.h
#ifdef CONFIG_THREAD_INFO_IN_TASK
/*
* For CONFIG_THREAD_INFO_IN_TASK kernels we need <asm/current.h> for the
* definition of current, but for !CONFIG_THREAD_INFO_IN_TASK kernels,
* including <asm/current.h> can cause a circular dependency on some platforms.
*/
#include <asm/current.h>
#define current_thread_info() ((struct thread_info *)current)
#endif
但current宏可以有很大不同。以x86为例,它通过如下方式返回进程描述符结构指针:
// In arch/x86/include/asm/current.h
struct task_struct;
DECLARE_PER_CPU(struct task_struct *, current_task);
static __always_inline struct task_struct *get_current(void)
{
return this_cpu_read_stable(current_task);
}
#define current get_current()
1.2. 进程状态、上下文和家族树
从上面x86进程描述符的部分截图中我们可以看到__state域,它表示进程的当前状态,对应于进程五态模型。Linux 2.6中可以通过set_task_state设定状态,但我在Linux 5.13中没找到该函数。
在进程执行系统调用后,内核代为用户进程执行,此时current宏是有效的,可以通过它获取当前进程描述符的各种信息,包括状态、父进程、子进程列表,等等。(顺道一提,Linux完成了对链表结构及其操作的封装,在include/linux/list.h中)
Linux系统中的进程间有明显的继承关系,而这棵继承关系树的树根是init进程,由init_task静态分配其描述符。
2. 进程的创建
Unix和Linux采用fork和exec进行进程的创建,而不是用一个单独的spawn函数。
2.1. 写时拷贝技术
传统fork时简单地将进程所有的资源复制给新的进程,这么做简单但是效率低下,特别是如果刚产生的新进程马上就要通过exec改头换面,那前面做的所有拷贝工作就前功尽弃了。
写时拷贝技术(copy-on-write)允许父子进程在读数据时共享一个拷贝,直到需要写时才以页为单位进行资源的复制。这样,fork后接exec就避免了资源的无用拷贝。
vfork则不拷贝父进程的页表项,而是利用父进程的资源运行,此时父进程被阻塞。直到子进程退出或执行exec,父进程才恢复就绪态。不过fork已经具备写时拷贝技术,所以vfork实际上没有太大意义。
2.2. fork的实现
fork、vfork、clone等都通过调用kernel_clone以完成任务,只是传入的参数不同。kernel_clone会在一些必要的检查后调用copy_process,它完成(但不止于)如下工作:
- 调用
dup_task_struct复制进程描述符,包括其中的thread_info和内核栈; - 对返回的进程描述符作修改,从而将父子进程区分开来;
- 调用
alloc_pid为新进程分配有效PID; - 解析来自
kernel_clone的参数,进行一些设置; - 进行一些扫尾工作后,返回指向子进程描述符的指针。
copy_process成功返回后,kernel_clone进行最后的设置,并通过wake_up_new_task唤醒子进程。
3. 线程的实现
3.1. 创建线程
Linux的线程在内核中就和进程一一对应。即如前文所述,thread_info和task_struct是一一对应的。
Linux就像创建进程一样创建线程,只是在传入kernel_clone的参数中指定需要共享某些资源。
例如,要通过clone来创建线程,clone会以如下的方式向kernel_clone传入参数(这里只是实例化了一组可能的参数):
// Changed from kernel/fork.c
struct kernel_clone_args args = {
.flags = (CLONE_VM | CLONE_VS | CLONE_FILES) & ~CSIGNAL,
.exit_signal = CLONE_SIGHAND & CSIGNAL,
...
};
return kernel_clone(&args);
而以下是fork的实现方式,和上面在形式上非常相似:
// In kernel/fork.c
struct kernel_clone_args args = {
.exit_signal = SIGCHLD,
};
return kernel_clone(&args);
所以某种意义上,fork算是clone的一个特例。
3.2. 内核线程
内核需要在后台完成一些操作,这些操作由内核线程完成。由于它们没有独立的地址空间,所以只能在内核空间运行(换言之彼此之间缺乏隔离保护!),不能切换到用户空间。除此之外,它们和普通进程一样,可以被调度或抢占。
内核线程会在后续的内容中进一步讨论。
4. 进程的终结
4.1. 每个进程最后执行的代码
进程可能因为主动调用exit而终结(注意,从main函数返回也算主动调用exit),也可能因为无法处理和忽略的信号或异常而被动调用exit。进程最终的归宿是回到内核,调用do_exit,随后去往进程天国。
在do_exit中,内核会做很多处理,其中包括调用很多释放资源的函数,例如exit_sem、exit_files、exit_mm,等等,这也是内核的兜底机制:我们的代码结束时可以不释放已申请内存空间、可以不关闭已打开的文件、可以不释放信号量,而不用担心之后会对操作系统留下什么负面影响。
在释放资源后,该进程通过exit_notify向父进程发送信号,并为子进程重新寻找养父,随后进入僵死状态。此时该进程已经不能再被调度,它最终执行do_task_dead函数,后者调用调度器的主函数__schedule,从此一去不复返。
4.2. 删除进程描述符
现在被终结的进程还剩下一个进程描述符,它并不会在do_exit中立即被释放掉,因为这样系统在子进程终结后仍能获取一些信息。父进程在得知子进程终结的消息后,或父进程通知内核它不关注这些消息后,系统才会真正释放进程描述符。