FS-Cache 和 FUSE 用于媒体播放 QoS

作者:Ben Martin

FS-Cache 项目与网络文件系统(如 NFS)协同工作,以维护网络文件的本地磁盘缓存。该项目分为一个内核模块 (fscache) 和一个守护进程 (cachefilesd),它们共同维护磁盘缓存。本地磁盘缓存维护在本地文件系统上的一个目录下。例如,ext3 文件系统 /var 上的 /var/fscache 目录。包含 fscache 目录的文件系统必须能够使用扩展属性 (EA)。此类文件系统非常常见,包括 ext3 和 xfs。

早期的 Fedora Core 6 内核 RPM 包含 fscache 内核模块。不幸的是,大约在更新内核的 2.6.18-1.2868.fc6 版本之后,该模块不再包含在内。Fedora 7 内核也不包含该内核模块。希望将来,该模块能够在标准的 Fedora 内核中再次可用。Fedora Core 6 更新内核 2.6.20-1.2948.fc6 包含了一个 FS-Cache 补丁,但它不包含内核模块。

Linux 内核的 FS-Cache 内核模块补丁是可用的(请参阅“资源”部分)。

cachefilesd 守护进程使用 /proc 中的一个文件 (/proc/fs/cachefiles) 或一个设备文件 (/dev/cachefiles) 与内核模块通信。0.7 及更早版本的 cachefilesd 只能通过 proc 文件进行通信;0.8 版本也可以使用设备文件(如果可用),并回退到 proc 文件。

设置 cachefilesd

对于 Fedora Core 6 和 Fedora 7,有一个 cachefilesd RPM 包。不使用包管理进行安装应该也很容易,因为该守护进程主要由一个可执行文件和一个配置文件 (/etc/cachefilesd.conf) 组成。

配置文件中需要设置的两个主要事项是用于存储文件系统缓存的目录路径,以及控制文件系统上允许使用多少空间的选项,该文件系统包含缓存目录。如果您想同时运行多个本地磁盘缓存,您还可以为缓存提供一个标签。

空间约束都具有可接受的默认值,因此缓存目录是您唯一需要关注的配置选项。确保此目录适合存储缓存,并且在尝试启动 cachefilesd 之前它已存在。对于媒体 PC,使用闪存卡或 RAM 磁盘上的目录是一个不错的选择。

由于缓存目录必须具有扩展属性,并且您的 tmpfs 可能不支持它们,因此您可能需要在 tmpfs 文件系统中的单个文件中创建一个 ext3 文件系统,然后将嵌入的 ext3 文件系统用于 cachefilesd 路径。单个文件内部的 ext3 文件系统将很高兴地支持扩展属性。由于整个 ext3 文件系统都在 RAM 磁盘上的单个文件中,因此它不会在媒体 PC 上引起令人分心的磁盘 IO。

列表 1 中的 fstab 条目设置了一个 64MB 的 RAM 文件系统以及嵌入式 ext3 文件系统的挂载点。列表 2 中显示的命令设置了嵌入式 ext3 文件系统。由于 cache.ext3fs 文件系统仅存在于 RAM 中,因此您必须将这些命令添加到您的 /etc/rc.local 或合适的启动时脚本中,以便在重启后设置缓存目录。此脚本必须在 cachefilesd 启动之前调用。将 cachefilesd 从您的标准 init 运行级别启动项中移除,并在您设置 cache.ext3fs 嵌入式文件系统后立即从 rc.local 手动启动它,这是一个不错的解决方案。

如果缓存目录位于持久文件系统上,例如 /var,请将 cachefilesd 设置为自动启动,如列表 3 所示。

列表 1. 使用 RAM 磁盘存储本地 fscache 磁盘缓存

tmpfs /var/fscache tmpfs size=64m,user,user_xattr   0 0
/var/fscache/cache.ext3fs /var/fscache/cache 
 ↪ext3 loop=/dev/loop1,user_xattr,noauto 0 0

列表 2. 设置嵌入式 ext3 文件系统

# mount /var/fscache
# cd /var/fscache
# dd if=/dev/zero of=cache.ext3fs \
      bs=1024 count=65536
# mkfs.ext3 -F cache.ext3fs 
# mount cache.ext3fs

列表 3. 启动 cachefilesd 守护进程并将其设置为下次启动时自动启动

$ su -l
# service cachefilesd start
# chkconfig cachefilesd on

