第19.2章:std::abort()、std::terminate() 与 __builtin_trap()

English

1. 有些失败不是错误

错误是调用者能对它做点什么的失败:文件不在、对端重置了连接、文本不是数字。这些走 throws 通道,本章其余部分都在讲它们。

还有第二类失败,而这个区分是本书最重要的一条规则:让抽象机无法继续的失败不是错误,而是损坏,唯一正确的反应是终止进程。有三类情形属于此类:

从上述任何一种情形继续,都是让程序从"一次崩溃"变成"一个安全漏洞"的方式:它会带着损坏的堆、损坏的尺寸或损坏的下标继续运行。专业的回答是就地立即死掉,让操作系统记录原因。fast_io 就是这么做的,处处如此;本页讲它的三种写法,以及你该用哪一种。

2. 三种写法


#include <fast_io.h>
#include <cstdlib>

int main()
{
	::fast_io::io::perr("about to die\n");
#if defined(USE_ABORT)
	::std::abort();
#elif defined(USE_TERMINATE)
	::std::terminate();
#else
	::fast_io::fast_terminate();
#endif
}

同一个程序的三个构建,各自选择一种死法:


$ ./crash -DUSE_ABORT
about to die
Aborted (core dumped)
$ ./crash -DUSE_TERMINATE
about to die
Aborted (core dumped)
$ ./crash
about to die
Illegal instruction (core dumped)

注意第三种情形的输出里没有什么:shell 说它死于信号,而这个信号是 SIGILL(退出码 132 = 128 + 4),不是 SIGABRT(134 = 128 + 6)。fast_terminate() 不是换了名字的 abort;它编译成一条指令:


  401169:       ud2
  40116b:       nopl   0x0(%rax,%rax,1)

ud2 是架构上保证非法的一条指令。陷阱由 CPU 自己抛出,所以不涉及任何库调用、信号帧、处理函数或 unwinder,而且这条指令就内联在检查失败的地方。

::std::abort() ::std::terminate() ::fast_io::fast_terminate()
需要什么 libc libc++ / libstdc++ 以及一个 terminate 处理函数 什么都不需要
信号 / 状态码 SIGABRT / 134 SIGABRT / 134(它的处理函数会 abort) SIGILL / 132
能否用于 freestanding、内核、引导程序 不能 不能 能:__builtin_trap,退路依次是 __builtin_abort 和 ::std::abort
是否运行析构函数或 atexit 处理函数 不运行 不运行 不运行
什么时候用它 遗留代码、测试 你选不了它:是语言替你调用它(异常逃出 noexcept、展开途中又抛出、遗留异常没有处理块) 你写下的每一处不变量检查,以及 fast_io 替你检查的每一处

::std::terminate() 值得知道,但作为工具没什么用:你几乎不会主动调用它。它是运行时在遗留异常机制无处安放异常时调用的东西 —— 第19.8章 把这一点算作那套机制的缺陷之一。

3. fast_io 在什么情况下崩溃

每个 fast_io 容器的默认分配器,以及每一处内部的尺寸计算,都把上面三种情形当作损坏并当场调用 ::fast_io::fast_terminate()。这里是一个真实例子:在只有几 GB 内存的机器上保留 240 个 int,也就是 8 TiB。


#include <fast_io.h>
#include <fast_io_dsal/vector.h>

int main()
{
	::fast_io::io::print(::fast_io::c_stdout(), "reserving 2^40 ints\n");
	::fast_io::operations::output_stream_buffer_flush(::fast_io::c_stdout());
	::fast_io::vector<int> v;
	v.reserve(static_cast<::std::size_t>(1) << 40);
	::fast_io::io::print("reserved ", v.capacity(), " ints\n");
}


reserving 2^40 ints
Illegal instruction (core dumped)

注意输出的第二行:shell 报告了信号,因为进程已经没了。没有错误值、没有返回码、没有异常,也没有任何东西可检查。程序不是保留失败,而是在那道栅栏处直接不存在了。

这就是 ::fast_io::vector、::fast_io::string 以及库里其他每个容器的预期行为,也是它有意区别于 std::vector 的地方。你的程序里不会有一个围着分配的 try,也不该有:一个能从 bad_alloc 里活下来的程序,是一个必须在"每次分配都可能失败"的状态下仍然正确的程序,包括它自己错误上报路径里的那些分配。没人会去审计这个,所以没人是正确的。

当你想要相反的策略 —— 你在写一个库,而你的调用者应该拿到赫布异常而不是一个死进程 —— 那在 fast_io 里是一个按分配器做的决定,而不是按调用做的决定,见 第19.9章。

4. 你会丢掉的输出

陷阱不会刷新任何东西。还留在进程用户态缓冲区里的输出会随进程一起丢弃,所以你程序死前打印的最后一句可能永远看不到。两个流的行为不同,这个差别值得测量一次:


#include <fast_io.h>
#include <fast_io_dsal/vector.h>

int main()
{
	::fast_io::io::print("about to reserve 2^40 ints\n");   // buffered stdout
	::fast_io::io::perr("about to reserve 2^40 ints\n");   // unbuffered stderr
	::fast_io::vector<int> v;
	v.reserve(static_cast<::std::size_t>(1) << 40);
}


about to reserve 2^40 ints
Illegal instruction (core dumped)          <- the perr line; the print line is gone
$ ./lose 2>/dev/null
Illegal instruction (core dumped)          <- neither line: stdout never flushed

print 那一行进了带缓冲的 stdout 流,随进程一起死了;perr 那一行进了 stderr,它是无缓冲的,活了下来。所以规则是:故意崩溃前的最后几句话走 perr(第11.1章 讲了两个流为何不同),其余都可以留在 print 上。

5. 你绝不能拿来替代的做法

在那道栅栏处死掉,如果付得起就在 stderr 上留一句话,然后让 core dump 或崩溃上报器告诉你位置。停下来的程序是可诊断的;在损坏之后继续运行的程序是一个谜、一个 CVE,或者两者都是。