【问题标题】:What precautions should I take to make a memory pool that does not invoke undefined behavior?我应该采取哪些预防措施来创建一个不会调用未定义行为的内存池?
【发布时间】:2017-01-31 02:59:30
【问题描述】:

我最初的问题是,在一个项目中,我有几个共享生命周期的对象(即,一旦我释放其中一个,我将释放它们全部),然后我想分配一个内存块。我有三种不同对象类型的数组,struct foovoid *char。一开始我想malloc()这样一个块:

// +---------------+---------+-----------+---------+---------+
// | struct foo[n] | padding | void *[m] | padding | char[o] |
// +---------------+---------+-----------+---------+---------+

但是……如果不调用未定义的行为,我怎么能做到这一点?即,尊重类型别名规则,对齐方式......如何正确计算内存块大小,声明内存块(及其有效类型),以及如何正确获取指向其中所有三个部分的指针?

(我确实知道我可以 malloc() 3 个块,这将导致三个 free(),但我想知道如何在仍然表现良好的情况下使用单个块。)

我想将我的问题扩展到一个更普遍的问题:应该采取哪些预防措施来为任意大小和对齐的对象实现memory pool,同时保持程序的良好运行? (假设可以在不调用未定义行为的情况下实现它。)

【问题讨论】:

  • 为什么要关闭投票?你能解释一下我该如何改进我的问题,还是它本身就不足?
  • 我在这里看到 5 个问题 - 可能会缩小范围。
  • @chux 我看到 1 个问题:如何正确地将不同类型的对象放入分配的内存中。我个人认为这是一个有趣的话题。
  • @bolov 同意主题和一个有趣的问题。 “喜欢扩展我的问题”部分超出了询问内存分配的范围。 IAC,而不是我的 DV,只提供了一个改进的想法。
  • @ray,我不是在问“我怎样才能实现 X”,我同意这太宽泛了。我的问题是关于标准中定义的规则:他们允许这样的事情吗?是否可以将不同类型的对象放在同一个内存块上?而且,如果是这样,如何正确计算偏移量?我从来没有要求过一段特定的代码。

标签: c language-lawyer undefined-behavior c11 strict-aliasing


【解决方案1】:

无论你多么努力,在纯 C 中实现 malloc 是不可能的。

您总是在某些时候违反严格的别名。为免生疑问,使用没有动态存储持续时间的char 缓冲区也将违反严格的别名规则。您还必须确保返回的任何指针都具有适当的对齐方式。

如果您乐于将自己束缚在特定平台上,那么您不妨转向 malloc 的特定实现来寻​​求灵感。

但是为什么不考虑编写一个调用malloc 并建立一个其他已分配对象表的存根函数呢?你甚至可以实现某种观察者/通知框架。另一个起点可能是用 C 编写的著名垃圾收集器。

【讨论】:

  • 在一个内存块中按顺序排列三种类型的对象的特殊情况如何?是否可以计算对齐和大小,并在不违反严格别名的情况下获取指向数据的指针?
  • 我试图实现的是在一个 malloc 块上分配三个不同类型的数组,大小为 nmo,正如我“画的” “ 更多。我可以得到sizeof(struct foo[n])sizeof(void *[m]),以及它们的_Alignof,但是我可以一起为它们计算一个合适的大小,并有效地声明内存块,这样我在获取指针时不会违反严格的别名三个数组的开始?另外,您是否有任何参考资料认为malloc 不能在纯兼容 C 中实现? (不过,凭直觉,我会同意。)
  • 使用字符缓冲区也会违反对齐规则。对齐不是唯一的问题,甚至可能不是问题。使用没有分配存储持续时间的 char 类型的对象,因为具有非字符类型的任意内存将始终违反严格的别名。
  • @2501:因为对char[] 的任何访问都将通过`char*, I see no reason to believe that the authors of the Standard didn't expect that compilers would naturally treat accesses to a char[]` 完成(与访问char 类型的独立变量不同)为因此,能够为其他任何东西起别名,而不需要单独的规则来允许这种用法。
【解决方案2】:

通过了解 3 种类型中 union 的大小,可以进行更有效的分配。

union common {
  struct foo f;
  void * ptr;
  char ch;
};

void *allocate3(struct foo **f, size_t m, void **ptr, size_t n, char **ch,
    size_t o) {
  size_t u_sz = sizeof (union common);
  size_t f_sz = sizeof *f * m;
  size_t f_cnt = (f_sz + u_sz - 1)/u_sz;
  size_t p_sz = sizeof *ptr * n;
  size_t p_cnt = (p_sz + u_sz - 1)/u_sz;
  size_t c_sz = sizeof *ch * o;
  size_t c_cnt = (c_sz + u_sz - 1)/u_sz;
  size_t sum = f_cnt + p_cnt + c_cnt;
  union common *u = malloc(sum * u_sz);
  if (u) {
    *f = &u[0].f;
    *ptr = &u[f_cnt].ptr;
    *ch = &u[f_cnt + c_cnt].ch;
  }
  return u;
}

