Skip to content

Latest commit

 

History

History
111 lines (90 loc) · 11.8 KB

fuse_crash.md

File metadata and controls

111 lines (90 loc) · 11.8 KB

实现思路

不论是单线程循环还是多线程循环,在循环退出之后,内核中仍然处于处理队列 (fpq) 中的请求均是未完成的(不管是工作进程未完成的请求,还是守护进程已经完成但是内核未能成功收到回复的请求),这些请求都需要重新处理。

内核态

故障恢复过程中,在内核态需要遍历所有的 fud,将所有 fud->fpq 移入 fc->fiq,然后重新设置这些请求的状态为 pending

if (cmd == FUSE_DEV_IOC_RECOVERY) {
	err=0;
	struct fuse_dev *thisfud = fuse_get_dev(file);
	struct fuse_conn *fc = thisfud->fc;
	struct fuse_conn *fiq = fc->iq;

	struct fuse_dev *fud;
	struct fuse_pqueue *fpq;
	struct fuse_req *req, *next;
	LIST_HEAD(recovery);
	unsigned int i;

	list_for_each_entry (fud, &fc->devices, entry) {
		fpq = &fud->pq;
		spin_lock(&fpq->lock);
		for (i = 0; i < FUSE_PQ_HASH_SIZE; i++)
			list_splice_tail_init(&fpq->processing[i],
					      &recovery);
		spin_unlock(&fpq->lock);
	}

	list_for_each_entry_safe (req, next, &recovery, list) {
		clear_bit(FR_SENT, &req->flags);
		set_bit(FR_PENDING, &req->flags);
	}

	spin_lock(&fiq->lock);
	list_splice(&recovery, &fiq->pending);
	spin_unlock(&fiq->lock);
}

用户态

故障恢复过程中,在用户态进程,则需要清空 se->reqlist 以及 se->intlist。

实现方法

这个过程的实现方法是,首先通过主要的文件描述符 se->fd, 获得它所对应的 fud, fud 中有个指向 fc 的字段,fc 中又维护了所有指向自己的 fud 的列表,因此需要遍历这个列表来重置每一个 fud 上的请求。

多线程注意事项

在多线程中,如果每个线程通过 FUSE_DEV_IOC_CLONE 复制了一个文件描述符, 那么每个文件描述符都会对应一个 fud。如果需要故障恢复的话,这些文件描述符需要首先保存下来,不能在循环结束后立刻关闭,否则会导致对应的 fud 也被释放,应该首先进行故障恢复工作,随后关闭对应的文件描述符。我们通过 se->clonefds 保存所有克隆的文件描述符,这些文件描述符会在解除挂载或者会话被 destroy 时被关闭,并且 se->clonefds 占有的内存会被释放。

细节

文件系统的守护进程退出,所有打开指向 /dev/fuse 的文件描述符均关闭之后(但是文件系统尚未解除挂载),此时执行文件系统的操作,会提示 Transport endpoint is not connected,在这个文件系统下不能接收新的请求,新的请求不会被放入内核的请求队列,而在进程退出之前正在进行的请求(阻塞于这个请求的进程)也不会完成。

