【问题标题】:What happens if 'throw' fails to allocate memory for exception object?如果“throw”未能为异常对象分配内存会怎样?
【发布时间】:2017-08-04 03:09:24
【问题描述】:

来自 C++11 标准 (15.1.p4):

异常对象的内存以未指定的方式分配 方式,除非在 3.7.4.1 中注明

如果分配失败怎么办——它会抛出std::bad_alloc 吗?致电std::terminate?未指定?

【问题讨论】:

  • FWIW,size of the exception object can be quite big,如果你尝试分配太大的东西,appears that std::terminate is called。这是否是标准中的指定行为我不确定。
  • 在一个不太极端的例子中,不涉及堆栈溢出,我希望bad_alloc。此外,一些编译器会为“合理”数量的异常预先分配空间。
  • @BoPersson 期望无关紧要——标准是否指定了将要发生的事情?看起来像一个需要修复的遗漏
  • Itanium ABI 指定__cxa_allocate_exception 在无法分配内存时调用terminate
  • @T.C.如果这种行为在标准 POV 中是可以的,那么就不可能编写可靠的 C++ 代码。在这种情况下,我找不到任何禁止或允许 std::teminate() 的东西。

标签: c++ c++11


【解决方案1】:

(提供我自己的答案...我会等几天,如果没有问题 - 我会将其标记为已接受)

我花了一些时间对此进行调查,这是我发现的:

  • C++ 标准没有指定在这种情况下会发生什么
  • Clang 和 GCC 似乎使用 C++ Itanium ABI

Itanimum ABI 建议使用堆来处理异常:

抛出异常需要存储。这种存储必须 在堆栈展开时持续存在,因为它将被 处理程序,并且必须是线程安全的。异常对象存储将 因此通常在堆中分配

...

内存将由__cxa_allocate_exception 运行时库例程分配。

