2014年(14)
分类: LINUX
2014-04-14 11:17:52
开发人员的视角
在前面的文章中,我们由外在的视角对内存进行了分类、分析,并观察到内存可以按照不同的方式和属性来进行分配。在接下来的文章中,我们将站在开发人员的角度看待内存。
在Intersec,我们所有的软件都是用C语言编写实现的,这也意味着我们得不断与内存管理打交道。我们希望开发人员对各种已有内存池有扎实的知识。 在这篇文章中,我们将概览Linux系统中C程序员获取内存的主要来源,同时还将介绍一些有助于保持程序正确、高效的内存管理规则。
局部性原理
对于内存页,我们已经谈论了很多,它是内核和硬件中的分配单元。CPU采用了更小的寻址单元:cache line。cache line通常是64字节长,这是CPU从主存中读取数据到内部各cache的大小。
最开始CPU没有cache,但CPU性能比内存总线性能发展更迅速。因此为了避免花费太多时间从主内存中读取数据,CPU直接在芯片内增加了少量的内存单元。最初CPU只有单一的、小的cache,但如今可能拥有三级cache。cache越接近CPU,其访问速度越快。离CPU越远,cache越大。下表是2010年酷睿i5-750处理器中每级cache的大小和访问速度:
级别 |
大小 |
预计CPU周期 |
观察到的CPU周期 |
访问时间 |
寄存器 |
几字节 |
0 |
0 |
0 ns |
L1 dCache |
32KiB |
4 |
2 |
0.8 ns |
L2 Cache |
256KiB |
11 |
5 |
1.9 ns |
L3 Cache |
8MiB |
31 |
38 |
15ns |
主存 |
几GiB |
100 + |
40+ ns |
|
硬盘 |
数百GiB |
10M + |
5M+ ns |
为了避免因读取内存数据而耽误CPU时间,必须尝试重用已在cache中的数据。这就是局部性原理。
空间局部性和时间局部性:
空间局部性:组织你的程序,使得一起访问的变量集中在一块。(译者注:保证相关数据的集中存放)
时间局部性:组织你的代码,以便对相同位置的访问在时间上靠近。
因为内存是按cache line进行读取的,一个好的做法是将经常一起访问的变量集中到一个cache line中。一些工具可以帮你检查结构体的布局,例如 pahole :
CPU也有访问模式检测原语,用于预取那些CPU认为可能即将访问到的内存。适当的预取操作能避免cpu更多的延迟,因为当cpu需要访问内存时它已经被读取到cache中了。
你可以在这里找到更多的细节:。
所有者
管理内存需要好的习惯。当程序与内存打交道时,它必须知道那块内存由哪个模块负责,并且当模块将该内存设置为无效时就不该再访问它。
这意味着,对于每块内存,开发人员必须维护两个属性:
所有者 :谁负责该内存?
生命周期 :内存什么时候失效?
可以用不同的方式来处理这些属性。首先,可以隐式地 处理 :一些结构可能永远拥有它们指向的内存。这种隐性声明可以是编码约定或在函数或结构体定义中说明。
其次,也可以明确声明: 将 指针与一个标志或变量关联起来,以表明它是否拥有这块内存。
清理函数在退出时重置变量,这样就能避免持有被释放的指针,从而确保这块内存以后不会被“意外”访问。我们仍然可能间接引用空指针,但这将直接导致错误而不会有破坏内存分配池的风险。顺便说一句,这里确保了清理函数的幂等性,它使内存清理代码更简单。(译者注:幂等性是指使用同样的参数重复调用同一方法时总能获得同样的结果。)
对所有者进行正确的跟踪可以避免如释放后使用、重复释放、内存泄露等一系列问题。
内存池
对词汇的简单说明:尽管在书本中pool和arena常作为同义词使用,在本文和接下来的文章中,我们将用它们表示两个不同的概念。pool表明数据源,而arena则是指将要被内存分配器分割为小块的大块内存 。(译者注:本文中的池都是指pool)
为了能跟踪一块特定内存的生命周期,开发人员必须知道其来自哪个内存池。内存中有几种标准的内存池。下面的章节会详细介绍其中最重要的几个。
静态池
所有者 |
运行时库(动态加载器) |
生命周期 |
与保存数据的文件相同 |
初始化 |
显式赋值或0 |
静态内存是runtime在程序启动时或动态链接库加载时分配的内存。 它包含了全局变量,无论它们的链接属性如何(extern,static,函数中定义的static变量…),以及所有的常量(包括文字字符串和导出的const 变量 )。在C语言中,除非进行显式的初始化,否则全局变量总是初始化为0。因此,当程序启动时可能有大量的静态内存会被初始化为0。
静态内存的内容是由可执行文件中解析出来的。在Linux中,大多数的可执行文件是格式。这种格式包含一系列的section,每个section有不同的作用:保存代码、数据或者二进制文件的各种元数据(调试信息,动态链接指针等)。当可执行文件加载时,这些section依据各自属性有选择性地加载到内存中。标准的section有几十种,本文只介绍其中几个。
首先是.text段。该section包含二进制文件的主要代码,代码的其它部分则在一些包含特殊执行代码的section中(如.init段包含文件被加载时执行的初始化代码,.fini段则包含文件被卸载时执行的终止代码)。这些section被映射到内存中并带有PROT_EXEC标志。 如果你回忆上篇文章,这些section在pmap的输出很容易辨认:
接下来是三个用于保存数据的section:.data,.rodata和.bss。第一个存放显式初始化的变量,第二个存放常量(因此它是只读的),最后一个是专门放未初始化的数据。数据在这三个section中的确切划分依赖于你的编译器。大多数时候我们可以观察到:
初始化为0的变量的和常量放在.bss段。
其它变量放在.data段。
其它常量放在.rodata段。
一般.rodata段紧随在可执行代码段后面,因为它们都不是可写的。这样就能同时进行只读数据和可执行代码的映射,从而简化了初始化过程。这在可执行代码段很小时,就比较明显。
同样的,因为.data段和.bss段都是可写的,所以.bss段通常直接放在.data段后。.bss段具有稀疏特性:因为它仅包含将会用零填充的未初始化数据,因此只用section的大小就足够定义它了。因此,如果需要的话,.bss段会被映射为可读写的匿名映射,直接跟在.data段后。
各section的开始和结束不会被限制在一个页面内。因此,当.bss段分布在为.data段映射的内存之后时,那段内存将被零填充,以符合.bss段的初始值。.bss段最终将映射到匿名内存。
正如你所看到的,二进制文件并不是直接装载到内存中。事实上,ELF可执行文件加载时按不同的映射区对应加载到不同的内存中,这些内存在运行时分配。
请注意,这里的说明并不详尽,我们并没有涵盖所有映射到内存的section。
栈
所有者 |
运行时(活动记录即栈帧) |
生命周期 |
在它声明的范围内 |
初始化 |
无(随机的) |
栈用于记录函数的上下文。它是由一系列栈帧(也称为活动记录 )组成,每个栈帧是不同函数的上下文。
分配
栈内存是在线程启动时分配的。主线程栈的分配与子线程栈有点不同。使用pmap你能发现主线程栈的位置在[stack]部分,而子线程栈显示为[anon](而这些信息在/proc/[pid]/maps中显示为[stack:pid_of_the_thread])。
主线程的栈是由内核动态管理的;映射区的初始大小是不确定的,它会随着栈的增长自动增长。子线程栈是在线程初始化时分配的私有匿名映射区,其大小为的stacksize属性值。这个栈是有大小限制的,不过在最近的Linux发行版中该默认限制值为数兆字节,这通常可以存储几千个栈帧。这个最大值的定义是用来的 ,可以在系统的不同层次上更改:通过/etc/limits.conf文件,使用ulimit命令行或者调用setrlimit()函数。
当栈的内容超过上限时,进程会因栈溢出导致的段错误而crash。对于主线程,栈区是由内核动态管理的,而其它线程的栈前有块小的不可访问区,对这块区域的任何访问都是非法的,会导致段错误。因此,递归函数调用过深时会导致栈溢出,因为每次递归 都 会创建一个新的栈帧。
每次函数被调用时,通过将参数和各种状态信息压入栈顶,一个新的栈帧即被压入栈的顶部。当函数返回时,它的栈帧被弹出,栈返回到调用前的状态。一个栈帧的确切大小和内容依赖于ABI,因此也依赖于操作系统、计算机的体系结构,甚至是编译选项。然而,它的增长一般与参数的数目和被调用函数的局部变量数量相关。
一般来说,栈帧的大小是由编译器静态决定的。然而,通过使用这一非标准方式可以实现动态分配。每次调用alloca可以扩展当前帧。没有调用能释放或调整alloca的分配。由于栈的大小受限,alloca必须小心处理以避免栈溢出 。循环调用alloca或者用动态定义的过大值来调用alloca都是糟糕的主意。
生命周期
一个常见的错误观念是,函数的每个局部变量都会在栈上分配内存。然而,当编译不进行优化时也许真是这样,但它通常都是错的。编译器的工作是确保生成的程序运行得尽可能快。因此,只要有可能它就会尽量让数据保存在CPU的寄存器中,以减少访问延时,但由于寄存器的数量有限,当变量太多时(或者当我们需要一个指针指向它们时),一些变量就必须放在栈上。编译器会分析对每个变量的访问,然后依据分析结果来分配。
其结果就是,两个不会同时使用的变量也许会共享相同的寄存器或栈上相同的位置。一个变量也 许 不会总被分配到相同的寄存器或内存地址。因此,你必须牢记,即使栈上的内存与栈帧仍然有效(即只要这个函数不返回),一个特定的内存位置可以被几个属于不同区域的局部变量使用:
因此,一个指向栈上变量的指针一定不能离开那个变量的词法范围,更不用说,它绝对不能作为函数的返回值。编译器能够报告一些指向栈上变量指针的错误用法,但这只是可能出错的一小部分。所以,当使用指向栈上变量的指针时你必须非常小心,尤其要确保指针永远无法脱离其有效范围(也就是说,它们从来没有被返回或存储在一个全局变量或其他具有更长生命周期的变量中)。
堆
所有者 |
brk()的调用者 |
生命周期 |
直到另一次调用brk()释放了这部分内存 |
初始化 |
0 |
堆是一个特殊的内存区域,它有固定的位置,并能够增长。通过使用调用来进行管理。这两个函数可以让你以页的粒度添加或删除堆尾部的内存。以前它们是用来增长数据段的(即增加.bss段对应的映射)。不过这是在内存管理不太先进并且内存受限的时期。如今虚拟地址空间很大,也没有必要再像原来那样使用它了。
正如你在/proc/[pid]/maps中看到的,在现代操作系统中,[heap]仍紧靠内存地址空间的起始地址,在可执行文件映射之后(当然也在.bss段之后了),不过它与.bss段有很大的差距:
与栈一样,堆的大小也受资源限制。
在实际编程中,你不必手动去管理堆。多数malloc的实现在内部已做了相关工作。
mmap()
所有者 |
调用者 |
生命周期 |
直到进行对应的munmap操作 |
初始化 |
文件内容或0 |
mmap系统调用是内核早期的虚拟内存管理接口。它可以以页的粒度申请我们在中定义的各种内存。映射的文件大小可以不是页大小的整数倍,最后一个页面中剩下的区域将简单地填充零(不会被添加到磁盘) 。
当调用mmap时,你操作的是内核内部的查找结构,实际上并不会分配内存块。因此当使用MAP_FIXED标志在同一页面上多次调用mmap并不会创建新的映射,它只会在指定的地址改变页面的属性。munmap与mmap相反,munmap由内核查找结构中移除特定的页。没有必要成对地调用mmap和munmap:你既可以通过一个munmap取消掉几个映射区,也可以通过多个mumap来取消一个映射区。(译者注:不是直接匹配区域,而是通过区域进行搜索,只要在区域范围内即可解除映射)
用一个例子来说明:
malloc
所有者 |
调用者 |
生命周期 |
直到用free()进行释放 |
初始化 |
无(随机内容) |
可能是c语言中最广为人知的内存来源,它是标准的动态分配器,相关函数有calloc() , realloc() , memalign() , free(),其工作原理是:向内核请求大块的内存,将它们分割为较小的满足调用者所要求的块,同时限制内存和CPU的开销。(译者注:这里的内存和CPU的开销是指内存分配器的算法开销和其所维护的内部数据开销)
Linux系统中有各种不同的malloc()实现。最引人注目的可能是ptmalloc系列和谷歌的tcmalloc。glibc使用了ptmalloc2的实现,ptmalloc自身是在(dlmalloc)上封装的。在那个版本中依据请求分配的大小不同使用了不同的分配算法。它使用两种来源的内存:对于小块内存分配它使用堆,而对于大块内存则使用mmap()。大块和小块间默认的阈值是动态估算的,在128KiB和64MiB之间。调用时通过使用M_MMAP_THRESHOLD和M_MMAP_MAX选项来改变这个阈值。
分配器将堆分割为未分配的内存块列表。当分配请求到来时,它遍历列表寻找最合适的块。当找不到可供使用的块时,它通过brk()系统调用增长堆,把添加的内存记录在其内部列表中。
当一块内存被释放时(通过调用free() 释放先前由malloc()返回的指针),它被添加在空闲块列表中。当堆中最后的页都被释放时,这些页面才真正被释放,堆被减小(通过brk()调用)。因此,在程序中调用free()可能不会对进程实际使用的内存有直接影响。如果程序执行很多各种生命周期内存的分配和释放,可能导致堆得碎片化:即使它包含很多已释放的内存,仍不会减少。然而,调用free()来释放一大块内存(因为太大使用mmap分配)将调用munmap,它将直接影响进程的内存消耗。
在64位的系统中ptmalloc2每次分配内存至少有8个字节的内存开销。这些字节用于存储其内部分配的元数据。这意味着它对于非常小的内存分配是非常低效率的(但大部分malloc()实现都会遇到这种情况)。这些管理字节 就 存储在返回的指针之前,这意味着他们很容易因为内存溢出而被覆盖。覆盖的后果就是分配器的异常,它将在后面导致段错误(这里的后面意味着crash的出现是完全随机的)。
如今,一个好的malloc()实现需要应对多线程的环境,并保证正常的运转。大部分时候它是通过线程本地缓存和共享堆实现的。分配器首先将尝试在本地缓存中找匹配块,如果没有找到才会去堆中分配(需要锁)。
下一章:intersec的自定义分配器
整整花了三篇相当长的文章打下了内存的基础,在接下来的文章中,我们将介绍intersec为了满足自身需求而开发的分配器的具体实现。
同时你也应该避免将大部分情况下只读的数据和大部分情况下只写的数据放在同一cache line中,以避免中cache的竞争。
如果.bss段真的很小,并且刚好能放入.data段的映射中,那么它就不会专门进行映射了。
由于映射是私有的,写0对其他进程是不可见的。
帧被放在栈中,由内存的高地址向低地址增长。
编码规范往往会禁止使用alloca(),因为它是既危险又不可移植。
请注意,如果由于某种原因,你要减小已映射文件的大小而不减小相关的映射大小,访问映射中没有底层存储的部分会产生SIGBUS信号。据我所知这是导致Linux中SIGBUS的最常见情况。
来源声明:本文来自Intersec Tech Talk的博文《》,由IDF实验室童进翻译。
(全文完)