sans-io:一种让库同时支持同步与异步的设计哲学

3 阅读15分钟

本文是对博客 The case for sans-io 的整理与翻译


从一个生态分裂问题说起

Rust 里最流行的 ZIP 解压库是 zip,写这篇文章时已有 4800 万次下载,功能完备,支持多种压缩算法、加密,甚至支持写入 ZIP 文件。

但并非所有人都用它。

一些应用场景需要异步 I/O——比如边下载边解压网络上的归档文件。以 Rust 写成的 Python 包管理器 uv 为例,它不用 zip,而是用 async_zip——一个由单人维护、关注度远不如前者的 crate。

这种状况在 Rust 生态里相当普遍:同一段逻辑要针对同步接口和异步接口各写一遍。结果是生态分裂、重复劳动,以及更多的 bug。

这篇文章想聊的,正是打破这种分裂的一种设计思路——sans-io


ZIP 格式:比你想象的更烂

在谈 sans-io 之前,先得理解为什么 ZIP 解析本身就不是一件简单的事。ZIP 是一个古老而怪异的格式,充满历史包袱。

字符编码的噩梦

ZIP 格式的诞生早于 UTF-8 的普及。那个年代,文件名用什么编码,取决于系统当前设置的代码页是什么。直到 2007 年,PKWARE 才在 APPNOTE 里更新文档,增加了一个"扩展字段"来标记文件名是否为 UTF-8 编码。

作者曾在 itch.io 工作时遇到过这个问题:一位日本游戏开发者用 Windows 自带的压缩工具打包,文件名是 Shift-JIS 编码(一种 1969 年制定的日本工业标准编码)。大多数 ZIP 工具却把它当成 Code Page 437(1981 年 IBM PC 的字符集)来处理——这在西方是个合理的猜测,因为格式本身只会告诉你"是 UTF-8"或"不是 UTF-8",仅此而已。

作者想出的解决方案是:提取 ZIP 文件里所有文本内容(文件名、注释等),做统计分析,根据特定字节序列的出现频率来猜测字符集。比如 Shift-JIS 的常见字节序列:

var commonChars_sjis = []uint16{
    0x8140, 0x8141, 0x8142, 0x8145, 0x815b, 0x8169, 0x816a, 0x8175, 0x8176, 0x82a0,
    0x82a2, 0x82a4, 0x82a9, 0x82aa, 0x82ab, 0x82ad, 0x82af, 0x82b1, 0x82b3, 0x82b5,
    // ... 更多字节序列
}

得出各种编码的概率后,取最高的那个……然后祈祷。

目录、路径与时间戳

ZIP 格式本身不区分文件和目录。目录就是长度为 0、路径以斜杠结尾的条目。

APPNOTE 明确规定:路径中不得包含驱动器号或前导斜杠,所有斜杠必须是正斜杠 /。但现实中,各种工具创建的 ZIP 文件五花八门,格式规范更像是对现实情况的描述,而不是约束。

时间戳也是一团糟。MS-DOS 时间戳压缩在 32 位里:

  • 日期占 16 位:年份是从 1980 年开始计数的 7 位整数,月份 4 位,日期 5 位
  • 时间占 16 位:时间精度是2 秒,不是 1 秒

用图示来看,整个结构大概是这样的:

|  年(7位)  | 月(4位) | 日(5位) | 时(5位) | 分(6位) | 秒/2(5位) |

当你下次见到有人说 IEEE 754 的 0.1 + 0.2 != 0.3 很奇怪时,不妨想想这个。

UNIX 风格的时间戳和 DOS 风格的时间戳完全不同,为了兼容现实中大多数 ZIP 文件,两种都得支持。


中央目录末尾记录:ZIP 格式最诡异的地方

大多数文件格式的结构很直接:文件头在开头,然后是内容体。读 PNG?从头开始读。读 MP4?从头开始,按 box 结构逐步往下。

ZIP 不是这样的。

正确读取 ZIP 文件的方式,是从文件末尾开始,向前扫描,找到"中央目录末尾记录"(End of Central Directory,EOCD)的签名,然后从那里定位到中央目录,再读取各个文件条目的索引。

这就是为什么 zip crate 的 API 要求输入同时实现 ReadSeek——哪怕只是列出文件列表,你也必须能在文件里随意跳转:

impl<R: Read + Seek> ZipArchive<R> {
    pub fn new(reader: R) -> ZipResult<ZipArchive<R>> {
        // ...
    }
}

寻找 EOCD 的演进史

最初的 zip crate:从文件末尾附近开始,每次读 4 字节,如果不匹配签名就向左移动 1 字节,一字节一字节地回溯。极度低效。

