《现代体系结构上的UNIX系统:内核程序员的对称多处理和缓存技术(修订版)》——1.5 内存管理和进程管理的系统调用-阿里云开发者社区

开发者社区> 开发与运维> 正文
登录阅读全文

《现代体系结构上的UNIX系统:内核程序员的对称多处理和缓存技术(修订版)》——1.5 内存管理和进程管理的系统调用

简介: UNIX系统为创建和消除进程以及改变进程的地址空间提供了几个系统调用。本节简要回顾这些系统调用的内部操作和语义,因为高速缓存和多处理器对UNIX操作系统中处理进程地址空间的部分影响最大。

本节书摘来自异步社区《现代体系结构上的UNIX系统:内核程序员的对称多处理和缓存技术(修订版)》一书中的第1章,第1.5节,作者:【美】Curt Schimmel著,更多章节内容可以访问云栖社区“异步社区”公众号查看

1.5 内存管理和进程管理的系统调用

UNIX系统为创建和消除进程以及改变进程的地址空间提供了几个系统调用。本节简要回顾这些系统调用的内部操作和语义,因为高速缓存和多处理器对UNIX操作系统中处理进程地址空间的部分影响最大。

1.5.1 系统调用fork
系统调用fork创建一个新进程。内核通过准确复制调用fork的进程的一个副本来创建一个新进程。调用fork的进程称为父进程(parent),新创建的进程称为子进程(child)。子进程不但获得了父进程的地址空间以及包括程序变量、寄存器、PC等值在内的进程状态,而且获得了访问父进程拥有的全部UNIX系统服务的权利,比如父进程打开的文件。子进程是用一个控制线程创建的,这个控制线程和父进程中调用fork的线程是一样的。子进程一旦创建出来,它就独立于父进程而执行。在fork调用完成的时候,两个进程是相同的。两个进程上下文的唯一区别在于fork系统调用本身的返回值。父进程返回的是子进程的进程ID(pid),而子进程得到的是0。这就让每个进程能够判断出它是父进程还是子进程。

因为复制一个较大的虚拟地址空间很花时间,所以要进行几种优化。首先,正文段一般由执行同一个程序(和/或共享相同的库)的所有进程只读共享。这意味着正文不需要在物理上复制给子进程。子进程只要共享父进程正在使用的同一个副本就可以了。因为UNIX内核不允许在正文段内擅自修改代码,所以才有可能共享正文(当出于调试的目的需要在正文中插入断点的时候,内核首先要创建一份正文的私用副本,以便执行相同程序的其他进程不会受到影响)。图1-4中父进程将要执行fork调用,其正文段是只读的,而它的其他页面可以进行读写访问。

screenshot

接下来,几乎所有的实现都使用一种称为写时复制(copy-on-write)的技术来避免复制地址空间中剩余部分的大量内容。大多数UNIX进程在执行fork调用之后会立即调用exec(在下一小节介绍)来执行新程序。这一操作丢弃了父进程的地址空间,所以在fork期间复制父进程空间而很快又丢弃它的做法会很浪费。相反,数据、bss和栈都不进行物理复制,而是临时在父进程和子进程之间只读共享。如图1-5所示。

注意,两个进程在逻辑上仍然对页面拥有写权限。当父进程或者子进程试图写一个页面的时候就复制单独的页面。这样一来,只有要写入的页面才会按照需要来复制,如果子进程只需要在它执行exec或者exit之前写入其地址空间的一部分的话,那么就有可能节省大量的复制开销。只读、写时复制共享(copy-on-write sharing)只是用作一种高效的实现技术,对于涉及到的进程来说是透明的。

只要两个进程都没有企图修改数据,那么就会继续保持共享关系。当两个进程中的一个要写入一个只读页面的时候,就发生了一次保护陷阱(protection trap),内核会截获到这个陷阱。内核复制出进程正在尝试修改的单个页面的一个副本,用它来替换该页面在那个进程的地址空间中被共享的副本。这种做法只用于执行写操作的进程,其他进程的地址空间不受影响。采用这种方式时,可以在父进程和子进程之间共享尽可能多的地址空间,而仅仅根据需要复制那些进程所修改的单独页面的副本。这种处理对于两个进程都是透明的,从而造成复制了整个地址空间的假象。图1-6给出了图1-5中的子进程修改了它的第3个虚拟页面之后地址空间的状态。内核把这个页面的内容复制到了一个新的物理页面中,并且重新映射子进程的地址空间,指向该物理页面上。

