这是一篇翻译,原文在Polymorphic Allocators, std::vector Growth and Hacking - C++ Stories
这篇博客非常适合了解pmr的基本用法和特性,故翻译它并作为我的内存篇的开篇。
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
C++17 中的多态分配器概念是对标准库中的标准分配器的增强。
它比普通分配器更易于使用,允许容器拥有相同的类型但具有不同的分配器,甚至可以在运行时更改分配器。
让我们看看如何使用它,并且以一种简单的方式来观察 std::vector 容器的增长。
简而言之,多态分配器遵循标准库中分配器的规则,但其核心是使用内存资源(memory resource)对象来执行内存管理。
多态分配器包含一个指向内存资源类的指针,因此它可以使用虚方法调度。您可以在运行时更改内存资源,同时保持分配器的类型不变。这与常规分配器相反,常规分配器使得使用不同分配器的两个容器也成为不同类型。
所有多态分配器的类型都位于单独的命名空间 std::pmr 中(PMR 代表多态内存资源),在头文件 <memory_resource> 中。
pmr 的核心要素(Core elements of pmr):
对pmr的主要特点的一些总结:
1. std::pmr::memory_resource - 是所有其他实现的抽象基类。它定义了以下纯虚方法:
- virtual void* do_allocate(std::size_t bytes, std::size_t alignment),
- virtual void do_deallocate(void* p, std::size_t bytes, std::size_t alignment)
- virtual bool do_is_equal(const std::pmr::memory_resource& other) const noexcept。
2. std::pmr::polymorphic_allocator - 是一个标准分配器的实现,它使用 memory_resource 对象执行内存分配和释放操作。
3. 通过 new_delete_resource() 和 null_memory_resource() 可以访问全局内存资源
4. 一组预定义的内存池资源类:
synchronized_pool_resource
unsynchronized_pool_resource
monotonic_buffer_resource
5. 标准容器使用多态分配器的模板特化,例如 std::pmr::vector、std::pmr::string、std::pmr::map 等。每个特化版本都在与相应容器对应的头文件中定义。
6. 值得一提的是,内存池资源(包括 monotonic_buffer_resource)可以串成串使用。如果池中没有可用内存,分配器将从“上游”资源分配。
此外,我们还有以下预定义的内存资源:
new_delete_resource()
这是一个自由函数,返回一个指向全局“默认”内存资源的指针。它使用全局的 new 和 delete 来管理内存。
null_memory_resource()
这是一个自由函数,返回一个指向全局“null”内存资源的指针,它在每次分配时抛出 std::bad_alloc。虽然听起来似乎没什么用,但当您希望保证您的对象不在堆上分配任何内存时,或者用于测试时,它可能会很有用。
synchronized_pool_resource
这是一个线程安全的分配器,它管理不同大小的内存池。每个池是一组被划分为均匀大小的块的内存片段。
unsynchronized_pool_resource
一个非线程安全的 pool_resource。
monotonic_buffer_resource
这是一个非线程安全的、快速的、专用的资源,它从预分配的缓冲区获取内存,但不会释放。它只能增长。
一个例子
下面是一个使用 monotonic_buffer_resource 和 pmr::vector 的简单示例:
#include <iostream>
#include <memory_resource> // pmr core types
#include <vector> // pmr::vector
int main() {
char buffer[64] = {}; // a small buffer on the stack
std::fill_n(std::begin(buffer), std::size(buffer) - 1, '_');
std::cout << buffer << '\n';
std::pmr::monotonic_buffer_resource pool{std::data(buffer), std::size(buffer)};
std::pmr::vector<char> vec{ &pool };
for (char ch = 'a'; ch <= 'z'; ++ch)
vec.push_back(ch);
std::cout << buffer << '\n';
}
可能的输出:
_______________________________________________________________
aababcdabcdefghabcdefghijklmnopabcdefghijklmnopqrstuvwxyz______
在上面的例子中,我们使用了一个从堆栈初始化的单调缓冲区资源。通过使用一个简单的 char buffer[] 数组,我们可以轻松地打印“内存”的内容。vector从池中获取内存(因为它在堆栈上,所以速度超快),如果空间不够用了,它将从“上游”资源请求内存。示例展示了在需要插入更多元素时vector的重新分配。每次向量获取更多的空间,所以最终囊括所有的字母。单调缓冲区资源不会删除任何内存,正如你所看到的,它只会增长。
我们也可以在vector上使用 reserve(),这将预先保留一些空间,但这个例子的重点是说明容器的“扩展”。
那么如何存储比简单的 char 更大的东西呢?
存储 pmr::string
把字符串插入到 pmr::vector 中会怎么样哪?
多态分配器的好处在于,如果容器中的对象也使用多态分配器,那么它们将请求父容器的分配器来管理内存。
如果你想使用这个特性,你必须使用 std::pmr::string 而不是 std::string。
看下面的例子,我们预先在堆栈上分配一个缓冲区,然后将其传递给字符串向量:
#include <iostream>
#include <memory_resource> // pmr core types
#include <vector> // pmr::vector
#include <string> // pmr::string
int main() {
std::cout << "sizeof(std::string): " << sizeof(std::string) << '\n';
std::cout << "sizeof(std::pmr::string): " << sizeof(std::pmr::string) << '\n';
char buffer[256] = {}; // a small buffer on the stack
std::fill_n(std::begin(buffer), std::size(buffer) - 1, '_');
const auto BufferPrinter = [](std::string_view buf, std::string_view title) {
std::cout << title << ":\n";
for (auto& ch : buf) {
std::cout << (ch >= ' ' ? ch : '#');
}
std::cout << '\n';
};
BufferPrinter(buffer, "zeroed buffer");
std::pmr::monotonic_buffer_resource pool{std::data(buffer), std::size(buffer)};
std::pmr::vector<std::pmr::string> vec{ &pool };
vec.reserve(5);
vec.push_back("Hello World");
vec.push_back("One Two Three");
BufferPrinter(std::string_view(buffer, std::size(buffer)), "after two short strings");
vec.emplace_back("This is a longer string");
BufferPrinter(std::string_view(buffer, std::size(buffer)), "after longer string strings");
vec.push_back("Four Five Six");
BufferPrinter(std::string_view(buffer, std::size(buffer)), "after the last string");
}
这是我在GCC 9.2/Coliru上得到的输出 :
以下是我们可以从这个例子中观察到的主要内容:
- pmr::string 的大小大于普通的 std::string。这是因为分配器不是无状态的,它必须存储指向内存资源的指针。
- 该示例为元素保留了五个位置,因此当我们插入四个元素时,vector不会增长。
- 前两个字符串很短,因此它们可以直接存储到保留的五个位置中(string有16个字节的成员变量),这里没有动态内存分配。
- 但是对于第三个字符串,我们需要一个单独的内存块,vector只存储指向它的指针。如您在输出中所见,“This is a longer string” 几乎位于缓冲区的末尾。
- 当我们插入另一个短字符串时,它再次进入vector的内存块。
- 为了对比,这里是使用普通 std::string 时的输出:
这一次,容器中的元素使用的内存更少,因为不需要存储指向内存资源的指针。短字符串存储在向量的内存块中,但请注意长字符串... 它不在缓冲区中!准确来说,vector存储了一个指向长字符串分配的内存块的指针,但是默认分配器分配了它,所以它不会出现在我们的输出中。
您可以在 @Coliru 中尝试这个示例。
我提到如果内存用完,分配器将从上游资源获取内存。我们如何观察到这一点呢?
一些hack
首先,让我们尝试做一些hacking :)
在我们的例子中,上游内存资源是默认的,因为我们没有改变它,它会使用 new() 和 delete()。
然而,我们必须记住 do_allocate() 和 do_deallocate() 成员函数也接受一个对齐参数。
这就是如果我们想要得知内存是否是由 new() 分配的我们就必须使用 C++17 的带有对齐支持的 new()的原因:
void* lastAllocatedPtr = nullptr;
size_t lastSize = 0;
void* operator new(std::size_t size, std::align_val_t align) {
#if defined(_WIN32) || defined(__CYGWIN__)
auto ptr = _aligned_malloc(size, static_cast<std::size_t>(align));
#else
auto ptr = aligned_alloc(static_cast<std::size_t>(align), size);
#endif
if (!ptr)
throw std::bad_alloc{};
std::cout << "new: " << size << ", align: "
<< static_cast<std::size_t>(align)
<< ", ptr: " << ptr << '\n';
lastAllocatedPtr = ptr;
lastSize = size;
return ptr;
}
在上面的代码部分中,我实现了对齐 new()(您可以在我的另一篇文章《New new() - The C++17’s Alignment Parameter for Operator new()).》中详细了解这个全新的特性)。
您可能已经注意到了这里有两个丑陋的全局变量 :) 但是,正是由于它们,我们可以看到我们的内存是何时分配的:
让我们重新考虑一下我们的例子:
constexpr auto buf_size = 32;
uint16_t buffer[buf_size] = {}; // a small buffer on the stack
std::fill_n(std::begin(buffer), std::size(buffer) - 1, 0);
std::pmr::monotonic_buffer_resource pool{std::data(buffer), std::size(buffer)*sizeof(uint16_t)};
std::pmr::vector<uint16_t> vec{ &pool };
for (int i = 1; i <= 20; ++i)
vec.push_back(i);
for (int i = 0; i < buf_size; ++i)
std::cout << buffer[i] << " ";
std::cout << std::endl;
auto* bufTemp = (uint16_t *)lastAllocatedPtr;
for (unsigned i = 0; i < lastAllocatedSize; ++i)
std::cout << bufTemp[i] << " ";
这次我们存储的是 uint16_t 而不是 char。
程序尝试在vector中存储 20 个数字,但由于vector增长,所以我们需要更多的缓冲区。这就是为什么在某个时候分配器转向全局 new 和 delete 的原因。
以下是您可能会得到的一个可能输出:
new: 128, align: 16, ptr: 0x21b3c20
1 1 2 1 2 3 4 1 2 3 4 5 6 7 8 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 0
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 0 0 0 0 0 .....
delete: 128, align: 16, ptr : 0x21b3c20
这看起来预定义的缓冲区最多只能存储到第 16 个元素,但是当我们插入第 17 个数字时,vector必须增长,这就是为什么我们看到新的分配 - 128 字节的原因。第二行显示了自定义缓冲区的内容,而第三行显示了通过 new() 分配的内存。
这里有一个活生生的例子@Coliru
一个更好的解决方案: 实现自己的内存资源类
前面的示例虽然有效,但是在生产代码中对 new() 和 delete() 进行hacking操作并不是您应该做的。实际上,内存资源是可扩展的,如果您想要最佳解决方案,您可以自己实现自己的memory resource!
您只需要实现以下内容:
- 从 std::pmr::memory_resource 派生
- 实现:
- do_allocate()
- do_deallocate()
- do_is_equal()
- 为您的对象和vector设置您的自定义内存资源为活动资源。
以下是您可以查看以了解如何实现它内存资源(memory resource):
- CppCon 2017: Pablo Halpern “Allocators: The Good Parts” - YouTube
- Taming dynamic memory - An introduction to custom allocators in C++ - Andreas Weis - code::dive 2018 - YouTube
- A whole extensive chapter in Nicolai’s book on C++17: C++17 - The Complete Guide.
- C++ Weekly - Ep 222 - 3.5x Faster Standard Containers With PMR! - YouTube
总结
通过本文,我想向您展示一些有关 pmr 和多态分配器概念的基本示例。正如您所见,为vector设置分配器比使用常规分配器要简单得多。您可以使用一组预定义的分配器,并且相对容易地实现您的自定义版本。文章中的代码只是为了说明内存从哪里获取而进行的简单hacking操作。
在下一篇文章中继续进行进一步的实验:C++17: Polymorphic Allocators, Debug Resources and Custom Types - C++ Stories