原文地址: https://techtalk.intersec.com/2013/08/memory-part-1-managing-memory/
在Intersec.com我们选择C语言。因为它能让我们完全控制想要做的事。并且能达到一个相当高的性能。对于许多人来说,性能就是尽可能的少用CPU指令。然而,现代的硬件是如此复杂而不能仅仅考虑CPU。算法必须考虑内存,CPU,磁盘和网络I/O等等。这些每一项都给算法增加了开销。为了同时保证算法的性能和可靠性我们必须正确的理解他们中的每一项。
CPU对性能的影响(由此产生了算法复杂度)已经被很好的认识到。磁盘和网络的延迟也是如此。但是内存似乎很少被深入的理解。根据从我们的客户那里得到的经验来看,即使是广泛使用的工具,例如top,对于大部分系统管理员来说都是神秘的。
这篇文章是五篇关于内存的一系列文章中的第一篇。我们将会讲解内存的定义,它是如何管理的,如何解读工具的输出等。这个系列的文章将面向开发人员和管理员都感兴趣的主题。尽管大部分的规则都适用于大部分的现代操作系统,我们也会特别谈到Linux和C语言相关的内容。
我们不是第一个写关于内存的文章。实际上,我们想推荐一篇很棒的文章,由Ulricht Drepper写的:[每一个程序员应该知道的关于内存的知识]http://www.akkadia.org/drepper/cpumemory.pdf
这是第一篇文章,我们会讲解内存的定义。假定你至少知道地址和进程这些基本的知识。也会涉及到系统调用和用户态及内核态的区别。不过你只需要知道你的进程(用户态)运行在内核之上,内核负责跟硬件交互,系统调用让你的进程跟内核交谈,以请求更多的资源。你可以阅读各个系统调用各自的手册来了解更多的细节。
现代操作系统中,每个进程存活在它自己的内存分配空间中。操作系统提供了硬件抽象层并为每个进程创建一个虚拟地址空间,而不是直接把内存地址映射到硬件地址。这个物理地址到虚拟地址的映射是由CPU完成的,而CPU是使用了内核为每个进程维护的一张转换表(每次内核改变了运行在一个特定CPU核上的进程,它也要改变那个CPU的转换表)。
使用虚拟内存有几个目的。第一,进程隔离。一个进程在用户空间只能以虚拟内存来表示内存地址空间。因此它只能访问被映射到虚拟地址空间的数据,而不能访问其他进程的内存(除了声明为共享的内存)。
第二个目的是对硬件抽象。内核可以自由的改变物理地址到虚拟地址的映射。也可以不映射特定的虚拟地址直到它被真正的用到。此外,在内存长时间没有被使用,或者系统内存不足时可以把内存交换到磁盘。总体上给了内核很大的自由,唯一的限制是程序在读取内存时实际上会看到之前写过的数据。
第三个目的是可以给实际上不在物理内存(RAM)中的东西分配地址。这是mmap和映射文件背后的原理。你可以把虚拟地址分配给一个文件,然后可以像访问内存buffer一样来访问它。这是非常有用的抽象,可以保持代码的简单行。由于64位系统有巨大的虚拟空间,只要你愿意,你可以映射你的整个硬盘到虚拟内存。
第四个目的是共享。由于内核知道各个运行进程在虚拟空间中的映射情况,它可以避免把数据重复加载到内存,使得使用相同资源的不同进程的虚拟地址指向相同的物理内存(即使每个进程实际的虚拟地址不一样)。内存共享使得内核可以使用写时拷贝(COW):当两个进程使用相同的数据,但其中一个进程修改了数据而另一个进程不允许看到改变时,内核会复制一份数据。更多时候,操作系统有能力检测到多个地址空间的内存相等,自动把它们映射到相同的物理内存(把他们标识为COW)。在Linux上这也称为KSM(内核相同页合并Kerneal SamePage Merging)。
关于COW最为人们所知的就是fork()。 在类UNIX系统上,fork()是一个系统调用,它通过复制当前进程来创建一个新的进程。当fork()返回时,两个进程在同一个点继续执行。这两个进程有着相同的打开文件,和相同的内存。感谢COW,fork()不会复制内存。只有数据被父进程或子进程修改时,数据才会在内存中复制。由于fork()大部分的使用场景是立刻跟一个exec()调用,使得整个虚拟内存地址空间失效,COW机制避免了对父进程内存的无用的拷贝。
另一个附带的好处是,fork()以很小的代价创建了一个进程(私有)内存的快照。如果你想在进程的内存上执行一些操作而不想冒被自己修改的风险,同时不想增加开销很大且容易出错的锁机制,那么仅仅fork,完成你的工作,然后把结果返回到父进程(通过返回码,文件,共享内存,管道等等)
如果你的计算足够快这个机制会非常好的工作。因此一个大块的内存在父进程和子进程直接共享。这样使你的代码简单。复杂性隐藏在内核的虚拟内存代码中,而不是你的代码。
虚拟内存被分为pages(页)。页的大小由CPU决定,通常是4KB。这意味着在内核中内存管理是以页为单位进行的。当你请求新的内存,内核会给你一个或多个页。统一,当你释放内存时,会释放一个或多个页。每个更精细的内存分配 API(例如,malloc)都在用户空间实现。
对每个分配的页,内核记录了一组权限:可读,可写和/或可执行(注意不是所有的组合都可行)。这些权限在映射内存时设置,也可以使用mprotect()系统调用在之后设置。没有分配的也无法访问。当你尝试对一个页执行禁止的操作(例如,读取没有读权限的页),你会触发(在Linux上)一个段错误(Segmentation Fault)。从侧面来说,你会看到,段错误的粒度也是页,你可能会执行一个超出地址的访问,但是没有引发段错误。
虚拟内存空间内分配的内存不都是一样的。我们从两个纬度来区分:第一个纬度是内存是私有的(private, 某个进程特有的)或共享的。第二个纬度是内存是否是文件备份的。非文件备份也叫做匿名。这两个纬度把内存分为四个类型:
私有内存,正如其名,是指进程专用的内存。你在程序中用到的内存大部分都是私有内存。
由于私有内存的变化对其他进程是不可见的,它是写时拷贝的。副作用是,即使内存是私有的,几个进程也可能用同样的物理内存来存放数据,特别是二进制执行文件和动态共享库。一个常见的误解是KDE占用了很多物理内存,因为每一个进程都加载了Qt和KDE库。实际上,感谢COW机制,所有的进程都用完全相同的物理内存来存放这些库的只读部分。
对于基于文件的私有内存来说,被进程修改的部分不会被写回到对应的文件中。但是对文件的修改,进程可以看到,也可以不看到。
共享内存被设计为用于进程间通信。它只能用明确的请求来创建,比如mmap或shm*系统调用。当一个进程修改共享内存时,所有映射同样内存的进程都能够看到修改。
对基于文件的共享内存来说,所有映射同一文件的进程都会看到文件的变化,因为这些变化会通过文件本身来传递。(注,也就是说,进程对这类内存的改变,会回写到文件中,这跟私有内存是不同的)
匿名内存完全在物理内存中。但是内核在这块内存被写入之前,不会给它映射到实际的物理内存地址。因此,匿名内存在真正使用前,不会给内核带来任何负担。这使得进程可以在虚拟地址空间“预留”很多的内存,而占用实际的物理内存。因此内核允许你分配比实际内存更大的内存。这个行为通常称为内存over-commit(或者memory overcommitment)。
当一个内存映射是基于文件的,数据从磁盘载入。大多数时间,载入是按需的。但是你可以给内核一些提示,让它提前预读取。当你使用一个特别的访问模式(通常是线性访问)时,这会让你的程序反应迅速。为了避免使用太多的物理内存,你可以告诉内核,你不再关心物理内存中的页,但是不要解除映射关系。所有这些都可以通过系统调用madvise()实现。
当系统缺少物理内存时,内核会尝试把一些数据从物理内存移动到磁盘上。如果内存是基于文件且共享的,这很容易。因为数据的源是文件,只要把数据从物理内存移除就行。下次要再使用这部分数据时,再从文件加载。
内核也可能选择从物理内存移除匿名/私有内存数据。这种情况下,数据会被写到磁盘上一个特别的地方。这被称为交换出去。在Linux上,swap(交换分区)通常被存放在一个特别的分区。在其他系统,它是一个特别的文件。然后它可以像基于文件的内存一样工作:当它被访问时,再从磁盘加载到物理内存。
因为使用了虚拟地址空间,页被换进换出对于进程是完全透明的。除了磁盘I/O带来的延迟。