内存解析(一):内存类型(译)

前言

本系列文章主要翻译自techtalk.intersec.com。目的在于帮助大家更好的理解现代计算机的内存管理知识。如有翻译不到位的地方还请指正。

简介

在Intersec,我们选择C语言作为编程语言是因为它有很强的操控性和很高的性能。对很多人而言,性能只不过是尽可能的少用几个CPU指令。然而,在现代硬件体系下,复杂的东西不仅仅是CPU。它还包括算法需要消耗的内存,CPU,磁盘,网络I/O等等。以上每一例都增加了算法的开销并且为了保证算法的性能以及可靠性,你必须正确的理解以上每一例。

正如磁盘性能和网络延迟一样,影响CPU性能(算法复杂度)的因素是很好理解的。但是对于内存,我们似乎对它了解的不多。根据我们的经验与我们的客户显示,即使是像top这样如此常见的工具对于大多是系统管理员来还是很神秘。

此文是本系列的第一篇。我们将会详细的探讨诸如内存的定义、内存是如何管理的、如何去解读一些工具的输出内容... 本系列教程将受众于开发人员和系统管理员。尽管大多数的规则应同样适用于现代的操作系统,我们仍选择详细的探讨Linux和C语言编程。

我们虽不是第一个写有关内存方面文章的人。但在此,我们想特别强调一下Ulricht Drepper写的一篇高质量的文献《What every programmer should know about memory》

本文将阐明内存的定义。本文假设读者已经具备了诸如地址和进程的基本知识。本文会经常探讨诸如系统调用(system calls)、用户态(user-land)与内核态(kernel mode)之间的区别。然而,你需要知道的是你的进程(用户态)是如何通过内核跟硬件打交道的并且系统调用可以使你的进程通过内核而请求更多的资源。你可以阅读帮助文档来了解系统调用的细节知识。

虚拟内存(Virtual Memory)

在现代操作系统中,每个进程都运行在自己的内存分配空间内。操作系统除了需要将内存地址直接映射到硬件地址之外,操作系统还充当一个硬件抽象层并且为每个进程创建虚拟内存空间(virtual memory space)。CPU利用内核维护的每进程转换表(translation table)来控制物理内存地址和虚拟地址之间的映射关系(每次内核切换某一CPU核上的运行进程时只需改变该CPU核上的转换表即可)。

虚拟内存有许多的目的。首先,它可以使进程彼此独立。一个用户进程只能通过虚拟内存对内存访问。因此,它只能访问之前已经映射到自己虚内存空间的数据,它也不能访问其他进程的内存(除非显示指明为共享的)。

第二个目的是提供硬件的抽象关系。内核可以自由的改变虚拟地址所映射的物理地址。它同样可以不提供物理地址给虚拟地址直到真正需要的时候才提供。甚至它可以在系统缺少物理内存的时候将长时间不用的内存交换到磁盘中。这总体上给了内核许多的自由,唯一的限制是当程序在读内存的时候它实际上是在找之前写在上面的内容。

第三个目的是给不在RAM中的内容提供地址。这是mmap和文件映射的实现原理。你可以为一个文件提供一个虚拟内存地址从而使文件的访问如同访问内存缓冲区(memory buffer)一样。这是一个可以使代码保持简单的有用的抽象方法。由于64位机器有巨大的虚拟地址空间1,你甚至可以把整个硬盘映射到虚拟内存当中。

第四个目的是为了共享(sharing)。既然内核知道每个运行进程的虚拟空间映射关系,那么内核就可以避免两次载入相同的东西并且使利用相同资源进程的虚拟地址指向同一块物理空间(即便每个虚拟地址是进程独立的)。内核通过写拷贝(copy-on-write: COW)的方式实现共享:当两个进程使用同一片数据但其中一个进程修改了该数据而另一个进程是看不到修改的,内核只有在数据修改的时候才会拷贝。近期,操作系统也具备了在几个地址空间内识别特定内存并自动将其映射到同一物理内存的能力2,在Linux上叫做KSM(Kernel SamePage Merging)

fork()

利用COW技术的典型代表就属fork()。在Unix-like系统中,fork()是一个复制自身进程来创建进程的系统调用。当fork()返回时,每个进程都将从同一个位置继续执行,并且具有相同打开的文件和相同的内存。多亏了COW技术,fork()并不会在fork的时候立刻复制内存,只有当数据修改的时候才会在RAM中复制内存无论是被父进程还是子进程。由于大部分使用fork()之后都会紧接着调用exec()来使整个虚拟内存地址空间无效,COW机制避免了无意义的父进程的内存拷贝。

另一个副作用则是fork()以极小的代价创建一个进程(私有)内存的快照。如果你想对一个进程做一些内存操作而又不想承担内存修改的风险,也不想添加复杂且易错的锁机制的话那就大胆是去fork,做你想做的事并把结果返回给父进程(通过返回码、文件、共享内存、管道等等方式)。

