loadlj / blog

19 stars 6 forks source link

《深入理解linux内核》笔记-进程 #40

Open loadlj opened 3 years ago

loadlj commented 3 years ago

《深入理解linux内核》笔记-进程

定义

从内核观点看,进程的目的就是担当分配系统资源(CPU 时间、内存等)的实体。

线程是进程的一个实体,是CPU调度和分派的基本单位,它是比进程更小的能独立运行的基本单位.线程自己基本上不拥有系统资源,只拥有一点在运行中必不可少的资源(如程序计数器,一组寄存器和栈),但是它可与同属一个进程的其他的线程共享进程所拥有的全部资源。

执行线程,简称线程(thread),是在进程中活动的对象。每个线程都拥有-一个独立的程序计数器、进程栈和一组进程寄存器。内核调度的对象是线程,而不是程。在传统的Unix系统中,一个进程只包含一个线程,但现在的系统中,包含多个线程的多线程程序司空见惯。

进程描述符

为了管理进程,内核必须对每个进程所做的事情进行清楚的描述。例如,内核必须知道进程的优先级,它是正在CPU.上运行还是因某些事件而被阻塞,给它分配了什么样的地址空间,允许它访问哪个文件等等。这正是进程描述符(process descriptor) 的作用。

进程状态

对应在Top命令的输出中:

一般来说,能被独立调度的每个执行上下文都必须拥有它自己的进程描述符;因此,即使共享内核大部分数据结构的轻量级进程,也有它们自己的task_struct结构。 在 Linux 中每一个进程都由 task_struct 数据结构来定义。task_struct 就是我们通常所说的 PCB。

进程链表把所有进程的描述符链接起来。每个task_struct 结构都包含一个list_head 类型的tasks字段,这个类型的prev和next字段分别指向前面和后面的task_struct元素。

进程链表的头是init_task 描述符,它是所谓的0进程(process 0)或swapper进程的进程描述符。init_task的tasks.prev字段指向链表中.最后插入的进程描述符的tasks字段。

程序创建的进程具有父1子关系。如果一个进程创建多个子进程时,则子进程之间具有兄弟关系。

等待队列惊群效应

在Linux内核中等待队列有很多用途,可用于中断处理、进程同步及定时。

要唤醒等待队列中所有睡眠的进程有时并不方便。例如,如果两个或多个进程正在等待互斥访问某一要释放的资源,仅唤醒等待队列中的一个进程才有意义。这个进程占有资源,而其他进程继续睡眠。(惊群问题)

因此,有两种睡眠进程:互斥进程(等待队列元素的flags字段为1)由内核有选择地唤醒,而非互斥进程(falgs值为0)总是由内核在事件发生时唤醒。等待访问临界资源的进程就是互斥进程的典型例子。等待相关事件的进程是非互斥的。例如,我们考虑等待磁盘传输结束的一组进程: 一但磁盘传输完成,所有等待的进程都会被唤醒。

进程切换

为了控制进程的执行,内核必须有能力挂起正在CPU上运行的进程,并恢复以前挂起的某个进程的执行。这种行为被称为进程切换(process switch)、任务切换(task switch)或上下文切换(contextswitch)。

进程切换只发生在内核态。在执行进程切换之前,用户态进程使用的所有寄存器内容都已保存在内核态堆栈上。

从本质上说,每个进程切换由两步组成:

  1. 切换页全局目录以安装一个新的地址空间;
  2. 切换内核态堆栈和硬件上下文,因为硬件上下文提供了内核执行新进程所需要的所有信息,包含CPU寄存器。

进程切换的关键操作无非就是切换地址空间、切换内核堆栈、切换内核控制流程以及必要寄存器的现场保护与还原。

进程创建

传统的Unix操作系统以统--的方式对待所有的进程:子进程复制父进程所拥有的资源。这种方法使进程的创建非常慢且效率低,因为子进程需要拷贝父进程的整个地址空间。实际上,子进程几乎不必读或修改父进程拥有的所有资源,在很多情况下,子进程立即调用execve(),并清除父进程仔细拷贝过来的地址空间。

现代Unix内核通过引入三种不同的机制解决了这个问题:

clone()、fork()及vfork()系统调用

clone的完整函数如下:

int clone(int (*fn)(void *), void *child_stack, int flags, void *arg);