screenshot

为了避免复制那些仅有一个映射关系的页面,内核要计算每个物理页面上的只读、写时复制的映射关系数量。所以,如果父进程现在要写入它的第3个虚拟页面,那么内核就会知道这个页面上没有其他的映射关系,只要把映射关系改为读写就行了,不需要复制该页。结果如图1-7所示。

screenshot

从应用的角度来看,系统调用fork是创建新进程的一种很方便的机制,因为它不带任何参数。因为子进程继承了父进程的全部状态,所以不需要像在其他操作系统中创建新进程那样给系统调用传递一组复杂的参数。子进程根据它从父进程那里得到的状态来判断出它应该执行什么任务。在大多数情况下,fork的目标是创建一个新进程来执行新程序。要做到这一点,子进程通过打开或者关闭文件(例如,可能为了I/O重定向)来准备进程状态,然后用系统调用exec来执行新程序。

1.5.2 系统调用exec
系统调用exec可以改变一个进程正在执行的程序。只有调用exec的进程才会受到影响。exec的参数是一个文件名(该文件包含要执行的新程序)和一组要传递给新程序的参数和环境变量。执行exec系统调用的进程保留它与大多数UNIX系统服务相关的状态信息,如它打开的文件、它的当前目录和主目录等。它的状态中和程序本身有关的部分,比如它的寄存器内容、变量、PC(程序计数器)以及地址空间,都要用新程序的来替换。更具体地说,原来程序的正文、数据、bss和栈以及诸如共享内存的其他存储对象都会被丢弃,而要为新程序创建新的虚拟地址空间。新程序的正文和初始化数据则从指定的文件中读入,内核将地址空间内的空间分配给bss和栈。进程内单个线程的PC(程序计数器)被设定在新程序的起始地址。当系统调用执行完毕的时候,原来的程序在进程中就不复存在了,新程序开始执行。新程序可以访问exec系统调用之前进程所打开的文件,因为这些文件都是和进程相关联的,而不是和程序相关联的。

如前所述,系统调用exec往往在fork之后执行。最常见的情形就是UNIX系统的命令解释器,它可以创建新进程来运行每条命令。命令解释器调用fork创建新进程,然后子进程调用exec运行该命令。

1.5.3 系统调用exit
系统调用exit会让调用它的进程(以及它的所有线程)终止执行。该系统调用在程序完成它的执行过程之后并希望终止的时候使用。一个进程还有可能采用系统调用kill来终止另一个进程(假定该进程有适当的权限)。如果发生了无法恢复的错误,系统也可以终止一个进程。在所有的情况下,内核终止一个进程以及在事后进行清理工作的步骤都是相同的。

要终止一个进程,内核必须丢弃进程的地址空间,取消进程正在使用的内核服务,例如,关闭进程留下的任何打开的文件。此刻,进程暂时以一种称为僵尸进程(zombie)的形式存在(“僵尸进程”一词来自UNIX的行话)。这就提供了一种便捷的手段,让父进程在有机会采用系统调用wait读取子进程的退出状态之前保持进程之间的父子关系(僵尸进程与本书的讨论无关,不再深入研究)。最后,代表进程本身的内部的内核数据结构被释放。此刻,进程就不复存在了,然后内核执行一次上下文切换,选择另一个要执行的进程。

1.5.4 系统调用sbrk和brk
系统调用sbrk和brk都可以被一个进程用来分配或者收回它的bss段空间。这两个系统调用都以bss段的“BReaK address”(断开地址)而得名。这是在bss段内进程能够访问的最大合法地址。断开地址和栈顶部地址之间的虚拟存储区不会被映射到任何物理存储器上,进程无法访问到它们(眼下忽略共享内存和映射文件)。系统调用sbrk和brk能够让进程改变它的断开地址,从而增长或者缩小bss段的大小。系统调用sbrk接受一个代表断开地址增量变化的有符号值作为参数,而系统调用brk接受一个作为新断开地址的虚拟地址作为参数。

