Linux中的任务和调度 [一]

在Linux中,进程(process)的概念不光指一个程序执行体,还包括了它的虚拟地址空间、打开的文件 、未处理的信号等,可以说进程是一种资源的集合。

任务管理

进程创建

Linux中进程的创建沿用了传统Unix的做法,新的进程(称为“子进程”)由一个既有的进程(称为“父进程”)通过”fork”调用产生。除了父进程的PID, page table和pending signals,子进程将共享父进程的几乎其他所有资源。因此,”fork”本身存在的开销主要是page table的复制和PID的申请。

父进程中属于”data”段的页面将被临时设置为read-only,并打上Copy-on-Write ( COW )的标志。子进程有自己的page table,但和父进程共享页面。直到子进程试图修改这些共享的页面,才会因为页面被标记为“只读”而触发page fault, 进而复制出一份新的页面。

图片来源于《Linux环境编程》

“fork”完成后,父进程和子进程都将从”fork”返回,且都执行相同的程序。但不同的进程是需要做不同的事情的,如果子进程调用”exec”,就表示接下来要独立运行了。因为是在同一个节点返回,为了区分,”fork”调用为父进程和子进程返回了不同的值,返回0给子进程,返回子进程的PID给父进程。

子进程运行结束后,成为zombie进程,等待父进程来查询自己退出的原因,记录原因的变量通过”wait”函数的输出参数传递(并不等于子进程调用”exit”函数时的入参)。

进程描述

进程在Linux中由”task_struct”结构体来描述,以链表的形式组织,以方便内核的统一管理。

“task_struct”可以算是一个巨大的结构体,在2.6.34版本中,基于32位系统就要占据1.7KB的空间。这也不难理解,因为它几乎包含了关于一个process的全部信息。

这里仅列出其中的一小部分(定义在”/include/linux/sched.h”):

struct task_struct {
    void          *stack;
    volatile long  state; 

    pid_t          pid;
    pid_t          tgid;

    struct mm_struct  *mm;
    struct mm_struct  *active_mm;

    struct files_struct  *files;
    ...
}

在理解”task_struct”中各个field的含义之前,先来思考一个最基本的问题:如何找到当前process所对应的”task_struct”?

在Linux的内核代码中,我们经常可以看到” current “这个单词(虽然是小写,其实是个宏),它指向了当前正在CPU上运行的process的”task_struct”。按理,”task_struct”这么重要的结构体应该用一个专门的寄存器来存储其地址,但是早期的x86寄存器数量确实比较稀有,能省即省。结合Linux的历史,来看下没有专用寄存器的话,可以采用的变通手段都有哪些。

  • 第一阶段

当用户态程序通过系统调用trap到内核态执行后,所需进行的函数调用也得通过「栈」的机制来存储相关信息,为此,内核为每个process都分配了一个kernel stack,由 “stack” 域指向。

在2.6内核之前,一个process的”task_struct”被放在了其对应kernel stack的末尾。末尾地址还不好计算么,stack的起始地址(底部)减去stack的大小不就得到了?可问题是,stack的起始地址是存放在”task_struct”中的,这又是个先有鸡还是先有蛋的问题。

好在,对于正在CPU上运行的process,其stack的顶部(低地址)是被SP寄存器指向的。在32位系统上,kernel stack的默认大小是8KiB(2个page size)且按8KiB对齐,通过mask掉stack pointer的低13位,刚好就可以得到stack的末尾地址,进而获取到”task_struct”。

  • 第二阶段

然而随着”task_struct”体积的增大,继续放在stack的末尾就显得不那么合适了,于是逐渐改用slab分配器来申请内存。也就是说,之前的”task_struct”的内存是在stack上,现在是在heap上。

在heap上分配到的地址是不确定的,因此又增加了一个新的”thread_info”结构体,来取代原来”task_struct”在kernel stack末尾的位置,然后由”thread_info”指向”task_struct”。

不过,”thread_info”的作用可不止作为指向”task_struct”的中介,它还保存了不同体系结构一些差异化的东西,让”task_struct”可以更加专注于通用的部分。

  • 第三阶段

可以说,”thread_info”是从”task_struct”分离出来的,但自2016年开始,”thread_info”中的很多东西又被还给了”task_struct”,自己则一点点被蚕食。这倒不是”thread_info”本身的错,而是它所依附的kernel stack,变了。

之前kernel stack一直都采用直接映射的方式,要求分配的内存必须在物理上连续,但在4.14版本之后,kernel stack也可在vmalloc区域分配(参考这篇文章)。如果希望释放kernel stack的同时保留”task_struct”(参考这篇文章),那么,依附于kernel stack的”thread_info”就成了绊脚石。