这样,3 个数组中的每一个都从 union 边界开始,因此可以解决对齐问题。通过将每个数组的空间调整为union 大小的倍数,与first answer 相比,浪费的空间更少,但仍符合 OP 发布的目标。

有点浪费是struct foo 很大,而o 很小。可以使用以下作为进一步的改进。最后一个之后不需要填充 数组。

malloc((f_cnt + p_cnt) * u_sz + c_cz);

进一步考虑压缩分配。每个后续的“联合计数元素”都可以使用省略早期类型的不同联合,依此类推。当到达终点时——这就是上面想法的要点,最后一个数组只需要依赖于最后一个类型。这使代码更复杂(容易出错),但确实提高了空间效率,而不会出现问题等。一些编码想法如下

union common_last2 {
  // struct foo f;
  void * ptr;
  char ch;
};

size_t u2_sz = sizeof (union common_last2);
size_t p_cnt = (p_sz + u2_sz - 1)/u2_sz;

... malloc(f_cnt*usz + p_cnt*u2_sz + c_cz);

*ch = tbd;

【讨论】:

    【解决方案3】:

    正如另一个答案中所说,您不能在 C 本身中重新实现 malloc 。原因是没有malloc就无法生成没有有效类型的对象。

    但是对于您的应用程序,您不需要这个,您可以使用malloc 或类似的,见下文,拥有一大块内存没有问题。

    如果您有这么大的块,您必须知道如何将对象放置在该块中。这里的主要问题是对齐,您必须将所有对象放置在符合其最小对齐要求的边界上。

    从C11开始,可以使用_Alignof操作符获得类型的对齐,使用aligned_alloc可以请求过度对齐的内存。

    将所有这些放在一起,内容如下:

    • 计算所有类型对齐的 lcm
    • aligned_alloc 请求与该值对齐的足够内存
    • 将所有对象放在该对齐的倍数上

    如果您从通过void* 指针接收的无类型对象开始,那么别名就不是问题。该大对象的每个部分都有您写入的有效类型,请参阅我最近的blog entry

    C标准的相关部分是6.5 p6:

    访问其存储值的对象的有效类型是 对象的声明类型,如果有的话。87) 如果一个值被存储到 没有通过具有类型的左值声明的类型的对象 不是字符类型,则左值的类型变为 该访问和后续的对象的有效类型 不修改存储值的访问。如果一个值被复制 使用 memcpy 或 memmove 进入没有声明类型的对象,或者是 复制为字符类型的数组,然后是有效类型 为该访问和后续访问修改的对象 不修改值是从中的对象的有效类型 该值被复制,如果它有一个。对于所有其他访问 没有声明类型的对象,对象的有效类型是 只是用于访问的左值的类型。

    这里的“没有声明类型的对象”是malloc或类似分配的对象(或子对象)。它清楚地表明,此类对象可以在任何时候以任何类型写入,并且这会将有效类型更改为所需的类型。

    【讨论】:

    • “那个大对象的每个部分都有你写入的有效类型”,所以,如果我理解正确,一旦我知道正确的偏移量,我可以保存 malloc 块转换成字符指针,然后获取内存块的不同区域并为其赋予不同的有效类型(例如,char *mem = malloc(n); struct foo *f = mem; void **p = mem + x;)?你能澄清一下吗?
    • memcpy 规范的唯一工作方式是 malloc 返回一个 single 对象,该对象可以是某种类型的数组,也可以是带有灵活的数组成员。标准的语言不承认“一个对象”可以有多种类型,memcpy 也不能处理相邻但不属于同一“对象”的存储区域。
    • @paulatorrens,基本上是的。但并不是指针的分配赋予了内存区域有效的类型,而只是将一个对象复制到其中,例如*f = totomemcpy(f, &toto, sizeof *f),其中totostruct foo类型的对象。
    • @supercat,不,你错了。请看我的编辑。提供有效类型的不是指针的转换,而是写入对象。没有什么可以说以后不能用不同的类型来完成。该标准做出的唯一规定是读取的类型必须与最后一次写入的类型一致。
    • @JensGustedt,但由于从指针转换为 uintptr_t 使用实现定义的语义,我怎么能确定我得到的指针是某种对齐的倍数?
    【解决方案4】:

    首先,请务必使用-fno-strict-aliasing 或编译器上的任何等效项。否则,即使满足所有对齐方式,编译器也可能使用别名规则来重叠同一内存块的不同用途。

    我怀疑这是否符合标准作者的意图,但考虑到优化器可能非常激进,以致安全地实现与类型无关的内存池的唯一方法是禁用基于类型的别名分析。该标准的作者希望避免将某些使用基于类型的别名的编译器标记为不兼容。此外,他们认为他们可以根据编译器编写者的判断来判断如何识别和处理可能出现别名的情况。他们确定了编译器编写者可能认为没有必要识别别名的情况(例如,在有符号和无符号类型之间),但希望编译器编写者能够做出合理的判断。我没有看到任何证据表明他们打算将他们的允许案例列表视为详尽无遗,即使在其他形式的别名也有用的平台上也是如此。

    此外,无论人们多么谨慎地遵守标准,都不能保证编译器无论如何都会应用破坏性“优化”。至少从 gcc 6.2 开始,有别名错误会破坏将存储用作 X 类型的代码,将其写入 Y,将其读取为 Y,写入与 X 相同的值,并将存储读取为 X--behavior,即 100根据标准定义的百分比。

    如果处理了别名(例如使用指示的标志),并且您知道系统的最坏情况对齐要求,那么为池定义存储很容易:

    union
    {
       char [POOL_BLOCK_SIZE] dat;
       TYPE_WITH_WORST_ALIGNMENT align;
    } memory_pool[POOL_BLOCK_COUNT];
    

    不幸的是,即使解决了所有平台相关的对齐问题,该标准也无法避免基于类型的别名问题。

    【讨论】:

    • 你的回答一点用都没有。该问题与混叠无关,仅与对齐有关。您提到的编译器选项(即gcc 特定)无助于将对象对齐在正确的边界上。
    • @JensGustedt:对齐可以通过联合轻松处理。然而,别名很容易成为 Heisenbugs 的来源,除非可以强制编译器实现允许内存重用的 C 方言。
    • 当您现在拥有专门为此目的引入的 C 语言工具时,为什么还要使用 unions 进行对齐?别名不是这里的交易,有效类型是。而通过malloc和朋友分配的内存有特殊的规则,在这里。它具有复制到其中的最后一个对象的有效类型(使用memcpy 时)或最后一个左值访问(使用*f = toto 时)。每个 C 编译器都必须为 malloced 对象实现这种内存“重用”。
    • @JensGustedt:规则的编写方式,避免别名问题需要内存池在重新分配内存之前清理内存,或者内存池的所有消费者保证他们将从该内存没有写入其中的每个字节,也不考虑是否有任何东西会使用其中包含的值。这两种做法都不利于提高效率。
    • 这是错误的。对于没有声明类型的对象,标准中没有任何规定。每次你写入它们时,它们基本上都会收到它们的有效类型。
    【解决方案5】:

    回答 OP 的问题之一

    如何在不调用未定义行为的情况下完成此操作(想要 malloc() 像这样的块)?

    一种空间低效的方法。分配一个union 的类型。如果较小类型所需的尺寸不太大,则合理。

    union common {
      struct foo f;
      void * ptr;
      char ch;
    };
    
    void *allocate3(struct foo **f, size_t m, void **ptr, size_t n, char **ch,
        size_t o) {
      size_t sum = m + n + o;
      union common *u = malloc(sizeof *u * sum);
      if (u) {
        *f = &u[0].f;
        *ptr = &u[m].ptr;
        *ch = &u[m + n].ch;
      }
      return u;
    }
    
    void sample() {
      struct foo *f;
      void *ptr;
      char *ch;
      size_t m, n, o;
      void *base = allocate3(&f, m, &ptr, n, &ch, o);
      if (base) {
        // use data
      }
      free(base);
    }
    

    【讨论】:

    • @2501 同意 OP 希望同时分配所有三个。这就是这段代码的作用——只有 1 个malloc() 调用。如果m, n, o 是常量,那么struct 可能会起作用。这个答案不依赖m, n, o 作为常量。
    • 是的,这就是重点:正如@chux 所说,mno 不是常量。如果是的话,一个简单的结构就足够了。 GCC 允许在方法内部使用可变大小的结构(例如,int x = 10; struct { int y[x]; } z;),但遗憾的是,这远非可移植。遗憾的是,这个解决方案不会真正有用,因为最好进行三个分配而不是浪费这样的空间。
    • 此代码不起作用。您可以使用m,n,o 分配多个特定类型的元素并将其分配给相应的指针,但该指针显然不能用作该类型的数组,因为它不指向该类型的数组,但为联合数组。
    • @2501 “但该指针显然不能作为该类型的数组工作。” -->> 嗯。地址右对齐,匹配类型和内存空间就足够了。什么不起作用?
    • @2501 同意 - 我认为他们在同一条轨道上 - 我使用它发布了一个 alternative answer,因为我也有类似的想法。
    猜你喜欢
    • 2020-02-19
    • 1970-01-01
    • 2012-01-15
    • 2010-11-24
    • 1970-01-01
    • 2012-08-02
    • 2013-02-08
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多