10.3.3 Linux中进程与线程的实现

Linux系统中的一个进程就像是一座冰山:你所看见的不过是它露出水面的部分,而很重要的一部分隐藏在水下。每一个进程都有一个运行用户程序的用户模式。但是当它的某一个线程调用系统调用之后,进程会陷入内核模式并且运行在内核上下文中,它将使用不同的内存映射并且拥有对所有机器资源的访问权。它还是同一个线程,但是现在拥有更高的权限,同时拥有自己的内核堆栈以及内核程序计数器。这几点非常重要,因为一个系统调用可能会因为某些原因陷入阻塞态,比如说,等待一个磁盘操作的完成。这时程序计数器和寄存器内容会被保存下来使得不久之后线程可以在内核模式下继续运行。

在Linux系统内核中,进程通过数据结构task_struct被表示成任务(task)。不像其他的操作系统会区别进程、轻量级进程和线程,Linux系统用任务的数据结构来表示所有的执行上下文。所以,一个单线程的进程只有一个任务数据结构,而一个多线程的进程将为每一个用户级线程分配一个任务数据结构。最后,Linux的内核是多线程的,并且它所拥有的是与任何用户进程无关的内核级线程,这些内核级线程执行内核代码。稍后,本节会重新关注多线程进程(一般的讲,就是线程)的处理方式。

对于每一个进程,一个类型为task_struct的进程描述符是始终存在于内存当中的。它包含了内核管理全部进程所需的重要信息,如调度参数、已打开的文件描述符列表等。进程描述符从进程被创建开始就一直存在于内核堆栈之中。

广告:个人专属 VPN,独立 IP,无限流量,多机房切换,还可以屏蔽广告和恶意软件,每月最低仅 5 美元

为了与其他UNIX系统兼容,Linux还通过进程标识符(PID)来区分进程。内核将所有进程的任务数据结构组织成一个双向链表。不需要遍历这个链表来访问进程描述符,PID可以直接被映射成进程的任务数据结构所在的地址,从而立即访问进程的信息。

任务数据结构包含非常多的分量。其中一些分量包含指向其他数据结构或段的指针,比如说包含关于已打开文件的信息。有些段只与进程用户级的数据结构有关,当用户进程没有运行的时候,它们是不被关注的。所以,当不需要它们的时候,这些段可以被交换出去或重新分页以达到不浪费内存的目的。举个例子,尽管对于一个进程来说,当它被交换出去的时候,可能会有其他进程给它发送信号,但是这个进程本身却不会要求读取一个文件。正因为如此,关于信号的信息才必须永远保存在内存里,即使这个进程已经不在内存当中了。换句话说,关于文件描述符的信息可以被保存在用户级的数据结构里,当进程存在于内存当中并且可以执行的时候,这些信息才需要被调入内存。

进程描述符的信息包含以下几大类:

1)调度参数。进程优先级,最近消耗的CPU时间,最近睡眠的时间。上面几项内容结合在一起决定了下一个要运行的进程是哪一个。

2)内存映射。指向代码、数据、堆栈段或页表的指针。如果代码段是共享的,代码指针指向共享代码表。当进程不在内存当中时,关于如何在磁盘上找到这些数据的信息也被保存在这里。

3)信号。掩码显示了哪些信号被忽略、哪些信号需要捕捉、哪些信号被暂时阻塞以及哪些信号在传递当中。

4)机器寄存器。当内核陷阱发生时,机器寄存器的内容(也包括被使用了的浮点寄存器的内容)会被保存。

5)系统调用状态。关于当前系统调用的信息,包括参数和返回值。

6)文件描述符表。当一个与文件描述符有关的系统调用被调用的时候,文件描述符作为索引在文件描述符表中定位相关文件的i节点数据结构。

7)统计。指向记录用户、进程占用系统CPU时间的表的指针。一些系统还保存一个进程最多可以占用CPU的时间、进程可以拥有的最大堆栈空间、进程可以消耗的页面数等。

8)内核堆栈。进程的内核部分可以使用的固定堆栈。

9)其他。当前进程状态。如果有的话,包括正在等待的事件、距离警报时钟超时的时间、PID、父进程的PID以及其他用户标识符、组标识符等。

记住这些信息,现在可以很容易地解释在Linux系统中是如何创建进程的。实际上,创建一个新进程的过程非常简单。为子进程创建一个新的进程描述符和用户空间,然后从父进程复制大量的内容。这个子进程被赋予一个PID,并建立它的内存映射,同时它也被赋予了访问属于父进程文件的权利。然后,它的寄存器内容被初始化并准备运行。

当系统调用fork执行的时候,调用fork函数的进程陷入内核并且创建一个任务数据结构和其他相关的数据结构,如内核堆栈和thread_info结构。这个结构位于进程堆栈栈底固定偏移量的地方,包含一些进程参数,以及进程描述符的地址。把进程描述符的地址存储在一个固定的地方,使得Linux系统只需要进行很少的有效操作就可以找到一个运行中进程的任务数据结构。

