第20.2章:fast_io 的独立实现能力
1. 依赖操作系统的部分都是类型参数
fast_io 是一个不假定下面有操作系统的 I/O 库。依赖操作系统的部分不是先编进库、再用选项关掉, 而是类型参数,或者由目标提供的符号。这就是"格式化代码是 header-only"和"这个库没有隐藏的堆、隐藏的控制台、 隐藏的错误运行时"之间的区别。
这个分层直接体现在伞形头文件上:
-
<fast_io_freestanding.h>— 核心、格式化、扫描、缓冲区、序列化和缓冲区设备。 没有文件、控制台或进程。 -
<fast_io_hosted.h>— 增加原生设备、文件、控制台、进程、线程和文件系统, 并且只在平台是宿主实现时启用。 -
<fast_io.h>— 包含宿主层,再加上遗留的<iostream>桥。
独立程序包含 fast_io_freestanding.h(或者更小的 fast_io_core.h
加上实际用到的单元),绝不包含 fast_io.h,因为那是在要求目标上并不存在的设备。
2. 容器把分配器带在类型里
::fast_io::containers::vector<T, Alloc> 始终可用。便利别名
::fast_io::vector<T> 会选择平台的默认堆,因此只在有宿主平台时存在。这个条件写在头文件里,
不是写在文档里:
#if ((__STDC_HOSTED__ == 1 && (!defined(_GLIBCXX_HOSTED) || _GLIBCXX_HOSTED == 1) && \
!defined(_LIBCPP_FREESTANDING)) || \
defined(FAST_IO_ENABLE_HOSTED_FEATURES))
namespace fast_io
{
template <typename T, typename Alloc = ::fast_io::native_global_allocator>
using vector = ::fast_io::containers::vector<T, Alloc>;
在独立目标上,::fast_io::vector 不存在。没有静默回退到隐藏堆的路径,因为没有名字可以回退。
你写 ::fast_io::containers::vector<int, my_allocator>,分配器是容器类型的一部分。
这就是与标准库的具体差别。::fast_io::containers::vector<T, Alloc> 是独立的;
::std::vector<T> 不是。数据结构是同一类东西;接口不是。fast_io 把分配器放进类型里,
所以容器不可能碰调用者没有提供的堆。std::vector 把分配器放在全局,失败时靠抛出报告,
所以无论怎么改标准措辞,它都不可能成为独立的。
视图也一样:独立的 std::string_view 会删掉那些会抛异常的方法,而 fast_io 在自己的容器层里
提供了 fast_io::string_view 和 fast_io::cstring_view。
默认分配器也由同一条边界选择。平台是宿主实现时,它是 c_malloc_allocator(或者 NT 堆、mimalloc);
是独立实现时,它是 freestanding_global_allocator。这个类在 fast_io 里只有声明,
它的三个函数在库中没有任何定义:
class freestanding_global_allocator
{
public:
static ::fast_io::allocation_least_result
allocate_aligned_conditional_zero_at_least_die(
::std::size_t alignment, ::std::size_t n, bool zero) noexcept;
static ::fast_io::allocation_least_result
allocate_aligned_conditional_zero_at_least_try(
::std::size_t alignment, ::std::size_t n, bool zero)
FAST_IO_HERBCEPTIONS_THROWS;
static void deallocate_aligned_n(
void* p, ::std::size_t alignment, ::std::size_t n) noexcept;
};
这就是完整的独立分配器契约。你为你的目标实现它——一个 bump arena、一个 slab、一个 per-CPU 池——然后每一个 fast_io 容器、每一个需要临时内存的格式化例程都只用它,不会用别的。一个不提供堆就无法链接的库, 才是一个不可能在你背后分配的库。
最小的可用 arena 长这样。它故意是单线程的:在板子上你知道哪个核在跑,而锁又是另一个运行时依赖。
#include <fast_io_core.h>
#include <fast_io_dsal/vector.h>
template <::std::size_t extent>
class arena
{
public:
alignas(::std::max_align_t) static inline ::std::byte storage[extent]{};
static inline ::std::size_t used{};
static ::fast_io::allocation_least_result
allocate_aligned_conditional_zero_at_least_die(
::std::size_t alignment, ::std::size_t n, bool) noexcept
{
::std::size_t const start{(used + alignment - 1u) / alignment * alignment};
if (start + n > extent) { ::fast_io::fast_terminate(); }
used = start + n;
return {storage + start, n};
}
static void deallocate_aligned_n(
void*, ::std::size_t, ::std::size_t) noexcept {}
};
using heap_a = ::fast_io::generic_allocator_adapter<arena<4096>>;
int main()
{
::fast_io::containers::vector<int, heap_a> v;
v.push_back(42);
return v[0];
}
失败策略就在你实现的函数名里。_die 版本在 arena 耗尽时调用 fast_terminate();
_try 版本通过 throws 通道报告 ::std::errc::not_enough_memory。
选哪一个是对每个容器类型做的编译期决策,不是全局开关。
3. 错误处理:赫布异常
赫布异常是返回路径上的一个错误值:一个域指针和一个错误码,再加上调用约定里的失败位。没有任何东西被展开,
所以不需要栈遍历器、帧表或 personality routine。一次遗留 throw 这些全都要,差距不是一点:
| 遗留异常 | 赫布异常 | |
|---|---|---|
| 链接符号 | 大约十个运行时入口 | 一个错误域数据符号 |
| 失败时分配堆 | 是,为异常对象 | 否 |
| 遍历栈 | 是 | 否 |
| 帧表、personality routine | 是 | 否 |
| RTTI | 是,用于匹配处理块 | 否 |
| 能在板子、内核、GPU 上工作 | 否 | 是 |
首要的一点不是速度:遗留机制本身就是一个独立的运行时——unwinder、异常表解析器、 personality routine、RTTI、分配器——而这个运行时恰恰是内核无法提供的 (20.1)。赫布异常之所以是答案, 是因为它移除了这个运行时,而不是因为它优化了它。
速度是随之而来的结果。在错误路径上,赫布异常比遗留 throw 快几个数量级:
没有堆分配、没有栈遍历、没有帧表查找。
19.8 实测每次遗留抛出
934,540 ps,每次赫布异常 885 ps,大约一千倍。在正常路径上,赫布异常只在返回路径上增加少量工作——
携带并检查失败位——这与返回错误码大致同一量级,而且通常小于那种必须在每一层同时传递状态和输出值的
错误码管线。
19.7 里的构建选项不会破坏它们。
-fno-exceptions 移除的是遗留机制,而赫布异常继续工作,因为它从来没用过那套机制。
一个独立构建可以、也应该在全程使用 throws 的同时加上
-fno-exceptions -fno-rtti。
剩下的代价是消息文本。独立运行时可以用 LIBHERBCEPTIONS_FREESTANDING 构建,
它默认打开 __HERBCEPTIONS_NO_MESSAGES;此时每个域只报告 [domain](code),
不再分配和复制字符串。消息表根本不会被碰到,flash 的节省就来自这里。一个内核对它实际会报告的那二十个错误码
想要描述文本,可以用自己的实现覆盖这个域(19.1)。
4. 没有控制台的 I/O
设备也是一个类型。::fast_io::basic_obuffer_view<char> 写入调用者提供的缓冲区,
print_freestanding 写入任何满足接口的输出流。格式化引擎不知道、也不关心目标是 UART、
帧缓冲、环形缓冲区还是一个文件。
同样的设计也适用于 I/O,而且这是库的另一半。fast_io 不是"容器库外加一个 I/O 附件":格式化和扫描层由
concepts 约束,所以任何满足输出流接口的类型都能用。你只需要为设备定义一次写操作——UART、帧缓冲或
环形缓冲区需要什么就写什么——print / print_freestanding 就知道如何把每个
支持的参数格式化进去。库不需要控制台、文件描述符或 locale 才知道怎么打印。
int render(int value) throws
{
char buffer[64];
::fast_io::containers::vector<int, heap_a> v;
v.push_back(value);
::fast_io::basic_obuffer_view<char> out{buffer, buffer + sizeof(buffer)};
::fast_io::operations::print_freestanding<false>(out, "v[0] = ", v[0], "\n");
return static_cast<int>(out.size());
}
int main()
{
try { return render(12345); }
catch throws (::std::error) { return -1; }
}
这个翻译单元只包含 fast_io_core.h 和 vector。它不碰文件、控制台或 locale。在真实板子上,
你写一个实现 UART 或帧缓冲写入操作的设备类型,把它交给同一个 print_freestanding,
格式化代码一行都不用改。
关键就在这里:操作是对设备泛型的,而不是绑定到某个固定的 std::ostream。编译器用 concept
检查设备;只要它满足接口,同一份格式化代码就为它实例化。没有运行时分发,也没有隐藏的控制台。
5. 检查清单
| 裸机上的问题 | fast_io 提供的答案 |
|---|---|
| 一个隐藏的全局堆 | 分配器是类型参数;宿主环境下 fast_io::vector 根本不存在 |
| 错误处理需要操作系统 | 返回路径上的赫布异常,失败策略由分配器决定 |
| 消息表消耗 flash | LIBHERBCEPTIONS_FREESTANDING 只报告 [domain](code) |
| 库假定存在控制台 | 设备是类型:缓冲区视图、UART、帧缓冲,或者你自己的流 |
按顺序给出实践规则:
- 包含
fast_io_freestanding.h,或者fast_io_core.h加上你用到的单元。裸机上不要包含fast_io.h。 - 永远不要写
new。使用containers::vector<T, Alloc>,分配器由你提供。 - 实现
freestanding_global_allocator,或者给每个容器传入明确的分配器。 - 可恢复错误用
throws,破坏的不变式用fast_terminate()。 - 补上目标没有的 C 库片段(
memcpy、memset、memmove)和编译器运行时(libgcc或compiler-rt)。 - 写启动代码,清零
.bss、复制.data、遍历.init_array;或者干脆不要动态初始化。 - 用
nm -u验证。独立程序不应该有意外的未定义符号。