后来的 async_zip crate:改进为每次读 2 KiB,向左移动 2 KiB 减去签名大小(用来处理签名横跨两个缓冲区的边界情况)。注释中提到相比之前有 500 倍的提速。

2024 年 5 月,zip crate 跟进:改为每次读 512 字节,性能大幅提升——直到 2024 年 8 月,修复了一个 EOCD 查找逻辑里的 bug,又引入了性能回退。

这个 bug 是什么?ZIP 文件里,注释或文件路径的内容可能恰好包含和 EOCD 签名一样的字节序列。如果第一个匹配点不是真正的 EOCD,停在那里就会出错。修复方案是:不停在第一个匹配点,而是扫描整个文件,记录所有看起来像 EOCD 签名的偏移量,最后综合判断。

但这意味着,对于一个很大的文件,你要以每次 512 字节的步长、不断向前 seek 的方式读完整个文件——这是对任何存储设备都最不友好的读取模式。有人在 GitHub 仓库里贴出了真实案例:

"我在一个 200 GB 的 ZIP 文件上测试了这个 PR(文件里有 233899 个条目),通过网络共享访问。"

200 GB,512 字节步长,那是将近 4 亿次读取操作。

2024 年 12 月,经过 11 周的来回讨论,一个重写 EOCD 检测算法的 PR 终于合入,修复了这次巨大的性能回退。


async_zip 有没有这些 bug?

可能有。它最后一次发布是 2024 年 4 月,此后的这些修复它都没有跟进。

作者的态度是:他不打算去查,因为他有自己的 ZIP crate——rc-zip


什么是 sans-io?

sans-io 这个词来自 Python 社区,字面意思是"没有 I/O"。它指的是这样一种设计思路:

把协议/格式解析的逻辑,与实际执行 I/O 的代码完全分离。

库本身不做任何读写操作,不调用任何 I/O 接口,不关心数据从哪里来、结果往哪里送。它只是一个纯粹的状态机:你往里喂数据,它告诉你解析结果,或者告诉你它还需要更多数据。

至于数据是从同步文件读来的、从异步网络 socket 读来的,还是从内存里的缓冲区读来的——库完全不在乎。这件事由调用方决定。

这种模式在 C 生态里其实更常见,因为 C 没有标准 I/O 接口,不得不如此。ZStandard 的解压 API 就是一个很好的例子:

// from the `zstd-sys` crate
pub unsafe extern "C" fn ZSTD_decompressStream(
    zds: *mut ZSTD_DStream,
    output: *mut ZSTD_outBuffer,
    input: *mut ZSTD_inBuffer,
) -> usize

输入和输出缓冲区就是一个指针、一个大小、一个当前位置:

struct ZSTD_inBuffer {
    pub src: *const c_void,
    pub size: usize,
    pub pos: usize,
}

调用一次 decompressStream,它会更新输入和输出缓冲区的 pos 字段,调用方根据这些值来判断发生了什么:

  • 如果输入的 pos < size:这次调用只消耗了部分输入,剩余部分需要在下次调用时再次传入(可能是因为输出缓冲区满了)。
  • 如果输出的 pos < size:解码完全结束,所有缓冲区都已刷新。
  • 如果输出的 pos == size:输出缓冲区已满,需要用更大的缓冲区再调用一次。

这些状态之间的转换出人意料地容易出错:如果解压器需要更多输入,但你没有更多输入可以给它,一不小心就会进入无限循环。你需要有一种方式告诉它"没有更多输入了,如果你认为输入被截断了就报错"。


rc-zip 的结构:两个状态机

rc-zip 实现了 sans-io 思路,暴露了两个状态机:

  • ArchiveFsm:读取中央目录,返回一个 Archive(包含所有文件条目的索引)
  • EntryFsm:在已知偏移量、压缩方式等信息的前提下,读取并解压某个具体条目

驱动 ArchiveFsm 完成工作的循环非常直接,围绕三个方法展开:

wants_read

pub fn wants_read(&self) -> Option<u64>

首先询问状态机是否需要更多数据。如果需要,返回 Some(offset),告诉调用方应该从文件的哪个偏移量开始读取。多数情况下,这个偏移量紧接着上次读取的位置,但并不总是如此(记住,ZIP 需要从末尾向前扫描)。

spacefill

pub fn space(&mut self) -> &mut [u8]
pub fn fill(&mut self, count: usize) -> usize

如果 wants_read 返回 Some,调用 space 获取状态机内部缓冲区的可变引用——这是一个 Rust slice,告诉你最多可以填入多少字节。执行读取后,调用 fill 告知实际读取了多少字节。读取大小为 0 表示文件结束(和标准 Read trait 的语义一致)。