进程描述符的主要内容根据父进程的进程描述符来填充。Linux系统只需要寻找一个可用的PID,更新进程标识符散列表的表项使之指向新的任务数据结构即可。如果散列表发生冲突,相同键值的进程描述符会被组成链表。它会把task_struct结构中的一些分量设置为指向任务数组中相应进程的前一/后一进程的指针。

理论上,现在就应该为子进程分配数据段、堆栈段,并且对父进程的段进行复制,因为fork函数意味着父、子进程之间不共享内存。其中如果代码段是只读的,可以复制也可以共享。然后,子进程就可以运行了。

但是,复制内存的代价相当昂贵,所以现代Linux系统都使用了欺骗的手段。它们赋予子进程属于它的页表,但是这些页表都指向父进程的页面,同时把这些页面标记成只读。当子进程试图向某一页面中写入数据的时候,它会收到写保护的错误。内核发现子进程的写入行为之后,会为子进程分配一个该页面的新副本,并将这个副本标记为可读、可写。通过这种方式,使得只有需要写入数据的页面才会被复制。这种机制叫做写时复制。它所带来的额外好处是,不需要在内存中维护同一个程序的两个副本,从而节省了RAM。

子进程开始运行之后,运行代码(shell的副本)调用系统调用exec,将命令名作为exec函数的参数。内核找到并核实相应的可执行文件,把参数和环境变量复制到内核,释放旧的地址空间和页表。

现在必须建立并填充新的地址空间。如果你使用的系统像Linux系统或其他基于UNIX的系统一样支持映射文件,新的页表会被创建,并指出所需的页面不在内存中,除非用到的页面是堆栈页,但是所需的地址空间在磁盘的可执行文件中都有备份。当新进程开始运行的时候,它会立刻收到一个缺页中断,这会使得第一个含有代码的页面从可执行文件调入内存。通过这种方式,不需要预先加载任何东西,所以程序可以快速地开始运行,只有在所需页面不在内存中时才会发生页面错误(这种情况是第3章中讨论的最纯粹的按需分页机制)。最后,参数和环境变量被复制到新的堆栈中,信号被重置,寄存器被全部清零。从这里开始,新的命令就可以运行了。

图10-8通过下面的例子解释了上述的步骤:某用户在终端键入一个命令ls,shell调用fork函数复制自身以创建一个新进程。新的shell调用exec函数用可执行文件ls的内容覆盖它的内存。

阅读 ‧ 电子书库
图 10-8 shell执行命令ls的步骤

Linux中的线程

我们在第2章中概括性的介绍了线程。在这里,我们重点关注Linux系统的内核线程,特别是Linux系统中线程模型与其他UNIX系统的不同之处。为了能更好地理解Linux模型所提供的独一无二的性能,我们先来讨论一些多线程操作系统中存在的有争议的决策。

引入线程的最大争议在于维护传统UNIX语义的正确性。首先来考虑fork函数。假设一个多(内核)线程的进程调用了fork系统调用。所有其他的线程都应该在新进程中被创建吗?我们暂时认为答案是肯定的。再假设其他线程中的其中一个线程在从键盘读取数据时被阻塞。那么,新进程中对应的线程也应该被阻塞么?如果是的话,那么哪一个线程应该获得下一行的输入?如果不是的话,新进程中对应的线程又应该做什么呢?同样的问题还大量存在于线程可以完成的很多其他的事情上。在单线程进程中,由于调用fork函数的时候,惟一的进程是不可能被阻塞的,所以不存在这样的问题。现在,考虑这样的情况——其他的线程不会在子进程中被创建。再假设一个没有在子进程中被创建的线程持有一个互斥变量,而子进程中惟一的线程在fork函数结束之后要获得这个互斥变量。那么由于这个互斥变量永远不会被释放,所以子进程中惟一的线程也会永远挂起。还有大量其他的问题存在。但是没有简单的解决办法。

文件输入/输出是另一个问题。假设一个线程由于要读取文件而被阻塞,而另一个线程关闭了这个文件,或者调用lseek函数改变了当前的文件指针。下面会发生什么事情呢?谁能知道?

信号的处理是另一个棘手的问题。信号是应该发送给某一个特定的线程还是发送给线程所在的进程呢?一个浮点运算异常信号SIGFPE应该被引起浮点运算异常的线程所捕获。但是如果它没有捕获到呢?是应该只杀死这个线程,还是杀死线程所属进程中的全部线程?再来考虑由用户通过键盘输入的信号SIGINT。哪一个线程应该捕获这个信号?所有的线程应该共享同样的信号掩码吗?通常,解决这些或其他问题的所有方法会引发另一些问题。使线程的语义正确(不涉及代码)不是一件容易的事。

Linux系统用一种非常值得关注的有趣的方式支持内核线程。具体实现基于4.4BSD的思想,但是在那个版本中内核线程没能实现,因为在能够解决上述问题的C语言程序库被重新编写之前,Berkeley就资金短缺了。