如果进程请求增大bss段,那么内核就正好在原来的断开地址之上分配虚拟内存,从而让进程能够访问到这部分地址空间。新分配的bss内存被定义用0来填充。bss段只能向更大的地址增长,它的起始地址是固定不变的。支持新分配的虚拟内存的物理存储器则根据需要来分配,因为它是由进程来引用的。如果缩小了bss,那么在新老断开地址之间地址范围内的虚拟和物理内存都将被释放。访问权限也变了,于是进程再也不能访问它了。

1.5.5 共享内存
有些UNIX系统的实现提供了一种能够让两个或者两个以上的进程共享一个物理内存区域的系统服务。这就是所谓的共享内存(shared memory)段。它是通过将同一个(或者多个)物理页面映射到两个或者更多进程的虚拟地址空间中来实现的。物理内存的共享区域无需在所有的进程中都出现在相同的虚拟地址上,每个共享区域都可以根据需要附着(attach)到不同的虚拟地址上。共享内存通常被附着在进程内bss和栈段之间未用的虚拟内存区中。

共享内存能够充当一种高速的进程间通信(interprocess communication,IPC)机制,因为进程可以通过共享内存传递数据,既不需要执行系统调用,也不需要牵扯到内核。当一个进程把数据写入共享内存中的时候,共享同一共享存储段的其他进程立即就能访问到这些数据,因为它们都共享着相同的物理页面。

1.5.6 I/O操作
在考虑内存操作的时候,I/O(输入/输出)的影响是很重要的。从用户进程请求I/O操作的系统调用有两个:read和write。这两个系统调用把数据从进程的地址空间传送到一个文件或者设备(write),或者反过来(read)。有些UNIX实现提供了额外的I/O系统调用,比如readv、writev、getmsg和putmsg(就本书所介绍的主题而言,这些系统调用的作用同read和write是一样的,所以我们只讨论这两个系统调用)。有两种类型的I/O操作:有缓冲的(buffered)和无缓冲的(unbuffered)。

在内核中,对特定文件类型的I/O是有缓冲的。内核和用户进程地址空间之间的数据通过一个复制操作来传输。缓冲机制的优点是它允许用户程序不必知道物理I/O设备的特性。程序不需要关心块或者记录的大小,或者任何对齐的限制。例如,磁盘往往以扇区来存取,这意味着I/O必须从一个扇区的边界开始,并且长度为扇区长度的整数倍。当一个用户程序读取有缓冲的文件时,它只要指定它希望I/O从这个文件内开始的字节偏移量以及它想读取的字节数就可以了。为了保持文件抽象的概念,内核把用户的字节偏移量转换为包含相应数据的扇区。一个或者更多扇区从磁盘读入到内核缓冲区中,然后用户想要读取的数据部分就被复制到用户的缓冲区中。

无缓冲的I/O则绕过了这种复制操作。用户进程可以使用这种类型的I/O,它在UNIX的行话中称为原始I/O(raw I/O)。术语“原始I/O”来源于这一事实,即通过一个缓冲区进行复制的数据被认为是经过处理的,或者说“被加工过的”。因此,没有复制的数据则被认为是“原始的”。在采用原始I/O的情况下,I/O设备使用DMA(direct memory access,直接内存访问)操作直接把数据传送到用户缓冲区。内核在有缓冲的I/O期间所缓冲的数据最终也使用DMA传送到设备上,或者从设备上传送到缓冲区中。所以,既可以在用户地址空间也可以在内核地址空间执行DMA操作。

1.5.7 映射文件
许多UNIX系统的实现都提供了将文件映射到一个进程地址空间内的功能。一旦文件被映射到进程地址空间内,这个文件就可以作为地址空间内一段连续的字节区被直接访问。这就让进程可以使用内存的加载和保存操作而不是系统调用read和write来访问文件的内容。映射文件在逻辑上与共享内存类似:映射到同一文件的多个进程可以选择共享映射,从而让一个进程所做的改动也可以出现在其他进程的地址空间内。

版权声明:本文内容由阿里云实名注册用户自发贡献,版权归原作者所有,阿里云开发者社区不拥有其著作权,亦不承担相应法律责任。具体规则请查看《阿里云开发者社区用户服务协议》和《阿里云开发者社区知识产权保护指引》。如果您发现本社区中有涉嫌抄袭的内容,填写侵权投诉表单进行举报,一经查实,本社区将立刻删除涉嫌侵权内容。

分享: