Linux 用户态文件系统 FUSE 简介
好书推荐
笔者最近在研究分布式存储系统,查了很多材料,也入手了以下书籍,感觉对于了解使用 Go 语言实现一个分布式存储系统有很大帮助。
FUSE 是什么,为什么要使用它?
FUSE 是一个开源框架,允许在用户空间构建文件系统,而不是走传统的内核空间路径。许多人认为在用户空间构建文件系统不适合用于生产环境,并且认为其开销过大,无法实际使用。但这一机制为程序员提供了一个更“友好”的开发环境,拥有更丰富的工具集,并且最重要的是,当代码中出现错误时,不会像在内核中开发那样导致系统崩溃。
需要注意的是,如果 Unix 文件系统(FS)是在用户空间中创建的,那么它们就可以在 Windows 系统下轻松访问。
FUSE 架构:它是如何工作的?
FUSE 有一个内核部分和一个用户空间部分。内核模块(fuse.ko)和一个fuse守护进程,它们通过一个字符设备/dev/fuse进行通信。
在初始化时,fuse.ko 会在 Linux 的虚拟文件系统(VFS)中注册以下 3 种文件系统类型:
1. fuse
• 用途:用于堆叠式文件系统(stackable filesystem),可以运行在现有内核文件系统之上,或用于基于内存和网络的文件系统。
• 典型应用:
• 网络文件系统(如
SSHFS)。• 虚拟文件系统(如
S3FS)。• 内存中的临时文件系统。
2. fuseblk
• 用途:用于基于块设备的用户空间文件系统,适合需要访问底层块存储设备的文件系统。
• 典型应用:
•
NTFS-3G(在 Linux 上访问 NTFS 文件系统)。• 其他需要块设备支持的用户态文件系统。
3. fusectl
• 用途:提供一个虚拟文件系统,允许用户控制和监控 FUSE 文件系统的行为。
• 典型功能:
• 挂载点管理。
• 用户态文件系统的调试与监控。
• 动态调整 FUSE 的运行参数。
由于FUSE文件类型能够表示许多不同的文件系统,与内核级文件系统(例如Ext4)不同,它们在Linux中列出时会附加一个名称,例如:
•
fuse.sshfs•
fuse.s3fs•
fuseblk.ntfs-3g
VFS 简介[1]
Linux中的**VFS**(虚拟文件系统)用于为用户空间应用程序提供所有挂载文件系统的抽象层,这使得它们看起来统一。它通过从每个挂载的文件系统中抽象出共同的文件系统代码来创建一个单独的层,该层反过来调用底层文件系统来管理自己的数据。
所有POSIX系统调用(与文件/目录相关)必须首先通过VFS,然后才能到达任何底层文件系统,这是“高级接口(high-level interface)”。“低级接口(low-level interface)”是VFS和文件系统本身之间的通信,称为VFS接口。文件系统开发者必须为VFS提供所有所需的VFS接口方法(read/write/readdir等)。正是因为这个接口,所有不同的文件系统类型才能共存,因为VFS不需要知道文件在哪里,只需要知道在哪里找到文件系统操作来处理文件。
当一个文件系统(FS)向 VFS注册时,它仅仅提供了一组包含所需方法的函数地址列表。例如,当一个文件系统被挂载到 /mnt 目录,用户请求读取其中的一个文件(read("/mnt/foo/file.txt"))时,操作流程如下:
1. VFS 查找请求的文件系统 VFS 首先在
/mnt目录中搜索所请求的文件系统。2. 找到文件系统的超级块和根目录 VFS 找到该文件系统的超级块(superblock)以及其根目录
/mnt/,该根目录存储在超级块中。3. 定位文件路径 VFS 通过超级块从根目录中找到
foo/file.txt文件路径。4. 创建 v-node 并获取 inode 信息 VFS 创建一个 v-node(虚拟节点),并调用所属的文件系统以收集
file.txt文件的 inode 信息。5. 将 inode 信息复制到 v-node VFS 将 inode 信息和其他相关信息复制到 v-node 中。
这些信息还包括指向底层文件系统 v-node 操作方法表的指针。6. 将 v-node 添加到文件描述符表 一旦 v-node 创建完成,VFS 将为调用进程在文件描述符表中创建一个新条目,并将其指向新创建的 v-node。
这个文件描述符随后被返回给调用进程,使其可以对该文件执行read、write和close等操作。7. 通过文件描述符执行读取操作 当调用进程使用文件描述符对文件进行
read操作时:
• VFS 通过进程的文件描述符表定位到对应的 v-node。
• 然后跟随指针,找到底层文件系统中包含该文件的操作方法表。
• 这些方法表中的函数地址指向底层文件系统的实现,VFS 使用它们来执行具体的文件操作。
上图显示了FUSE和VFS之间基本通信的高级概述。更详细地说,如果用户级应用程序访问FUSE文件系统:
APIs:低级与高级
FUSE为开发人员提供了两个不同的API级别选择:高级和低级。高级文件系统实现将看到一个更简单的代码库,但通常性能相对较差。低级 API 是两者中唯一直接与内核通信的,这意味着它有4个关键区别:
• 它接收并解析内核请求
• 它向内核发送正确格式化的回复
• 它促进文件系统配置和挂载
• 最后,它隐藏用户和内核空间之间的版本差异
这意味着高级API建立在低级API之上,如果高级API希望与内核通信,所有请求/回复都必须通过它传递。高级API减少复杂性的一个原因是开发者不需要进行路径到inode的映射,而是直接给出路径名。这在这里显示:
inode 说明[3]
在现代Linux系统中,每个文件都有一个关联的inode。这个数据结构包含以下信息:
• 文件的元数据
•
12个间接指针,每个指针“指向”文件的数据块,依次排列•
1个间接指针,指向另一个包含12个间接指针的列表的头部,如果前12个不足以覆盖文件的大小•
2个间接指针,上述逻辑相同,但包含2•
3个间接指针,再次是上述逻辑,但包含3
下面是一个使用file.txt的inode的例子:
inode structure
--------------- inode file.txt
| metadata |
| 12 ind. | ptr(0) --> | datablock |
| | ...
| ptrs | ptr(11) --> | datablock |
| ind. ptr | --> | 12 ind. | ptr(0) --> | datablock |
| | ...
| ptrs | ptr(11) --> | datablock |
| 2 x ind. | --> | ind. ptr | --> | 12 ind. | ptr(0) --> | datablock |
| | ...
| ptrs | ptr(11) --> | datablock |
| ptrs | --> | ind. ptr | --> | 12 ind. | ptr(0) --> | datablock |
| | ...
| ptrs | ptr(11) --> | datablock |
|3 x ind. ptr| ...
遵循Unix哲学“一切都是文件”,目录也有inode,但与将第一个间接指针映射到文件的数据块不同,它们将文件名映射到其inode:
inode structure
---------------directory inode
| metadata |
| 12 ind. | ptr(0) --> | file1.txt's inode |
| | ...
| ptrs | ptr(11) --> | file12.txt's inode |
| ind. ptr | --> | 12 ind. | ptr(0) --> | file13.txt's inode |
| | ...
| ptrs | ptr(11) --> | file24.txt's inode |
| 2 x ind. | ...
| ptrs | ...
|3 x ind. ptr| ...
所以要从‘/’获取文件‘/foo/file.txt’的数据:
"/" "/foo" "/foo/file.txt"| "/"s inode |
...
| ind. ptr | --> | "foo"s inode |
... ...
| ind. ptr | --> | "file.txt"s inode |
... ...
| ind. ptr | --> | data block |
...
低级和高级之间的主要区别:
• 低级方法都接受一个
fuse请求作为参数,高级方法不接受• 查找和忘记方法被高级用于当内核从
inode或dentry缓存中移除inode时。由于高级不使用inode,低级处理这一点
当请求从内核进来时,低级库接受它,并根据开发者使用的API,在low_level_ops结构或high_level_ops结构中搜索并运行找到的代码。高级将响应传递给低级,后者在将响应传递给内核之前在那里处理它,因此这是两个API性能差异的主要原因之一。
每个连接会话信息
用户-内核协议(前面提到)提供了一种存储打开的文件/目录信息的机制。在低级库中,每个方法都会获得一个fuse_file_info结构,用于保存此类信息。它包含一个可以指向文件/目录的文件句柄。利用这种能力允许协议成为有状态的。开发者也可以选择无状态(不使用结构),但这会导致守护进程在每次操作时都需要手动打开/关闭文件和目录,从而导致潜在的性能下降。
队列
FUSE 内核模块维护着 5 个不同的队列,每个队列负责不同的任务,确保用户态与内核态的文件操作能够高效、可靠地完成。以下是每个队列的作用及其详细说明:
1. Interrupts 队列(中断队列,最高优先级)
• 用途:用于处理所有中断请求(interrupt requests)。
• 场景:当需要打断或取消当前正在处理的 I/O 操作时,
FUSE将优先使用该队列。例如,用户在文件读取或写入过程中强制终止操作时,会触发中断请求。
2. Forgets 队列(忘记队列)
• 用途:用于处理所有的
forget请求,即释放不再需要的inode缓存。• 场景:当文件或目录的
inode缓存被用户态文件系统认为已无用时,FUSE会发出forget请求,通知内核可以安全地释放这些inode。
3. Pending 队列(挂起队列,延迟敏感请求)
• 用途:用于处理所有延迟敏感的请求,主要与文件的元数据操作有关(metadata operations)。
• 场景:
• 文件的
stat信息查询(如文件大小、访问时间)。• 文件系统的权限检查(如
access系统调用)。• 目录列表请求(如
readdir)。
这些请求通常对用户操作的响应时间非常敏感,因此优先级高于普通 I/O 请求。
1. Background 队列(后台队列)
• 用途:用于处理所有其他非延迟敏感的请求,主要包括文件的读取(
read)和写入(write)等数据操作。• 场景:
- 文件的顺序读取或写入操作。
- 大量文件数据的传输。
此类操作通常需要较长的时间处理,因此被分配到后台队列,以避免影响延迟敏感操作的响应。
1. Processing 队列(处理中队列)
• 用途:存储当前正在由用户态守护进程处理的请求。
• 场景:一旦请求从
Pending或Background队列中被提取出来,进入Processing队列,直到守护进程完成处理并将结果返回给内核。
FUSE 队列的运行机制
1. 请求的调度与优先级:
•
FUSE内核模块根据每个请求的类型,将其放入不同的队列。• 中断请求优先级最高,确保及时响应。
• 元数据相关的请求也会被优先处理,以减少用户对文件系统操作的感知延迟。
2. 请求的处理流程:
• 请求进入队列后,由用户态的
FUSE守护进程逐一提取并处理。• 处理完成后,结果通过
/dev/fuse设备返回给内核,并从Processing队列中移除。
3. 性能与资源管理:
• 多队列机制 保证了高优先级请求不会因大量数据操作而被延迟。
• 通过对不同类型请求的分类和调度,
FUSE实现了用户态文件系统的高效性和灵活性。
需要注意的是,一个请求在任何时候只能属于一个队列。出于性能考虑,forget 请求必须被限流,因为它们可能会同时大量到达并占据队列,因此设置了单独的 forget 队列。通常情况下,每处理 8 个非 forget 请求时,会处理 16 个 forget 请求。
当用户空间守护进程从 pending 队列中“取走”最旧的请求时,内核会更新队列状态,将该请求从 pending 队列移至 processing 队列。一旦用户空间守护进程通过 /dev/fuse 字符设备向内核回复操作完成,内核会从 processing 队列中移除该请求。
需要注意的是,processing 队列没有尾部(tail),因为守护进程可以以任意顺序确认处理完成的请求,因此该队列没有固定的顺序。此外,interrupt 和 forget 请求不需要回复,因此当这些请求被处理后,会直接从队列中彻底移除,而无需等待确认。
Splice和FUSE缓冲区
每次对 /dev/fuse 进行读写操作时,都需要在内核空间和用户空间之间进行数据的内存复制。写请求和读回复操作可能对性能影响最大,因为需要处理的数据量可能非常大。为减少数据复制带来的开销,FUSE 利用 Linux 内核提供的 splice 技术,允许用户空间在两个内核内存缓冲区之间传输数据,而无需将数据复制到用户空间。
在低级 API 中,可以使用 write_buf 方法将数据从 /dev/fuse splice 到一个 Linux 管道中,然后将数据作为一个包含管道文件描述符(fd)的缓冲区传递出去。需要注意的是,只有当数据量超过一个内存页(通常为 4KB)时,才能使用 splice。
在低级 API 的读操作中,开发者必须在 read 方法中区分 splice 和非 splice 流程,以正确处理不同的数据传输模式。
FUSE 与内核文件系统的优劣势对比
FUSE与传统的内核文件系统相比,它在灵活性和易用性上有显著的不同,但也存在一些性能和应用场景的局限性。
优势
1. 开发环境友好
• 用户态开发避免了内核开发的复杂性,降低了开发和调试的难度。
• 开发人员不需要深入了解内核编程,也不需要处理内核级错误。
• 开发调试更加安全,即使文件系统出现问题,也不会导致整个系统崩溃。
2. 跨平台支持
• 用户态文件系统更容易跨平台,例如在 Unix 环境中创建的文件系统可以更容易地移植到 Windows 系统中。
3. 更丰富的工具支持
• 用户空间的开发环境可以使用更多的编程语言、调试工具和库,提升开发效率和灵活性。
4. 降低内核污染
• 通过在用户态运行,可以减少内核空间中引入不稳定或错误代码的风险,降低系统崩溃的可能性。
5. 权限隔离
• 由于在用户空间运行,文件系统的权限管理与内核隔离,减少了潜在的安全风险。
劣势
1. 性能较低
• 由于在内核和用户空间之间频繁进行上下文切换以及内存复制,FUSE 文件系统的性能通常低于内核态文件系统,特别是在处理大量 I/O 操作时。
• 对于延迟敏感的操作(如数据库或高性能存储),FUSE 可能表现不佳。
2. 高 I/O 开销
• 读写操作需要在用户空间和内核空间之间复制数据,增加了
I/O开销。• 尽管
FUSE使用了 splice 技术来优化数据传输,但依然无法完全避免内存复制导致的性能损耗。
3. 不适合实时/高性能场景
• 由于性能上的局限性,
FUSE文件系统通常不适用于需要高性能和低延迟的应用场景,如实时数据处理或高吞吐量存储系统。
4. 部分系统功能受限
• 一些内核态文件系统功能(如文件锁定、直接内存访问等)在
FUSE文件系统中可能无法高效实现,甚至可能不被支持。
5. 依赖用户态守护进程
•
FUSE文件系统依赖于用户态的守护进程来处理文件操作请求,如果守护进程崩溃或停止运行,文件系统将无法正常工作。
总结
| 特性 | FUSE 文件系统 | 内核文件系统 |
| 开发难度 | 低,友好、安全 | 高,涉及内核编程 |
| 性能 | 较低,高 I/O 开销 | 高,低延迟、低开销 |
| 安全性 | 高,不易引发系统崩溃 | 低,错误可能导致系统崩溃 |
| 工具支持 | 丰富的用户态工具集 | 有限的内核工具 |
| 跨平台支持 | 较好,容易移植 | 较差,与内核绑定 |
| 适用场景 | 文件存储、虚拟文件系统等非性能敏感场景 | 高性能存储、数据库、实时系统等 |
FUSE 适用于开发周期短、灵活性高的文件系统,但对于性能要求高的应用场景,内核态文件系统仍然是更好的选择。
参考文献
• [1] : Vangoor, Bharath Kumar Reddy, Vasily Tarasov, and Erez Zadok. “To {FUSE} or Not to {FUSE}: Performance of User-Space File Systems.” In 15th {USENIX} Conference on File and Storage Technologies ({FAST} 17), pp. 59-72. 2017.
• [2] : Tanenbaum, A. S., and H. Bos. “Modern Operation Systems 4th Edition.” (2014).
• [3] : Inode structure, Udacity, 2015, https://www.youtube.com/watch?v=tMVj22EWg6A