process

pub fn process(self) -> Result<FsmResult<Self, Archive>, Error>

这个设计是作者比较满意的地方:process 消耗掉状态机本身。如果解析完成,返回 Done 变体,此后你永远无法再误调用状态机的方法;如果还需要继续,返回 Continue 变体,把状态机的所有权还给调用方:

pub enum FsmResult<M, R> {
    /// I/O 循环需要继续,状态机被归还
    Continue(M),
    /// 状态机已完成,结果被返回
    Done(R),
}

这种设计使得 rc-zip 可以被同步和异步代码无缝复用:


io_uring:异步文件 I/O 的正确答案

聊完 rc-zip,作者深入了一个相关话题:Linux 上的异步文件 I/O。

你知道 tokio 在 Linux 上怎么做异步文件读取吗?用后台线程。

// tokio 1.42, `src/fs/file.rs`
inner.state = State::Busy(spawn_blocking(move || {
    let res = buf.read_from(&mut &*std);
    (Operation::Read(res), buf)
}));

这就是为什么有人抱怨"用 tokio 读文件比用标准库慢"。当然慢——它在背后做了大量额外工作。

注意:这只影响文件,不影响 TCP socket——后者才是 tokio 真正擅长的领域。

用数据说话

作者写了一个测试程序,从 /dev/urandom 读取 1 GiB 数据,分别用 tokio 异步版和标准库同步版测试。用 lurk(类似 strace 的 Rust 工具)观察系统调用:

tokio 异步版本:每次读 128 KiB,每次读完唤醒另一个线程、那个线程再排队更多工作,如此循环往复——整个过程中有大量 futex 系统调用,贯穿始终。

标准库同步版本

[1000457] read(10, "...", 1073741824) = 0x40000000

一次壮丽的、1 GiB 大小的 read 系统调用。

这不是 tokio 的问题,而是 Linux 上长期缺乏真正意义上的异步文件 I/O——直到 io_uring 出现。

io_uring 是什么

io_uring 是 Linux 5.1(2019 年)引入的内核接口,通过共享内存环形队列在用户态和内核态之间传递 I/O 请求和完成通知,极大减少了系统调用次数和上下文切换开销。它支持真正的异步文件 I/O,而不是用线程池模拟。

作者把测试程序改为每次最多读 128 KiB(和 tokio 一致),加入 tokio-uring 变体后,性能稳定地与同步版本持平,并比经典 tokio 快约 10%。

tokio-uring 的热循环系统调用序列大概是这样:

io_uring_enter  → 提交读操作
epoll_wait      → 等待完成通知
write           → 唤醒自己(这是 tokio channel 的工作方式)

为什么要用 write 来唤醒自己?tokio 底层用 mio,在 Linux 上用 eventfd——一种专门用来发信号的文件描述符,比管道更轻量,并且可以像普通文件描述符一样被 epoll 监听。

这种 epoll 和 io_uring 混用的开销,促使一些团队选择完全绕开 tokio,自己造了专用 runtime:

加入 monoio 变体后,热循环退化为纯粹的 io_uring_enter

[1142572] io_uring_enter(9, 1, 1, 1, 0x0, 128) = 1
[1142572] io_uring_enter(9, 1, 1, 1, 0x0, 128) = 1
[1142572] io_uring_enter(9, 1, 1, 1, 0x0, 128) = 1
...

没有多余的 epoll_wait,没有 write 来唤醒自己,干净利落。


把 rc-zip 接入 monoio

为了验证 sans-io 设计的价值,作者实现了 rc-zip-monoio——只需写一次接入层,就能让 rc-zip 在 monoio 的 io_uring 驱动下工作。

monoio 的文件读取 API 和 tokio 有一个关键区别:

pub async fn read_at<T: IoBufMut>(
    &self,
    buf: T,
    pos: u64,
) -> BufResult<usize, T>

pub type BufResult<T, B> = (Result<T>, B);

获取缓冲区的所有权,并在完成后(无论成功与否)归还缓冲区

这是 io_uring 内存安全接口的必然要求:在操作完成或取消之前,缓冲区不能被释放——相当于把缓冲区的所有权临时交给内核。

完整实现如下:

use monoio::{buf::IoBufMut, fs::File};
use rc_zip::{
    error::Error,
    fsm::{ArchiveFsm, FsmResult},
    parse::Archive,
};

