文件系统锁与并发
文件系统同时跨越 syscall/task、用户缺页、内存回收、块 worker 和 hard IRQ。锁的首要边界不是“读写哪个字段”,而是该路径是否可能睡眠、是否调用外部实现、以及回调会不会进入地址空间或文件系统。
1. 同步边界
文件系统根据执行上下文和阻塞能力选择 spin lock、IRQ-safe lock、sleep mutex 或原子。锁类型决定能否等待块完成或调用外部实现,锁域图则说明不同 owner 之间应当嵌套还是先 snapshot 后释放。
1.1 锁类型
公共 VFS、文件访问策略和块运行时使用不同同步原语。下表把每种原语的状态 owner 与禁止动作对应起来,避免仅按字段读写选择锁。
| 类型 | 文件系统中的用途 | 约束 |
|---|---|---|
axfs-ng-vfs::Mutex(ax_sync::SpinLock) | 短时 dentry cache、mount local relation/flags | 不能等待 I/O、不能调用可能睡眠的文件系统实现 |
ax-fs-ng::os::sync::IrqMutex | provider registry、短时 runtime 状态、FS registry | IRQ-safe 短临界区,析构/回调移到 guard 外 |
SleepMutex | FsContext、ext4/FAT state、cached-file I/O/page state | 可等待任务通知或块完成;不能在 hard IRQ 获取 |
| 原子 | length、generation、mount flags、runtime state/counter | 只发布明确事实,不替代复合事务锁 |
| topology mutation guard | mount tree/propagation 事务 | 不在 guard 内 flush 或执行 node/filesystem callback |
VFS 的节点 trait 可以由磁盘文件系统实现,因此即使 VFS 自身使用 spin lock,也必须先释放内部 guard 再调用 ops.lookup()、ops.rename()、FilesystemOps::flush() 等外部能力。
1.2 主要锁域
主要锁域覆盖 mount topology、任务 context、具体文件系统、页缓存和块运行时。图中的实线表示允许的局部获取方向,虚线表示必须在上游 guard 释放后再进入下游。
虚线表示必须先释放上游 guard 后再进入下游,而不是允许嵌套。
2. 名字空间并发
名字空间并发由 dentry generation、mount topology transaction 和 context registry snapshot 三种机制处理。它们分别防止旧 lookup 复活、挂载树部分提交和全局 IRQ lock 嵌套任务 sleep lock。
2.1 目录缓存
DirNode::lookup_and_cache() 不在 cache lock 下调用具体 lookup()。它用 cache_generation 检测慢 lookup 期间的并发 mutation:
load generation (Acquire)
-> short cache lookup under spin lock
-> release lock
-> ops.lookup() may sleep
-> reacquire cache lock
-> generation unchanged: publish result
-> changed: return result without inserting stale entry
create/link/unlink/rename 先让底层名字空间操作成功,再更新 cache。跨目录 rename 不同时持有两个目录 cache lock;同目录则在同一个 lock 中删除 source/destination。递归 forget() 先从 parent cache 整体取走 children,再逐项递归,避免父 lock 跨递归持有。
2.2 挂载拓扑
所有会改变 parent/children 或 propagation graph 的操作在 topology guard 内串行,并增加 version。local mount locks 的获取必须通过这些编排函数,不能在外部代码任意嵌套两个 mount 的 children/location lock。
文件系统 flush 是明显的睡眠边界,因此 unmount 使用:
topology guard: plan + snapshot version/targets
release guard
filesystem/page-cache callbacks
topology guard: revalidate + atomic commit
release guard
drop external lifetime resources
mount callback 同样在 topology guard 外完成。测试用会重入 topology 的 filesystem callback 固定这条约束;新增回调不能因“当前实现很快”而放回 guard 内。
2.3 上下文登记
FS_REGISTRY 保存 weak FsContext,受 IrqMutex 保护;FsContext 自身是 SleepMutex。固定顺序不是 registry -> context 嵌套,而是两阶段 snapshot:
- registry guard 下 prune weak 并 clone live
Arc; - 释放 registry guard;
- 逐个取得 context sleep lock。
is_mount_busy() 和 pivot root propagation 都遵守此模式。否则持 IRQ lock 等待另一个任务释放 filesystem context 会扩大 atomic 临界区,并可能与任务退出/登记形成环。
3. 缓存并发
页缓存同时与具体文件系统、用户地址空间和内存回收交互。CachedFileShared 的局部锁序只保护 cache/backing 状态,用户复制、地址空间 callback 和全局 registry 都必须通过锁外阶段连接。
3.1 缓存锁序
CachedFileShared 的主要 sleepable lock 是 io_lock、page_cache、evict_listeners;EOF discard
另有只用于转移 retained-page 容器的 discarded_pages mutex。外部 callback 一律不持有
evict_listeners 或 discarded_pages;truncate/reclaim callback 也不持有 io_lock/page_cache,
容量替换 callback 则为保持 LRU pop/restore 原子性持有 io_lock/page_cache,只能执行非阻塞确认。
discard_transition 是原子 try-enter gate,不等待另一个 owner,不能当成可嵌套 mutex。
允许的局部顺序:
io_lock -> page_cache
禁止形成全局固定嵌套的关系:
page_cache -> listener callback
listener lock -> AddrSpace callback(持 guard)
cached-file lock -> user-memory copy / page fault
GLOBAL_CACHED_FILES spin lock -> cached-file sleep lock
容量 LRU eviction 是一个特殊路径:page_or_insert() 仍持有 page-cache lock,但
evict_cache() 只在 listener-list lock 下克隆 callback Arc,释放 registry lock 后才调用。
snapshot 发生在旧页已脱离 cache 之后;其后登记的新 listener 只能在 page-cache lock 释放后映 射
replacement page,不会漏掉旧 frame 的确认。populate 发起者可能已经持有对应 AddrSpace,因此
listener 以 address-space owner 分组。当前 populate 只排除自己的 owner,并在当前 TlbGather 中
处理该 owner 的全部 VMA;其他 owner 必须各自完成 unmap/shootdown,任一 callback 返回 false
都会让 cache 回插旧页并返回 ResourceBusy。替换页分配和 backing read、gather retained-page 容量
预留都先于旧页脱离 cache;populate 取得旧页后立即把 PageCache 所有权移入 gather,再允许物理
地址解析或安装新 PTE。顶层地址空间 mutation 无论 populate 成功或失败,都会在同一事务中完成
当前 owner 的旧映射 unmap、跨 CPU shootdown 和延迟释放。全局 reclaim 则先从 cache 取出
candidate,释放 page-cache/global registry guard 后调用 listener;listener 拒绝时重新插回。
新增 listener API 时必须明确属于哪一种调用上下文,不能假设所有 eviction callback 的锁环境相同。
truncate shrink 是另一种已发布事务:backing EOF 和 cache length 更新后,EOF 外页面才通知 mmap
listener。listener 拒绝表示远端 TLB 尚未确认,不表示 truncate 可以回滚;页面转入
discarded_pages 单槽 quarantine,syscall 保留已提交的成功结果。下一次 truncate 通过原子 gate
在普通 task context 重试;page insert 不等待这个 gate,也不取得 retained frame,因此不会形成
discard_transition -> AddrSpace -> discard_transition 环。确认前旧 frame 不回到 allocator,
pending 容器只在 mutex 内 take/restore,listener 回调始终在 mutex 外;地址空间 teardown 仍先
完成自己的 TLB quarantine,最后一个 file backend owner 消失后 cached-file quarantine 才可析构。
io_lock 保护 backing I/O 与 page state 的复合变化,但大块 backing read 会主动释放 page-cache lock 。返回后重新取得 cache lock逐页发布;并发者已经填入的 page 被保留,不覆盖新数据。
3.2 用户映射
用户 buffer read/write 可能触发缺页并取得 StarryOS AddrSpace 锁。cached I/O 使用 kernel scratch 将顺序改为:
用户 -> scratch(不持 file cache lock)
scratch -> cached page(持 io/page lock)
cached page -> scratch(持 io/page lock)
scratch -> 用户(不持 file cache lock)
mmap listener 会从 page cache 回调地址空间。writeback protection 和 reclaim 都先选定 page/callback,再在 file page lock 外进入 AddrSpace。反方向的 page fault 可在持 AddrSpace 时调用 with_page_or_insert(),所以任何 CachedFile -> AddrSpace 嵌套都可能形成真实死锁。