随着 C++17 的引入,C++ 语言增加了所谓的 PMR 分配器(PMR allocator)。一个 PMR 容器将分配器信息存储为运行时值,而不是将其作为类型的一部分在编译时确定。在这种模型中,PMR 容器保存一个指向 PMR 分配器的指针,这减少了所需类型的数量,但在每次使用内存分配服务时引入了虚函数调用的开销。
这同样不是一个“零成本”的选择,它与传统模型之间存在权衡:
这种新的分配器模型假设容器保存了一个指向分配策略的指针,这通常(并非总是)使得 PMR 容器比非 PMR 容器更大。有趣的是,std::pmr::vector
每次调用分配或释放内存的服务都会产生一次多态间接寻址(polymorphic indirection)的开销。在那些被调用函数本身执行大量计算的程序中,这种开销微不足道甚至无法察觉;但如果被调用函数本身执行的计算很少,这种开销可能会变得明显。
PMR 容器是基于内存资源进行参数化的,而 PMR 内存资源操作的是字节(byte),而不是对象(object)。这究竟是好是坏尚无定论(可能取决于视角),因为两种方法都可行,但以字节为单位(最简单的通用单位)进行操作可以减少程序中所需的类型数量。
PMR 方法也有一些优势:
容器的类型不再受其分配器类型的影响。所有 PMR 容器都只是简单地持有一个指向名为 std::pmr::memory_resource 的所有 PMR 内存资源基类的指针。
实现一个 PMR 分配器所需的工作量非常小,只需要重写三个虚函数。例如,这为编写可重用的分配器库提供了可能性。
在 PMR 模型中,一个 std::pmr::polymorphic_allocator<T> 对象使用一个 std::pmr::memory_resource* 指针来决定内存的管理方式。通常,设计一种内存分配策略时,要做的就是编写一个继承自 std::pmr::memory_resource 的类,并定义在这种策略下分配或释放内存的意义。
来看一个使用顺序缓冲区内存资源的 PMR 容器的简单示例,就像之前使用传统分配器实现的机制一样:
#include <print>
#include <vector>
#include <string>
#include <memory_resource>
int main() {
enum { N = 10'000 };
alignas(int) char buf[N * sizeof(int)]{};
std::pmr::monotonic_buffer_resource
res{ std::begin(buf), std::size(buf) };
std::pmr::vector<int> v{ &res };
v.reserve(N);
for (int i = 0; i != N; ++i)
v.emplace_back(i + 1);
for (auto n : v)
std::print("{} ", n);
std::print("\n {}\n", std::string(70, '-'));
for (char * p = buf; p != buf + std::size(buf); p += sizeof(int))
std::print("{} ", *reinterpret_cast<int*>(p));
}
这非常简单,不是吗?可能需要注意以下几点:
本程序的目标是在线程执行栈上的一块字节缓冲区中“分配”对象。
由于这些对象的类型是 int,我们确保缓冲区 buf 具有合适的对齐方式,并且大小足以容纳要存储在内的对象。
一个名为 res 的 std::pmr::monotonic_buffer_resource 对象知道要管理的缓冲区从哪里开始、以及它的大小,这代表了对一块连续内存的视角。
本程序中使用的 std::pmr::vector
实际上,这个程序甚至没有从堆(free store)中分配哪怕一个字节来存储这些 int 对象。与我们过去为了实现类似效果所必须做的工作相比,这可能让人感到些许欣慰。在程序结束时,遍历该字节缓冲区和遍历容器会得到相同的结果。
这种方式运行良好,编码工作也非常少。但如果想表达的是一个字符串(string)对象的vector,并且希望 vector 本身以及它所存储的每个 string 对象都使用相同的分配策略,那该怎么办呢?
PMR 分配器默认会传播其分配策略。请看以下示例:
#include <print>
#include <vector>
#include <string>
#include <memory_resource>
int main() {
auto make_str = [](const char *p, int n) ->
std::pmr::string {
auto s = std::string{ p } + std::to_string(n);
return { std::begin(s), std::end(s) };
};
enum { N = 2'000 };
alignas(std::pmr::string) char buf[N]{};
std::pmr::monotonic_buffer_resource
res{ std::begin(buf), std::size(buf) };
std::pmr::vector<std::pmr::string> v{ &res };
for (int i = 0; i != 10; ++i)
v.emplace_back(make_str("I love my instructor ", i));
for (const auto &s : v)
std::print("{} ", s);
std::print("\n {}\n", std::string(70, '-'));
for (char c : buf)
std::print("{} ", c);
}
这个示例同样使用了栈上的一个缓冲区,但该缓冲区既用于 std::pmr::vector 对象及其元数据,也用于其中的 std::string 对象。分配策略会从外层容器隐式地传播到内层容器。
该程序中的 make_str lambda 表达式用于将格式化为以整数结尾的 std::string 转换为 std::pmr::string。如前所述,来自 std 命名空间的类型与来自 std::pmr 命名空间的类型之间的整合有时需要一些额外的工作,但这些命名空间中类的 API 足够相似,使得这种工作量仍然在合理范围内。
如果运行这个程序,会注意到 std::pmr::string 对象中包含了预期的文本,但可能也会从最后一个循环中注意到,缓冲区 buf 中(除其他内容外)还包含了这些字符串中的文本。这是因为字符串相对较短,在大多数标准库实现中会应用小型对象优化(small object optimization),从而使得字符串的实际文本直接嵌入到各个 std::pmr::string 对象内部,而不是单独分配。这清楚地表明,相同的分配策略 —— 由类型为 std::pmr::monotonic_buffer_resource 的对象所代表 —— 已经从 std::pmr::vector 对象传播到了其中的每个 std::pmr::string 对象。
即使本书中没有涉及,使用传统的分配器模型也可以配合作用域分配器(scoped allocator)系统。如果对此感兴趣,可以查阅 std::scoped_allocator_adapter 类型的相关资料,以获得更多信息。
接下来,将看最后一个示例,它使用分配器来跟踪内存分配过程。
正如第8章中所见,在编写自己简单而实用的内存泄漏检测器时,内存管理工具通常用来收集信息。举几个不完全列举的例子,一些公司使用它们来跟踪内存碎片,或者评估对象在内存中的放置位置,可能旨在优化缓存的使用。另一些公司则希望评估在程序执行过程中何时何地发生内存分配,以判断是否可以通过重组代码来提升性能。当然,检测内存泄漏也很有用,这一点我们早已了解。
作为我们介绍PMR分配器使用的第三个也是最后一个例子,我们将实现一个跟踪型资源(tracing resource),跟踪某个容器的内存分配和释放请求,以理解该容器在实现上的一些决策。为了说明这个例子,我们将使用标准库中的 std::pmr::vector,并尝试理解它在向已满的容器中插入对象时如何增加其容量。标准要求像 push_back() 这样的操作具有常数时间复杂度(amortized constant complexity),所以容量应该很少增长,而大多数在尾部插入的操作都应该在常数时间内完成。然而,标准并未规定具体的增长策略:例如,一个实现可以选择以2倍增长,另一个可以选择1.5倍,还有的可能选择1.67倍。还有其他可能的选项;每种策略都有其权衡取舍,每个库都会做出自己的选择。
我们将把这个工具实现为一个名为 tracing_resource 的类,它继承自 std::pmr::memory_resource,这正是 std::pmr 容器所期望的类型。这样就可以展示向这个框架中添加一个内存资源类型有多么简单
基类暴露了三个我们需要重写的成员函数:do_allocate(),用于执行内存分配请求;do_deallocate()。顾名思义,用于释放之前通过 do_allocate() 分配的内存;do_is_equal(),用于让用户代码测试两个内存资源是否相等。请注意,在此语境中,“相等”意味着从一个资源分配的内存可以由另一个资源来释放。
由于我们希望跟踪内存分配请求,但并不打算自己实现实际的内存分配策略,将使用一个“上游”资源来完成分配和释放操作。在我们的测试实现中,该资源是一个通过 std::pmr::new_delete_resource() 获取的全局资源,它通过调用 ::operator new() 和 ::operator delete() 来完成内存管理任务。
因此,我们的分配函数将简单地“记录”(在本例中为打印)请求的分配和释放的大小,然后将实际的分配工作委托给上游资源。
下面是完整的实现:
#include <print>
#include <iostream>
#include <vector>
#include <string>
#include <memory_resource>
class tracing_resource : public std::pmr::memory_resource {
void* do_allocate(
std::size_t bytes, std::size_t alignment
) override {
std::print ("do_allocate of {} bytes\n", bytes);
return upstream->allocate(bytes, alignment);
}
void do_deallocate(
void* p, std::size_t bytes, std::size_t alignment
) override {
std::print ("do_deallocate of {} bytes\n", bytes);
return upstream->deallocate(p, bytes, alignment);
}
bool do_is_equal(
const std::pmr::memory_resource& other
) const noexcept override {
return upstream->is_equal(other);
}
std::pmr::memory_resource *upstream;
public:
tracing_resource(std::pmr::memory_resource *upstream)
noexcept : upstream{ upstream } {
}
};
int main() {
enum { N = 100 };
tracing_resource tracer{
std::pmr::new_delete_resource()
};
std::pmr::vector<int> v{ &tracer };
for (int i = 0; i != N; ++i)
v.emplace_back(i + 1);
for (auto s : v)
std::print("{} ", s);
}
如果运行这个简单的程序,将能够直观地了解标准库中 std::pmr::vector 实现的增长策略。
PMR 模型有许多令人喜爱之处,使用简单,相对容易理解,并且易于扩展。在许多应用领域中,其性能已经足够满足大多数开发者的需求。
当然,也存在一些领域需要对执行时间和运行行为进行更精细的控制,而这正是传统分配器模型所允许的:没有来自模型的间接开销,也不会在对象大小上引入的负担……有时候,只是需要尽可能多的控制权。所以两种模型都有其存在的合理性和适用场景。
PMR 分配器的一大显著优势在于,它使得构建可组合和可复用的分配器与资源库变得更加容易。标准库在 <memory_resource> 头文件中提供了一些非常有用的示例:
我们已经见过 std::pmr::new_delete_resource() 函数,它提供了一个全局资源,其内存分配和释放是通过 ::operator new() 和 ::operator delete() 实现的。就像 std::pmr::monotonic_buffer_resource 类一样,将已有缓冲区内顺序分配的过程进行了形式化。
std::pmr::synchronized_pool_resource 和 std::pmr::unsynchronized_pool_resource 类用于从大小不等的内存块池中分配对象。在多线程代码中,当然应使用带同步的。
std::pmr::get_default_resource() 和 std::pmr::set_default_resource() 函数分别用于获取或替换程序的默认内存资源。默认内存资源与 std::pmr::new_delete_resource() 返回的资源相同。
还有一个 std::pmr::null_memory_resource() 函数,返回一个从不进行实际分配的资源(当其 do_allocate() 成员函数被调用时,会抛出 std::bad_alloc 异常)。这在作为“上游”资源使用时非常有趣:考虑一个通过 std::pmr::monotonic_buffer_resource 实现的顺序缓冲区分配器系统,其中一次内存分配请求可能导致缓冲区溢出。默认情况下,上游资源会使用调用 ::operator new() 和 ::operator delete() 的资源,这种潜在的溢出会导致实际的内存分配,可能对性能产生不良影响。而将上游资源设为 std::pmr::null_memory_resource 可确保不会发生此类分配。
正如我们已经看到并实践过的那样,使用 PMR 模型,向这一小套内存资源中添加自定义资源、并根据需要定制容器行为其实非常简单。