pub async fn read_zip_from_file(file: &File) -> Result<Archive, Error> {
    let meta = file.metadata().await?;
    let size = meta.len();
    let mut buf = vec![0u8; 256 * 1024].into_boxed_slice();
    let mut fsm = ArchiveFsm::new(size);

    loop {
        if let Some(offset) = fsm.wants_read() {
            let dst = fsm.space();
            let max_read = dst.len().min(buf.len());
            // 获取所有权,传给内核
            let slice = IoBufMut::slice_mut(buf, 0..max_read);
            // 等待完成,无论如何都拿回缓冲区
            let (res, slice) = file.read_at(slice, offset).await;
            let n = res?;
            // 把读到的数据复制进状态机的缓冲区
            (dst[..n]).copy_from_slice(&slice[..n]);
            fsm.fill(n);
            // 取回缓冲区所有权,以备下次循环使用
            buf = slice.into_inner();
        }

        fsm = match fsm.process()? {
            FsmResult::Done(archive) => {
                break Ok(archive);
            }
            FsmResult::Continue(fsm) => fsm,
        }
    }
}

这里有一个 Rust 所有权带来的有趣约束:buf 在循环体中被 move 进 slice_mut,必须在当次迭代结束前通过 slice.into_inner() 取回来,否则下一次迭代引用 buf 时编译器会拒绝编译:

error[E0382]: borrow of moved value: `buf`
    ...
    let slice = IoBufMut::slice_mut(buf, 0..max_read);
    |                                --- `buf` moved due to this method call, in previous iteration of loop

这种所有权约束,恰好与 io_uring 的内存安全要求完美契合——你不能提前释放缓冲区,Rust 的类型系统直接强制保证了这一点。

调用这个函数的完整程序:

use monoio::fs::File;
use rc_zip_monoio::read_zip_from_file;

#[cfg(not(target_os = "linux"))]
type DefaultDriver = monoio::LegacyDriver;
#[cfg(target_os = "linux")]
type DefaultDriver = monoio::IoUringDriver;

fn main() {
    monoio::start::<DefaultDriver, _>(async_main())
}

async fn async_main() {
    let zip_path = [
        std::env::var("HOME").unwrap().as_str(),
        "zip-samples/wine-10.0-rc2.zip",
    ]
    .join("/");

    let file = File::open(&zip_path).await.unwrap();
    let archive = read_zip_from_file(&file).await.unwrap();
    for (i, e) in archive.entries().enumerate() {
        println!("- {}", e.sanitized_name().unwrap_or_default());
        if i > 10 {
            break;
        }
    }
}

这个程序在 macOS 上使用 monoio 的 legacy 驱动运行,在 Linux 上使用 io_uring 驱动。

lurk 观察 Linux 上的系统调用,从 io_uring_setup 到打印文件列表,中间没有一次 readwrite 系统调用——所有 I/O 都以 io_uring ops 的形式完成,唯一出现的 write 是最终打印结果到标准输出。


一个值得注意的权衡

在目前的实现里,rc-zip 状态机只把内部缓冲区以可变引用的形式借出(space() 返回 &mut [u8]),而不是转让所有权。这意味着 monoio 接入层必须准备自己的缓冲区 buf,读取完成后再把数据复制进状态机的缓冲区。

这个额外的拷贝并不理想。理想情况下,状态机应该能直接交出缓冲区的所有权,让 io_uring 直接写入,完成后再归还——这样就能完全消除这次拷贝。

作者坦承这是一个需要在未来解决的 API 问题,改起来不难,但是破坏性变更,所以今天先欠着。


总结与展望

sans-io 的核心价值在于:把"做什么"和"怎么做 I/O"分开

格式解析、协议状态机写一次,无论是同步代码、tokio 异步、还是 monoio 的 io_uring——调用方只需实现一层薄薄的适配层,就能复用全部解析逻辑。所有的 bug 修复、功能改进,自动惠及所有调用方。

关于 Rust 生态里的同步/异步鸿沟,有人在探索"关键字泛型"等方向,试图让一段代码同时支持同步和异步调用方式。但作者认为,无论是统一 libstd 还是统一 tokio,都是错误方向,因为两者都不兼容 io_uring 这样的现代 I/O API。

正确的方向是:用 sans-io 方式实现格式和协议,让 I/O 层完全可替换。

有趣的是,C 生态(没有标准 I/O 接口,天然 sans-io)和 Node.js 生态(高层抽象,I/O 方式统一)反而比 Rust 更早拥抱了 io_uring。Rust 在这件事上被自己早期形成的 Read/Write 抽象层拖了后腿——当大量代码已经写在这个模型上时,迁移的成本就变得非常高昂。

这也是一个关于技术债的故事:好的抽象设计,远比事后迁移要便宜。


原文:The case for sans-io — fasterthanli.me
配套视频:YouTube