故障恢复的方法

  1. 通过 fork 调用创建一个子进程,父进程作为故障恢复进程,子进程作为工作进程(处理用户请求)。由于 fork 会在子进程中拷贝一份文件描述符,因此此时对 /dev/fuse 的引用计数为 2,在工作进程故障退出之后,由于 /dev/fuse 的引用计数不会清零,因此对应的 fc 不会在工作进程 crash 之后被释放,这就使得工作进程在故障退出之后,后续对文件系统的请求不会因为 Transport endpoint is not connected 而退出,而是阻塞直到故障恢复完成。既然父进程作为故障恢复进程,那么一般就需要保证父进程所做的工作相对简单,不会出现故障,如果父进程因为意外(如 kill -9)而退出,不会导致子进程退出,只是故障恢复功能无法启用。
  2. 工作进程正常处理用户的文件系统请求,故障恢复进程等待工作进程完成,并观察工作进程的退出状态。如果工作进程是因为故障而终止(exit(1) 或者 接收到信号退出),则启动故障恢复程序。
  3. 工作进程在处理过程中可能需要打开新的文件或是保存文件的相关信息。如果工作进程需要打开新的文件,那么需要将打开的文件描述符通过 sendmsg 发送给故障恢复进程,使得故障恢复之后对该打开文件的操作能够继续进行;如果工作进程需要保存文件的相关信息,那么这些相关信息应该通过共享内存来保存,使得故障恢复之后这些信息不会丢失。因此故障恢复进程需要额外启动一个线程,用于接收工作进程传递过来的信息,而工作进程在每次打开文件或者保存文件的相关信息之后都需要通知到故障恢复进程。
  4. 工作进程以及故障恢复进程对信号的处理在 fuse_signal.md 中已经说明。
  5. 在故障发生后,一方面故障恢复进程需要设置共享内存中的信息,另一方面还需要恢复内核以及用户态的请求队列,最后需要重新一个新的进程。

Filesystem connection: A connection between the filesystem daemon and the kernel. The connection exists until either the daemon dies, or the filesystem is umounted. Note that detaching (or lazy umounting) the filesystem does not break the connection, in this case it will exist until the last reference to the filesystem is released.

进程退出的状态

  1. 正常退出:在 main() 函数中执行 return;调用 exit() 函数;调用 _exit() 函数
  2. 异常退出:调用 abort 函数;进程收到某个信号,该信号使得进程终止

exit 和 _exit 的区别

exit 在头文件 stdlib.h 中声明,_exit 在头文件 unistd.h 中声明。exit 函数是在 _exit 函数之上的一个封装,_exit() 执行后立即返回给内核,而exit()要先执行一些清除操作,然后将控制权交给内核。 最大区别就在于 exit() 函数在 exit 系统调用之前要检查文件的打开情况,把文件缓冲区的内容写回文件

异常退出

进程异常退出由两种情况:

  1. 代码错误导致进程运行时异常退出;
  2. 向进程发送信号导致进程异常退出。

第二种情况我们已经对一些能够导致进程异常退出的信号的处理函数进行了重新设置,在收到相应的信号时能够使得进程在执行一些必要地清理工作之后,安全地退出。因此,我们不会将这种情况下进程退出视为故障的情况,不对其做故障恢复处理。我们主要针对 1 中的情况进行故障恢复处理。

编程错误导致进程运行时异常退出

第一类情况导致的进程异常退出,起源于进程自身的编程错误,错误的编码执行非法操作,操作系统和硬件制止它的非法操作,并且让进程异常退出。

异常和异常处理函数

当进程执行非法操作时,计算机会抛出处理器异常,系统执行异常处理函数以响应处理器异常,异常处理函数往往会终止进程运行。

广义的异常包括软中断和外设中断。外设中断是系统外围设备发送给处理器的中断,它通知处理器 I/O 操作的状态,这种异常是外设的异步异常,与具体进程无关,所以它们不会造成进程的异常退出。这里主要需要关注的异常时软中断,是进程非法操作所导致的处理器异常,这类异常是进程执行非法操作所产生的同步异常,比如内存保护异常,除零异常,缺页异常等等。

异常处理函数的过程

  1. 进程执行非法指令或执行错误操作;
  2. 非法操作导致处理器异常产生;
  3. 系统挂起进程,读取异常号并且跳转到相应的异常处理函数;
  4. 异常处理函数首先查看异常是否可以恢复。如果无法恢复异常,异常处理函数向进程发送信号,发送的信号根据异常类型而定,比如内存保护异常 GPF 相对应的信号是 SIGSEGV,而除零异常 DEE 相对应的信号是 SIGFPE;
  5. 异常处理函数调用内核函数 issig() 和 psig() 来接收和处理信号。内核函数 psig() 执行默认信号处理程序,终止进程运行;
  6. 进程异常退出。

