素槿
Published on 2025-08-22 / 2 Visits
0

C++多态分配器

从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::vectorstd::pmr::stringstd::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_resourcepmr::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 )。这就是为什么在某些时候分配器会转向全局 newdelete

以下可能获得的输出:

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()
  • 将自定义内存资源设置为对象和容器的资源池。

下面是一些如何实现的参考:

调试内存资源

为了有效地使用分配器,如果有一个工具可以让我们跟踪容器中的内存分配,这会很方便。

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设置分配器比使用常规分配器简单得多。您可以使用一组预定义的分配器,并且实现您的自定义版本相对容易。文章中的代码仅展示了一个简单的例子,以说明从何处获取内存。

在这篇博文中,我们在标准库的深层进行了另一次旅程。虽然分配器很可怕,但似乎多态分配器让事情变得更舒服。

参考