用户空间文件系统 FUSE:架构和实现细节

最近在研究 FUSE 文件系统,看到一篇不错的论文《To FUSE or Not to FUSE: Performance of User-Space File Systems》,翻译出来研究一下。

论文比较长,本章翻译前半部分,主要包括 FUSE 的架构和实现细节。

摘要

传统上文件系统是作为操作系统内核一部分进行实现的。然而, 随着文件系统复杂性的增加, 许多新的文件系统开始在用户空间中开发。目前, 用户空间文件系统经常被用来作为新文件系统原型开发和设计评估。用户空间文件系统的主要缺点是性能差, 但没人对用户空间文件系统的性能进行深入探讨。 因此, 用户空间文件系统目前仍然存在相当大的争议: 而有些人认为用户空间文件系统只是一个不能用于生产环境的玩具, 另一些人在用户空间中开发了成熟的、用于生产环境的文件系统.。本文分析了FUSE的设计和实现框架,在各种工作负载下分析了它的性能特点。实验表明,在不同工作负载和硬件环境中, FUSE 性能变化很大,某些情况和内核文件系统差不多,某些情况下即使优化后也下降83%,CPU 利用率也增加了31%。

1. 简介

文件系统为应用程序提供了访问数据的通用接口。虽然微内核在用户空间实现文件系统,大多数文件系统都是宏内核的一部分。文件系统的内核实现避免了微内核和用户空间守护进程之前消息传递的高昂开销。

近些年来,用户空间文件系统越来越受欢迎,有以下4个原因:

  1. 多个可堆叠(stackable)文件系统在现有文件系统上添加了专用功能,例如去重和压缩;
  2. 在学术界和研发环境中, 这种框架加速了实验和新方法的原型设计;
  3. 一些现存的内核层文件系统被移植到用户空间,例如 ZFS 和 NTFS;
  4. 许多公司依赖用户空间文件系统的实现,例如 IBM 的 GPFS,Nimble Storage 的 CASL,Apache 的 HDFS ,GFS,RedHat 的 GlusterFS 和 Data Domain 的 DDFS。

A stackable (layered) file system is a file system that does not store data itself. Instead, it uses another file system for its storage. We call the stackable file system the upper file system, and the file systems it stacks on top of the lower file systems.

由于文件系统越来越复杂,用户空间文件系统也越来越受欢迎,例如 Btrfs 代码量超过了8万行。用户空间的代码更易于开发和维护。内核的 Bug 会导致系统整体瘫痪,用户空间的 bug 影响范围更小。许多库和编程语言在用户空间是多平台可用的。尽管用户文件系统的目标不是完全取代内核文件系统,但是目前用户文件系统地位不断提高,也导致了支持者和反对者的更激烈的讨论。辩论主要围绕两个权衡因素:(1)用户空间实现带来的性能开销有多大;(2)用户空间开发有多容易。 易于开发是非常主观的,难以形式化并因此进行评估;但性能更容易根据经验进行评估。 奇怪的是,关于用户空间文件系统框架的性能几乎没有人去进行研究。

文本中我们使用 FUSE,并探究其性能。我们基于以下4个原因首先讨论 FUSE 的设计和实现:

  1. FUSE 架构有点复杂;
  2. FUSE 信息在网络上很少;
  3. FUSE 的源码中有很多复杂的异步调用和用户内核通信,很难分析;
  4. 由于 FUSE 的受欢迎程度,FUSE 的实现细节分析很有价值。

我们使用 FUSE 框架开发了一个简单的可堆叠文件系统,将其构建在 EXT4 文件系统上,然后评估、对比其性能与原生Ext4的性能。我们使用了各种各样的微观、宏观工作负载,并且针对不同硬件使用了 FUSE 的基本和优化配置。 我们发现, FUSE 的性能取决于工作负载和硬件,某些情况下 FUSE 性能与 EXT4接近,但在最坏的情况下 FUSE 的可能会比 EXT4 慢3倍。 接下来,我们为 FUSE 设计并构建了一个丰富的测试系统来收集详细的性能指标。 这些统计数据适用于任何基于 FUSE 的系统。 我们使用这个测试系统来识别 FUSE 瓶颈,并解释为什么,例如,为什么 FUSE 的性能在不同的工作负载下有巨大的差异。

