第19.9章:fast_io 的分配器选项
1. 策略是类型参数,不是全局开关
第19.2章 说了 fast_io 在分配无法满足、尺寸无法表示、不变量被破坏时怎么做:当场调用 ::fast_io::fast_terminate()。那是默认行为,也是库里每个容器的默认行为。
它不是自然规律,而是分配器类型的一个属性。fast_io 不使用标准分配器模型 —— 它不用 ::std::allocator,不用 ::operator new,和 std::allocator_traits 毫无关系。fast_io 里的分配器是一个具有静态接口和策略的类型,而策略就写成分配器自己类型里的一个位标志:
enum class allocator_adapter_flags
{
none = 0,
secure_clear = 1,
throws_on_allocation_failure = 2, // by default we fail fast
throws_on_violations = 4, // by default we fail fast
throws_on_overflow = 8, // by default we fail fast
prefer_big_allocation_on_stack = 16, // pick stack scratch (e.g. print) over heap
};
中间那三个标志就是本章要讲的:它们把 第19.2章 里的三种损坏情形,从"终止进程"变成"在 throws 通道上抛出 ::std::error"。secure_clear 让分配器在归还内存前擦除它,prefer_big_allocation_on_stack 让打印操作在需要大临时缓冲区时优先用栈上的草稿区 —— 两者都是同一类策略,这正是要点:分配器就是那个策略对象。
2. 分配器是怎么拼起来的
两层。内层是一个带静态函数的类,函数有两种口味:allocate_die,失败时可以不返回;allocate_try,失败时抛出赫布异常。::fast_io::c_malloc_allocator 是最小的例子,把整套想法都展示出来了:
class c_malloc_allocator
{
public:
static inline void *allocate_die(::std::size_t n) noexcept
{
if (n == 0)
{
n = 1;
}
void *p{__builtin_malloc(n)};
if (p == nullptr)
{
::fast_io::fast_terminate();
}
return p;
}
static inline void *allocate_try(::std::size_t n) FAST_IO_HERBCEPTIONS_THROWS
{
if (n == 0)
{
n = 1;
}
void *p{__builtin_malloc(n)};
if (p == nullptr)
{
::fast_io::herbceptions::throws_errc(::std::errc::not_enough_memory);
}
return p;
}
// deallocate and the reallocate pair follow the same two-flavour pattern
};
外层是 ::fast_io::generic_allocator_adapter<alloc, flags>。它读取标志,并对每个操作挑选策略要求的那种口味:带 throws_on_* 标志时,它要求存在 _try 函数并传播它们的错误;没有这些标志时,它优先用 _die,而如果分配器只有 _try 函数,适配器会调用它,并在它抛出时终止进程。这个退路是有意的:策略说要快速失败,就必须快速失败,哪怕底层分配器被写成了抛出版本。
每个 fast_io 容器都接受分配器类型:
template <typename T, typename Alloc = ::fast_io::native_global_allocator>
using vector = ::fast_io::containers::vector<T, Alloc>;
template <::std::integral chartype, typename allocator = ::fast_io::native_global_allocator>
using basic_string = ::fast_io::containers::basic_string<chartype, allocator>;
所以这个选择是在每个容器类型上、在编译期做一次。没有运行时能翻转的全局开关,没有虚分配器,没有 std::pmr,也不可能让程序的两个部分对它们共享的容器的策略产生分歧:策略是类型的一部分。
3. 要求抛出赫布异常
下面这个程序和 第19.2章 那个是同一个,只是分配器说了"报告它,不要死"。底层分配器还是同一个 c_malloc_allocator,容器还是同一个 basic_string —— 只有标志变了:
#include <fast_io.h>
#include <fast_io_dsal/string.h>
/* the same malloc-backed allocator, but with the failure policy changed */
using throwing_allocator = ::fast_io::generic_allocator_adapter<
::fast_io::c_malloc_allocator,
::fast_io::allocator_adapter_flags::throws_on_allocation_failure |
::fast_io::allocator_adapter_flags::throws_on_violations |
::fast_io::allocator_adapter_flags::throws_on_overflow>;
using throwing_string = ::fast_io::basic_string<char, throwing_allocator>;
int main()
{
try
{
throwing_string s;
::std::size_t const huge{static_cast<::std::size_t>(1) << 40};
::fast_io::io::print("reserving ", huge, " bytes\n");
s.reserve(huge);
::fast_io::io::print("reserved ", s.capacity(), " bytes\n");
}
catch throws (::std::error e)
{
::fast_io::io::print("caught ", e, "\n");
}
}
reserving 1099511627776 bytes
caught [posix](12)Not enough space
这个错误是 ::std::errc::not_enough_memory,由 c_malloc_allocator::allocate_try 在 malloc 返回 nullptr 时报出,而它作为一个带正常错误域的普通赫布异常抵达,所以 ::fast_io::mnp::message(e) 打印的就是操作系统给它自己的文本。注意没有发生什么:没有遗留异常、没有 bad_alloc、没有展开栈、没有 ::std::terminate,也没有空指针。
huge 这个值是操作系统根本不可能满足的尺寸(1 TiB);一个无法被表示的尺寸属于 throws_on_overflow 的情形,而传给容器操作的空句柄属于 throws_on_violations。它们是分开的标志,因为程序可能想上报其中一种而对另外两种直接死掉。
4. 什么时候打开它
默认值对应用程序来说是对的。只在一种情况下打开这些标志:你在写一个库,而你的调用者必须能看到失败。一个直接终止的库无法与任何东西组合;一个上报 ::std::errc::not_enough_memory 的库,可以被一个在它自己的边界上决定重试、缩小请求还是干脆死掉的应用程序使用。
| 你在写 | 分配器策略 | 为什么 |
|---|---|---|
| 一个应用程序 | 默认:快速失败 | 你上面没有别人可以上报,而且一个能从分配失败中活下来的程序,是一个没人审计过的程序 |
A library with a throws interface |
throws_on_allocation_failure(如果你暴露尺寸,再加上另外两个)
|
你的调用者只能处理被告知的事情 |
| A test harness | throws_on_allocation_failure |
你想验证失败路径,而不是让测试运行器中止 |
| Anything holding secrets | 默认,再加 secure_clear |
被释放的内存不该残留明文 |
还有一条让这件事保持诚实的规则:不要在一个对象里混用策略。容器的分配器类型是容器类型的一部分,所以 ::fast_io::basic_string<char, throwing_allocator> 不可能被悄悄传给期待 ::fast_io::string 的代码。这和本书其余部分一样,是"策略就是类型"的原则,也是这个选择不可能在运行时出错的原因。
5. 为什么不用 ::std::allocator
标准分配器模型无法表达这里的任何一点,原因是结构性的:
-
没有策略。标准分配器只有一种失败模式,由分配器自己的实现决定:
::std::allocator<T>::allocate抛出::std::bad_alloc,它也只能这样。没有办法说"这里要快速失败"或"尺寸溢出要另作报告",除非替换整个分配器,而那时你已经不再使用标准模型了。 -
它按错误的东西做类型参数化。
::std::allocator<T>按元素类型参数化,所以两个元素类型不同的容器即使策略完全一样,分配器类型也不同;而 rebind 出来的分配器让策略根本无法命名。 -
它通过
::operator new分配,而::operator new本身就是一个带不可见失败策略的全局,并且在每个实现里,在要紧的目标上都不是 freestanding(第20章)。 -
它通过抛遗留异常报告失败,而 第19.8章 已把它从大多数目标上排除。
std::nothrow是另一个选项,而 第19.2章 把它也排除了。
fast_io 的分配器是一个带策略、无状态的静态接口:编译器按标志替你挑选 _die 或 _try 实现,而失败路径就是那个策略所说的样子。这正是为什么可以让 ::fast_io::vector 在游戏里终止进程、在库里上报 ::std::errc::not_enough_memory —— 同一个头文件,没有配置文件,没有运行时开关。