原文地址:https://techtalk.intersec.com/2013/08/memory-part-3-managing-memory/
之前的文章中,我们对内存进行了分类并且从外部视角进行了分析。我们看到内存可以用不同的方式进行分配,并具有各种不同的属性。接下来的文章中我们会采用开发人员的视角。
在Intersec我们所有的软件都用C来写,这意味着我们经常处理内存管理。我们希望我们的开发人员有关于各种内存池方面的扎实的知识。这篇文章我们会概要的看一下对C程序员来说在Linux上有哪些主要的内存资源。我们也会看到一些内存管理的规则,用于帮助你的程序保持正确和高效。
我们谈论了很多内核(和硬件)中分配的内存页。CPU使用更小的地址单位:缓存行(cache line)。一个缓存行是64字节大小。这是CPU从主内存取得数据并放到内部各种缓存的大小(注,即一次取64个字节)。
老一点的CPU没有缓存,但是CPU性能的发展比内存总线性能更快(注,原文是that,我认为这里应该是than)。因此,为了避免花费太多的时间从主内存获取数据,CPU在芯片内部直接增加了少量的内存。一开始只是很少的缓存,但是现代CPU可以最多有3级缓存。越接近CPU的缓存就越快。离CPU越远,它的大小就越大。这儿有一张小的表格向我们展示了按照缓存的大小和访问时间排序的各级缓存的一些数据,这些数据来自2010年产的个i5-750CPU:
为了避免CPU从内存取数据消耗CPU时间,你必须尝试使用已经在缓存中存在的数据。这就是局部性规则。
这里我们讨论空间局部性和时间局部性:
既然内存是按照缓存行来获取的,一个很好的实例是把经常一起访问的变量按照缓存行分组。一些工具,例如pahole,可以用于检查结构的布局:
struct mem_stack_pool_t { const void * start; /* 0 8 */ size_t size; /* 8 8 */ dlist_t blk_list; /* 16 16 */ mem_stack_frame_t base; /* 32 32 */ /* --- cacheline 1 boundary (64 bytes) --- */ mem_stack_frame_t * stack; /* 64 8 */ size_t stacksize; /* 72 8 */ uint32_t minsize; /* 80 4 */ uint32_t nbpages; /* 84 4 */ uint32_t nbpops; /* 88 4 */ uint32_t alloc_nb; /* 92 4 */ size_t alloc_sz; /* 96 8 */ mem_pool_t funcs; /* 104 24 */ /* --- cacheline 2 boundary (128 bytes) --- */ /* size: 128, cachelines: 2, members: 12 */ };
你可以在这里发现更多的细节[What every programmer should know about memory]。
# 所有权
内存管理需要好的习惯。当一个程序处理内存时,它必须知道哪个模块为该内存负责,并且在该模块把这块内存置为无效后不再访问它。
这意味着,对于每个内存块,开发人员要维护两个属性:
这些属性可以用不同的方式来处理。首先,隐含的方式,一些结构可以总是拥有它们指向的内存。这个隐含的契约可以是代码编写惯例的一部分,也可以在函数或结构的原型的文档中说明。
struct im_ptr_t { /* This pointer is owned by the structure */ void *ptr; }; void im_ptr_wipe(struct im_ptr_t *ptr) { free(ptr->ptr); ptr->ptr = NULL; }
第二,显式的方式:指针可以带一个标识(或者另外的变量),用于指明指针是否是指向内存的所有者。
struct ex_ptr_t { bool own; void *ptr; }; void ex_ptr_wipe(struct ex_ptr_t *ptr) { if (ptr->own) { free(ptr->ptr); } ptr->own = false; ptr->ptr = NULL; }
这个擦除(注,即上面的ex_ptr_wipe)函数在退出时充值了变量,避免指针指向释放的内存,由此确保该内存不会在将来某个时刻被“意外的”访问到。我们仍然可以解引用NULL指针,但我们会得到一个立即的错误,而不会存已分配的内存池被破坏的风险。顺便说一下,擦除函数确保是幂等的,这使内存清除代码更简单。
合理的追踪所有权能避免释放后使用,两次释放,内存泄漏等问题。
# 内存池
一个快速的词汇注解:尽管在一些文献中pool和arena常常是同义词,但在本文及后续的文章中,我们使用这两个术语来表示两个不同概念。(注,后面pool和arena不再翻译为中文,大家应该能更好的理解原文的意思)Pool表示数据的来源,而arena则表示会被内存分配器分为很多小块的大块内存。
静态内存是在程序运行时的开始阶段,或者动态库加载时分配的。其中包括全局变量,无论他们的可见范围是(外部,静态,还是函数内静态),也包括所有的常量(包括字面量字符串和const常量)。在C中,除非显式初始化,全局变量总是被初始化为0。因此,当程序启动时,大量的静态内存将会被初始化为0。
静态内存的内容是由可执行文件推算出来的。在Linux上,绝大部分可执行文件是ELF格式。该格式是一些连续的段,每个段有一些特别的用途:存放代码,数据或二进制文件的各种元数据(调试信息,动态链接指针...)。当可执行文件被加载时,这些段按照标识被有选择的加载到内存。在几个个标准段中,本文只对其中的几个段感兴趣。
首先是.text段。该段包含二进制文件主要的代码,此外还有一些其他的段包含特殊目的的可执行代码(例如.init段包含在文件被加载时执行的初始化代码,.fini包含一些在文件被卸载时执行的终止代码)。这些段以PROT_EXEC模式映射到内存。如果你能回想起前面的文章,这些段很容易在pmap的输出标识出来:
然后是存放数据的段。又分为三类:.data,.rodata和.bss。第一类包含明确初始化的可变变量,第二类包含常量(因此是只读的),最后是存放初始化的数据。准确的把三类数据分布到各自的段依赖于你的编译器。大部分时候我们可以观察到:
通常.rodata段紧挨着可执行代码段后面存放,因为两者都不可写。这样只读数据和可执行代码只需要映射一次,也简化了初始化的过程。当可执行代码很小时,可以看得更清楚:
同样,因为.data和.bss都是可写的,后者通常紧挨着前者。.bss段还有一个额外的属性,分布比较稀疏:因为它仅包含将会填充0的为初始化数据,段的大小要足够存放该定义。因此,如果需要,.bss段会作为读写匿名内存映射直接跟在读写映射的.data段后面:
各种段的开始和结束无需刚好限制在一个页上。因此,.bss段从.data段的映射尾部开始,这部分内存被初始化为0,也就是符合.bss段的初始化要求。然后.bss段在一个匿名内存结束。
正如你所见,加载一个二进制文件并不是直接了当的。一个ELF可执行文件的加载实际上是在运行时刻根据需要映射不同的内存块。
注意,在这里我们描述地并不全面,因为我们没有提到所有段在内存的映射。
栈是函数存放上下文的地方。它由连续的帧(frame)组成(也成为活动记录,activation record),每一帧是一个函数的上下文。
栈是线程开始的时候分配的一块内存区。主线程栈的分配和其他线程有一点不同。使用pmap你可以识别出主线程栈的位置(出现[stack]字样),但是其他栈显示为[anon](在/proc/[pid]/maps文件中显示为[stack:pid_of_the_thread])。
主线程的栈是由内核动态管理的,它的映射的初始化大小是未定义的,它会随着栈的增长而增长。其他的栈在线程初始化时以私有匿名映射的方式分配,它的大小由pthread_attr的stacksize值决定。栈有大小限制,不过现在的Linux发行版上默认大小有几兆,通常足够放数千个帧。最大值定义为一个资源限制,可以在系统的不同层面上进行修改:通过/etc/limits.conf文件,使用ulimit命令行工具,或者调用setrlimit()。
当栈上的内容超过允许的大小时,进程会因为栈溢出而发生段错误并崩溃。在主线程中,内核会动态接管这个情况,但是其他线程的栈在前面放了一块小的无法访问的内存区域,因此这是非法的并会产生一个段错误。所以深度递归的函数会导致栈溢出,因为每一次递归创建了一个新的帧。
每一次函数调用,一个新的带有参数和其他状态信息的帧会被压入栈顶。当函数返回时,它的帧会被弹出栈并恢复到之前的状态。帧的实际大小和内容依赖于ABI,以及操作系统,计算机架构,甚至编译选项。但是,通常它随着参数的数量和被调函数的本地变量的数量增长。
传统上,帧的大小由编译器静态指定。但是,使用alloca(3)调用的非标准方式动态分配也是允许的。每次调用alloca会扩大当前帧。没有调用来释放或修改alloca分配的大小。因为栈的大小是受限的,使用alloca必须注意不能造成栈溢出(编码规范通常不允许使用alloca,因为它既危险又不可移植)。把alloca放进一个循环或者用一个动态定义的值(可能很大)来调用alloca很明显都是馊主意。
一个通常的(错误的)观点是,函数的每一个本地变量都在栈上分配它自己的内存空间。可惜,即使在完全不加任何优化选项编译的情况下,这通常也是错误的。编译器的工作是让进程运行的尽可能快。因此,它会尽可能的把数据放到CPU的寄存器来减少访问延迟。不过由于寄存器数量的限制,当有太多的变量时(或我们需要一个指针指向它们),一些必须被放在栈上。编译器分析每一个变量的访问,并根据结果执行寄存器和栈的分配。
结果是,两个从未在同一时刻被使用的变量可能共享同一个寄存器,或者栈上相同的位置。一个变量可能不会总是被分配给同一个寄存器,或者相同的内存地址。因此你必须注意,当帧是活动的时候(也就是只要函数还没有返回),即使栈上的内存是有效的,一个特别的内存地址可能被不同范围的多个本地变量使用。
void my_broken_function() { int b; void *ptr; { int a = 1; ptr = &a; } /* ptr is now invalid */ b = 2; assert (&b == ptr); /* this assert may be true because b wasn't "alive" before so the compiler is allowed to delay it's allocation on the stack and to reuse the placeholder of a "dead" variable" such as "a" that is now out of scope */ { int c = 3; assert (&c == ptr); /* this assert may be true too */ } }
因此,一个指向栈上变量的指针,不能离开那个特定变量的词法范围,更不用说作为函数的返回值返回了。编译器能够报告一些指向栈上变量的错误用法,但仅限于一小部分可能的错误。因此你必须非常小心的使用指向栈上变量的指针,特别是确保该指针永远不要离开它的有效范围(也就是不要把它返回,存储在全局变量,或者有其他更长生命周期的变量)。
堆是一块特别的内存区,它的位置固定,并且可以增长。它被brk()和sbrk()调用管理。这些调用让你可以在堆的底部以页为单位增加或减少内存。通常认为,这些调用会增加数据段(也就是增加.bss段的映射大小)。然而,只是在内存管理还比较低级,内存也受限的那些时候是这样。如今的虚拟地址空间很大,不再需要对它们进行打包了。
正如你在/proc/[pid]/maps文件中看到的,现代操作系统中,堆仍然分配在靠近内存地址的开头,在可执行段映射后面(也在.bss段后面),但是跟.bss段之间有很大的一块空隙:
跟栈一样,堆的大小也是一种资源限制。
在实际情况下,你永远不会需要手动管理堆,而是在大部分的malloc()在内部实现。
mmap系统调用是跟内核虚拟内存管理器交互的基础。它以页为单位分配我们在第一篇中定义的所有类型的内存。这个粒度并不会阻止你映射大小不是恰好是页的倍数的文件,页里多出来的部分会被填充0(这并不会被写回磁盘)。
当调用mmap时,你修改的是内核中的一个查找结构的属性。你不会实际分配一块内存。因此在同一页多次调用mmap(使用MAP_FIXED标识)不会创建新的映射,只是指定地址的页属性改变了。munmap,mmap的反操作,从内核的查找结构移除一组特定的页。并不需要对称的调用mmap和munmap:你可以通过调用大的munmap一次解除多个映射,也可以通过多次调用小的munmap解除一块映射。
下面的例子描述了如何做到这些:
/** mmap a file of 256MB and ensure it is aligned on 256MB. */ void *mmap_256(int fd) { #define FILE_SIZE (256 << 20) /* Create a first map to find a sequence of pages that can contain * a sequence of 256MiB properly aligned */ byte *ptr = mmap(NULL, 2 * FILE_SIZE, PROT_READ | PROT_WRITE, MAP_ANONYMOUS | MAP_SHARED, -1, 0); byte *aligned_ptr; if (ptr == MAP_FAILED) { return ptr; } /* Find an aligned pointer in the mapped chunk */ aligned_ptr = (byte *)ROUND_UP((uintptr_t)ptr, FILE_SIZE); /* Unmap pages that do not belong to the aligned 256MB long chunk. * * This is done by calling munmap twice: once for the pages at the beginning * of the map, and once for those at the end of the map. */ if (aligned_ptr != ptr) { munmap(ptr, aligned_ptr - ptr); } munmap(aligned_ptr + FILE_SIZE, FILE_SIZE - (aligned_ptr - ptr)); /** Map the file in the remaining pages. * * Provides both an explicit address (from aligned_ptr) and the MAP_FIXED flags * to tell the kernel that we really want to map those pages. Since these are pages we just mapped * we know they are available. */ return mmap(aligned_ptr, FILE_SIZE, PROT_READ | PROT_WRITE, MAP_SHARED | MAP_FIXED, fd, 0); } /** Unmap a file mapped by mmap_256 */ void munmap_256(void *ptr) { /* A simple mnumap suffice */ if (munmap(ptr, FILE_SIZE) < 0) { assert (false); } }
malloc()是C里面知道的人最多的内存分配函数。它跟calloc(),realloc(),memalign(),free()等等都是标准的动态分配器。它从内核请求大块的内存,然后分割成小块来满足调用者的需求,减少了内存和CPU的额外开销。
Linux体系中有不同的malloc实现。最著名的实现可能是ptmalloc和谷歌的tcmalloc。glibc使用ptmalloc2实现,而ptmalloc2则适配了dlmalloc。这个版本使用了有点复杂的算法,对不同的大小的分配请求有不同方法。它使用两类内存。对于小的分配请求,它使用堆。而对于大的分配请求,它使用mmap()。默认的阈值是动态估算的,估算范围在128KB到64MB。这可以通过mallopt的M_MMAP_THRESHOLD和M_MMAP_MAX选项修改。
分配器把对分割为小块的未分配内存块并用列表管理起来。当有分配请求时,它遍历这个列表,查找最合适的块。如果没有发现块,它调用brk()增加堆大小并把增加的内存放入内部列表。
当一块内存被释放(通过在malloc分配的指针上调用free),它被加入空闲块列表。当堆的最后一页被释放,所有的页被释放,堆大小减少(再次调用brk)。因此,程序中调用free()可能不会对实际内存造成影响。如果程序在不同时间进行了大量分配释放操作,堆可能有很多碎片:堆不能被释放,即使它包含了很多空闲内存。不过调用free()释放大块内存(大到足够用mmap()分配)会调用munmap,这会直接影响你的进程的内存占用。
64位系统上,ptmalloc2每次分配有8个字节的额外内存开销。这些字节被用来存放内存分配信息的元数据。这意味着非常小的分配是极其低效的(但大部分malloc的实现都如此)。这些用于管理的字节存放在返回的指针之前,这意味着它们很容易被内存的向下溢出意外的覆盖掉。这个覆盖会破坏分配器,在后面会导致段错误(这个“后面”意味着程序的崩溃第一眼看上去完全是随机的)。
如今,实现一个好的malloc()的主要挑战在于在多线程下能很好工作。大部分时间,这有线程本地缓存和共享堆来实现。分配器会首先在本地缓存查找合适的块,然后查不到再查找堆(需要上锁)。