Go-FUSE 教程
- Go-FUSE GitHub:https://github.com/hanwen/go-fuse
1. 概述
Go-FUSE 是用于 FUSE 内核模块的 Go 原生绑定。
与其它 FUSE 库相比,该库的亮点是:
- 全面和最新的协议支持(截至 7.12.28)。
- 性能可与 libfuse 媲美。
1.1. macOS 支持
虽然该库的主要开发者(hanwen@)没有 Mac 进行测试,但接受补丁,以使 Go-FUSE 可以在 Mac 上运行。
- OSXFUSE 的所有限制,包括缺乏对 NOTIFY 的支持。
- OSX 不断地发起 STATFS 调用(导致性能问题)。
- OSX 在从 FUSE 设备并发读取数据时存在问题,这将导致性能问题。
- 测试应该通过;将任何失败报告为 bug!
1.2. 示例
- example/hello/ 包含 60 行的“hello world”文件系统
- zipfs/zipfs 包含用于 zip 和 tar 文件的小而简单的只读文件系统。对应的命令在 example/zipfs/。比如
mkdir /tmp/mountpoint
example/zipfs/zipfs /tmp/mountpoint file.zip &
ls /tmp/mountpoint
fusermount -u /tmp/mountpoint
- zipfs/multizipfs 展示如何将简单的 Go-FUSE 文件系统组合成更大的文件系统。
- example/loopback 挂载文件系统的另一部分。其功能与符号链接类似。二进制文件在 example/loopback/。比如:
mkdir /tmp/mountpoint
example/loopback/loopback -debug /tmp/mountpoint /some/other/directory &
ls /tmp/mountpoint
fusermount -u /tmp/mountpoint
2. fs
包
2.1. 设计
- Node 包含对其子节点的引用。这很有用,因为大多数文件系统需要构造树状结构。
- Node 包含对其父节点的引用。因此,可以为每个 Inode 派生路径,而不需要单独的 PathFS。
- Node 可以是“持久的”,这意味着它们的生命周期不受内核的控制。这对于提前构建 FS 树非常有用,而不是由 LOOKUP 驱动。
- FS 树节点的 NodeID 必须在创建时定义,并且不可变。相比之下,重用 NodeID(比如 rsc/bazil FUSE,以及旧版本的 go-fuse/FUSE/nodefs)需要额外的同步,以避免与 notify 和 FORGET 产生竞争,并且使处理 inode 生成更复杂。
- Inode 的模式在创建时定义。文件在其生命周期内不能改变类型。这还可以防止忘记在 Lookup/GetAttr 中返回文件类型之类的常见错误。
- NodeID(用于与内核通信)等于Attr.Ino (Stat 和 Lstat 返回值中显示的值)。
- 没有全局树锁,以确保可伸缩性。
- 支持硬链接。libfuse 在高级 API 中不支持此功能。在通过不同路径查找同一文件时,需要特别注意竞态条件。
- 不要将 Notify{Entry,Delete} 作为 AddChild/RmChild/MvChild 的一部分发起:因为 NodeID 唯一且不可变,所以不会混淆无效节点,并且通知不必发生在锁定状态下。
- 目录读取也使用 FileHandle,用于读取的 API 一次读取一个 DirEntry。FileHandle 可以实现 Seek,如果传入请求中的偏移量发生变化,那么调用 Seek。
- 方法名称基于系统调用名称。没有系统调用的地方(比如 “open directory”),倾向于将所有东西写在一起(Opendir)
2.2. api
包 fs
为构建树状文件系统提供基础设施。
2.2.1. 文件系统实现的结构
为创建文件系统,需要先为文件系统树的节点定义类型。
type myNode struct {
fs.Inode
}
// Node 类型必须是 InodeEmbedders
var _ = (fs.InodeEmbedder)((*myNode)(nil))
// Node 类型需要实现一些文件系统操作,比如 Lookup
var _ = (fs.NodeLookuper)((*myNode)(nil))
func (n *myNode) Lookup(ctx context.Context, name string, out *fuse.EntryOut) (*fs.Inode, syscall.Errno) {
ops := myNode{}
out.Mode = 0755
out.Size = 42
return n.NewInode(ctx, &ops, fs.StableAttr{Mode: syscall.S_IFREG}), 0
}
方法名的灵感来自系统调用名,所以使用 Listxattr 而不是 ListXAttr。
通过在树根上调用 mount 的方式,挂载文件系统:
server, err := fs.Mount("/tmp/mnt", &myNode{}, &fs.Options{})
..
// 开始服务文件系统
server.Wait()
2.2.2. 错误处理
所有错误报告必须使用 syscall.Errno
类型。这是带有预定义错误码的整数,其中值 0 (OK
)应该用于表示成功。
2.2.3. 文件系统概念
FUSE API 与用于在内核中定义文件系统的 Linux 内部 VFS API 非常相似。因此理解一些术语很有用。
文件内容:在常规文件中存储的原始字节。
路径:以 /
分隔的字符串路径,描述节点在文件系统树中的位置。比如:
dir1/file
表示路径 root → dir1 → file。
从树根到特定节点可能有多条路径,称为硬链接。比如:
root
/ \
dir1 dir2
\ /
file
Inode:(“索引节点”)指向文件内容,存储文件或目录的元数据(大小、时间戳)。每个 inode 都有类型(目录、符号链接、常规文件等)和标识(64 位数字,对文件系统而言唯一)。目录可以有子目录。
内核中的 inode 在 Go-FUSE 中表示为 Inode
类型。
虽然常见的 OS API 用路径(字符串)表达,但文件系统的精确语义最好用 Inode 描述。这允许指定在极端情况下怎么做,比如将数据写入已删除文件时。
文件描述符:打开文件返回的句柄。文件描述符始终引用单个 Inode。
目录项(DirEnt):目录项将(父 inode 编号,名称字符串)元组映射到子节点,因此表示父/子关系(或不存在)。在 Go-FUSE 中没有等价类型,但是 Lookup
操作的结果本质上是目录项,内核将其放入缓存中。
2.2.4. 内核缓存
内核缓存来自 FUSE 进程的若干信息:
- 文件内容:在
Open
中使用fuse.FOPEN_KEEP_CACHE
返回标记启用。ReadCache 和 WriteCache 操作文件内容,使用Inode.NotifyContent
使该缓存失效。
- 文件属性(大小、修改时间等):使用
fuse.AttrOut
和fuse.EntryOut
中的超时属性字段控制。在Getattr
和Lookup
中填充这些字段。
- 目录项(FS 树中的父子关系):使用
fuse.EntryOut
中的超时字段控制。使用Inode.NotifyEntry
和Inode.NotifyDelete
使其失效。如果没有目录项超时,那么每次操作文件 "a/b/c" 都必须先对 "a"、"a/b" 和 "a/b/c" 进行查找,成本很高,因为涉及内核和 FUSE 进程之间的上下文切换。未成功的目录项查找也可以通过在Lookup
返回ENOENT
时设置超时进行缓存。libfuse C 库默认为属性和目录项指定 1 秒的超时时间,但对负项没有超时。在 Go-FUSE 中,可以通过在挂载时设置选项实现,比如:
sec := time.Second
opts := fs.Options{
EntryTimeout: &sec,
AttrTimeout: &sec,
}
2.2.5. 锁
网络文件系统支持通过 Getlk
、Setlk
和 Setlkw
系列方法实现锁定。它们支持对常规文件的区域进行锁定。
2.2.6. 并行
内核中的 VFS 层被优化为高度并行,并行影响 FUSE 文件系统:多个 FUSE 操作并行运行可能导致竞态条件。强烈建议以并行发起文件操作的方式,测试 FUSE 文件系统,并且使用竞态检测器排除数据竞争。
2.2.7. 死锁
Go 运行时将 Goroutine 复用到操作系统线程上,并且假设一些系统调用不会阻塞。当从服务文件系统的同一进程访问文件系统时(比如在单元测试中),可能导致死锁,特别是当 GOMAXPROCS=1 时。Go 运行时假设系统调用不会阻塞,但实际上是由 Go-FUSE 进程服务的。
以下死锁是已知的:
- 使用 fork/exec 序列生成子进程:进程将自己分叉为父进程和子进程。父进程等待子进程发出 exec 失败或成功的信号,而子进程则准备调用 exec()。子进程中触发 FUSE 请求的任何设置步骤都可能导致死锁。
1.a. 如果子进程已指定目录,那么子进程将 chdir 到该目录。这将在目录上生成 ACCESS 操作。
可以通过禁用 ACCESS 操作避免这种死锁:在 Access 中实现中返回 syscall.ENOSYS,并且确保在初始化子进程前触发调用它。
1.b. 如果子进程继承文件,并且子进程使用 dup3() 重新映射文件描述符。如果目的 fd 恰好由 Go-FUSE 支持,那么 dup3() 调用将隐式关闭该 fd,同时生成 FLUSH 操作。比如:
f1, err := os.Open("/fusemnt/file1")
// f1.Fd() == 3
f2, err := os.Open("/fusemnt/file1")
// f2.Fd() == 4
cmd := exec.Command("/bin/true")
cmd.ExtraFiles = []*os.File{f2}
// f2 (fd 4) is moved to fd 3. Deadlocks with GOMAXPROCS=1.
cmd.Start()
通过确保指向 FUSE 挂载的文件描述符和传递给子进程的文件描述符不重叠的方式,可以避免这种死锁,比如,在上面的示例之前插入以下内容:
for {
f, _ := os.Open("/dev/null")
defer f.Close()
if f.Fd() > 3 {
break
}
}
- Go 运行时使用 epoll 系统调用了解哪些 Goroutine 可以响应 I/O。运行时假设 epoll 不会阻塞,但是如果文件位于 FUSE 文件系统上,内核将生成 POLL 操作。为防止这种情况发生,Go-FUSE 在挂载时禁用 POLL 操作码。为确保已经如此,请调用 WaitMount。
2.2.8. 动态地发现文件系统
文件系统数据通常不能全部放入 RAM,因此内核必须动态地发现文件系统:当输入并列出目录内容时,内核向 FUSE 服务询问正在读/写的文件和目录,并且在内存不足时忘记文件系统的部分内容。
动态文件系统的两个重要操作是:
- Lookup,NodeLookuper 接口的一部分用于发现目录的各个子目录
- Readdir, NodeReaddirer 接口的一部分用于列出目录的内容。
2.2.9. 静态内存文件系统
对于小型的只读文件系统,正确使用 Lookup
的锁定机制非常繁琐,因此 Go-FUSE 提供简化构建此类文件系统的功能。
可以通过 OnAdd
方法构造整个树,而不是动态地发现 FS 树。然后,内存中的树形结构成为实际来源。这意味着 Go-FUSE 必须记住 Inode,即便内核不再对它们感兴趣。可以通过从根节点的 OnAdd
方法实例化“持久的” Inode 的方式实现。参阅 ZipFS 示例,获取可运行示例。