只要你的电脑够快这将非常有用。因为,大部分的内存会在父子进程间共享。而且,这样会帮你把代码变得简单,复杂性也被隐藏在内核的虚拟内存代码中而不是你的代码内。

页(Pages)

虚拟内存被分割成页。每一页的大小是由CPU来决定的,通常为4KB3。也就是说,内核的内存管理粒度是页。当你申请新的内存时,内核会给你一页或几页。当你释放内存时,也是释放一页或几页。每个细粒度的API(如malloc)是在用户态实现的。

对于每分配的页,内核管理着一堆权限:页的可写、可读、与/或可执行(不是所有的组合都是可能的)。这些权限既可以在内存映射的时候设置也可以在mprotect()系统调用后设置。未分配的页是不可以被访问的。当你在某一页执行一个禁止的操作时(比如去读一个没有读权限的页),你将触发(Linux上)段错误(Segmentation Fault)。说句题外话,由于Segmentation fault是以页为粒度的,你可能会遇到越界访问(out-of-buffer accesses)没导致Segfault的情况。

内存类型

并非所有的虚拟内存空间中分配的内存是相同的。我们可以通过两个轴来分类:第一个轴为内存是否为私有的(针对进程来说)或共享的,第二个轴为内存是否是文件背景的(若为非文件背景的则称之为匿名:anonymous)。下面来看看这四类:

PRIVATE SHARED
ANONYMOUS statck, malloc(), mmap(ANON, PRIVATE), brk()/sbrk mmap(ANON, SHARED)
FILE-BACKED mmap(fd, PRIVATE), binary/shared libraries mmap(fd, SHARED)

私有内存(Private Memory)

正如其名,私有内存即某进程专属的内存。你操作的绝大多数内存事实上都是私有内存。

由于对私有内存的改动是不受其他进程所见的,他受控于COW机制。作为一个副作用,这意味着即使内存是似有的,其他进程仍然可能共享同一块物理内存的数据。尤其是当二进制文件和共享库的情况。一个普遍的误解:KDE占用很多的内存,因为每个单独的进程都要载入Qt和KDElibs。然而,多亏于COW机制,所有的进程将对那些库的只读部分使用同一块物理内存。

在有文件背景的私有内存中,进程对其修改并不会写到文件中。然而,对文件的改动可能也可能不会对进程可见。

共享内存(Shared Memory)

共享内存是为进程间通信而设计的。你只能通过指定对应mmap()调用或者使用专门的shm*调用。当一个进程写共享内存区域时,它的改动对所有映射到同一内存区的进程可见。

如果该内存是有文件背景的,任何映射到该文件的进程将会看到改动,因为那些改动是通过文件本身传播的。

匿名内存(Anonymous Memory)

匿名内存在RAM中的内存。但是内核只会在内存被写的时候才会将其映射到物理地址中。最终,匿名内存只有当它被使用的时候才会给内核增加压力。这使得一个进程可以在它的虚拟地址空间中“保留”许多的内存而不必用到真正的RAM。因而,内核可以保留比实际内存更多的内存。这种行为通常被称作内存过量分配(over-commit 或 memory overcommitment)。

文件背景和交换(File-backed and Swap)

当一内存是文件背景的时,这意味着数据是来自于磁盘。大多数时候,数据是按需加载的。然而,你可以给内核暗示,让他在读之前提前去取。当你的程序有特定的访问类型的时候(顺序访问),这会让你的程序反应灵敏。为了避免使用过多的RAM,你可以告诉内核你不关心那些在RAM中除了未映射的内存。这都是通过madvise()系统调用控制的。

当系统物理内存短缺的时候,内核会尝试把一部分数据从RAM移动到磁盘中。如果,内存是具有文件背景且共享的话,这会非常的简单。因为,文件是数据的来源,只是从RAM中先移除,当下次要用的时候再冲文件里加载过来就好了。

内核同样也可以从RAM中移除匿名/私有内存。在这种情况下数据会被写到磁盘上的一个特定的位置。也就是所谓的换出(swapped out)。在Linux中,swap通常是保存在特定的分区中,其他的系统中者可能是个专门的文件。在工作机理上和具有文件背景的方式完全一样:当需要读取的时候,再从磁盘上加载到RAM中。

多亏了利用虚地址空间,页的换入换出对进程来说完全是透明的(看不见的)。看的见的则是由I/O带来的延时。

下一章:驻留内存和工具

我们介绍了关于内存的一些极其重要的概念。虽然我们几次谈到物理内存和保留的地址空间的区别,我们避免了处理实际过程中的实际内存压力。我们会在下一章谈到那些话题并介绍一些帮助你理解一个进程消耗多少内存的工具。

  1. 目前消费级的CPU有48位的地址,这意味着可以访问248字节(256TB)的地址。
  2. 当你有大量重复使用的内存时这将十分有用,比如:当你运行一个虚拟服务器并且有许多虚拟机运行相同操作系统的时候
  3. 在Intel CPU上,有一个可选的大小:2MB。但在Linux中,存在2MB的可用是有限的,而且他还需要一个伪文件的映射,使用起来会非常尴尬。
linux