配置文件中的空间约束用于设置应使用的本地缓存目录所在文件系统上可用块和文件百分比。对于这两种资源类型中的每一种,都有三个阈值:cull-off、cull-start 和 cache-off。当达到 cull-off 限制时,不会执行磁盘缓存的剔除,而当达到 cull-start 限制时,磁盘缓存的剔除开始。例如,对于磁盘块类型约束,将 cull-off 设置为 20%,cull-start 设置为 10% 意味着只要磁盘有超过 20% 的可用块,就不会从缓存中剔除任何内容。一旦磁盘达到 10% 的可用块,缓存剔除就开始释放一些空间。如果磁盘设法达到 cache-off 限制(例如,5%),则缓存将被禁用,直到有更多 cache-off 空间可用为止。

配置选项以 b 为前缀表示块类型约束,以 f 为前缀表示可用文件约束。配置文件具有与上面使用的方法略有不同的命名方法。对于块约束,cull-off 限制称为 brun。对于 cull-start,限制称为 bcull,cache-off 称为 bstop。

修改挂载

要为挂载点启用 FS-Cache,您必须向其传递 fsc 挂载选项。我注意到我必须为给定 NFS 服务器的所有挂载点启用 FS-Cache,否则 FS-Cache 将不会维护其缓存。对于用作媒体 PC 的机器来说,这应该不是什么大问题,因为您可能不会介意缓存来自文件服务器的所有 NFS 挂载。

列表 4 中显示的 fstab 条目包含 fsc 选项。将此 fsc 选项添加到对 fileserver:/... 的所有挂载点引用将启用 FS-Cache。

列表 4. 用于在文件服务器上挂载带有 FS-Cache 的 NFS 目录的 fstab 条目

fileserver:/foo  /foo  nfs bg,intr,soft,fsc  0 0
抢占式缓存

在此阶段,FS-Cache 将存储从文件服务器读取的文件(或部分文件)的本地缓存副本。我们真正想要的是将我们在媒体 PC 上查看的文件中的数据预先读入本地磁盘缓存。

为了将信息放入本地磁盘缓存,我们可以使用 FUSE 模块作为 NFS 挂载点和查看媒体的应用程序之间的垫片。借助 FUSE,您可以将文件系统编写为用户地址空间中的应用程序,并通过 Linux 内核像任何其他文件系统一样访问它。为了保持简单,我将提供 FUSE 文件系统的应用程序简称为 FUSE 模块。

FUSE 文件系统应获取我们要缓存的 NFS 文件系统(委托)的路径,以及内核公开 FUSE 文件系统的挂载点。例如,如果我们有一个 /HomeMovies NFS 挂载点,我们在其中存储了所有数字家庭电影,则 FUSE 模块可以挂载在 /CacheHomeMovies 上,并将 /HomeMovies 路径作为委托路径。

当读取 /CacheHomeMovies 时,FUSE 模块将读取委托 (/HomeMovies) 并显示相同的目录内容。当读取文件 /CacheHomeMovies/venice-2001.dv 时,FUSE 模块从 /HomeMovies/venice-2001.dv 读取信息并返回该信息。实际上,对于应用程序而言,/CacheHomeMovies 将与 /HomeMovies 完全相同。

在这个阶段,与直接使用 /HomeMovies 相比,我们没有获得任何好处。但是,在 FUSE 模块的 read(2) 实现中,我们可以同样容易地要求委托 (/HomeMovies) 读取应用程序请求的内容以及接下来的 4MB 数据。FUSE 模块可以丢弃那些额外的信息。FUSE 模块读取 4MB 数据的行为将触发 FS-Cache 通过网络读取它并将其存储在本地磁盘缓存中。

使用 FUSE 的主要优点是允许在视频播放寻找时缓存正常工作。主要缺点是额外的本地复制,其中 FUSE 模块请求的信息多于返回给视频播放器的信息。这可以通过让 FUSE 模块仅偶尔请求额外信息来缓解——例如,仅当应用程序消耗了 2MB 数据时才进行预读。

为了获得最佳性能,预读应该在 FUSE 模块的单独控制线程中发生,并使用 readahead(2) 或异步 IO,以便视频播放应用程序不会被阻塞,等待大型预读请求完成。

FUSE 垫片

fuselagefs 包是 FUSE 的 C++ 封装器。它包括 Delegatefs 超类,该超类为实现采用委托文件系统并添加一些附加功能的 FUSE 模块提供支持。对于编写像上面 nfs-readahead FUSE 模块这样的简单垫片文件系统,Delegatefs 是一个完美的起点。

预读算法旨在使用异步 IO 读取 8MB,并且当使用 FUSE 文件系统向应用程序显示前 4MB 数据时,它会使用异步 IO 再次读取 8MB。因此,在最坏的情况下,应该始终有 4MB 的缓存数据可供 FUSE 模块使用。

