Linux-Kernel-Notes

Thinking in linux kernel

View on GitHub

2.5 mmap、virtio-fs 与 DAX

中文 English 目录

第二章:FUSE 代码实现分析 · 第 5/6 篇

本文分析普通 mmap 与 virtio-fs DAX 的实现,重点回答三个问题:DAX window 不够时当前代码会怎样;等待 timeout 后能否 fallback 到 non-DAX direct I/O;为什么 dax_iomap_rw() 的部分完成返回规则决定了 fallback 的安全边界。

1. 普通 FUSE mmap

非 DAX 文件通常通过 page cache 建立映射。缺页进入 filemap fault,必要时触发 FUSE READ;共享可写映射把 folio 标脏,之后由 FUSE writeback 写给 daemon。

mmap()
  -> fuse_file_mmap()
  -> page-cache vm_operations
  -> fault -> read_folio/readahead -> FUSE_READ
  -> shared write fault -> dirty folio
  -> writepages -> FUSE_WRITE

mmap() 成功不代表数据已经加载,绝大部分 I/O 在后续 page fault 时发生。

2. virtio-fs DAX 的基本模型

virtio-fs 可以向 guest 暴露一段共享 DAX window。guest 不是把整个文件永久映射进去,而是把文件的某个固定大小区间临时绑定到 window slot。

当前 fs/fuse/dax.c 定义:

#define FUSE_DAX_SHIFT 21
#define FUSE_DAX_SZ    (1 << FUSE_DAX_SHIFT)  /* 2 MiB */

每个 struct fuse_dax_mapping 表示一个 2 MiB window range,记录 window offset、对应 inode/file offset、读写属性、引用数以及空闲/忙碌链表状态。

3. 建立映射的协议

当某个 file offset 没有 mapping 时,fuse_iomap_begin() 调用 fuse_setup_new_dax_mapping()

  1. free_ranges 取一个 slot;
  2. 没有空闲 slot 时尝试 inline reclaim;
  3. 使用 inode DAX semaphore 避免同一区间重复建立;
  4. 发送 FUSE_SETUPMAPPING,携带 file offset、window offset、长度和读写 flags;
  5. 把 mapping 插入 inode interval tree;
  6. 填写 iomap,让 DAX core 访问共享内存。

删除或回收时发送 FUSE_REMOVEMAPPING,在 host/backend 解除关系后才能把 slot 返回 free pool。

4. window slot 为什么会耗尽

slot 数量由 DAX window 总大小除以 2 MiB 得到,是连接级有限资源。耗尽常见于:

因此 window 不够不等同于永久访问失败;它首先是资源竞争和回收问题。

5. 当前普通 read/write 的耗尽行为

非 fault 路径在 fuse_setup_new_dax_mapping() 中调用 alloc_dax_mapping_reclaim()。其循环为:

  1. 尝试 alloc_dax_mapping()
  2. 尝试从当前 inode inline reclaim 一个 mapping;
  3. 临时不可回收则重试;
  4. 当前 inode 没有可回收 mapping 且全局无 free range 时,在 range_waitqwait_event_killable_exclusive()
  5. slot 归还时 wake_up(&range_waitq)

所以当前实现没有固定等待超时。可被信号打断时返回 -EINTR;否则可能一直等到 slot 可用。若 daemon 处理 setup/remove 失败,则相应错误沿 iomap 路径返回。

6. fault 路径为什么不同

page fault 已持有 mapping->invalidate_lock 的共享锁,不能在这个上下文内做需要释放/独占该锁的 inline reclaim。于是 fault 中分配不到 slot 时,fuse_setup_new_dax_mapping() 返回 -EAGAIN

__fuse_dax_fault() 识别 -EAGAIN,释放 invalidate shared lock,在 range_waitq 等待 free range,然后重试 fault。这里也没有内建 timeout。

fault 的结果是 vm_fault_t,不能像 read() 一样简单返回“前 N 字节成功”;对用户态最终可能表现为阻塞、重试或页错误信号,取决于错误类型和 fault 处理。

7. DAX read/write 调用链

file.c 先检查 FUSE_IS_DAX(inode),DAX 优先级高于 open 上的 direct/passthrough:

fuse_file_read_iter
  -> fuse_dax_read_iter
  -> inode shared lock
  -> dax_iomap_rw(..., fuse_iomap_ops)
  -> fuse_iomap_begin
  -> 已有 mapping 或建立/回收 mapping
  -> 直接在 DAX window 与 iov_iter 间复制

