第20.2章:fast_io 的独立实现能力

English

1. 依赖操作系统的部分都是类型参数

fast_io 是一个不假定下面有操作系统的 I/O 库。依赖操作系统的部分不是先编进库、再用选项关掉, 而是类型参数,或者由目标提供的符号。这就是"格式化代码是 header-only"和"这个库没有隐藏的堆、隐藏的控制台、 隐藏的错误运行时"之间的区别。

这个分层直接体现在伞形头文件上:

独立程序包含 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、帧缓冲,或者你自己的流

按顺序给出实践规则:

  1. 包含 fast_io_freestanding.h,或者 fast_io_core.h 加上你用到的单元。裸机上不要包含 fast_io.h。
  2. 永远不要写 new。使用 containers::vector<T, Alloc>,分配器由你提供。
  3. 实现 freestanding_global_allocator,或者给每个容器传入明确的分配器。
  4. 可恢复错误用 throws,破坏的不变式用 fast_terminate()。
  5. 补上目标没有的 C 库片段(memcpy、memset、memmove)和编译器运行时(libgcc 或 compiler-rt)。
  6. 写启动代码,清零 .bss、复制 .data、遍历 .init_array;或者干脆不要动态初始化。
  7. 用 nm -u 验证。独立程序不应该有意外的未定义符号。