在共享/静态库中集成C++自定义内存分配器

3
我开始在我的项目中使用一些自定义内存分配器,例如rpmallocltmalloc,但我对其集成有些担忧。我的项目有各种内部模块,构建为共享库或静态库(取决于如何在构建系统中配置它们),并且应该在Windows / Linux / FreeBSD / Mac OS X等平台上构建/运行,支持x86和ARM等架构。我不知道是否应该将内存分配器的调用放在头文件中还是应该保留在cpp文件中。
如果内存分配器调用留在头文件中,每个模块都应链接内存分配器的静态库;如果将其保留在.cpp文件中,则调用被包含在包含它们的库中,并且只有该模块应链接自定义内存分配器,但是该模块应包含一个接口,以便每个模块都可以分配它们(避免内存分配不一致) 。
我读到过 这里 ,如果内存是通常分配的(例如malloc/free/syscalls),则每个共享库都有自己的堆,但是如果使用mmap分配内存,则其内存不属于程序的堆。
我的问题是,如果将它们保存在一个库中(但是每个其他库都应链接它以访问其内存分配接口),是否会引入任何危险?还是应该在头文件中进行所有内联,并且每个库都应链接内存分配器库?

不要过度使用内联函数,我建议你阅读Scott Meyer的《Effective C++》以了解更多信息。 - Onur A.
你应该添加更多关于你的操作系统的信息,因为这对于这个问题非常重要。 - user1143634
1个回答

5
内存分配方式主要取决于操作系统。你需要了解在这些操作系统中共享库的工作方式,以及C语言与这些操作系统和共享库概念的关系。
首先,我想提到的是C语言不是一种模块化语言,例如它不支持模块或模块化编程。对于像C和C++这样的语言,模块化编程的实现留给底层操作系统。共享库是实现C和C++模块化编程的机制之一,因此我将把它们称为“模块”。
模块=共享库和可执行文件
最初,Unix系统上的所有内容都是静态链接的。共享库稍后才出现。由于Unix是C语言的起点,这些系统尝试提供接近于C语言编程的共享库编程接口。
其想法是,最初没有考虑到共享库的C代码应该构建并且应该在不更改源代码的情况下工作。结果,提供的环境通常具有单个进程范围的符号命名空间,该命名空间由加载的所有模块共享,例如整个进程中只能有一个名为“foo”的函数,除了静态函数(以及使用特定于操作系统的机制在模块中“隐藏”的某些函数)。基本上,这与静态链接相同,您不允许有重复的符号。
对于您的情况来说,这意味着在整个进程中始终存在一个名为"malloc"的函数,并且每个模块都在使用它。所有模块共享相同的内存分配器。
如果进程恰好具有多个malloc函数,则只会选择一个函数,并将由所有模块使用。此处的机制非常简单:因为共享库不知道每个被引用函数的位置,它们通常通过某个表(GOT、PLT)调用这些函数,该表在第一次调用或加载时进行填充所需地址。同样的规则适用于提供原始函数的模块——即使在原始模块中也会通过同一张表来调用该函数,从而使得在提供它的原始模块甚至可以覆盖该函数(这是与在Linux上使用共享库相关的许多效率问题的源头,搜索-fno-semantic-interposition、-fno-plt来克服这些问题)。
一般规则是,首个引入符号的模块将提供该符号。因此,原始进程可执行文件在这里具有最高优先级,如果它定义了malloc函数,则该函数将在整个进程中使用。对于函数callocreallocfree和其他函数同样适用。通过使用这种技巧以及像LD_PRELOAD这样的技巧,您可以覆盖应用程序的“默认内存分配器”。但这不能保证总是奏效,因为还存在一些特殊情况。在进行此操作之前,请查阅您库的文档。
我想特别指出,这意味着在进程中有一个共享的堆,并且有一个很好的理由。类Unix系统通常提供两种在进程中分配内存的方式:
1. brksbrk系统调用 2. mmap系统调用
第一种提供了一种访问单个进程内存区域的方法,通常在可执行映像后直接分配。因为只有一个这样的区域,所以在进程中仅可由单个分配器使用此内存分配方式(通常已由您的C库使用)。
在将自定义内存分配器添加到进程之前,必须理解这一点-它不应使用brksbrk,或者应该覆盖您的C库的现有分配器。
第二种可以直接从底层内核请求内存块。由于内核知道进程虚拟内存的结构,因此它能够分配内存页而不干扰任何用户空间分配器。这也是在进程中具有多个完全独立的内存分配器(堆)的唯一方法。