从历史观点上说,进程是资源容器,而线程是执行单元。一个进程包含一个或多个线程,线程之间共享地址空间、已打开的文件、信号处理函数、警报信号和其他。像上面描述的一样,所有的事情简单而清晰。

2000年的时候,Linux系统引入了一个新的、强大的系统调用clone,模糊了进程和线程的区别,甚至使得两个概念的重要性被倒置。任何其他UNIX系统的版本中都没有clone函数。传统观念上,当一个新线程被创建的时候,之前的线程和新线程除了寄存器内容之外共享所有的信息。特别是,已打开文件的文件描述符、信号处理函数、警报信号和其他每个进程(不是每个线程)都具有的全局属性。clone函数可以设置这些属性是进程特有的还是线程特有的。它的调用方式如下:


pid=clone(function,stack_ptr,sharing_flags,arg);


调用这个函数可以在当前进程或新的进程中创建一个新线程,具体依赖于参数sharing_flags。如果新线程在当前进程中,它将与其他已存在的线程共享地址空间,任何一个线程对地址空间做出修改对于同一进程中的其他线程而言都是立即可见的。换句话说,如果地址空间不是共享的,新线程会获得地址空间的完整副本,但是新线程对这个副本进行的修改对于旧的线程来说是不可见的。这些语义同POSIX的fork函数是相同的。

在这两种情况下,新线程都从function处开始执行,并以arg作为惟一的参数。同时,新线程还拥有私有堆栈,其中私有堆栈的指针被初始化为stack_ptr。

参数sharing_flags是一个位图,这个位图允许比传统的UNIX系统更加细粒度的共享。每一位可以单独设置,且每一位决定了新线程是复制一些数据结构还是与调用clone函数的线程共享这些数据结构。图10-9显示了根据sharing_flags的设置,哪些项可以共享,哪些项需要复制。

阅读 ‧ 电子书库
图 10-9 sharing-flags位图中的各个位

CLONE_VM位决定了虚拟内存(即地址空间)是与旧的线程共享还是需要复制。如果该位置1,新线程加入到已存在的线程中去,即clone函数在一个已经存在的进程中创建了一个新线程。如果该位清零,新线程会拥有私有的地址空间。拥有自己的地址空间意味着存储的操作对于之前已经存在的线程而言是不可见的。这与fork函数很相似,除了下面提到的一点。创建新的地址空间事实上就定义了一个新的进程。

CLONE_FS位控制着是否共享根目录、当前工作目录和umask标志。即使新线程拥有自己的地址空间,如果该位置1,新、旧线程之间也可以共享当前工作目录。这就意味着即使一个线程拥有自己的地址空间,另一个线程也可以调用chdir函数改变它的工作目录。在UNIX系统中,一个线程通常会调用chdir函数改变它所在进程中其他线程的当前工作目录,而不会对另一进程中的线程做这样的操作。所以说,这一位引入了一种传统UNIX系统不可能具有的共享性。

CLONE_FILES位与CLONE_FS位相似。如果该位置1,新线程与旧线程共享文件描述符,所以一个线程调用lseek函数对另一个线程而言是可见的。通常,这样的处理是对于同属一个进程的线程,而不是不同进程的线程。相似的,CLONE_SIGHAND位控制是否在新、旧线程间共享信号句柄表。如果信号处理函数表是共享的,即使是在拥有不同地址空间的线程之间共享,一个线程改变某一处理函数也会影响另一个线程的处理函数。CLONE_PID位控制新线程是拥有自己的PID还是与父进程共享PID。这个特性在系统启动的时候是必需的。用户进程不允许对该位进行设置。

最后,每一个进程都有一个父进程。CLONE_PARENT位控制着哪一个线程是新线程的父线程。父线程可以与clone函数调用者的父线程相同(在这种情况下,新线程是clone函数调用者的兄弟),也可以是clone函数调用者本身,在这种情况下,新线程是clone函数调用者的子线程。还有另外一些控制其他项目的位,但是它们不是很重要。

由于Linux系统为不同的项目维护了独立的数据结构(见10.3.3小节,如调度参数、内存映射等),因此细粒度的共享成为了可能。任务数据结构只需要指向这些数据结构即可,所以为每一个线程创建一个新的任务数据结构变得很容易,或者使它指向旧线程的调度参数、内存映射和其他的数据结构,或者复制它们。事实上,条理分明的共享性虽然成为了可能,但并不意味着它是有益的,毕竟传统的UNIX系统都没有提供这样的功能。一个利用了这种共享性的Linux程序将不能移植到UNIX系统上。

Linux系统的线程模型带来了另一个难题。UNIX系统为每一个进程分配一个独立的PID,不论它是单线程的进程还是多线程的进程。为了能与其他的UNIX系统兼容,Linux对进程标识符(PID)和任务标识符(TID)进行了区分。这两个分量都存储在任务数据结构中。当调用clone函数创建一个新进程而不需要和旧进程共享任何信息时,PID被设置成一个新值;否则,任务得到一个新的任务标识符,但是PID不变。这样一来,一个进程中所有的线程都会拥有与该进程中第一个线程相同的PID。