译者注: 本系列总共有5篇,对于软件开发人员讲的很通俗易懂,有助于我们对进程内存的理解,故拿来翻译一下,希望对大家有帮助。
引言
在Intersec公司我们选择使用C语言作为编程语言,因为C语言对于我们所做的事情上给予了全部的控制力,可以达到一个很高的性能水平。对于大多数人,性能是尽可能的减少CPU指令。但是,在现代硬件上是远远要比单纯的CPU复杂的多,任何一个算法都要考虑内存、cpu、硬盘以及网络IO的处理,这些都增加了算法的代价。只有适当的理解了这些,才能保证算法的性能和稳定性。
cpu对于性能以及磁盘、网络对于延迟的影响都能很容易被理解,但是对于内存的影响就没那么容易被理解。在我们与客户打交道中发现,即使是被广泛使用的工具,例如top命令,大多数系统管理员也无法很明确的理解这些工具的输出内容。
本文是本系列的第一篇(总共五篇),我们将介绍这些主题——内存的定义、内存是如何被管理的、如何理解相关工具的输出结果等等,这些应该会是开发和系统管理员都感兴趣的。虽然绝对大多数的规则都适用于大多数现代操作系统,但我们具体只针对Linux系统和C语言来介绍。本文不是第一个写关于内存的,我们强烈推荐这篇高质量的文章(作者:Ulricht Drepper)—— What every programmer should know about memory.(翻译文:https://www.oschina.net/translate/what-every-programmer-should-know-about-memory-part1?lang=chs& )
本系列第一篇将介绍内存的定义。 我们假设读者已经对内存地址和进程有了一定基础的理解,包括系统调用、用户态与内核态的区别。你还需要知道,进程是运行在内核之上的,进程要获取资源,需要通过系统调用(system call),由内核与硬件交互。更多关于系统调用的细节,可以通过阅读相关的手册。
虚拟内存(Virtual Memory)
现代操作系统里,每一个进程都是活动到自己的内存空间中。操作系统并不是直接把内存地址映射到硬件地址的,作为一个硬件抽象层,操作系统为每一个进程提供了独有的内存空间。操作系统为每一个进程在内核中维护了一个地址转换表,cpu通过这张表实现了虚拟地址与物理内存地址的转换(每次内核进行进程切换,都会改变这个地址转换表)。
译者注:这个地址转换表在linux中就是页表,进程间发生context switch,就会切换整个页目标的基址,以实现不同进程的同一个虚拟地址映射到不同的物理内存。
虚拟内存有多个目标。首先是让进程间能够相互隔离。一个进程在用户态中访问内存是以虚拟地址来表达的,因此,它只能访问自己空间中的内存,而不能访问到其他进程的内存空间(除非是显示的声明为共享)。
第二个目标是对硬件的抽象。内核能够随意的将一个物理地址指向一个虚拟地址,同时也能选择对一个虚拟内存不申请真实的物理内存,直到真正的需要被使用为止。此外,当占用的内存长时间不使用或者内存不够的时候,还能将内存交换到磁盘,来释放内存占用。这样对于内核使用硬件而言就很自由,唯一的约束就是要确保读的时候是之前写的同一块内存。
第三个目标是对于那些非内存的硬件资源也能用类似的地址方式来访问,这是mmap和文件映射背后的原理。通过一个与文件映射的虚拟地址,你能够用访问内存的方式通过内存缓冲(Memery Buffer)来访问该文件。这是个非常有用的抽象,能够使得代码非常的简单,尤其是在64位的操作系统上,有了一个巨大的虚拟地址空间,只要你想,你能够映射整个磁盘。
第四个目标是共享。由于内核知道每个进程的虚拟地址映射的情况,内核知道如何避免同样的物理资源被加载多次。为此,内核采用写时复制(Copy-on-Write, COW)的方式来实现共享。即,当两个进程共享同一块数据时,如果有其中一个进程要修改而另一个又不希望看到该变化(译者注:对于进程而言这块内存是私有的),内核会对修改的数据做一份copy。最近,操作系统更具有了对不同地址空间中侦测同一内存的能力,能够自动让这些内存映射到同一个物理地址,在Linux中称为KSM技术(Kernel SamePage Merging)。
fork()
写时复制(COW)最常见的例子就是fork()函数了。 在类Unix的系统中,fork()是一个系统调用,利用复制当前进程来创建一个新进程。当fork()函数返回时,两个父子进程会在同样的代码位置、同样的文件打开表以及同样的内存空间下继续执行。有了COW,当fork一个进程时,fork()函数不再需要重新复制一遍进程的内存,只有当父子进程对数据有修改时,才需要复制。由于大多数fork()的使用是伴随着exec函数的使用,该函数会使得当前的虚拟内存空间整个无效,所以COW的机制能够很好的避免内存复制的浪费。
fork()函数的另外一个效果是可以没有任何代价的创建一个进程的快照。如果你想对进程的内存进行一些操作,但又不想对当前进程产生数据修改的风险,而且也不想使用代价高且容易产生错误的锁机制方式,那么就请使用fork,将你的操作在fork出来的子进程上操作,最后将计算结果返回给父进程,例如通过return code,文件,共享内存,管道等等。只要你的计算足够快,使得大部分的内存仍然是父子进程共享的,那么这种方式是特别好的。它能使得你的代码很简单,将隐藏的复杂性交给了内核的虚拟内存而不是你。
内存页(Pages)
虚拟内存被划分为一个个页(page),页是它的最小单位。页的大小受CPU影响,通常是4KB。当需要更多内存的时候,内核会给你一页或更多的内存页。同样,释放内存的时候也是按页为单位。对于每一个已分配的内存页,内核都保持了一组权限,即该页是否可读、可写以及可执行。这些权限要么是在分配内存的时候设置或者是之后调用mprotect()函数来设置。内存页在没被分配的时候是不能被访问的,当你对内存页做一个不被允许的操作时,会触发一个段错误(Segmentation Fault),例如对一个没有读权限的内存页进行读操作。
内存类型(Memory Types)
在虚拟内存中,并不是所有的内存是一样的。我们可以用横纵坐标来区分内存类型,横轴是内存是私有还是共享,纵轴是内存是否是基于文件(file-backed)的,对于不是基于文件的内存,我们称为匿名(anonymous)内存。
PRIVATE | SHARED | |
---|---|---|
ANONYMOUS | stack malloc() mmap(ANON, PRIVATE) brk()/sbrk() |
mmap(ANON, SHARED) |
FILE-BACKED | mmap(fd, PRIVATE) binary/shared libraries |
mmap(fd, SHARED) |
私有内存(Private Memory)
私有内存,顾名思义,就是该内存是进程独有的。在我们程序中,大多数内存都是私有内存。因为在私有内存上的修改是对其他进程不可见的,所以采用的是写时复制(COW)。因此,基于COW的方式,即使内存是私有的,几个进程仍然可以通过同一块物理内存来共享数据,特别是共享库和二进制文件。一个常见的错误认识是,KDE因为每个进程都会加载Qt以及KDElibs而会消耗大量的内存。但实际上不会,因为有COW机制,所以所有的进程只会访问这些共享库只读部分所在的同一份物理内存。
共享内存(Shared Memory)
共享内存是设计作为进程间通信用的。它只能通过显示的mmap或者专用的shm*函数调用来创建。当一个进程在共享内存上写数据时,数据的修改能够被所有与该内存相关的进程看到。
如果内存是基于文件的,任何映射了该文件的进程都能看到文件的修改,因为修改会通过文件来传递。
匿名内存(Anonymous Memory)
匿名内存是只存在于RAM的内存。对于匿名内存,内核只有当进行写操作时,才会真的分配物理地址给该内存。因此,匿名内存在被真正使用之前,是不会对内核产生任何代价的。这使得进程可以在虚拟内存中保留很多内存而又不需要真正使用物理内存。也因此,内核允许进程拥有比实际内存更多的虚拟内存。这种方式通常被称为内存过量使用,over-commit (memory overcommitment)。
基于文件的内存和交换区(File-backed and Swap)
当一个内存类型是基于文件的,那么表示该内存的数据是从磁盘加载而来。大多数情况,都是按需加载的,但也可以主动告知内核,使其预先加载。当你知道具体访问方式的时候,比如顺序访问,那么预先加载有助于你的程序跑的更快。为了避免占用太多内存,你也可以告知内核来处理内存的卸载。这些都可以通过madvise()的系统调用来实现。
当系统缺少物理内存时,内核会试着移动一些内存到磁盘里。如果内存是基于文件且共享的,那么是相当容易的。因为文件才是数据的源头,所以只需要从RAM中移出即可,等到下次需要读的时候,再加载回内存。
对于匿名或者私有的内存,内核也能够从RAM中移出,通过将数据写入到指定的磁盘区域中来实现,这块区域叫做交换区(swap),这种移出方式称为换出(swap-out)。在Linux中,swap区是一个特定的磁盘分区,而其他系统可能是特别的文件。它的工作方式和文件一样,当需要访问的时候再次从swap区中加载。
有了虚拟地址空间,内存页的换进/换出(swap in/out)对于进程而言都是透明的,唯一的区别的是从磁盘访问的延时较长。
总结
本文主要讲述了一些关于内存的重要概念。虽然,我们已经谈论到了物理内存以及与保留的地址空间的区别,但还未涉及实际进程的内存处理。在下一篇文章中,我们会讲述与这个话题并介绍一些工具让你明白一个进程对内存的占用。