高级内存分配
处理动态内存一直是 C 和 C++ 编程中最棘手的问题之一。毫不奇怪,一些据称更简单的语言(如 Java)引入了垃圾回收机制,以减轻程序员的这种负担。但对于硬核 C 程序员来说,GNU C 库包含一些工具,允许他们调整、检查和跟踪内存的使用情况。
进程的内存通常分为静态内存(大小在编译时预先确定)或动态内存(空间在运行时根据需要分配)。后者又分为堆空间(malloc() 分配的内存来源)和栈(放置函数临时工作空间的地方)。如图 1 所示,堆空间向上增长,而栈空间向下增长。
当进程需要内存时,会通过向前移动堆的上界来创建一些空间,使用 brk() 或 sbrk() 系统调用。由于系统调用在 CPU 使用率方面代价很高,因此更好的策略是调用 brk() 来获取一大块内存,然后根据需要将其拆分成更小的块。这正是 malloc() 所做的。它将许多较小的 malloc() 请求聚合为较少的 brk() 调用。这样做可以显著提高性能。malloc() 调用本身比 brk() 便宜得多,因为它是一个库调用,而不是系统调用。当进程释放内存时,会采用对称行为。内存块不会立即返回给系统,因为这需要使用负参数进行新的 brk() 调用。相反,C 库会聚合它们,直到可以一次释放足够大的连续块为止。
对于非常大的请求,malloc() 使用 mmap() 系统调用来查找可寻址的内存空间。当释放大型内存块但被位于它们和已分配空间末端之间的小型、最近分配的块锁定时,此过程有助于减少内存碎片的不利影响。事实上,在这种情况下,如果该块是使用 brk() 分配的,即使进程释放了它,它也仍然无法被系统使用。
处理动态内存的库函数不限于 malloc() 和 free(),尽管这些是迄今为止最常用的调用。其他可用的函数包括 realloc()(调整已分配块的大小);calloc()(分配已清除的块);以及 memalign()、posix_memalign() 和 valloc()(分配对齐的块)。
C 库内存管理代码采用的策略针对通用内存使用配置文件进行了优化。尽管此策略在大多数情况下都能产生良好的性能,但某些程序可能会受益于稍微不同的参数调整。首先,通过使用 malloc_stats() 或 mallinfo() 库调用来检查您的内存使用统计信息。前者以标准错误形式打印程序中内存使用情况的简要摘要。此摘要包括已从系统分配的字节数(使用 brk() 收集);实际使用的字节数(使用 malloc() 查找);以及已声明的内存量(使用 mmap())。以下是示例输出
Arena 0: system bytes = 205892 in use bytes = 101188 Total (incl. mmap): system bytes = 205892 in use bytes = 101188 max mmap regions = 0 max mmap bytes = 0
如果您需要更精确的信息并且想要进行比打印输出更多的操作,mallinfo() 会很有帮助。此函数返回一个 struct mallinfo,其中包含各种与内存相关的状态指示器;最有趣的总结在侧边栏“mallinfo() 提供的有用参数”中。有关该结构的完整描述,请查看 /usr/include/malloc.h。
libc 提供的另一个有用的函数是 malloc_usable_size(),它返回您实际可以在先前分配的内存块中使用的字节数。由于对齐和最小大小的限制,此值可能大于您最初请求的量。例如,如果您分配 30 个字节,则可用大小实际上是 36 个字节。这意味着您可以向该内存块写入最多 36 个字节,而不会覆盖其他块。然而,这是一种极其糟糕且依赖于版本的编程实践,因此请不要这样做。malloc_usable_size() 最有用的应用可能是作为调试工具。例如,它可以检查从外部传递的内存块的大小,然后再写入它。
您可以通过调整 mallopt() 函数(列表 1 和 2)公开的一些参数来更改内存管理函数的行为。
此函数的原型和一组四个基本参数是 SVID/XPG/ANSI 标准的一部分。当前的 GNU C 库实现(截至撰写本文时的版本 2.3.1)仅遵守其中之一 (M_MXFAST),而忽略了另外三个。另一方面,该库提供了标准未指定的四个附加参数。mallopt() 接受的可调参数在侧边栏“mallopt() 的可调参数”中进行了描述。
即使不在程序内部引入 mallopt() 调用并重新编译它,也可以进行分配调整。如果您想快速测试值或者没有源代码,这可能很有用。您所要做的就是在运行应用程序之前设置适当的环境变量。表 1 显示了 mallopt() 参数和环境变量之间的映射,以及一些附加信息。例如,如果您希望将修剪阈值设置为 64KB,则可以运行此程序
MALLOC_TRIM_THRESHOLD=65536 my_prog
说到修剪,可以通过调用 malloc_trim(pad) 来修剪内存区域并将任何未使用的内存返回给系统。此函数调整数据段的大小,在其末尾至少留下 pad 字节,并且如果可以释放的字节少于一页,则会失败。段大小始终是一页的倍数,在 i386 上为 4,096 字节。可修剪的内存大小存储在 mallinfo() 返回的 struct 的 keepcost 参数中。如果在 free() 函数内部,keepcost 的当前值高于 M_TRIM_THRESHOLD 值,并且使用 M_TOP_PAD 的值作为参数,则通过调用 memory_trim() 来完成自动修剪。
在开发复杂程序时,调试内存通常是最耗时的任务之一。此问题的两个基本方面是检查内存损坏和跟踪块的分配和释放。
当写入位于合法数据段内但在您打算使用的内存块边界之外的位置时,会发生内存损坏。一个例子是写入数组末尾之外。事实上,如果您写入合法数据段之外,段错误会立即停止程序或触发适当的信号处理程序,从而使您能够识别行为不当的指令。因此,内存损坏更加微妙,因为它可能会在不被注意的情况下发生,并导致程序中远离冒犯部分的某个部分出现错误行为。因此,您越早检测到程序中的内存损坏,捕获错误的机会就越高。
损坏可能会影响其他内存块(扰乱应用程序数据)和堆管理结构。在前一种情况下,出现问题的唯一症状来自分析您自己的数据结构。在后一种情况下,您可以依靠一些特定的 GNU libc 一致性检查机制,这些机制会在检测到错误时提醒您。
可以在程序中启用自动或手动内存检查。前者通过设置环境变量 MALLOC_CHECK_ 来完成
MALLOC_CHECK_=1 my_prog
此机制能够捕获相当多的边界溢出,并且在某些情况下,可以保护程序免于崩溃。检测到故障时采取的措施取决于 MALLOC_CHECK_ 的值:1 将警告消息打印到 stderr,但不中止程序;2 中止程序,但不输出任何内容;3 结合了 1 和 2 的效果。
自动检查仅在调用与内存相关的函数时进行。也就是说,如果您写入数组末尾之外,则在下次 malloc() 或 free() 调用之前不会注意到它。此外,并非所有错误都会被捕获,并且您获得的信息并不总是非常有用。在 free() 的情况下,您知道在检测到错误时正在释放哪个指针,但这并没有提示是谁破坏了堆。在分配期间检测到错误的情况下,您只会收到“堆已损坏”消息。
另一种方法是在程序中的某些位置放置手动检查点。为此,您必须在程序开始时调用 mcheck() 函数。此函数允许您安装自定义内存故障处理程序,该处理程序可以在每次检测到堆损坏时调用。如果您不提供自己的处理程序,也可以使用默认处理程序。一旦调用了 mcheck(),您就可以获得与 MALLOC_CHECK_ 相同的所有一致性检查。此外,您可以随时手动调用 mprobe() 函数来强制检查给定的内存指针。mprobe() 返回的值在侧边栏“mprobe() 结果”中进行了总结。
如果您想检查整个堆而不仅仅是一个块,则可以调用 mcheck_check_all() 来遍历所有活动块。您还可以指示内存管理例程使用 mcheck_check_all(),而不是仅通过初始化 mcheck_pedantic() 而不是 mcheck() 来检查当前块。但是请注意,这种方法相当耗时。
启用内存检查的第三种方法是将您的程序与 libmcheck 链接
gcc myprog.c -o myprog -lmcheck
mcheck() 函数在第一次内存分配发生之前自动调用 - 在某些动态块在进入 main() 之前分配的情况下很有用。
跟踪内存块的历史记录有助于查找与内存泄漏以及使用或释放已释放块相关的问题。为此,GNU C 库提供了一种跟踪工具,可以通过调用 mtrace() 函数来启用。一旦进行此调用,每个堆操作都会记录到一个文件中,该文件的名称必须在环境变量 MALLOC_TRACE 中指定。然后可以使用库提供的 Perl 脚本(不出所料,名为 mtrace)离线执行日志文件分析。可以通过调用 muntrace() 停止日志记录,但请记住,将跟踪应用于程序的一部分可能会使后处理结果无效。例如,如果您在跟踪时分配一个块,然后在 muntrace() 之后释放它,则可能会检测到虚假泄漏。
以下是使用列表 3 中的程序进行的示例跟踪会话
$ gcc -g Listing_3.c -o Listing_3 $ MALLOC_TRACE="trace.log" ./Listing_3 $ mtrace trace.log Memory not freed: ----------------- Address Size Caller 0x08049718 0xa at malloc_debug/Listing_3.c:9
内存跟踪与错误保护无关;调用 mtrace() 不会阻止程序崩溃。更糟糕的是,如果程序出现段错误,跟踪文件很可能会被截断,并且跟踪可能不一致。为了防止这种风险,始终最好安装一个 SIGSEGV 处理程序,该处理程序调用 muntrace(),因为它会在中止之前关闭跟踪文件(列表 4)。有关内存跟踪的更多信息,请参见 libc 信息页面。
有时,GNU C 库提供的标准调试工具可能不适合您程序的特定需求。在这种情况下,您可以求助于外部内存调试工具(请参阅资源),或者在库内部构建自己的工具。这样做只是编写三个函数并将它们挂钩到这些预定义的变量
__malloc_hook 指向一个函数,该函数在用户调用 malloc() 时被调用。您可以在此处执行自己的检查和记帐,然后调用真正的 malloc() 来获取请求的内存。
__free_hook 指向一个函数,该函数被调用来代替标准 free()。
__malloc_initialize_hook 指向一个函数,该函数在内存管理系统初始化时调用。这允许您在任何与内存相关的操作发生之前执行某些操作,例如,设置先前挂钩的值。
挂钩也适用于其他与内存相关的调用,包括 realloc()、calloc() 等等。请务必保存挂钩的先前值,并在您的例程内部调用 malloc() 或 free() 之前恢复它们。如果您不这样做,无限递归会阻止您的代码正常工作。请查看 libc 信息页面中给出的内存调试示例,以了解所有巧妙的细节。
最后,请考虑这些挂钩也由 mcheck 和 mtrace 系统使用。谨慎地结合使用所有这些挂钩是一个好主意。
GNU C 库提供了几个扩展,这些扩展在处理内存时非常有用。如果您想微调应用程序的内存使用情况或构建针对您需求量身定制的内存调试解决方案,您可能会发现这些工具有用,或者至少是开发自己的机制的良好起点。