Modern C++ 内存篇0 - std::allocator VS pmr

 这是一篇翻译,原文在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):

总结

通过本文,我想向您展示一些有关 pmr 和多态分配器概念的基本示例。正如您所见,为vector设置分配器比使用常规分配器要简单得多。您可以使用一组预定义的分配器,并且相对容易地实现您的自定义版本。文章中的代码只是为了说明内存从哪里获取而进行的简单hacking操作。

在下一篇文章中继续进行进一步的实验:C++17: Polymorphic Allocators, Debug Resources and Custom Types - C++ Stories

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值