gcsfuse与中断FUSE_INTERRUPT

8 阅读5分钟

什么是FUSE_INTERRUPT

FUSE_INTERRUPT是FUSE协议中的一个控制消息(opcode 36),不是普通文件操作,而是内核发给用户态daemon的一条指令:”发起某个请求的那个进程被信号打断了,请尽快终止那个在途请求“。

按协议定义(linux/include/uapi/linux/fuse.h):

FUSE_INTERRUPT = 36,
struct fuse_interrupt_in {
    uint64_t unique;   // 指向被中断的原始请求的唯一 ID
};

它由内核发起,携带一个unique字段,指明要终止的是哪一个在途请求。核心语义是“尽力而为的提示”, daemon可以终止该请求,也可以无视它继续做完。

发送中断后,内核仍然会等待daemon恢复原始请求。

为什么需要FUSE_INTERRUPT

FUSE的daemon往往是网络文件系统,例如当前的gcsfuse。单个请求可能挂在远端RPC上几百毫秒甚至更久,没有中断机制就会出现很糟糕的体验:

用户Ctrl+C -> 进程收到SIGINT -> 进程正阻塞在某个FUSE系统调用上 -> 该调用要等daemon完成远端RPC才能返回 -> 进程“杀不死”,而是“卡死”。

FUSE_INTERRUPT就是为此设计的:宕内核发现阻塞在FUSE请求上的进程收到了信号,它通知daemon “这个请求的发起者被打断了,能终止就终止“,让系统调用尽快以EINTR返回,进程得以响应信号。这是内核吧“中断语义”从本地块设备(请求交给硬件后无法撤销)延伸到“可编程的daemon"的手段。

FUSE_INTERRUPT如何工作

1. 内核侧:信号触发中断

(linux/fs/fuse/dev.c:368-386):

if (!fc->no_interrupt) {
    /* Any signal may interrupt this */
    err = wait_event_interruptible(req->waitq, test_bit(FR_FINISHED, &req->flags));
    if (!err)
        return;
    set_bit(FR_INTERRUPTED, &req->flags);
    if (test_bit(FR_SENT, &req->flags))
        queue_interrupt(req);
}

任何未屏蔽的信号(SIGCHLD, SIGURG, SIGINT,...)都可能触发,且触发后继续wait_event_killable等原始请求的回复,所以中断不等于请求失败。

2. 内核发出FUSE_INTERRUPT

通过fuse_interrupt_in.unique指认原始请求。

3. 用户态jacobasa/fuse库:每个请求配一个可取消的ctx

内核 -> jacobsa/fuse库中的实现如下所示:

a. Connection.beginOp中,为每个正常op创建一个可cancel的context.WithCancel,并把cancel函数记录在c.cancelFuncs[fuseID]里。

func (c *Connection) beginOp(
	opCode uint32,
	fuseID uint64) context.Context {
	// Start with the parent context.
	ctx := c.cfg.OpContext

	if opCode != fusekernel.OpForget {
		var cancel func()
		ctx, cancel = context.WithCancel(ctx)
		c.recordCancelFunc(fuseID, cancel)
	}

	return ctx
}

b. Connection.ReadOp在读到OpInterrupt消息时特殊处理:调用handleInterrupt,查表拿到对应的cancel并调用它,这会让传给gcsfuse业务方法的ctx.Done()触发。

func (c *Connection) ReadOp() (_ context.Context, op interface{}, _ error) {
  ...
	  // connection.go:489-493  ReadOp 里特殊处理 interruptOp:不返回给用户,
		// 而是内联调用 handleInterrupt 后 continue 循环
		if interruptOp, ok := op.(*interruptOp); ok {
			c.handleInterrupt(interruptOp.FuseID)
			continue
		}
  ...
}

func (c *Connection) handleInterrupt(fuseID uint64) {
	c.mu.Lock()
	defer c.mu.Unlock()

	cancel, ok := c.cancelFuncs[fuseID]
	if !ok {
    // 已经Reply过了,interrupt 来迟了,忽略即可
		return
	}

	cancel()
}