所以,是的...抛出异常可能涉及锁定互斥锁和搜索空闲内存块。 :-(

它还提到了这一点:

如果 __cxa_allocate_exception 在这些约束下无法分配异常对象,它会调用 terminate()

是的...在 GCC 和 Clang 中“throw myX();”可以杀死您的应用程序,而您对此无能为力(也许编写自己的 __cxa_allocate_exception 会有所帮助 - 但它肯定不会是可移植的)

它变得更好:

3.4.1 分配异常对象

异常对象的内存将由 __cxa_allocate_exception 运行时库例程,一般要求如第 2.4.2 节所述。如果正常分配 失败,然后它将尝试分配紧急缓冲区之一, 在第 3.3.1 节中描述,在以下约束条件下:

  • 异常对象大小(包括标头)小于 1KB。
  • 当前线程尚未拥有四个缓冲区。
  • 持有缓冲区的其他线程少于 16 个,或 此线程 将等到其中一个释放其缓冲区,然后再获取一个。

是的,您的程序可以简单地挂起!这种可能性很小——您需要耗尽内存,并且您的线程需要用完所有 16 个紧急缓冲区并等待另一个应该生成异常的线程。但是如果你用std::current_exception 做事(比如链接异常并在线程之间传递它们)——这并不是那么不可能。

结论:

这是 C++ 标准的一个缺陷——你不能编写 100% 可靠的程序(使用异常)。教科书示例是一个服务器,它接受来自客户端的连接并执行提交的任务。处理问题的明显方法是抛出异常,这将解除一切并关闭连接——所有其他客户端不会受到影响,服务器将继续运行(即使在内存不足的情况下)。唉,这样的服务器是不可能用 C++ 编写的。

您可以声称现代系统(即 Linux)无论如何都会在我们达到这种情况之前杀死此类服务器。但是(1)它不是一个论点; (2)内存管理器可以设置为overcommit; (3) 运行在 64 位硬件上且内存足够的 32 位应用程序(或应用程序人为限制内存分配)不会触发 OOM 杀手。

就我个人而言,我对这个发现非常生气——多年来我一直声称我的代码可以优雅地处理内存不足。原来我对我的客户撒了谎。 :-( 不妨开始拦截内存分配,调用std::terminate 并将所有相关函数视为noexcept - 这肯定会让我的生活更轻松(编码方面)。难怪他们仍然使用Ada来编程火箭。

【讨论】:

  • 不幸的是,当一个未捕获的异常关闭系统时,使用 Ada 并没有帮助Ariane 5 rocket
  • @BoPersson 这是因为他们在代码中有一个错误。该咆哮的重点是,即使是 100% 正确的 C++ 代码仍然会崩溃。
  • 关于最后几段...您声称 Itanium ABI 的实现细节是标准中的一个缺陷。我会说这是实施中的缺陷。
  • @André afaik 标准允许在这种情况下进行任何行为——因此安腾 ABI 很好。但由于任何行为意味着无法用于实际目的——我认为这是 C++ 标准的缺陷。
  • @SebastianRedl 几周前我已经提交了一份提案 (P0770R0) 来解决这个问题。忙于其他事情,不知道发生了什么。
【解决方案2】:

[intro.compliance]/2 尽管本国际标准仅规定了对 C++ 实现的要求,但如果将这些要求表述为对程序、程序的一部分或执行的要求,则通常更容易理解的节目。此类要求的含义如下:

(2.1) — 如果程序不违反本国际标准中的规则,则符合标准的实现应在其资源限制内接受并正确执行该程序。

强调我的。基本上,该标准设想未能分配动态内存(并在这种情况下规定行为),但没有任何其他类型的内存;并且没有以任何方式规定实现在达到其资源限制时应该做什么。

另一个例子是由于递归太深而导致堆栈耗尽。标准没有说明允许递归的深度。产生的堆栈溢出是实现在“资源限制内”执行失败的权利。

【讨论】:

  • 我可以轻松查看和控制我的堆栈深度。我没有很好的方法来估计我的程序中使用了多少“不可见的异常堆”——库可以积累异常对象(通过std::exception_ptr)。每个线程可以累积几个。仅通过查看我的代码就无法估计这个“隐形堆”的使用情况。恕我直言,在这种情况下终止是不行的——需要更新标准以提供更好的保证。
  • 将 OOM 与堆栈溢出进行比较似乎是一个常见的错误。不同之处在于堆栈的使用不依赖于输入数据的大小(除非使用递归算法),但堆的使用通常取决于它。
  • @CM:如果你不知道你的库是做什么的(就累积异常对象而言),我也不知道你怎么知道它们需要多少堆栈空间。
  • @MikeMB 我并没有暗示我对库代码一无所知。关键是“没有很好的方法来估计我的程序中使用了多少‘不可见的异常堆’”。例如。我可以用“std::current_exception”“固定”异常并将它们累积到某个地方。另一方面,堆栈的使用可以通过查看代码来确定(嗯......除非你使用特定于实现的东西,比如alloca()/etc,或者除非你的递归深度取决于输入数据)。
  • @C.M.出于同样的原因,您可以估计您的程序使用了多少“不可见的异常堆”(嗯......除非您使用std::current_exception 来固定异常并在某处累积它们)。避免这样做,就像避免深度取决于输入数据的递归一样。我不知道为什么你认为一种不好的做法可以挥手离开,而另一种做法则是天塌下来的可怕。根据我的经验,std::current_exception 的错误使用比递归的错误使用要少得多;事实上,我认为我从未见过有人在实际代码中使用std::current_exception
【解决方案3】:

当前答案已经描述了 GCC 的作用。我检查了 MSVC 行为 - 它在堆栈上分配异常,因此分配不依赖于堆。这使得堆栈溢出成为可能(异常对象可能很大),但标准C++没有涵盖堆栈溢出处理。

我使用这个简短的程序来检查异常抛出期间发生的情况:

#include <iostream>

class A {
public:
    A() { std::cout << "A::A() at " << static_cast<void *>(this) << std::endl; }
    A(const A &) { std::cout << "A::A(const A &) at " << static_cast<void *>(this) << std::endl; }
    A(A &&) { std::cout << "A::A(A &&) at " << static_cast<void *>(this) << std::endl; }
    ~A() { std::cout << "A::~A() at " << static_cast<void *>(this) << std::endl; }
    A &operator=(const A &) = delete;
    A &operator=(A &&) = delete;
};

int main()
{
    try {
        try {
            try {
                A a;
                throw a;
            } catch (const A &ex) {
                throw;
            }
        } catch (const A &ex) {
            throw;
        }
    } catch (const A &ex) {
    }
}

当使用 GCC 构建时,输出清楚地表明抛出的异常被分配到远离堆栈的地方:

A::A() at 0x22cad7
A::A(A &&) at 0x600020510
A::~A() at 0x22cad7
A::~A() at 0x600020510

当使用 MSVC 构建时输出显示异常分配在堆栈附近:

A::A() at 000000000018F4E4
A::A(A &&) at 000000000018F624
A::~A() at 000000000018F4E4
A::~A() at 000000000018F624

使用调试器进行的额外检查表明,catch 处理程序和析构函数在堆栈顶部执行,因此堆栈消耗随着每个 catch 块从第一次 throw 开始而增长,直到 std::uncaught_exceptions() 变为 0。

这种行为意味着正确的内存不足处理需要您证明有足够的堆栈空间供程序执行异常处理程序和所有在途的析构函数。

要证明与 GCC 相同,您似乎需要证明嵌套异常不超过四个,并且异常的大小小于 1KiB(这包括标头)。另外,如果某个线程有四个以上的嵌套异常,还需要证明不存在紧急缓冲区分配导致的死锁。

【讨论】:

  • 所以 MSVC 遵循 c++ 标准中所说的内容!
  • 根据您的回答,看起来 GCC 在这方面不符合标准。尽管我讨厌 MSVC 及其运行时,但它在这里胜过 GCC。
  • 我也在 Linux 上使用 GCC,我已经放弃使用 MSVC,因为有太多的不符合项:在 MSVC 上编译代码的工作量太大,维护它的工作量太大,错误太多在刚刚来自 MSVC 错误的可执行文件中!这是我看到 MSVC 比 GCC 做得更好的第一个案例。我希望 MSVC 很快就会参加聚会,我知道他们正在努力。 GCC 似乎从竞争中受益。
  • @Ivan -- 问题是关于 C++ 标准,而不是特定的实现。我知道 MSVC 使用堆栈来处理异常,这使它不受这个问题的影响,但正因为如此,现在很难(或不可能)估计我的程序需要多少堆栈空间——这意味着如果我的猜测是错误的,结果是与 GCC 相同(崩溃)。顺便说一句,GCC 和 MSVC 都符合这里的标准
  • @Ivan 您的回答提供了很好的 MSVC 特定信息。如果你稍微更新一下——我也会标记它。特别是这些点需要一些改进:(1)它并不是真正预期的——结果标准允许它“依赖于编译器”; (2) 分配可能失败——只是失败不同(堆栈耗尽); (3) 不仅catch-handlers,而且dtors也在栈顶执行; (4) MSVC这里确实符合标准
【解决方案4】:

实际上规定如果异常对象分配失败,则应抛出bad_alloc,实现也可以调用新的处理程序。

这实际上是在您的站点 [basic.stc.dynamic.allocation] 的 c++ 标准部分(第 3.7.4.1 节)中指定的:

分配存储失败的分配函数可以调用当前安装的新处理函数 (21.6.3.3),如果有的话。 [ 笔记: 程序提供的分配函数可以获得当前的地址 已安装 新处理程序 使用 std::get_new_handler 功能(21.6.3.4)。 ——尾注 ] 如果一个分配 具有非抛出异常规范 (18.4) 的函数无法分配存储,它应返回 null 指针。任何其他分配存储失败的分配函数只能通过抛出一个 与类型的处理程序 (18.3) 匹配的类型的异常 (18.1) std::bad_alloc (21.6.3.1)。

然后在[except.terminate]中召回

在某些情况下,必须放弃异常处理以使用不太微妙的错误处理技术。 [ 笔记: 这些情况是: — (1.1) 异常处理机制时,在完成异常对象的初始化后但是 在激活异常处理程序之前 (18.1)*

所以安腾 ABI 不遵循 c++ 标准规范,因为如果程序无法为异常对象分配内存,它可以阻塞或调用terminate

【讨论】:

  • 6.7.4.1 的第一段声明“分配函数应为类成员函数或全局函数;” -- 看不到它如何应用于 throw-expression,文本中没有任何内容需要 throw-expression 才能使用 6.7.4.1 中的分配函数
  • 18.5.1.p(1-1) 也与我的问题无关
  • @C.M. y 更改了新标准中的章节编号。 3.7.4.1 -> 6.7.4.1。我已将链接指向您在问题中引用的部分...是的,它确实适用于您的问题(您自己引用!)。所以引用标准时的好习惯是不要使用节号,而是节名3.7.4.1是[basic.stc.dynamic.allocation]。
  • @C.M.编译器必须遵循 [intro.execution] 中描述的基本规则,它必须执行所要求的内容并继续执行(有进度保证)。例如 [expr.add] 中没有说明“1+1”会导致程序终止或程序阻塞。没有说明这一事实并不意味着编译器可以因为执行1+1 而被允许阻塞或允许终止。这同样适用于 throw 表达式!
  • @C.M.似乎您不是第一个提出这个问题的人,所以他们在 [except.terminate] 中回忆了可能导致程序终止的原因,并且对象异常分配被明确排除在可能导致终止的操作集中。
猜你喜欢
  • 2019-05-13
  • 1970-01-01
  • 2015-01-31
  • 1970-01-01
  • 2022-11-28
  • 2011-05-22
  • 1970-01-01
  • 2023-04-06
  • 1970-01-01
相关资源
最近更新 更多