2. FUSE 设计

FUSE(Filesystem in Userspace)是使用最广的用户空间文件系统框架。保守估计目前至少有100多个基于 FUSE 的文件系统可以在 Internet 上被找到。虽然仍然有其他用户空间文件系统框架,我们仍然选择了最受欢迎的 FUSE。

虽然很多人因为 FUSE 提供的简单易用的 API 选择了 FUSE 来实现文件系统,但是几乎没人讨论 FUSE 的内部架构、实现和性能。为了评估 FUSE 的性能,我们不仅要了解 FUSE 的架构设计,还要了解一些实现细节。在本节中,我们首先介绍FUSE 的基础知识,然后解释某些重要的实现细节。FUSE 适用于多种操作系统,我们选择了使用最广泛的 Linux。我们在 Linux kernel 4.1.13 进行代码分析和实验。我们还使用了commit id 为 386b1b 的 FUSE 库。该版本在FUSE v2.9.4之上,提交了几个重要的补丁,我们不希望从我们的评估中排除这些补丁。 在撰写本文时,我们手动检查了所有新提交,并确认自从所选版本发布以来没有向FUSE添加任何新的主要功能或改进。

2.1 架构

image-20190426160102085

FUSE 包含内核模块和一个用户层的守护进程。内核模块加载时被注册成 Linux 虚拟文件系统的一个 fuse 文件系统驱动。这个 fuse 驱动充当不同用户空间文件系统的代理。除此之外,在注册一个新 fuse 文件系统时,FUSE 的内核模块还注册了一个/dev/fuse的块设备。该设备作为用户空间 FUSE 守护进程和内核通信的接口。通常,守护进程从/dev/fuse读取 FUSE 请求,处理,然后将回复写入/dev/fuse

图1显示了 FUSE 的架构。当用户应用在一个挂在的 FUSE 文件系统上执行某些操作时,VFS 将操作路由至 FUSE 的内核驱动。该驱动创建了一个 FUSE 请求结构体,并把该结构体放到了 FUSE 的队列中。此时,执行操作的进程通常处于等待状态。FUSE 的用户空间守护进程通过读取/dev/fuse将请求从内核队列中取出,并且处理请求。处理请求通常需要重新进入内核:例如,在一个堆叠文件 FUSE 文件系统中,守护进程会提交操作到底层文件系统中(例如 EXT4);或者在基于块的 FUSE 系统中,守护进程对一个块设备读写数据。当处理完请求后,FUSE 守护进程将响应写会/dev/fuse;FUSE 的内核驱动会将请求标记为 completed,然后唤醒原用户进程。

应用程序调用的某些文件系统操作可以不与用户空间守护进程通信。例如,读取一个文件被内核缓存的内容,无需转发至 FUSE 驱动。

2.2 实现细节

我们需要讨论一些 FUSE 的实现细节:用户-内核通信协议,库和 API,内核 FUSE 队列,拼接,多线程和缓存写回。

image-20190426162723351

用户-内核协议

当 FUSE 内核驱动和用户空间守护进程通信时,会构造一个 FUSE 请求结构体。请求的类型取决于传输的文件系统操作。表1中列出了 FUSE 全部43种请求类型,并以语义进行了分组。如表1所示,大部分请求类型和传统 VFS 文件操作有直观映射:忽略如 CREATE/READ 这些操作,我们把重点放在那些不太直观的请求类型上,这些请求在表1中加粗标记。

当一个文件系统被挂载内核会构造一个INIT请求。此时用户空间守护进程会和内核协商(1)通信协议版本,(2)支持的特定功能集,(3)参数设定。相反,当文件系统卸载时内核发送DESTROY请求。当收到该请求时,守护进程应当执行所有必要的清理工作。由于守护程序正常退出,此会话不再收到来自内核的请求,后续对/dev/fuse的读取都将返回0。