c. finishOp在正常回复前清理map。

这一层gcsfuse无法关闭,因为这是vendor库内部行为,中断信号照常会被内核发出、被库处理、cancel掉传入的context。

// connection.go:336-357  finishOp 在原请求 Reply 之后清理 cancelFuncs,
// 避免 map 泄漏,也避免 unique id 被复用时状态串了
func (c *Connection) finishOp(opCode uint32, fuseID uint64) {
    ...
    cancel, ok := c.cancelFuncs[fuseID]
    cancel()
    delete(c.cancelFuncs, fuseID)
}

最终这个cancel()触发的是暴露给用户文件系统实现的ctx.Done()

4. FUSE daemon侧:

gcsfuse使用--ignore-interrupts(默认为true)来实现“gcsfuse是否忽略中断请求“的功能。

若该值设置为false,则流程为:FS op拿到的ctx被取消 --> 在途GCS FUSE RPC被终止 --> 返回context canceled --> 内核收到EIO。该值默认为true,是如何实现“不受到内核中断的影响”呢?

gcsfuse自己的“忽略”逻辑:

在拿到已经被库cancel掉的ctx之后,重新包一层不受它影响的新context:

func (fs *fileSystem) getInterruptlessContext(ctx context.Context) context.Context {
	if fs.newConfig.FileSystem.IgnoreInterrupts {
		// When ignore interrupts config is set, we are creating a new context not
		// cancellable by parent context.
		newCtx := context.Background()
		return fs.traceHandle.PropagateTraceContext(newCtx, ctx)
	}
	return ctx
}

几乎每个FUSE操作入口(LookUpInode, ReadFile, WriteFile, FlushFile, SyncFile,...共18个调用点)都会先执行ctx = getInterruptlessContext(),这一行的效果就是当配置为true时,丢弃库传进来的、会被中断打断的ctx,换成一个基于context.Background()的全新context(只保留trace信息,不保留取消关系),这样即使内核后续对该请求发了FUSE_INTERRUPT并cancel了原ctx,gcsfuse内部真正用来做GCS I/O的context也不会被cancel,操作会一直跑到完成。

为什么GCSFUSE要设置ignore-interrupts默认值为true?

这是因为GCSFUSE后端为远端对象存储,其后端操作不是幂等/原子可回滚的本地内存操作,而是远端网络I/O(上传/下载/rename的copy+delete等)。如果一个Ctrl-C导致gcsfuse的WriteFile/FlushFile/SyncFile的context被cancel,正在进行的GCS请求就会在半途中被掐断。造成:

  • 文件在 GCS 端处于不完整/不一致状态;
  • 缓存文件与远端对象不一致;
  • 用户看到的错误令人困惑(不是"写失败"而是莫名其妙的 context canceled)。

而本地文件系统这种“半途取消”通常是安全的(内存操作可回滚),但gcsfuse的很多操作一旦发往GCS就很难“干净地”半途撤销,所以开发者选择让gcsfuse默认不理会内核转发过来的中断请求,让原有的文件系统操作(如上传)无论如何都跑完,以保证数据的完整性,牺牲的是“用户按Ctrl-C能立刻打断卡住的操作“这一响应性。

对应的历史演进(PR 时间线,来自 git log):

  • #1863/#1860:先加 flag/config,再实现"忽略中断"的逻辑;
  • #2034 "Ignore interrupts by default":把默认值从 false 改成 true ——说明团队观察到默认响应中断反而带来更多问题(数据不一致 / 中断态奇怪报错),所以把"忽略"设为默认行为,只给需要"可被 Ctrl-C 打断"语义的用户开一个开关关闭它。

总结

本质上gcsfuse 中的ignore-interrupts的参数时在“POSIX中断语义(可被Ctrl-C打断)“与”远端写操作的原子性/一致性“之间做的一个全局粗粒度的取舍。默认对所有操作类型的中断都是”不理会中断“。