趁新买的硬件还在路上,给自己再挖一个新坑。之前一直很喜欢《Linux Kernel Development》这本书,这本书写得简洁明了,而又章章经典,中译本仅300多页,把整个Linux Kernel的基本设计框架讲解得明明白白。虽说经典永远都是经典,但毕竟当时的内核只发行到2.6版本,而经过广大开源开发者的贡献,内核现在已经发展到5.13版了。书中的内容有哪些已经被改得天翻地覆,又有哪些经典永存呢?想到这里,突然觉得重温这本书和对照现在的代码会是一件十分有趣的事情。这就是大概为什么我会开始这个新坑。所有内容建议和书对应章节一起阅读。
2021/6/28
第三章 进程管理
本章引入进程的概念,进程是Unix操作系统抽象概念中最基本的一种。其中涉及进程的定义以及相关的概念,比如线程;然后讨论Linux内核如何管理每个进程:他们在内核中如何被列举,如何创建,最终如何消亡。我们拥有操作系统就是为了运行用户程序,因此,进程管理就是所有操作系统的心脏所在,Linux也不例外。
3.1 进程
进程(Task)就是处于执行期的程序(目标代码存放在某种存储介质上),通常还包含其他资源,如打开的文件,挂起的信号,内核内部数据,处理器状态,内存地址空间以及一个或多个线程。
线程(Thread),是进程中的活动对象。每个线程都拥有一个独立的程序计数器、进程栈和一组进程寄存器,是内核调度的对象。
3.2 进程描述符及任务结构
内核把进程的列表存放在任务队列(task list),它是一个双向链表,链表中的每一项都是类型为task_struct,被称为进程描述描述符(process descriptor)的结构,该结构定义在<linux/sched.h>文件中。进程描述符包含一个具体进程的所有信息,包括打开的文件,进程的地址空间,挂起的信号,进程的状态等。在书中提到,当时结构体的大小已经有1.7KB,而我尝试在Ubuntu16.04看了一下当前task_struct的大小,现在足足有9KB!不得不说硬件算力的提升给软件提供了更大更广阔的发展空间,那么接下来我们再仔细看看task_struct是在Linux kernel中如何存放和使用的吧。
3.2.1-3.2.2进程描述符的存放
在书中提到,每个进程的task_struct通过slab进行分配,并记录在thread_info中,如下图,task_struct保存在thread_info的其实地址。
而令我惊讶的是,在Linux5.13的thread_info中,已经不存在task_struct的指针了。
其实,在32bit或以下的架构仍然保留着task_struct在thread_info中。而在64bit系统中,内核提供了一个get_current函数,以arm64架构举例,在arch/arm64/include/asm/current.h中,我们能找到这样一段代码。
static __always_inline struct task_struct *get_current(void)
{
unsigned long sp_el0;
asm ("mrs %0, sp_el0" : "=r" (sp_el0));
return (struct task_struct *)sp_el0;
}
可以暂时理解为task_struct的地址会被保存在sp_el0中,我们直接读取保存在sp_el0的地址便可以找到task_struct,至于怎么放在sp_el0的,sp_el0有什么作用,可以以后慢慢了解。同时在task_struct中保存了thread_info,所以可以理解为现在是调转过来了,真是风水轮流转,同时也说明了task_struct和thread_info是紧密联系的。
struct task_struct {
#ifdef CONFIG_THREAD_INFO_IN_TASK
struct thread_info thread_info;
#endif
……
}
而由于当前主流64bit架构CPU都已经提供单独的寄存器存放task_struct,再也不需要通过复杂的运算得到该结构体了。
3.2.2-3.2.6 task_struct中的重要成员
-
PID
内核通过一个唯一的进程标识值(process identification value)或者PID来标识每个进程 -
进程状态
在task_struct中,task->state会记录进程的状态,系统主要用到的5种状态,依然延续到了当前内核版本,但同时又多了几种延伸的状态,可参考当前的状态定义,至于用途可在状态切换章节深挖。
/* Used in tsk->state: */
#define TASK_RUNNING 0x0000
#define TASK_INTERRUPTIBLE 0x0001
#define TASK_UNINTERRUPTIBLE 0x0002
#define __TASK_STOPPED 0x0004
#define __TASK_TRACED 0x0008
/* Used in tsk->exit_state: */
#define EXIT_DEAD 0x0010
#define EXIT_ZOMBIE 0x0020
#define EXIT_TRACE (EXIT_ZOMBIE | EXIT_DEAD)
/* Used in tsk->state again: */
#define TASK_PARKED 0x0040
#define TASK_DEAD 0x0080
#define TASK_WAKEKILL 0x0100
#define TASK_WAKING 0x0200
#define TASK_NOLOAD 0x0400
#define TASK_NEW 0x0800
#define TASK_STATE_MAX 0x1000
但主体依然遵从这个经典的图,且状态切换的代码实现也没有改变(通过内存屏障保证CPU cache一致)。
-
进程上下文
可执行程序代码是进程的重要组成部分。这些代码从一个可执行文件载入到进程的地址空间执行。一般程序在用户空间执行。当一个程序执行了系统调用或者触发了某个异常,我们称之为陷入内核空间。此时,可以称为内核“代表进程执行”并处于进程上下文。 -
进程家族树
在Linux中,所有的进程都是PID为1——init进程的后代。系统中的每个进程都必有一个父进程,相应也可以拥有多个子进程。拥有同个父进程的所有进程被称为兄弟进程,以下为描述进程关系树的结构体成员。
/*
* Pointers to the (original) parent process, youngest child, younger sibling,
* older sibling, respectively. (p->father can be replaced with
* p->real_parent->pid)
*/
/* Real parent process: */
struct task_struct __rcu *real_parent;
/* Recipient of SIGCHLD, wait4() reports: */
struct task_struct __rcu *parent;
/*
* Children/sibling form the list of natural children:
*/
struct list_head children;
struct list_head sibling;
struct task_struct *group_leader;
3.3 进程创建
进程的创建经过,fork()——通过拷贝进程创建一个子进程,和exec()——负责读取可执行文件并将其载入地址空间开始运行。
在Linux5.13中fork的实现:
普通程序调用fork()–>库函数fork()–>系统调用(fork功能号)–>由功能号在 sys_call_table[]中寻到sys_fork()函数地址–>调用kernel_clone(),这就完成拉从用户态到内核态的变化过程。而sys_fork主要动作都在kernel_clone中实现。
在实现中虽然函数名从do_fork改成kernel_clone,但大致实现是一致的,主要在copy_process中实现主要功能。
在copy_process中,如书中所讲,总体上现在的代码流程基本上没变:
- dup_task_struct(),获取一个新的task_struct并将父进程的task_struct拷贝到新的task_struct,同时获取并设置thread stack,获取vm区域等。
- 区别父子的task_struct,包括设置父子、兄弟进程间的关系,清零一些标志位、关键信息等。
- sched_fork,和调度器相关的初始化,同时把任务状态设置成TASK_NEW(2.6中是TASK_UNINTERRUPTBLE),保证任务不会被调度。
- 获取PID,并根据新建的是否为线程,区别设置任务资源。
3.3.3 vfork
vfork 和创建thread其实就是kernel_clone时带入的clone_flag不一样。
从调用kernel_clone的函数中能找到,
SYSCALL_DEFINE0(vfork)
{
struct kernel_clone_args args = {
.flags = CLONE_VFORK | CLONE_VM,
.exit_signal = SIGCHLD,
};
return kernel_clone(&args);
}
但是vfork在当前适合使用的场景越来越少,所以重点来看看thread的创建。
3.4 thread
线程的创建也可以看作是子进程创建,是通过不同的标志位组合实现的。其原理上就是通过CLONE_VM、CLONE_FS、CLONE_FILES、CLONE_SIGHAND等标志位实现与父进程的资源共享,不同的标志位表示了父子进程之间共享不同的资源。
3.5 进程终结
进程终结简单的来讲就是在不影响其他进程的前提下,释放所有进程所占用的资源,依然是在kernel/exit.c中do_exit中实现。流程就不赘述了,基本没有变化:
- 判断执行exit的合法性。
- 把进程状态设置成PF_EXITING。
- 清理资源,包括内存,状态量,计时器,文件句柄,线程等。
- 在exit_notify维护进程间的父子关系。
- 调用schedule切换到新进程。