当任何先前发送的请求被取消时,例如一个读请求被用户中止,内核会发送一个INTERRUPT请求。每个请求都有一个唯一的序号,因此INTERRUPT可以用该序号干掉对应的请求。序号由内核分配,同时被用作用户空间回复时定位完成的消息。每个请求还包含一个node ID: 一个无符号的64位整形,用来在内核和用户空间标识一个 inode。路径到inode的转换由LOOKUP请求执行。 每次查找现有的inode(或者创建一个新的inode)时,内核都会将inode保存在inode缓存中。 从dcache中删除inode时,内核会将FORGET请求传递给用户空间守护程序。 此时,守护程序可能决定回收相应的数据结构。 BATCH FORGET允许内核使用请求删除多个inode。

当用户应用程序打开文件时,内核会生成OPEN请求。回复此请求时,FUSE守护程序有可能选择为打开的文件分配一个64位的文件句柄。随后内核返回与此打开文件关联的每个请求时都会返回此文件句柄。用户空间守护程序可以使用句柄来存储每个打开的文件信息。例如,可堆叠文件系统可以存储在底层文件系统中打开的文件的描述符,作为FUSE文件句柄的一部分。每次关闭一个被打开文件时都会生成FLUSH请求;当没有对打开的文件的引用时发送RELEASE请求。

OPENDIRRELEASEDIR请求是针对目录的,具有与OPENRELEASE相同的语义。 READDIRPLUS请求与READDIR类似,会返回一个或多个目录条目,除此之外它还包括每个条目的元数据信息。这允许内核预填充每个条目的inode缓存(类似于NFSv3的READDIRPLUS过程)。

当内核判断一个用户进程是否有权访问某个文件时,它会生成一个ACCESS请求。 通过处理此请求,FUSE 守护程序可以实现自定义的权限逻辑。 但是,通常用户使用默认权限选项挂载FUSE,该选项允许内核根据标准Unix属性(所有权和权限位)授予或拒绝对文件的访问权限。 在这种情况下,内核不会生成ACCESS请求。

库和 API

从概念上讲,FUSE库包含两个级别。低级库负责(1)接收和解析来自内核的请求,(2)向内核发送回复,(3)配置和挂载文件系统,以及(4)隐藏内核和用户空间之间潜在的版本差异。 此部分导出为 Low-level FUSE API

High-level Fuse API 构建在 Low-level FUSE API 之上,允许开发人员忽略文件路径到inode映射的实现细节。 为了简化了代码开发,high-level API中既不存在inode也不存在查找操作。因此,所有高级API方法直接在文件路径上操作。 高级API还可以处理请求中断,同时也提供了其他方便的功能:例如,开发人员可以使用更常见的chown()chmod()truncate()方法,避免使用低级的setattr()。 文件系统开发人员必须通过平衡操作灵活性与开发简易性来决定使用哪个API。

队列

image-20190427123228159

在2.1节中,我们提到 FUSE 的内核模块种有一个请求队列。 实际上, FUSE 在内核中维护了五个队列,如图2所示:inperruptsforgetspendingprocessingbackgroupd。一个请求在任何时候都只属于一个队列。 FUSE 将 INTERRUPT 请求放入 inperrupts 队列,FORGET 请求放在 forgets 队列中,同步请求(例如,元数据)放在 pending 队列中。当文件系统守护程序从/dev/fuse读取请求时,请求将按如下方式发送到用户的守护程序:

  1. 优先处理中断队列中的请求,保证中断请求在任何其他请求之前将被发送到用户空间。
  2. 公平对待 FORGET 请求和非 FORGET 请求:每发送8个非 FORGET 请求,就发送16个 FORGET 请求。这样做可以在出现大量突发性的 FORGET 请求,其他请求也可以被同时处理。pending 队列中最旧的请求被发送到用户空间的同时也被加入到 processing 队列。因此,processing 队列中的请求都是当前正在被守护程序处理的请求。如果 pending 队列为空,则在FUSE守护程序阻塞在读取调用上。当守护程序回复请求时(通过写入/dev/fuse),相应的请求将从 processing 队列中删除。