用于实现垫片的 C++ 类大约有 70 行代码,如列表 5 所示。声明了两个偏移量来跟踪上次调用 fs_read() 中的文件偏移量以及我们应该启动另一个异步预读调用的偏移量。aio_buffer_sz 被声明为常量枚举,因此可以使用它来声明 aio_buffer 的大小。当使用 FUSE 文件系统向应用程序显示 aio_consume_window 字节的 aio_buffer 时,将执行另一次预读。如果 debug_readahread_aio 为 true,则 FUSE 模块会显式等待异步预读完成,然后再返回。这在调试时很方便,以确保异步 IO 的返回值有效。一个非说明性的示例将有一些回调报告异步 IO 操作是否失败。

schedule_readahread_aio() 的主要工作可能是执行单个异步预读调用。它更新 m_startNextAIOOffset 以告知自身何时应进行下一次异步预读调用。forceNewReadAHead 参数允许调用者强制进行新的异步预读,例如在执行查找的情况下。

fs_read() 方法是 Delegatefs 中的一个虚方法。它具有与 pread(2) 系统调用类似的语义。数据应读取到指定偏移量的给定大小的缓冲区中。fs_read() 方法由 FUSE 间接调用。我们的 fs_read() 的主要逻辑是检查给定偏移量是否与上次读取调用中的逻辑序列一致。如果偏移量与上次 read() 返回的最后一个字节不连续,则 fs_read() 将强制 schedule_readahread_aio() 执行另一次预读。schedule_readahread_aio() 始终从 fs_read() 调用,因此它可以处理滑动异步预读窗口。

由于 Delegatefs 知道如何从委托文件系统中读取字节,因此我们可以通过调用基类来简单地返回。nfs-fuse-readahead-shim.cpp 的其余部分用于解析命令行选项,并且不是从 main() 返回,而是通过 CustomFilesystem 类的实例调用 Delegatefs 的 main 方法。垫片使用列表 6 中显示的 Makefile 进行编译。

列表 5. 完整的 FUSE 垫片 C++ 类

#include <fuselagefs/fuselagefs.hh>
using namespace Fuselage;
using namespace Fuselage::Helpers;

#include <aio.h>
#include <errno.h>

#include <string>
#include <iostream>
using namespace std;
...
class CustomFilesystem
 :
 public Delegatefs
{
 typedef Delegatefs _Base;
 off_t m_oldOffset;
 off_t m_startNextAIOOffset;
 enum
 {
   aio_buffer_sz = 8 * 1024 * 1024,
   aio_consume_window = aio_buffer_sz / 2,
   debug_readahread_aio = false
 };
 char aio_buffer[ aio_buffer_sz ];
    
 void schedule_readahread_aio( int fd, 
     off_t offset, bool forceNewReadAHead )
 {
   if( m_startNextAIOOffset <= offset 
        || forceNewReadAHead )
   {
     cerr << "Starting an async read request"
          << " at offset:" << offset << endl;

     ssize_t retval; ssize_t nbytes; 
     struct aiocb arg; 
     bzero( &arg, sizeof (struct aiocb)); 
     arg.aio_fildes = fd;
     arg.aio_offset = offset; 
     arg.aio_buf = (void *) aio_buffer; 
     arg.aio_nbytes = aio_buffer_sz; 
     arg.aio_sigevent.sigev_notify = SIGEV_NONE; 
 
     retval = aio_read( &arg );
     if( retval < 0 )
       cerr << "error starting aio request!" 
            << endl;
 
     m_startNextAIOOffset = offset 
        + aio_consume_window;

     if( debug_readahread_aio )
     {
       while ( (retval = aio_error( &arg ) ) 
           == EINPROGRESS )
       {}
       cerr << "aio_return():" 
            << aio_return( &arg ) 
             << endl;
      }
    }
 }
    
public:

 CustomFilesystem()
 :
 _Base(),
 m_startNextAIOOffset( 0 ),
 m_oldOffset( -1 )
 {
 }
    
 virtual int fs_read( const char *path, 
    char *buf, size_t size,
    off_t offset, struct fuse_file_info *fi)
 {
   cerr << "fs_read() offset:" << offset
        << " sz:" << size << endl;
   int fd = fi->fh;

   bool forceNewReadAHead = false;
   if( (offset - size) != m_oldOffset )
   {
     cerr << "possible seek() between read()s!" 
          << endl;
     forceNewReadAHead = true;
     aio_cancel( fd, 0 );
   }
   schedule_readahread_aio( fd, offset, 
                            forceNewReadAHead );
   m_oldOffset = offset;
   return _Base::fs_read( path, buf, 
                          size, offset, fi );
 }
};

列表 6. FUSE 垫片的 Makefile

nfs-fuse-readahead-shim: nfs-fuse-readahead-shim.cpp
	g++ nfs-fuse-readahead-shim.cpp \
          -o nfs-fuse-readahead-shim \
          -D_FILE_OFFSET_BITS=64 -lfuselagefs
