std::vector (ab)使用自动存储

std::vector (ab)uses automatic storage

提问人:Igor R. 提问时间:1/7/2020 最后编辑:Igor R. 更新时间:1/21/2020 访问量:3169

问:

请考虑以下代码片段:

#include <array>
int main() {
  using huge_type = std::array<char, 20*1024*1024>;
  huge_type t;
}

显然,它会在大多数平台上崩溃,因为默认堆栈大小通常小于 20MB。

现在考虑以下代码:

#include <array>
#include <vector>

int main() {
  using huge_type = std::array<char, 20*1024*1024>;
  std::vector<huge_type> v(1);
}

令人惊讶的是,它也崩溃了!回溯(使用最新的 libstdc++ 版本之一)指向文件,我们可以在其中看到以下行:include/bits/stl_uninitialized.h

typedef typename iterator_traits<_ForwardIterator>::value_type _ValueType;
std::fill(__first, __last, _ValueType());

调整大小构造函数必须默认初始化元素,这就是它的实现方式。显然,临时崩溃会使堆栈崩溃。vector_ValueType()

问题是它是否符合要求。如果是,这实际上意味着使用大类型的向量是相当有限的,不是吗?

c 语言律师 stdvector libstdc++ 自动存储

评论

2赞 NathanOliver 1/7/2020
只是记忆。正在运行的 C++ 实现不使用虚拟内存。
3赞 ChrisMM 1/7/2020
顺便说一句,哪个编译器?我无法使用 VS 2019 (16.4.2) 重现
3赞 walnut 1/7/2020
从 libstdc++ 代码来看,仅当元素类型为简单且可复制分配且使用默认值时,才使用此实现。std::allocator
2赞 walnut 1/7/2020
@Damon 正如我上面提到的,它似乎只用于具有默认分配器的琐碎类型,因此应该没有任何可观察到的差异。
1赞 walnut 1/8/2020
@Damon 前者不是程序可观察行为的一部分,只要可观察行为相同,标准的实现就可以做任何它想做的事情,参见 as-if 规则。后者应包含在未对库调用设置任何内存要求的标准和实现限制规则中,请参阅问题的答案。

答:

19赞 Yakk - Adam Nevraumont 1/7/2020 #1

任何 std API 使用的自动存储量没有限制。

它们都可能需要 12 TB 的堆栈空间。

但是,该 API 只需要 ,并且您的实现会在构造函数所需的内容上创建一个额外的实例。除非它被门禁在检测对象是微不足道的可复制和可复制的之后,否则该实现看起来是非法的。Cpp17DefaultInsertable

评论

8赞 walnut 1/7/2020
从 libstdc++ 代码来看,仅当元素类型为简单且可复制分配且使用默认值时,才使用此实现。我不确定为什么首先要制造这种特殊情况。std::allocator
3赞 Yakk - Adam Nevraumont 1/7/2020
@walnut 这意味着编译器可以自由地创建该临时对象,就好像没有实际创建该临时对象一样;我猜在优化的构建上有很大的机会它不会被创建?
4赞 walnut 1/8/2020
是的,我想它可以,但对于大型元素,GCC 似乎没有。使用 libstdc++ 的 Clang 确实优化了临时的,但似乎只有当传递给构造函数的向量大小是编译时常量时,参见 godbolt.org/z/-2ZDMm
2赞 Jonathan Wakely 4/9/2020
@walnut特殊情况存在,因此我们调度到琐碎类型,然后用于将字节爆炸到位置,这可能比在循环中构造大量单个对象要快得多。我相信 libstdc++ 实现是合规的,但导致大型对象的堆栈溢出是实现质量 (QoI) 错误。我已将其报告为 gcc.gnu.org/PR94540,并将修复它。std::fillmemcpy
0赞 walnut 4/9/2020
@JonathanWakely 是的,这是有道理的。我不记得为什么我在写评论时没有想到这一点。我想我会认为第一个默认构造的元素将直接就地构造,然后可以从中复制,这样就不会构造元素类型的其他对象。但当然,我还没有真正详细考虑过这一点,也不知道实现标准库的来龙去脉。(我意识到为时已晚,这也是您在错误报告中的建议。
9赞 eerorika 1/7/2020 #2
huge_type t;

显然,它会在大多数平台上崩溃......

我对“大多数”的假设提出异议。由于从未使用过巨大对象的内存,因此编译器可以完全忽略它并且从不分配内存,在这种情况下不会崩溃。

问题是它是否符合要求。

C++ 标准不限制堆栈的使用,甚至不承认堆栈的存在。所以,是的,它符合标准。但人们可以认为这是一个执行质量问题。

这实际上意味着使用大类型的向量是相当有限的,不是吗?

libstdc++ 似乎就是这种情况。libc++(使用 clang)没有重现崩溃,因此这似乎不是语言的限制,而只是该特定实现的限制。

评论

6赞 Ruslan 1/7/2020
“尽管堆栈溢出,但不一定会崩溃,因为程序永远不会访问分配的内存”——如果在此之后以任何方式使用堆栈(例如调用函数),即使在过度使用平台上也会崩溃。
0赞 user253751 1/8/2020
任何不会崩溃的平台(假设对象未成功分配)都容易受到 Stack Clash 的影响。
0赞 eerorika 1/8/2020
@user253751 乐观地假设大多数平台/程序都不容易受到攻击。
0赞 Jonathan Wakely 1/21/2020
我认为过度使用仅适用于堆,不适用于堆栈。堆栈的大小有固定的上限。
0赞 eerorika 1/21/2020
@JonathanWakely 你是对的。它之所以没有崩溃,似乎是因为编译器从不分配未使用的对象。
5赞 Adrian McCarthy 1/7/2020 #3

我不是语言律师,也不是C++标准专家,但 cppreference.com 说:

explicit vector( size_type count, const Allocator& alloc = Allocator() );

使用 T 的默认插入实例计数构造容器。

也许我误解了“默认插入”,但我希望:

std::vector<huge_type> v(1);

等同于

std::vector<huge_type> v;
v.emplace_back();

后一个版本不应该创建堆栈副本,而是直接在向量的动态内存中构造一个huge_type。

我不能权威地说你所看到的是不合规的,但这肯定不是我对高质量实施的期望。

评论

4赞 walnut 1/7/2020
正如我在对这个问题的评论中提到的,libstdc++ 仅将此实现用于具有副本赋值和 的琐碎类型,因此直接插入向量内存和创建中间副本之间应该没有明显的区别。std::allocator
0赞 Adrian McCarthy 1/8/2020
@walnut:是的,但是巨大的堆栈分配以及初始化和复制对性能的影响仍然是我不希望从高质量实现中得到的东西。
2赞 walnut 1/8/2020
是的,我同意。我认为这是实施过程中的疏忽。我的观点只是,就标准合规性而言,这并不重要。
0赞 dyp 1/8/2020
IIRC 您还需要可复制性或可移动性,但不仅仅是为了创建矢量。这意味着您可以拥有但不能拥有类似的东西,您可能仍然拥有第二个版本的分配和移动操作。两者都不应创建临时对象。emplace_backvector<mutex> v(1)vector<mutex> v; v.emplace_back();huge_type
1赞 dyp 1/10/2020
@IgorR。 需要 (Cpp17)DefaultInsertablevector::vector(size_type, Allocator const&)