从C++17开始支持的多态分配器是标准库对标准分配器的增强。
它比常规分配器容易使用得多,并且允许相同的类型容器具有不同的分配器,甚至有可能在运行时改变分配器。
多态分配器符合标准库分配器的规则。尽管如此,它的核心还是使用内存资源对象来进行内存管理。
多态分配器包含指向内存资源类的指针,这就是为什么它可以使用虚函数调度。您可以在运行时更改内存资源,同时保留分配器的类型。这与常规分配器相反,后者使用不同分配器时是不同类型。
多态分配器的所有类型都位于单独的命名空间 std::pmr
在头文件 <memory_resource>
(PMR, Polymorphic Memory Resource)中。
pmr
核心要素
pmr
的主要部分:
-
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
-
std::pmr::polymorphic_allocator
- 是标准分配器的实现,它使用memory_resource
对象来管理内存分配和释放。 -
new_delete_resource()
和null_memory_resource()
访问的是全局内存资源。 -
一组预先定义的内存池资源类:
synchronized_pool_resource
unsynchronized_pool_resource
monotonic_buffer_resource
-
模板特化的标准容器与多态分配器,例如
std::pmr::vector
,std::pmr::string
,std::pmr::map
等。每个特化容器都在与相应容器相同的头文件中定义。 -
同样值得一提的是,池资源可以链式包含。如果资源池中没有可用内存,分配器将从"上游"资源中分配。
预先定义的内存资源
1. new_delete_resource()
它是一个函数,返回指向全局“默认”内存资源的指针。它使用全局 new 和 delete 管理内存。
2. null_memory_resource()
它返回一个指向全局“空”内存资源的指针,它在每次分配时抛出 std::bad_alloc
。虽然听起来没什么用,但当你想保证你的对象不会在堆上分配任何内存时,它可能会很方便。或用于测试。
3. synchronized_pool_resource
这是一个线程安全的分配器,用于管理不同大小的池。每个池都是一组块,这些块被分成大小均匀的块。
4. unsynchronized_pool_resource
非线程安全的资源池。
5. monotonic_buffer_resource
这是一个非线程安全的、快速的、特殊用途的资源,它从预先分配的缓冲区中获取内存,但不会通过release来释放它。它只能单向递增。
多态分配器示例
使用 monotonic_buffer_resource
和 pmr::vector
的简单例子.
#include <iostream>
#include <memory_resource>
#include <vector>
int main(int argc, char* argv[])
{
char buffer[64] = {0};
std::fill_n(std::begin(buffer), std::size(buffer) - 1, '-');
std::cout << buffer << std::endl;
std::pmr::monotonic_buffer_resource pool{std::data(buffer),
std::size(buffer)};
std::pmr::vector<char> vec{&pool};
for (char i = 'a'; i <= 'z'; i++) {
vec.push_back(i);
}
std::cout << buffer << std::endl;
return 0;
}
输出:
---------------------------------------------------------------
aababcdabcdefghabcdefghijklmnopabcdefghijklmnopqrstuvwxyz------
在上面的例子中,我们使用了一个 monotonic_buffer_resource
,这个资源是用堆栈中的一个内存块(buffer[]
)初始化的。通过使用一个简单的 char buffer[]
数组,我们可以轻松打印“内存”的内容。vector
从池中获取内存(并且由于它在堆栈上,所以速度非常快),如果没有更多可用空间,它将从“上游”资源中请求内存。该示例显示了需要插入更多元素时的 vector
重新分配。向量每次获取更多空间来存放所有字母。正如您所见,monotonic_buffer_resource
不会删除任何内存,它只会向前增长。
我们也可以在向量上使用 reserve()
,这会限制内存分配的数量,但这个例子的重点是说明容器的“扩展”。
在容器中存储比 char
大的类型会怎么样?
存储 pmr::string
将字符串插入 pmr::vector
会怎么样?
多态分配器的好处在于,如果容器中的对象也使用多态分配器,那么它们将要求父容器的分配器来管理内存。
如果你想使用这个特性,你必须使用 std::pmr::string
而不是 std::string
.
请查看下面的示例,其中我们预先在堆栈上分配缓冲区,然后将其传递到字符串 vector
:
#include <iostream>
#include <memory_resource>
#include <vector>
#include <string>
int main(int argc, char* argv[])
{
std::cout << "sizeof(std::string): " << sizeof(std::string) << '\n';
std::cout << "sizeof(std::pmr::string): " << sizeof(std::pmr::string)
<< std::endl;
char buffer[256] = {0}; // 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 << std::endl;;
};
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 11.1 上收到的输出
sizeof(std::string): 32
sizeof(std::pmr::string): 40
zeroed buffer:
---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
after two short strings:
`#!6#####!6###########Hello World####`#!6#####!6###########One Two Three###-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------#
after longer string strings:
`#!6#####!6###########Hello World####`#!6#####!6###########One Two Three###`#!6#####!6###################----------------------------------------------------------------------------------------This is a longer string#-------------------------------#
after the last string:
`#!6#####!6###########Hello World####`#!6#####!6###########One Two Three###`#!6#####!6###################--------`#!6###`#!6###########Four Five Six###----------------------------------------This is a longer string#-------------------------------#
以下是这个例子观察到的主要事情:
pmr::string
大小大于常规std::string
。这是因为分配器不是无状态的,它必须存储指向内存资源的指针。- 例子中的
vector
保留了五个元素,因此当插入四个元素时vector
不会变长。 - 前两个字符串很短,因此它们可以放入
vector
的内存块中,此处没有动态内存分配。 - 但是对于第三个字符串,要求它是一个单独的内存块,并且
vector
只存储一个指向它的指针。正如您在输出中所见,`"This is a longer string"几乎位于缓冲区的末尾。 - 当我们插入另一个短字符串,然后它再次进入
vector
内存块。
比较一下,下面使用常规 std::string
时的输出:
sizeof(std::string): 32
sizeof(std::pmr::string): 40
zeroed buffer:
---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
after two short strings:
@l#############Hello World####`l#############One Two Three###-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------#
after longer string strings:
@l#############Hello World####`l#############One Two Three#####n3#V##################-----------------------------------------------------------------------------------------------------------------------------------------------------------------------#
after the last string:
@l#############Hello World####`l#############One Two Three#####n3#V##################--------#l#############Four Five Six###-------------------------------------------------------------------------------------------------------------------------------#
这次容器中的元素使用更少的内存,因为不需要存储指向内存资源的指针。短字符串存储在 vector
的内存块中,但请注意较长的字符串......它不在缓冲区中!正确地说,向量存储一个指向分配长字符串的内存块的指针,但默认分配器分配了它,因此它不会出现在我们的输出中。
我提到,如果内存资源不够,则分配器将从上游资源获取内存。我们如何观察它?
一些手段
在前面的例子中,上游内存资源是默认的,因为我们没有改变它。这意味着使用的是new() 和 delete()。但是,我们必须记住, do_allocate() 和 do_deallocate() 成员函数也采用对齐参数。
这就是为什么如果我们想要查看内存是否由 new()
分配,我们必须使用 C++17 带对齐支持的 new()
:
#include <iostream>
#include <memory_resource>
#include <vector>
#include <string>
void* lastAllocatedPtr = nullptr;
size_t lastSize = 0;
void* operator new(std::size_t size, std::align_val_t align)
{
auto ptr = aligned_alloc(static_cast<std::size_t>(align), size);
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;
}
void operator delete(void* ptr, std::size_t size,
std::align_val_t align) noexcept
{
std::cout << "delete: " << size
<< ", align: " << static_cast<std::size_t>(align)
<< ", ptr : " << ptr << '\n';
free(ptr);
}
int main(int argc, char* argv[])
{
constexpr size_t buf_size = 32;
uint16_t buffer[buf_size] = {0}; // a small buffer on the stack
std::pmr::monotonic_buffer_resource pool{
std::data(buffer), std::size(buffer) * sizeof(uint16_t)};
std::pmr::vector<uint16_t> vec{&pool};
for (size_t i = 1; i <= 20; ++i) vec.push_back(i);
for (size_t i = 0; i < buf_size; ++i) std::cout << buffer[i] << " ";
std::cout << std::endl;
auto* bufTemp = (uint16_t*)lastAllocatedPtr;
for (size_t i = 0; i < lastSize; ++i) std::cout << bufTemp[i] << " ";
std::cout << std::endl;
}
在上面的代码中实现了对齐的 new()
还有两个丑陋的全局变量 :) 然而,多亏了它们,我们可以看到我们的内存何时消失.
这一次,我们存储 uint16_t
而不是 char
.
该程序试图在一个向量中存储 20 个数字,但由于向量不断增长,我们需要的不仅仅是预定义的缓冲区(只有 32 )。这就是为什么在某些时候分配器会转向全局 new
和 delete
。
以下可能获得的输出:
new: 128, align: 16, ptr: 0x55e22faebeb0
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 0 ......
delete: 128, align: 16, ptr : 0x55e22faebeb0
看起来预定义的缓冲区最多只能存储第 16 个元素,当我们插入数字 17 时,向量必须增长,这就是我们看到新分配128 字节的原因。第二行显示自定义缓冲区的内容,而第三行显示通过 new()
分配的内存。
更好的解决方案
前面的示例有效并向我们展示了一些东西,但是在生产代码中使用 new()
和 delete()
进行特殊操作并不是您应该做的。事实上,内存资源是可扩展的,如果你想要最好的解决方案,你可以重用你的资源!
您所要做的就是实现以下内容:
- 继承
std::pmr::memory_resource
- 实现相关接口:
do_allocate()
do_deallocate()
do_is_equal()
- 将自定义内存资源设置为对象和容器的资源池。
下面是一些如何实现的参考:
- 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
调试内存资源
为了有效地使用分配器,如果有一个工具可以让我们跟踪容器中的内存分配,这会很方便。
class debug_resource : public std::pmr::memory_resource
{
public:
explicit debug_resource(
std::string name,
std::pmr::memory_resource* up = std::pmr::get_default_resource())
: _name{std::move(name)}, _upstream{up}
{}
void* do_allocate(size_t bytes, size_t alignment) override
{
std::cout << _name << " do_allocate(): " << bytes << '\n';
void* ret = _upstream->allocate(bytes, alignment);
return ret;
}
void do_deallocate(void* ptr, size_t bytes, size_t alignment) override
{
std::cout << _name << " do_deallocate(): " << bytes << '\n';
_upstream->deallocate(ptr, bytes, alignment);
}
bool do_is_equal(
const std::pmr::memory_resource& other) const noexcept override
{
return this == &other;
}
private:
std::string _name;
std::pmr::memory_resource* _upstream;
};
debug_resource
只是实际内存资源的包装器。正如在 do_allocate
/do_deallocate
函数中看到的那样,我们只记录数字,然后使用上游资源进行实际内存分配。
示例用例:
int main(int argc, char* argv[])
{
constexpr size_t BUF_SIZE = 128;
char buffer[BUF_SIZE] = {0};
std::fill_n(std::begin(buffer), std::size(buffer) - 1, '-');
debug_resource default_dbg{"default"};
std::pmr::monotonic_buffer_resource pool{std::data(buffer),
std::size(buffer), &default_dbg};
debug_resource dbg{"pool", &pool};
std::pmr::vector<std::string> strings{&dbg};
strings.emplace_back("Hello Short String");
strings.emplace_back("Hello Short String 2");
return 0;
}
输出:
pool do_allocate(): 32
pool do_allocate(): 64
pool do_deallocate(): 32
pool do_deallocate(): 64
上面我们使用了两次 debug_resource
,第一个“池”用于记录请求到 monotonic_buffer_resource
的每个分配。在输出中,您可以看到我们有两次分配和两次解除分配。
还有另一个 debug_resource
- default
。这被配置为monotonic_buffer_resource 的父级。这意味着如果池需要分配。那么它必须通过我们的“default”对象请求内存。
如果您添加三个字符串,如下所示:
strings.emplace_back("Hello Short String");
strings.emplace_back("Hello Short String 2");
strings.emplace_back("Hello A bit longer String");
那么输出就不一样了:
pool do_allocate(): 32
pool do_allocate(): 64
pool do_deallocate(): 32
pool do_allocate(): 128
default do_allocate(): 256
pool do_deallocate(): 64
pool do_deallocate(): 128
default do_deallocate(): 256
这次您可以注意到,对于第三个字符串,我们预定义的小缓冲区内没有空间,这就是为什么 monotonic_buffer_resource
必须要求另外256个字节的“default”。
自定义类型
定义一个自定义类型:
struct Product {
std::string name;
char cost{0};
};
如果将它插入向量:
std::pmr::vector<Product> vec{&pool};
然后,vector将使用提供的内存资源,但不会应用于自定义类型中。所以,如果 Product 必须为name分配内存,它将使用默认分配器。
我们必须“启用”我们的类型并让它知道分配器,以便它可以利用来自父容器的分配器。
配备了调试资源和一些“缓冲区打印技术”,我们现在可以检查我们的自定义类型是否与分配器一起工作。让我们来看看:
struct SimpleProduct {
std::string name;
char cost{0};
};
int main(int argc, char* argv[])
{
constexpr size_t BUF_SIZE = 256;
char buffer[BUF_SIZE] = {0};
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 (size_t i = 0; i < buf.size(); ++i) {
std::cout << (buf[i] >= ' ' ? buf[i] : '#');
if ((i + 1) % 64 == 0) std::cout << '\n';
}
std::cout << '\n';
};
BufferPrinter(buffer, "initial buffer");
debug_resource default_dbg{"default"};
std::pmr::monotonic_buffer_resource pool{std::data(buffer),
std::size(buffer), &default_dbg};
debug_resource dbg{"buffer", &pool};
std::pmr::vector<SimpleProduct> products{&dbg};
products.reserve(4);
products.emplace_back(SimpleProduct{"car", '7'});
products.emplace_back(SimpleProduct{"TV", '9'});
products.emplace_back(SimpleProduct{"a bit longer product name", '4'});
BufferPrinter(std::string_view{buffer, BUF_SIZE}, "after insertion");
}
输出:
initial buffer:
----------------------------------------------------------------
----------------------------------------------------------------
----------------------------------------------------------------
---------------------------------------------------------------
buffer do_allocate(): 160
after insertion:
###############car#er##@P#####7-------###############TV##er##
@P#####9-------##N##U##################--------4---------------
----------------------------------------------------------------
---------------------------------------------------------------#
buffer do_deallocate(): 160
让我们解密代码和输出:
该 vector
包含 SimpleProduct
对象,它只是一个字符串和一个数字。我们保留了四个元素,您可以注意到我们的调试资源记录了 160 字节的分配。插入三个元素后,我们可以发现car和数字 7(这就是我使用 char 作为价格类型的原因)。然后是 9 的TV。我们也可以注意到 4
作为第三个元素的价格,但没有名字 a bit longer product name
让自定义类型分配器感知并不是很难,但我们必须记住以下几点:
- 尽可能使用 pmr::* 类型,以便您可以将分配器传递给它们。
- 声明 allocator_type 以便分配器特征可以“识别”您的类型使用分配器。您还可以为分配器特征声明其他属性,但在大多数情况下,默认值就可以了。
- 声明接受分配器并将其进一步传递给您的成员的构造函数。
- 也注意声明分配器的复制和移动构造函数。
- 与分配和移动操作相同。
这意味着我们相对简单的自定义类型声明必须变长:
struct Product {
using allocator_type = std::pmr::polymorphic_allocator<char>;
explicit Product(allocator_type alloc = {})
: _name{alloc}
{}
Product(std::pmr::string name, char price,
const allocator_type& alloc = {})
: _name{std::move(name), alloc}, _price{price}
{}
Product(const Product& other, const allocator_type& alloc)
: _name{other._name, alloc}, _price{other._price}
{}
Product(Product&& other, const allocator_type& alloc)
: _name{std::move(other._name), alloc}, _price{other._price}
{}
Product& operator=(const Product& other) = default;
Product& operator=(Product&& other) = default;
std::pmr::string _name;
char _price{'0'};
};
int main(int argc, char* argv[])
{
constexpr size_t BUF_SIZE = 256;
char buffer[BUF_SIZE] = {0};
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 (size_t i = 0; i < buf.size(); ++i) {
std::cout << (buf[i] >= ' ' ? buf[i] : '#');
if ((i + 1) % 64 == 0) std::cout << '\n';
}
std::cout << '\n';
};
debug_resource default_dbg{"default"};
std::pmr::monotonic_buffer_resource pool{std::data(buffer),
std::size(buffer), &default_dbg};
debug_resource dbg{"buffer", &pool};
std::pmr::vector<Product> products{&dbg};
products.reserve(4);
products.emplace_back(Product{"car", '7'});
products.emplace_back(Product{"TV", '9'});
products.emplace_back(Product{"a bit longer product name", '4'});
BufferPrinter(std::string_view{buffer, BUF_SIZE}, "after insertion");
}
输出:
buffer do_allocate(): 192
buffer do_allocate(): 26
after insertion:
#######(##############car#+## ###+##7-------#######X######
########TV##+## ###+##9-------##############################
--------4-------------------------------------------------------
a bit longer product name#-------------------------------------#
buffer do_deallocate(): 26
buffer do_deallocate(): 192
在输出中,第一个内存分配 192
字节用于 vector.reserve(3)
,然后有另一个用于更长的字符串(第三个元素)。还打印了完整的缓冲区,显示字符串所在的位置。
我们的自定义对象由其他 pmr
容器组成,因此更加简单!而且我想在大多数情况下您可以利用现有类型。但是,如果您需要访问分配器并执行自定义内存分配,那么您应该查看 Pablo 的演讲,他在其中介绍了自定义列表容器的示例。
CppCon 2017: Pablo Halpern "Allocators: The Good Parts" - YouTube
总结
通过本文,我想向您展示一些有关pmr的基本示例和多态分配器的概念。如您所见,为 vector
设置分配器比使用常规分配器简单得多。您可以使用一组预定义的分配器,并且实现您的自定义版本相对容易。文章中的代码仅展示了一个简单的例子,以说明从何处获取内存。
在这篇博文中,我们在标准库的深层进行了另一次旅程。虽然分配器很可怕,但似乎多态分配器让事情变得更舒服。
参考
- CppCon 2017: Pablo Halpern “Allocators: The Good Parts” - YouTube - in-depth explanations of allocators and the new PMR stuff. Even with a test implementation of some node-based container.
- CppCon 2015: Andrei Alexandrescu “std::allocator…” - YouTube - from the introduction you can learn than
std::allocator
was meant to fix far/near issues and make it consistent, but right now we want much more from this system. - c++ - What is the purpose of allocator_traits in C++0x? - Stack Overflow
- Jean Guegant’s Blog – Making a STL-compatible hash map from scratch - Part 3 - The wonderful world of iterators and allocators - this is a super detailed blog post on how to make more use of allocators, not to mention good anecdotes and jokes :)
- Thanks for the memory (allocator) - Sticky Bits - a valuable introduction to allocators, their story and how the new model of PMR fit in. You can also see how to write your tracking pmr allocator and how
*_pool_resource
works. - CppCon 2018: Arthur O’Dwyer “An Allocator is a Handle to a Heap” - a great talk from Arthur where he shares all the knowledge needed to understand allocators.
- C++17 - The Complete Guide by Nicolai Josuttis - inside the book, there’s a long chapter about PMR allocators.