在不违反严格别名规则的情况下,使用C++中的共享内存缓冲区

8

我正在尝试实现一个共享内存缓冲区,但不想违反C99的严格别名规则。

假设我有一些处理数据的代码,并需要一些“临时”内存来操作。我可以像这样编写它:

void foo(... some arguments here ...) {
  int* scratchMem = new int[1000];   // Allocate.
  // Do stuff...
  delete[] scratchMem;  // Free.
}

然后我有另一个函数,它会执行一些其他需要刮擦缓冲区的操作:

void bar(...arguments...) {
  float* scratchMem = new float[1000];   // Allocate.
  // Do other stuff...
  delete[] scratchMem;  // Free.
}

问题在于foo()和bar()可能在操作期间被调用多次,在各个地方进行堆分配可能会对性能和内存碎片化造成很大影响。一个显而易见的解决方案是一次性分配适当大小的共享内存缓冲区,然后将其作为参数BYOB-style传递给foo()和bar():
void foo(void* scratchMem);
void bar(void* scratchMem);

int main() {
  const int iAmBigEnough = 5000;
  int* scratchMem = new int[iAmBigEnough];

  foo(scratchMem);
  bar(scratchMem);

  delete[] scratchMem;
  return 0;
}

void foo(void* scratchMem) {
  int* smem = (int*)scratchMem;
  // Dereferencing smem will break strict-aliasing rules!
  // ...
}

void bar(void* scratchMem) {
  float* smem = (float*)scratchMem;
  // Dereferencing smem will break strict-aliasing rules!
  // ...
}

我现在有两个问题:
- 如何实现一个共享的通用划痕存储器,而不违反别名规则?
- 尽管上述代码确实违反了严格的别名规则,但使用别名并没有造成任何“伤害”。因此,任何理智的编译器都可能生成(优化的)代码,使我陷入麻烦吗?
谢谢。
3个回答

3
实际上,你所写的并不是一个严格的别名违规问题。
C++11规范3.10.10说:
如果程序试图通过非以下类型之一的glvalue访问对象的存储值,则行为未定义。
因此,导致未定义行为的是访问存储的值,而不仅仅是创建指向它的指针。你的例子没有违反任何东西。需要执行下一步:float badValue = smem [0]。smem [0]从共享缓冲区中获取存储的值,从而创建了别名违规。
当然,你不会在设置之前只是抓取smem [0]。你将首先写入它。在相同的内存位置赋值不会访问存储的值,因此不存在别名问题。但是,在对象仍然存活的情况下覆盖对象的顶部是非法的。为了证明我们是安全的,我们需要从3.8.4获得对象生命周期:
程序可以通过重用对象占用的存储空间或显式调用具有非平凡析构函数的类类型对象的析构函数来结束任何对象的生命周期。对于具有非平凡析构函数的类类型对象,程序不需要在重新使用或释放对象所占用的存储空间之前显式调用析构函数;...[继续关于不调用析构函数的后果]
你有一个POD类型,因此具有平凡的析构函数,因此可以简单地口头宣布“int对象全部处于生命周期的末尾,我正在使用浮点数的空间。”然后重新使用浮点数的空间,不会发生别名违规。

