【问题标题】:Allocator-aware `std::array`-style container?分配器感知`std::array`风格的容器?
【发布时间】:2023-04-04 06:28:02
【问题描述】:

我正在编写一些处理密码机密的代码,并且我创建了一个自定义的 ZeroedMemory 实现 std::pmr::memory_resource,它处理释放时清理内存并使用您必须使用的魔法进行封装,以防止优化编译器被忽略离开手术。这个想法是避免专门化std::array,因为缺少虚拟析构函数意味着类型擦除后的销毁会导致内存在没有被清理的情况下被释放。

不幸的是,后来我意识到std::array 不是AllocatorAwareContainer。我的std::pmr::polymorphic_allocator 方法有点误导,因为显然std::array 中没有空间来存储指向特定分配器实例的指针。尽管如此,我还是无法理解为什么不允许使用 std::allocator_traits<A>::is_always_equal::value == true 的分配器,并且我可以轻松地将我的解决方案重新实现为通用 Allocator 而不是更易于使用的 std::pmr::memory_resource...

现在,我通常可以只使用std::pmr::vector,但std::array 的一个不错的功能是数组的长度是类型的一部分。例如,如果我正在处理一个 32 字节的密钥,我不必进行运行时检查以确保传递给我的函数的 std::array<uint8_t, 32> 参数实际上是正确的长度。事实上,这些都很好地转换为 const std::span<uint8_t, 32>,它极大地简化了需要与 C 代码互操作的函数的编写,因为它们使我能够基本上免费地处理来自任何来源的任意内存块。

具有讽刺意味的是,std::tuple 采用分配器...但我不寒而栗地想象处理 32 字节 std::tuple<uint8_t, uint8_t, uint8_t, uint8_t, ...> 所需的 typedef。

那么:是否有任何标准类型可以保存固定数量的同质类型项目,例如std::array,但可以感知分配器(并且最好将项目存储在连续区域中,因此它可以向下 -转换为std::span)?

【问题讨论】:

  • std::array 不在堆上分配任何内存,因此不需要分配器。它还需要是一个聚合,因此不能具有可以采用分配器参数的用户定义构造函数。 std::tuple 采用分配器只是为了将其传递给其组件的构造函数,如果他们愿意的话;分配器不用于其他任何事情。 std::tuple<uint8_t, uint8_t> 不会将其分配器用于任何事情。
  • @IgorTandetnik ...这是有道理的。我想我需要将它提升为基于堆的对象,这意味着类似于std::shared_ptr<std::array<uint8_t, 32>>,此时我可以使用自定义删除器。我总是返回std::unique_ptr,以便调用者可以选择他们自己的指针风格,我很担心,因为该类型需要(内部)删除器作为参数,但看起来std::shared_ptr 没有这个问题。
  • 好吧,如果您担心客户滥用,shared_ptr 可以是released。
  • @IgorTandetnik Footguns 是可以的,只要它们有足够的安全性。我的工作不是阻止客户不安全地释放内存,就像阻止他们将缓冲区的内容通过电子邮件发送给中国人一样。我的工作就是确保他们不会意外。
  • @IgorTandetnik 另外,std::shared_ptr 没有release();它有一个reset(),但它仍然调用删除器。

标签: c++ c++17 allocator c++20 stdarray


【解决方案1】:

您需要编译器和操作系统的合作才能使这种方案发挥作用。 P1315 是解决编译器/语言方面的建议。至于操作系统,您必须确保内存从未被分页到磁盘等,才能真正实现零内存。

【讨论】:

  • 我不知道那个提议;感谢您引起我的注意!尽管如此,即使它更难阅读并且效率可能更低,std::fill((volatile uint8_t*)data, (volatile uint8_t*)data + length, 0) 仍将在编译器方面完成这项工作。我很想声明交换问题超出范围,尽管我可能会让我的分配器只将内存用于固定到 RAM 的页面而无需太多工作......但这在我的应用程序中并不重要,因为目标设备没有任何交换空间。
  • 那也行不通。粗略地说,如果是栈内存,编译器还是可以优化出来的。请查看wg21.link/p1152R0,以更详细地了解可悲地未充分指定的 volatile 关键字的含义。 (注意:论文的后续版本省略了其中一些细节。)
  • 非常有趣!我想我喜欢这个提议。但是,我看不出它如何破坏 std::fill 的使用?参数本身不是 volatile,而是指向 volatile 的指针。
  • 如果编译器确定您指向的是堆栈内存(而且他们在这方面做得很好),编译器可以假定它不是内存映射 IO 并可以优化它。
【解决方案2】:

这听起来像是一个 XY 问题。您似乎在滥用分配器。分配器用于处理运行时内存分配和释放,而不是挂钩堆栈内存。您正在尝试做的事情——使用后将内存归零——实际上应该使用析构函数来完成。您可能想为此编写一个课程Key

class Key {
public:
    // ...
    ~Key()
    {
        secure_clear(*this); // for illustration
    }
    // ...
private:
    std::array<std::uint8_t, 32> key;
};

您可以轻松实现迭代器和跨度支持。而且您不需要使用分配器。

如果你想减少样板代码并使新类自动迭代器/跨度友好,请使用继承:

class Key :public std::array<std::uint8_t, 32> {
public:
    // ...
    ~Key()
    {
        secure_clear(*this); // for illustration
    }
    // ...
};

【讨论】:

  • 我不喜欢这种方法,原因有很多。首先,它需要大量的样板才能获得由一行代码组成的行为改变,这似乎是错误的。其次,它要求客户要么了解并支持我的自定义容器(降低可移植性),要么以接受任何 stl 样式容器的方式自己进行模板化(需要显着的额外复杂性)。第三,也是最重要的,任何时候你返回这个类的一个实例,编译器都可以移动优化进程并且不在旧副本上运行析构函数
  • 在我看来,如果您想确保控制数据的释放,必须使用堆分配来存储数据。此外,由于您明确关心程序将永远不再可见的内存的命运,因此您必须在运行时内存管理级别挂钩——这是适当的,因为您实际上关心的是管理内存,而不是管理对象或值等更高级别的结构。
  • @ReidRankin 第一点是有道理的,但我猜其他方法需要更多样板代码。如果您的目标是更少的 LOC,您可以从数组公开派生并继承迭代器/跨度支持。如果使用分配器,第二点是一个更严重的问题,对吗?第三点对我来说没有多大意义。你能举个例子吗?
  • @ReidRankin 不,总是调用析构函数。移动/复制省略不会改变这一点。没有“破坏省略”这样的东西。如果它存在,那么任何具有重要析构函数的类都会被搞砸。
  • @ReidRankin 顺便说一句,您对第二点的解释似乎表明无分配器方法实际上更好。
猜你喜欢
  • 1970-01-01
  • 2017-04-09
  • 1970-01-01
  • 1970-01-01
  • 2019-07-07
  • 2016-08-28
  • 1970-01-01
  • 2019-12-12
  • 1970-01-01
相关资源
最近更新 更多