饭要一口一口吃,路要一步一步走,直接拿掉”thread_info”可不是件那么容易的事。经过一系列调整,虽然”thread_info”现在依然存在于内核中,但它目前只保留了一些用于线程同步的flag,不再作为访问”task_struct”的跳板了。

既然在某一时间点,一个CPU只会运行一个process,而”task_struct”又是一个需要频繁访问的信息,那干脆就改成用per-CPU变量的形式存储吧:

“task_struct”现在算是找到了,接下来就可以进里面探索一番了。首先是作为process唯一性标识的 “pid” ,就像每个人的身份证号一样。 “tpid” 是由线程模型引入的概念,这里先按下不表。

“state” 代表进程的运行状态,其中最主要的是TASK_RUNNING,TASK_INTERRUPTIBLE和TASK_UNINTERRUPTIBLE。

对比其他一些OS,你会发现Linux好像没有一个描述ready的状态,其实是因为ready和running都被归为了TASK_RUNNING,所以这个状态表示的不是“正在运行”,而是“具备运行的条件”( runnable )。至于「可中断」和「不可中断」,指的是“被信号中断”,关于两者的具体区别,可参考这篇文章。

“mm” 指向包含了进程address space信息的memory descriptor。至于这个” active_mm “,则是和内核线程有关(将在本文后半部分详细阐述)。

“files” 指向记录process打开文件列表信息的”files_struct”,其内嵌的”fd_array”的长度是”BITS_PER_LONG”,在Linux所使用的LP64模型中,”long”类型包含的bits数目是64。因此,对于现在主流的64位系统,process默认可打开的文件系统数目是64。这对于大部分的process来说是够用的,如果需要超过64,就得另外分配一个数组,并由”fd”指向。

线程创建

传统的进程模型存在两个重要的局限性:一是某些应用程序希望并发的执行一些独立的任务,但又必须和其他进程共享同一个地址空间和其他资源,二是无法充分利用SMP的优势,因为一个进程在某个时刻只能使用一个CPU。

因此,Linux支持在一个进程内,创建多个程序的执行体,称为“线程”,每个线程有自己的stack,记录了该线程的执行上下文。以使用NPTL线程库为例,可通过”pthread_create”生成并启动线程。之后,”pthread_join”可用于阻塞等待线程的结束(如果等待时线程已经退出,则不会阻塞),作用同”wait”类似。

同一进程的线程共同构成了一个thread group,由第一个被创建的线程(主线程)作为group leader。Linux虽然支持线程机制,但从抽象的角度,它依然把线程视为进程的一种,因此也没有为线程设定单独的数据结构,描述线程依然使用”task_struct”。

而前面讲的”task_struct”里的”tgid”,就是thread group中主线程的PID,对于主线程来说,其”pid”和”tgid”的值是相同的。

在上图的示意中,是由主线程来创建并”join”其他线程的,但这些操作其实并不非要由主线程来完成。thread group中的线程本质上是平级的关系,它们的父进程都是同一个,主线程之所以成为主线程,仅仅是因为它出生的早。

创建线程的系统调用是”sys_clone”,但和创建进程的”sys_fork”一样,最后都是调用”_do_fork”,可谓殊途同归。

区别在于两者传入的标志位不同,进程的创建没有太多选择,该继承的都得继承,该copy的都得copy,但线程的创建就灵活多了,对于进程的资源,可以选择共享(对应资源的引用计数加1),也可以选择不共享。

世界是平衡的,共享资源虽然有利于线程之间的交互,但当同一进程的线程运行在不同的CPU上时,就涉及到资源的并发访问,需要配合使用一些同步机制,这在编程中是要格外谨慎思考的。

内核线程

还有一类process,它们从创建到消亡,都 只运行 在在内核空间,这就是内核线程(kernel thread)。内核线程也由”task_struct”来表示,且和普通的用户态process一起参与调度,但它们不需要访问user space的内存,因此被设定为不能拥有自己的address space和page table,所以其”task_struct->mm”域的值为空。

但是,内核线程需要访问kernel space的内存,而访问这些内存也需要经过page table啊(参考这篇文章)。既然自己没有,就临时借用下别人的吧。那借用谁的呢?就借用在自己之前运行的那个process的吧。怎么借用呢?用” active_mm “域指向这个process的memory descriptor。

这样,内核线程可以通过借用的page table来获取公共的kernel memory里的信息,既避免了单独申请address space和page table的内存开销,还顺便减少了address space切换的开销,一举两得。

一个内核线程只能由另一个内核线程来创建,为了方便,统一使用” kthreadd “这个内核线程来创建其他的内核线程。用户进程的“祖宗”是init/systemd进程,PID是1,而kthreadd的PID是2,足可见这两位的江湖地位。

参考:

原创文章,转载请注明出处。