信号是进程异常退出的直接原因

归根揭底,信号都是导致进程异常退出的直接原因。第一类情况是进程非法操作触发处理器异常,然后异常处理函数在内核态向进程发送信号,这种情况下发送的信号是同步信号,信号的到来与进程的运行是同步的;第一类情况是因为外部环境向进程发送信号,这种情况下发送的信号是异步信号,信号的到来与进程的运行是异步的。这两种情况都有信号产生,并且最终都是信号处理程序终止进程运行。它们的区别是信号产生的信号源不同,前者是外部信号源产生异步信号,后者是进程自身作为信号源产生同步信号。

判断子进程退出状态

  1. 首先通过 pid=wait(&status) 保存子进程退出状态
  2. 通过如下几个宏定义判断子进程的退出状态: |状态|判断宏|取值宏|说明| |----|----|----|----| |进程正常结束|WIFEXITED(status)|WEXITSTATUS(status)|如果进程通过调用 exit() 或者 _exit() 退出,那么 WIFEXITED(status) 返回 true,WEXITSTATUS(status) 返回调用的值| |进程异常终止|WIFSIGNALED(status)|WTERMSIG(status)|如果进程是被信号终止而退出,那么 WIFSIGNALED(status) 返回 true,WTERMSIG(status) 返回对应信号的值| |进程暂停|WIFSTOPPED(status)|WSTOPSIG(status)|如果进程被暂停,那么 WIFSTOPPED(status) 返回 true,WSTOPSIG(status) 返回对应信号的值|
  3. 如果子进程是通过 exit 退出,那么我们需判断对应的状态是否是 EXIT_FAILURE,如果是则启动故障恢复;如果子进程是因为信号而退出(子进程中已经设置了对 SIGINT 等信号的处理函数,它们都会使得进程平稳地退出,如果因为信号退出,就必然是收到其他信号),那么启动故障恢复。

故障恢复进程

passthrough 实现过程中,每次 lookup 都会为一个新的 lo_inode 分配内存,由于分配内存的过程是在子进程中进行的,因此负责故障恢复的父进程看不到这个分配的内存。现在设想如下一种情况,用户想要调用 cat 打印一个文件的内容,于是用户会先产生 lookup 的 VFS 系统调用,lookup 的调用结果会在子进程中分配一个 lo_inode 数据结构,然后根据 lookup 的返回结果对相应的文件inode号,随后用户通过 open 打开对应的 inode 号返回对应的文件描述符,最后调用 read 对相应的文件描述符进行读取。如果在读取的过程中工作进程发生故障,于是启动故障修复,在父进程中重新 fork 一个子进程,但是此时子进程中没有之前分配的 lo_inode 这个数据结构,也没有之前打开的文件描述符,因此用之前返回的文件描述符重新执行 read 会提示输入输出错误。

  1. 一种方法是使用一个线程执行文件系统工作,另外创建一个线程执行故障恢复工作。线程之间共享内存数据以及打开的文件描述符,但是如果工作线程崩溃会导致整个进程崩溃,随后故障恢复线程也会退出,因此这种方案是不可行的。
  2. 另一种方法是创建两个进程,一个工作进程,一个故障恢复进程。然后我们将需要共享的数据通过共享内存来保存,然后工作进程打开的文件描述符通过 sendmsg 传递给故障恢复进程。

共享内存

这里考虑两种实现方式:

  1. 基于传统的 System V 的共享内存(sys/shm.h),这会在物理内存中划分一块内存用于进程间共享;
  2. 基于 POSIX mmap 文件映射实现共享内存,可以在磁盘上划分一块区域用于进程间的共享,对内存的操作实际上是对磁盘上文件的操作。这种实现方式在效率上可能不如第一种,但是它可以划分的区域比第一种更大。

其他

另外 se->conn 信息在 do_init() 完成之后被设置,因此需要使用一个共享内存结构体 fuse_session,来保存在 INIT 完成之后协商的信息,随后在故障恢复时要重新设置这部分的信息。