具体处理 FORGET 和非 FORGET 请求的逻辑需要在源码中验明

background 队列用于暂存异步请求。在默认配置下,只有读请求进入 background 队列;当writeback cache启用时,写请求也会进入 background 队列。当开启写回缓存时,来自用户进程的写入先在页缓存中累积,然后当bdflush 线程被唤醒时会下刷脏页。在下刷脏页时,FUSE会构造异步写入请求,并将它们放入 background 队列中。background 队列中的请求会周期进入 pending 队列。 FUSE pending 队列中异步请求的max_backgroud时(默认为12)。

在 fs/fuse/inode.c:56 #define FUSE_DEFAULT_MAX_BACKGROUND 12

pending 队列中的异步请求少于12个时,backgroup 队列中的请求将被移动到 pending 队列。这样做的目的是限制突发大量的异步请求对重要同步请求造成的延迟。

队列的长度没有明确限制。但是,当 pendingprocessing 队列中的异步请求数达到阈值congestion_thresholdmax_backgroud的75%,默认为9)时,FUSE 会通知 VFS 已拥塞。之后 VFS 会限制此用户进程文件系统的写操作。

在 fs/fuse/inode.c:59 #define FUSE_DEFAULT_CONGESTION_THRESHOLD (FUSE_DEFAULT_MAX_BACKGROUND * 3 / 4)

splice 和 FUSE buffer

在默认配置中,FUSE 守护程序必须调用read()/dev/fuse读取请求,调用write()写入/dev/fuse回复请求。每次读写调用都需要进行一次内核-用户空间的内存拷贝哦。这样对 WRITEREAD 请求性能损耗特别严重,因为一次内存拷贝需要处理大量数据。为了缓解这个问题,FUSE 可以使用Linux内核提供的 splice 功能。splice 允许用户空间在两个内核内存缓冲区之间传输数据,而无需将数据复制到用户空间。该功能对于 FUSE 非常有用,例如一个堆叠文件系统,数据可以不进入内核空间而直接传入到底层文件系统中。

Linux 中的零拷贝技术,第 1 部分:概述

Linux 中的零拷贝技术,第 2 部分:技术实现

为了支持 splice,FUSE以两种形式之一表示其缓冲区:(1)由用户守护程序的地址空间中的指针标识的常规内存区域,或(2)由文件描述符指向的内核空间内存。如果用户空间文件系统实现了write_buf()方法,则 FUSE 从/dev/fuse读取数据,并以包含文件描述符的缓冲区的形式将数据直接传递给此方法。 FUSE 拼接包含多页数据的 WRITE 请求。类似的逻辑适用于对具有两页以上数据的 READ 请求的回复。

多线程

由于并行越来越受欢迎,FUSE 增加了多线程支持。在多线程模式下,FUSE的守护进程以一个线程开始。如果 pending 队列中有两个以上的可用请求,则 FUSE 会自动生成其他线程。每个线程一次处理一个请求。处理完请求后,每个线程检查目前是否有超过10个线程;如果有,则该线程退出。 FUSE 库创建的线程数没有明确的上限。但是由于以下两个原因存在隐式的限制:(1)默认情况下,pending 队列中一次最多只有 max_background 个异步请求; (2)pending 队列中的同步请求数取决于用户进程生成的 I/O 活跃数的总量。此外,对于每个 INTERRUPTFORGET 请求,都会生成一个新线程。在没有中断支持且 FORGET 很少的典型系统中,FUSE 守护程序线程的总数最多为 $12+number of requests in pending queue$。

写回缓存和最大写

FUSE 的写操作默认是同步的,且一次发送到用户守护程序的数据量只有 4KB。 这会导致某些工作负载下出现性能瓶颈;使用/bin/cp程序将一个大文件复制到 FUSE 文件系统时,数据会被切分成若干个4KB,然后顺序、同步发送到用户空间。 FUSE 为了解决这个问题,实现了页面缓存的回写策略,然后写入变为异步。 利用该机制,文件数据可以以大块的格式()推送到用户守护程序,目前现在现在为32个page,即128KB。