谢谢,这非常有帮助。如果我理解正确,在读取scratchMem[i]之前写入scratchMem[i]将始终从别名的角度安全,因为我通过简单地写入它来结束了位于scratchMem + i位置的对象生命周期。是这样吗?此外,我对“重用存储”一词感到困惑。它是如何定义的?例如:int64_t a = 0; ((int16_t*)a)[1] = 1;a的生命周期是否已经在第二个赋值中结束?正确的“重用存储”是什么样子的? - rsp1984
正如BenVoigt在另一条评论中指出的那样,当指针类型不同时,编译器可以自由并行化/延迟访问内存。这是因为编译器假定这些指针不会别名。因此,即使使用float类型设置了smem[0],我仍然认为可能会遇到与严格别名相关的问题。编译器可能会自由地发出修改以int类型解释的smem中的内存的指令,从而在此之后通过后门更改smem[0]的值,因为它假定内存不重叠。但愿能够得到澄清! - rsp1984
编译器只有在能够证明乱序执行不会改变有效形成程序的结果时才允许这样做。因为程序可以通过重用存储空间随时结束一个微不足道的可销毁对象的生命周期,所以编译器除非能够证明对象的生命周期尚未结束,否则不能并行化这些任务。如果没有这个限制,那么你就无法写出 void* mem = malloc(max(sizeof(A), sizeof(B)); A* a = new (mem) A; a->~A(); B* b = new (mem) B; 这样的代码,而不必担心构造函数的顺序被打乱。 - Cort Ammon
我不得不重新阅读规范以更加精确:我说错了,赋值=1确实是“a”必须死亡的官方点,因为那是在“a”后面的存储被重用的地方。我倾向于说“在赋值之前结束”,因为这包括优化器可以做的事情:如果这样做不影响结果,优化器可以缩短任何对象的生命周期。无论你选择用什么措辞,赋值=1都会将一个值分配到未初始化的存储空间中,该存储空间事先恰好是“a”。 - Cort Ammon
谢谢。你可能感兴趣的另一个后续问题是:http://stackoverflow.com/questions/18667056/c-bypassing-strict-aliasing-through-union-then-use-restrict-extension - rsp1984
显示剩余6条评论

1

将一个对象解释为一系列字节始终是有效的(即,将任何对象指针视为指向字符数组的第一个元素的指针不是别名违规),并且您可以在足够大且适当对齐的任何内存块中构造对象。

因此,您可以分配一个大的char数组(任何符号),并定位一个在alignof(maxalign_t)对齐的偏移量;现在,一旦您在那里构造了适当的对象(例如,在C++中使用就地新建),就可以将该指针解释为对象指针。

当然,您必须确保不要写入现有对象的内存;实际上,对象的生命周期与表示对象的内存发生的情况密切相关。

示例:

char buf[50000];

int main()
{
    uintptr_t n = reinterpret_cast<uintptr_t>(buf);
    uintptr_t e = reinterpret_cast<uintptr_t>(buf + sizeof buf);

    while (n % alignof(maxalign_t) != 0) { ++n; }

    assert(e > n + sizeof(T));

    T * p = :: new (reinterpret_cast<void*>(n)) T(1, false, 'x');

    // ...

    p->~T();
}

请注意,通过mallocnew char[N]获得的内存始终对齐于最大对齐方式(但不超过最大对齐方式,您可能希望使用超对齐地址)。

@BenVoigt:只要在调用析构函数之前将非平凡对象的存储用于其他用途就会产生未定义行为,我认为这个说法本身并没有什么问题。 - Kerrek SB
2
如果您使用float的存储来创建一个int,那么您就会打开严格别名违规的大门。实际上,我认为编译器甚至可以延迟对float的写入,直到内存被重用为int(破坏了int的值),因为严格别名允许编译器假设不同类型的对象不重叠。由于这个问题特别关注严格别名规则,所以我认为这很重要。最好使用起始生命为char[]的内存。 - Ben Voigt
这就是为什么我建议你删除第一句话的第一个从句。它在回答的其余部分中并不需要,并且可能会导致某些人认为他们可以像无类型存储一样处理任何对象并放入新内容。 - Ben Voigt
@BenVoigt:嗯,也许吧……我稍微扩展了一下。但如果你认为它太误导人了,请随意编辑帖子。我现在想得不是很清楚。 - Kerrek SB
@BenVoigt:(或者发布一个答案!)实际上,我认为以下代码是合法的:float x; *(int*)(char*)(&x) = 10;,现在可以始终通过一个int指针访问该变量。由于float是POD,当其内存被重用时,其生命周期结束,并且假设大小和对齐方式匹配,则在其位置创建一个新的int。这基本上是“活动成员”联合语义。 - Kerrek SB
显示剩余5条评论

0

1
为此目的,可能会使用union,但是它并不能解决大多数人使用它来读取不同成员而写入的严格别名问题。 - Ben Voigt

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接