26、SMP文件系统:并发文件访问、目录缓存一致性、日志文件系统
好,我们进入第26章。多核环境下的文件系统,说实话,是个容易翻车的地方。
你想想看,单核时代,文件系统操作基本是串行的。一个进程在读文件,另一个在写,大不了加个锁排队等。但到了SMP多核,事情就复杂了。多个CPU核心可能同时访问同一个文件、同一个目录、甚至同一个inode。这时候,文件系统必须保证数据的一致性,还不能让性能掉得太难看。
我个人习惯把SMP文件系统的挑战归纳为三个核心问题:并发文件访问、目录缓存一致性、日志文件系统的并发安全。咱们一个一个来拆。
并发文件访问:读写锁与细粒度锁
先说说最基础的——多个线程同时读写同一个文件。
在RT-Thread的DFS(Device File System)层,每个打开的文件都有一个struct dfs_file结构体。这个结构体里有个struct rt_mutex,用来保护文件偏移量和状态。但问题是,如果两个CPU核心同时调用read(),它们会竞争这个互斥锁。
嗯,这里要注意。如果只是简单的互斥锁,性能会很难看。尤其是大量读操作时,读操作之间其实不冲突,但互斥锁把所有人都堵在门外了。
我建议的做法是:读写锁(rwlock)。读操作可以并行,写操作独占。RT-Thread内核里提供了rt_rwlock,可以直接用。
/* 伪代码:使用读写锁保护文件操作 */
struct smp_file {
rt_rwlock_t rwlock;
off_t pos;
/* 其他字段 */
};
ssize_t smp_file_read(struct smp_file *fp, void *buf, size_t len) {
rt_rwlock_take_read(&fp->rwlock);
/* 执行读操作,更新pos */
rt_rwlock_release(&fp->rwlock);
return len;
}
ssize_t smp_file_write(struct smp_file *fp, const void *buf, size_t len) {
rt_rwlock_take_write(&fp->rwlock);
/* 执行写操作,更新pos */
rt_rwlock_release(&fp->rwlock);
return len;
}
我在项目中遇到过一个问题:某个数据采集系统,8个核心同时往一个日志文件里写数据。一开始用互斥锁,结果CPU利用率上去了,但吞吐量上不去。换成读写锁后,读操作基本无锁,写操作虽然还是串行,但整体吞吐量提升了将近3倍。
目录缓存一致性:DCache的同步问题
接下来是目录缓存。说白了,就是文件系统在内存里维护的目录项缓存(dcache)。
单核环境下,dcache的更新很简单:创建文件时插入一个条目,删除文件时移除。但在SMP环境下,两个CPU可能同时操作同一个目录。比如CPU0在/data下创建file_a,CPU1在/data下创建file_b。如果dcache没有保护,可能会出现缓存不一致——CPU0看到的目录里没有file_b,CPU1看到的目录里没有file_a。
为什么会这样?因为每个CPU都有自己的L1/L2缓存。CPU0修改了dcache,但修改可能还留在自己的缓存里,没有写回内存。CPU1读到的还是旧数据。
解决这个问题,RT-Thread的SMP版本里,dcache操作必须使用自旋锁保护。而且,在释放锁之前,需要调用rt_hw_dcache_ops相关的接口,确保缓存行被刷回内存。
/* 伪代码:SMP安全的目录项插入 */
int smp_dcache_insert(struct dentry *parent, struct dentry *child) {
rt_spin_lock(&parent->lock);
/* 插入到哈希表或红黑树 */
list_add_tail(&child->node, &parent->subdirs);
/* 刷缓存,确保其他核心能看到 */
rt_hw_dcache_flush((void *)child, sizeof(struct dentry));
rt_spin_unlock(&parent->lock);
return 0;
}
我曾经踩过一个坑:在某个嵌入式设备上,两个核心同时执行mkdir操作,结果目录项出现了重复。排查了半天,发现是自旋锁保护的范围不够——只保护了链表操作,但没有保护哈希表的更新。嗯,教训就是:锁的粒度要精确,但范围要完整。
日志文件系统:JFFS2与并发事务
最后说说日志文件系统。RT-Thread里常用的日志文件系统是JFFS2和YAFFS2。它们都是针对Flash设计的,通过日志(journal)来保证崩溃一致性。
在SMP环境下,日志文件系统的挑战在于:多个核心可能同时提交事务。如果两个事务同时写日志,日志的顺序可能乱掉,导致恢复时数据不一致。
JFFS2的做法是:整个文件系统只有一个全局的日志写锁。所有写操作必须先获取这个锁,才能写日志。这保证了日志的顺序性,但代价是——写操作完全串行化了。
我个人的看法是:对于大多数嵌入式场景,这个串行化是可以接受的。因为Flash的写速度本身就不快,瓶颈通常在硬件上,而不是锁上。但如果你的系统对写性能要求极高,可以考虑预写日志(WAL)的变种——每个CPU核心有自己的日志缓冲区,定期合并。
/* 伪代码:SMP安全的JFFS2写操作 */
int jffs2_write_smp(struct jffs2_sb_info *c, struct jffs2_raw_inode *ri) {
/* 获取全局日志锁 */
rt_spin_lock(&c->erase_completion_lock);
/* 写入日志 */
jffs2_write_node(c, ri);
/* 更新inode缓存 */
jffs2_update_inode_cache(c, ri);
rt_spin_unlock(&c->erase_completion_lock);
return 0;
}
总结一下
这一章我们聊了三个问题:
- 并发文件访问:用读写锁替代互斥锁,提升读并发性能。
- 目录缓存一致性:自旋锁保护dcache操作,配合缓存刷写指令。
- 日志文件系统:全局日志锁保证事务顺序,但牺牲了写并发。
最后说一句:文件系统的并发问题,很多时候不是代码逻辑错了,而是缓存一致性和内存屏障没处理好。如果你在调试SMP文件系统时遇到奇怪的数据错乱,先检查一下有没有漏掉rt_hw_dcache_flush或rt_hw_dcache_invalidate。我曾经因为这个排查了整整两天,最后发现就是少刷了一行缓存。
下一章,我们会聊聊SMP下的设备驱动模型。到时候见。