fuse_file_write_iter
  -> fuse_dax_write_iter
  -> 非扩展写:dax_iomap_rw
  -> 扩展文件写:fuse_direct_io

文件扩展写不用 DAX,是因为 window 中写数据与后端 i_size 增长无法在现有协议中原子完成。

8. dax_iomap_rw() 如何隐藏后续错误

DAX core 的关键代码为:

while ((ret = iomap_iter(&iomi, ops)) > 0)
        iomi.status = dax_iomap_iter(&iomi, iter);

done = iomi.pos - iocb->ki_pos;
iocb->ki_pos = iomi.pos;
return done ? done : ret;

假设跨越多个 2 MiB mapping 的大 I/O:第一个 mapping 已复制 2 MiB;建立第二个 mapping 时等待 slot 超时并得到 -ETIMEDOUT。此时 done == 2 MiB,函数返回 2 MiB,而不是 timeout。

这正是 Linux 部分 I/O 语义:已完成字节数优先。调用者只看到 short I/O,下一次系统调用才可能再次遇到资源等待。

9. 给 slot 等待增加 timeout 是否可行

技术上可以把 wait 改成 killable timeout 版本,并在超时后让 mapping 分配返回 -ETIMEDOUT。但必须先定义:

只替换 wait 宏会引入机制,却没有给出完整语义。

10. timeout 后 fallback 到 non-DAX direct I/O

一个相对安全的目标策略是:

  1. 不为小 I/O启用这套 fallback,以免路径选择成本大于收益;
  2. 只有“等待 DAX slot 超时”这一特定错误才尝试 direct I/O;
  3. 只有本次 DAX 调用完成字节数为 0 时,才对原范围 fallback;
  4. 若已经部分完成,则直接向上返回 short I/O,不能整体重发;
  5. fallback 复用同一 open handle 和正确的 offset/iterator 状态;
  6. 写入前处理 DAX layout 与 page-cache alias,保证无重叠写者。

伪代码应更接近:

snapshot = iov_iter_count(iter);
ret = dax_iomap_rw(iocb, iter, &fuse_iomap_ops);
if (ret == -ETIMEDOUT && iov_iter_count(iter) == snapshot)
        ret = fuse_direct_io(...);   /* zero progress only */
return ret;

但仅检查 iterator 仍不够:需要保证 iomap/setup 没有留下已发布 mapping,且 file position 没有推进。最好由分配层返回一个不会与普通后端 timeout 混淆的内部错误,再在 DAX 顶层做零进度转换。

11. 为什么“如果返回 timeout 才 direct I/O”仍有盲区

该规则对零进度失败成立;对大 I/O 的中途耗尽却不会触发,因为 dax_iomap_rw() 返回正数。若希望同一个 syscall 自动完成剩余后缀,需要改造 DAX core/FUSE 边界以同时获得“done + terminal error”,然后:

这比零进度 fallback 风险高得多。第一版设计建议只实现零进度 fallback,并允许中途耗尽表现为 short I/O。

12. mmap fault 不能直接 fallback 为一次 direct read/write

mmap 需要建立可长期由页表访问的内存。一次 direct read 只能把数据复制到某个缓冲区,不能替代 DAX PTE。若 fault timeout 后要降级,必须切换整个 VMA/inode 的映射模式到 page-cache fault,并处理已存在 DAX PTE、layout break 和并发 fault;这不是对单次 fault 下发 FUSE_READ 就能完成的。

因此 read/write fallback 和 mmap fallback 应作为两个独立设计。初始实现可以只覆盖显式 read/write。

13. 建议的可观测性

至少增加这些计数和延迟直方图:

14. 本文小结

DAX window 是按 2 MiB slot 管理的有限资源。当前 read/write 在耗尽时回收或可中断等待,fault 则以 -EAGAIN 退出锁域后等待重试;二者都没有固定 timeout。可以增加 slot-wait timeout,并在 零进度 时 fallback 到 direct I/O;一旦 DAX 已传输前缀,dax_iomap_rw() 会返回正字节数并隐藏后续错误,整体重发会导致重复 I/O。

上一篇:请求生命周期与 /dev/fuse 传输 下一篇:卸载、调试与性能分析