14.4. 多态内存资源分配器

随着 C++17 的引入,C++ 语言增加了所谓的 PMR 分配器(PMR allocator)。一个 PMR 容器将分配器信息存储为运行时值,而不是将其作为类型的一部分在编译时确定。在这种模型中,PMR 容器保存一个指向 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));
}

这非常简单,不是吗?可能需要注意以下几点:

实际上,这个程序甚至没有从堆(free store)中分配哪怕一个字节来存储这些 int 对象。与我们过去为了实现类似效果所必须做的工作相比,这可能让人感到些许欣慰。在程序结束时,遍历该字节缓冲区和遍历容器会得到相同的结果。

这种方式运行良好,编码工作也非常少。但如果想表达的是一个字符串(string)对象的vector,并且希望 vector 本身以及它所存储的每个 string 对象都使用相同的分配策略,那该怎么办呢?

14.4.1 嵌套分配器

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 类型的相关资料,以获得更多信息。

接下来,将看最后一个示例,它使用分配器来跟踪内存分配过程。

14.4.2 分配器与数据收集

正如第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 容器所期望的类型。这样就可以展示向这个框架中添加一个内存资源类型有多么简单

下面是完整的实现:

#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 实现的增长策略。

14.4.3 优势与代价

PMR 模型有许多令人喜爱之处,使用简单,相对容易理解,并且易于扩展。在许多应用领域中,其性能已经足够满足大多数开发者的需求。

当然,也存在一些领域需要对执行时间和运行行为进行更精细的控制,而这正是传统分配器模型所允许的:没有来自模型的间接开销,也不会在对象大小上引入的负担……有时候,只是需要尽可能多的控制权。所以两种模型都有其存在的合理性和适用场景。

PMR 分配器的一大显著优势在于,它使得构建可组合和可复用的分配器与资源库变得更加容易。标准库在 <memory_resource> 头文件中提供了一些非常有用的示例:

正如我们已经看到并实践过的那样,使用 PMR 模型,向这一小套内存资源中添加自定义资源、并根据需要定制容器行为其实非常简单。