传统的fork()系统调用在Linux中是用clone()实现的,其中clone()的flags参数指定为SIGCHLD信号及所有清0的clone标志,而它的child_stack参数是父进程当前的堆栈指针。因此,父进程和子进程暂时共享同一个用户态堆栈。但是,要感谢写时复制机制,通常只要父子进程中有一个试图去改变栈,则立即各自得到用户态堆栈的一份拷贝。

vfork创建的子进程与父进程共享数据段,而且由vfork()创建的子进程将先于父进程运行

内核线程

线程定义: Linux实现线程的机制非常独特。从内核的角度来说,它并没有线程这个概念。Linux 把所有的线程都当做进程来实现。内核并没有准备特别的调度算法或是定义特别的数据结构来表征线程。相反,线程仅仅被视为一个与其他进程共享某些资源的进程。每个线程都拥有唯一隶属于自己的task_struct, 所以在内核中,它看起来就像是一个普通的进程(只是线程和其他一些进程共享某些资源,如地址空间)。

在 Linux 的实现中,task_struct 结构体中除了存在一个 pid 字段之外还存在一个 tgid 字段,也就是线程组的概念。从上一小节的第三点中我们知道,当一个进程创建为 普通的进程的时候,pid 和 tgid 属于同一个值,也就是说它属于一个只包含它自己的 线程组。但是从一个进程派生一个线程(比如通过 pthread_create() 函数)的时候, 新产生的 task_struct 会分配到一个新的 pid,但是它的 tgid 和它的父进程保持 一致,这样一来子进程(线程)就加入到了父进程的线程组中

传统的Unix系统把一些重要的任务委托给周期性执行的进程,这些任务包括刷新磁盘高速缓存,交换出不用的页框,维护网络连接等等。事实上,以严格线性的方式执行这些任务的确效率不高,如果把它们放在后台调度,不管是对它们的函数还是对终端用户进程都能得到较好的响应。因为一些系统进程只运行在内核态,所以现代操作系统把它们的函数委托给内核线程(kernel thread),内核线程不受不必要的用户态上下文的拖累。

进程0 所有进程的祖先叫做进程0,idle进程或因为历史的原因叫做swapper进程,它是在Linux的初始化阶段从无到有创建的一个内核线程。

在多处理器系统中,每个CPU都有一个进程0。只要打开机器电源,计算机的BIOS就启动某一个CPU,同时禁用其他CPU。运行在CPU0上的swapper进程初始化内核数据结构,然后激活其他的CPU,并通过copy_process()函数创建另外的swapper进程, 把0传递给新创建的swapper进程作为它们的新PID。此外,内核把适当的CPU索引赋给内核所创建的每个进程的thread_info描述符的cpu字段。

进程1 由进程0创建的内核线程执行init()函数,init()依次完成内核初始化。init()调用execve()系统调用装入可执行程序init。 结果,init内核线程变为一个普通进程,且拥有自己的每进程(per-process) 内核数据结构(参见第二十章)。在系统关闭之前,init进程一直存活,因为它创建和监控在操作系统外层执行的所有进程的活动。

撤销进程

进程终止的一般方式是调用exit()库函数,该函数释放C函数库所分配的资源,执行编程者所注册的每个函数,并结束从系统回收进程的那个系统调用。exit ()函数可能由编程者显式地插入。另外,C编译程序总是把exit ()函数插人到main()函数的最后一条语句之后。

在Linux2.6中有两个终止用户态应用的系统调用:

孤儿进程:

如果父进程在子进程之前退出,必须有机制来保证子进程能找到-一个新的父亲,否则这些成为孤儿的进程就会在退出时永远处于僵死状态,白白地耗费内存。前面的部分已经有所暗示,对于这个问题,解决方法是给子进程在当前线程组内找-一个线程作为父亲,如果不行,就让init做它们的父进程。

暂时没有太多危害

僵尸进程

当一个子进程结束运行(一般是调用exit、运行时发生致命错误或收到终止信号所导致)时,子进程的退出状态(返回值)会回报给操作系统,系统则以SIGCHLD信号将子进程被结束的事件告知父进程,此时子进程的进程控制块(PCB)仍驻留在内存中。一般来说,收到SIGCHLD后,父进程会使用wait系统调用以获取子进程的退出状态,然后内核就可以从内存中释放已结束的子进程的PCB;而如若父进程没有这么做的话,子进程的PCB就会一直驻留在内存中,也即成为僵尸进程

解决方案: