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():
- 从
free_ranges取一个 slot; - 没有空闲 slot 时尝试 inline reclaim;
- 使用 inode DAX semaphore 避免同一区间重复建立;
- 发送
FUSE_SETUPMAPPING,携带 file offset、window offset、长度和读写 flags; - 把 mapping 插入 inode interval tree;
- 填写
iomap,让 DAX core 访问共享内存。
删除或回收时发送 FUSE_REMOVEMAPPING,在 host/backend 解除关系后才能把 slot 返回 free pool。
4. window slot 为什么会耗尽
slot 数量由 DAX window 总大小除以 2 MiB 得到,是连接级有限资源。耗尽常见于:
- 许多文件同时有活跃 mmap;
- 工作集远大于 window,且 fault 不断建立新 mapping;
- mapping 被页表或正在进行的 I/O 引用,暂时不可回收;
- writable/shared mappings 需要先 break layout 和同步;
- 回收线程被 inode、invalidate lock 或 daemon/backend 阻塞;
- host 处理
REMOVEMAPPING太慢。
因此 window 不够不等同于永久访问失败;它首先是资源竞争和回收问题。
5. 当前普通 read/write 的耗尽行为
非 fault 路径在 fuse_setup_new_dax_mapping() 中调用 alloc_dax_mapping_reclaim()。其循环为:
- 尝试
alloc_dax_mapping(); - 尝试从当前 inode inline reclaim 一个 mapping;
- 临时不可回收则重试;
- 当前 inode 没有可回收 mapping 且全局无 free range 时,在
range_waitq上wait_event_killable_exclusive(); - 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。但必须先定义:
- timeout 只覆盖 free slot 等待,还是也覆盖
SETUPMAPPING/REMOVEMAPPINGreply; - 计时从第一次分配失败还是从整个 syscall 开始;
- signal 与 timeout 的优先 errno;
- fault 路径超时转成何种
vm_fault_t; - 迟到的 mapping/setup 是否仍可能生效;
- 指标如何区分真正 window 压力与 daemon 卡顿。
只替换 wait 宏会引入机制,却没有给出完整语义。
10. timeout 后 fallback 到 non-DAX direct I/O
一个相对安全的目标策略是:
- 不为小 I/O启用这套 fallback,以免路径选择成本大于收益;
- 只有“等待 DAX slot 超时”这一特定错误才尝试 direct I/O;
- 只有本次 DAX 调用完成字节数为 0 时,才对原范围 fallback;
- 若已经部分完成,则直接向上返回 short I/O,不能整体重发;
- fallback 复用同一 open handle 和正确的 offset/iterator 状态;
- 写入前处理 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”,然后:
- 从当前
ki_pos和 iterator 剩余部分发 direct I/O; - 保留已完成前缀,绝不回退位置;
- 避免与前一个 DAX mapping 的边界重叠;
- 合并返回值时仍遵守 short I/O 和错误规则;
- 写路径证明 direct 与 DAX 对同一 inode 的锁/失效顺序安全。
这比零进度 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. 建议的可观测性
至少增加这些计数和延迟直方图:
- free/busy/total DAX ranges;
- allocation fast hit、inline reclaim、wait、timeout;
- setupmapping/removemapping 延迟与 errno;
- zero-progress direct fallback 次数和字节数;
- partial DAX short I/O 次数;
- fault wait 时长;
- 不可回收原因:refcount、layout busy、锁竞争或 daemon failure。
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 传输 |
下一篇:卸载、调试与性能分析 |