试用

一个以预定速率从给定文件读取数据的简单应用程序可以验证缓存是否按预期填充,如列表 7 所示。没有进行大量的错误检查,但是会引起问题的事件(例如失败的 read())会报告到控制台。该应用程序重复从指定文件中一次读取 4KB 的块并丢弃结果。每 256KB 报告状态,以便可以知道大致读取到文件的哪个字节而关闭应用程序。

列表 7. simpleread.cpp 以 argv[2] 中指定的 usec 速率从 argv[1] 读取数据

#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <errno.h>

#include <iostream>
#include <sstream>
using namespace std;

int main( int argc, char** argv )
{
    cerr << "opening argv[1]:" << argv[1] << endl;
    
    long offset = 0;
    int fd = open( argv[1], O_RDONLY );

    unsigned long usec = 10000;
    if( argc > 2 )
    {
        stringstream ss;
        ss << argv[2];
        ss >> usec;
    }
    cerr << "using delay of usec:" << usec << endl;
    
    const int bufsz = 4096;
    char buf[ bufsz ];
    bool error = false;
    
    while( true )
    {
        ssize_t rc = read( fd, buf, bufsz );
        if( rc > 0 )
        {
            if( error )
            {
                cerr << "reading resumed" << endl;
            }
            error = false;
            offset += rc;
        }
        else if( rc == 0 )
        {
            cerr << "end of file" << endl;
            exit(0);
        }
        else
        {
            error = true;
            cerr << "read error:" << errno 
                 << " at offset:" << offset 
                 << endl;
        }
        usleep( usec );
        if( offset % (1024*256) == 0 )
            cerr << "offset:" << offset << endl;
    }
    return 0;
}

如列表 8 所示,我们首先清理缓存目录并重启 cachefilesd。然后,挂载 NFS 共享,并针对它运行 FUSE 垫片以创建 /Cache-HomeMovies 目录。告知 FUSE 可执行文件保持在前台运行,这会阻止 FUSE 将其作为守护进程运行,从而允许显示 FUSE 文件系统的标准输出和标准错误。我们使用 bash 将 nfs-fuse-readahead-shim 放入后台(尽管仍然将其标准输出重定向到捕获文件),并为稍多于 500KB 的数据运行 simpleread。然后,停止 simpleread 和 nfs-fuse-readahead-shim 以调查缓存是否已按预期填充。

列表 8. 针对 FUSE 垫片运行 simpleread

# rm -rf /var/fscache/*
# /etc/init.d/cachefilesd restart
# mount fileserver:/HomeMovies /HomeMovies -o fsc
# nfs-fuse-readahead-shim --fuse-forground \
  -u /HomeMovies /Cached-HomeMovies \
  >|/tmp/nfs-fuse-out 2>&1 \
  &

# simpleread /Cached-HomeMovies/venice-2001.dv 1000
using delay of usec:1000
offset:262144
offset:524288
^C
# fg
^C
# 

simpleread 在仅读取略多于半兆字节后停止。但是,FUSE 模块在启动时有一个异步 IO 调用,请求发送 8MB 的数据。在 /var/fscache 中查找与 venice-2001.dv 大小相同的文件应该会显示缓存文件。将此缓存文件的前 8MB 与 NFS 共享上的版本进行比较,应该显示前 8MB 是相同的。请注意,首先读取本地缓存文件以确保随后使用 NFS 共享不会在读取缓存文件之前填充缓存文件。这在列表 9 中显示。

列表 9. 检查缓存是否已读取前 8MB

# cd /var/fscache
# ll -R
...
---------- 1 root root 800M Jun 10 02:19 Ek0...000000
# dd if=./path/to/Ek0...000000 \
   of=/tmp/8mb bs=1024 count=8192
# dd if=/HomeMovies/venice-2001.dv \
   of=/tmp/8mb.real bs=1024 count=8192
# diff /tmp/8mb.real /tmp/8mb
#
总结

FS-Cache 的一个限制是它不会缓存以 O_DIRECT 或用于写入方式打开的文件。

通过利用内核 FS-Cache 代码,可以非常简单地创建用于处理预读的 FUSE 模块。Delegatefs C++ FUSE 基类允许在应用程序执行 IO 时非常轻松地实现其他功能。

FUSE nfs-fuse-readahead-shim 模块的启动方式如列表 8 所示,当未传递 --fuse-forground 选项时,nfs-fuse-readahead-shim 静默地作为守护进程运行。

Ben Martin 从事文件系统工作已超过十年。他目前正在攻读博士学位,将语义文件系统与形式概念分析相结合,以改善人与文件系统的交互。

加载 Disqus 评论