Windows

Windows没有像类Unix系统那样依赖于C运行时。相反,它提供了自己的运行时-Windows API。
使用Windows API分配内存通常有两种方式:
1. 使用像VirtualAllocMapViewOfFile这样的函数。 2. 堆分配函数 - HeapCreateHeapAlloc
第一种等同于mmap,而第二种则是更高级版本的malloc,它在内部基于VirtualAlloc(据我所知)。
现在,因为Windows与C语言的关系与类Unix系统不同,它不会为您提供mallocfree函数。相反,这些函数是由实现在Windows API之上的C运行时库提供的。
关于Windows的另一个问题是它没有单一进程符号命名空间的概念,例如您无法像在类Unix系统上那样覆盖函数。这使您可以在同一进程中存在多个C运行时,并且每个运行时都可以提供其独立的实现,如mallocfree等,每个操作都在单独的堆上。
因此,在Windows上,所有库都将共享单个进程Windows API特定堆(可通过GetProcessHeap获得),同时它们将共享进程中某个C运行时的堆。
那么,如何将内存分配器集成到程序中呢?
这取决于您想要实现什么目标。
您是否需要替换整个进程中使用的内存分配器,例如默认分配器?这只在类Unix系统上可能。
在这里,唯一可移植的解决方案是明确使用您特定的分配器接口。这并不重要,您只需要确保在Windows上所有库都共享相同的堆即可。
通常的规则是,要么全部静态链接,要么全部动态链接。两者之间有某种混合可能会非常复杂,并且需要您在头脑中保持整个架构,以避免在程序中混合堆或其他数据结构(如果模块不多,则不是大问题)。如果需要混合静态和动态链接,应将分配器库构建为共享库,以使在进程中具有单个实现更容易。
Unix类系统和Windows之间的另一个区别是,Windows没有“静态链接可执行文件”的概念。在Windows上,每个可执行文件都依赖于特定于Windows的动态库,例如ntdll.dll。而ELF可执行文件对于“静态链接”和“动态链接”可执行文件有单独的类型。
这主要是由于单一的进程符号命名空间,它使在Unix类系统上混合共享和静态链接变得危险,但允许Windows很好地混合静态和动态链接(几乎可以,但并非完全如此)。
如果您使用自己的库,则应确保在动态链接的可执行文件中动态链接它。想象一下,如果您将分配器静态链接到共享库中,但进程中的另一个库也使用了相同的库-您可能会意外使用另一个分配器,而不是您期望的那个。

这里的一般规则是,要么所有内容都应该是静态链接的,要么所有内容都应该是动态链接的。我的项目中每个模块或库都将是静态的或共享的,没有混合使用。其中一个库是其他所有库的“核心”,该“核心”库包含我的自定义内存分配器,每个其他库都应该使用它们的内存函数。 - Cristopher Sosa
因此,您可以从“核心”模块提供单独的内存分配接口,并在最重要的地方使用它。这是最简单和最便携的解决方案。只需确保您的分配器不依赖于Unix类中的brksbrk。覆盖整个进程中的使用不是可移植的-在Windows上甚至在Unix类似系统上也可能很危险。 - user1143634
它不依赖于这两个,只在Unix中使用mmap,在Windows中使用它们的等效物:VirtualAlloc - Cristopher Sosa
由于它们似乎都使用全局状态,并且默认情况下不尝试替换标准的 malloc。如果您在进程中仅保留一个副本,则应该是安全的。这在全动态链接或全静态链接中不应该成为问题(除非您的进程中的另一个模块静态链接了其中一个分配器)。 - user1143634
不开玩笑,我有一个严格的规则,即每个模块只能有一个分配器,这个分配器模块掌管着其他所有模块。 - Cristopher Sosa
我已经在静态和动态链接可执行文件上添加了一个小注释。在Linux上,通常要么使用“-static”标志将所有内容静态链接,要么全部动态链接。 - user1143634

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接