2

Linux中的任务和调度 [一]

 3 years ago
source link: https://zhuanlan.zhihu.com/p/100030111
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

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

任务管理

进程创建

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

FfYFJjv.jpg!mobile

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

u6FRvqI.jpg!mobile 图片来源于《Linux环境编程》

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

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

M7remeF.jpg!mobile

进程描述

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

UF7VFve.jpg!mobile

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

qQ3Ifmq.jpg!mobile

这里仅列出其中的一小部分(定义在"/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"。

bU3IRju.jpg!mobile

不过,"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变量的形式存储吧:

ZrEBJrJ.jpg!mobile

"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"指向。

a2UvMjf.jpg!mobile

线程创建

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

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

JrIbqm.jpg!mobile

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

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

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

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

Q7juEvE.jpg!mobile

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

QZnUNzy.jpg!mobile

世界是平衡的,共享资源虽然有利于线程之间的交互,但当同一进程的线程运行在不同的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,足可见这两位的江湖地位。

bUfEbm6.png!mobile

参考:

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


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK