一次项目中,我们用的基于fuse的文件系统进程出core挂掉了,排查发现,这并不是个BUG,算是个缺陷吧。出core表现是这样的,libfuse从/dev/fuse中调用read读取内容,读返回成功了,但缓冲区只更新了部分内容,还有部分内容是旧的,导致libfuse执行了旧的命令,而我们实现的fuse文件系统,某些命令重复执行是会出core的,也幸亏会出core才能暴露这个bug,不然某些命令重复执行,造成数据丢失就玩大发了
当排查到是read系统调用有问题的时候,我是不愿意相信的,堂堂centos 3.10.0 的内核还会有这种bug嘛?当然,事实这不算是bug,只是一个缺陷,用户层在多线程里fork,并使用非对齐到页的内存时,就会触发这个缺陷。下面我们来探下究竟。
fuse的原理还是比较简单易懂的,在用户层有个设备名为/dev/fuse,用户层(libfuse)就是通过这个来和内核的fuse通信。当用户层读写fuse实现的文件系统的时候,内核就会产生一个请求挂到队列里等待libfuse的读取,而libfuse读/dev/fuse来读取请求并执行相应操作后写回/dev/fuse。问题就在这个/dev/fuse的读实现里。
/dev/fuse是个混杂设备,其的最终的读函数是fuse_dev_read,我们分析下它的代码:
static ssize_t fuse_dev_read(struct kiocb *iocb, const struct iovec *iov,
unsigned long nr_segs, loff_t pos)
{
struct fuse_copy_state cs;
struct file *file = iocb->ki_filp;
struct fuse_conn *fc = fuse_get_conn(file);
if (!fc)
return -EPERM;
fuse_copy_init(&cs, fc, 1, iov, nr_segs);
return fuse_dev_do_read(fc, file, &cs, iov_length(iov, nr_segs));
}
//fuse_copy_state 初始化, 这个结构体用于维护copy一个请求的状态。
static void fuse_copy_init(struct fuse_copy_state *cs, struct fuse_conn *fc,
int write,
const struct iovec *iov, unsigned long nr_segs)
{
memset(cs, 0, sizeof(*cs));
cs->fc = fc; //fc连接
cs->write = write; //读写方向,1为读
cs->iov = iov; //用户地址iov,一般一个
cs->nr_segs = nr_segs; //一般为1
}
fuse_copy_init初始化fuse_copy_state 这个结构体,这个结构体是维护fuse拷贝请求的,fuse_dev_do_read进行具体的读。
//读取一个请求到用户空间的缓存里,这个调用会阻塞。 当一个请求不需要回复(FORGET)或者被abort或发生
//错误,它会调用request_end来结束。其它情况下,这个请求被读取后会被挂载到processing 链表,然后设置 sent 标记
static ssize_t fuse_dev_do_read(struct fuse_conn *fc, struct file *file,
struct fuse_copy_state *cs, size_t nbytes)
{
int err;
struct fuse_req *req;
struct fuse_in *in;
unsigned reqsize;
restart:
spin_lock(&fc->lock);
err = -EAGAIN;
//nonblock
if ((file->f_flags & O_NONBLOCK) && fc->connected &&
!request_pending(fc))
goto err_unlock;
//等待队列有请求
request_wait(fc);
err = -ENODEV;
if (!fc->connected)
goto err_unlock;
err = -ERESTARTSYS;
if (!request_pending(fc))
goto err_unlock;
//如果有中断要发送到user space
if (!list_empty(&fc->interrupts)) {
req = list_entry(fc->interrupts.next, struct fuse_req,
intr_entry);
return fuse_read_interrupt(fc, cs, nbytes, req);
}
if (forget_pending(fc)) {
if (list_empty(&fc->pending) || fc->forget_batch-- > 0)
return fuse_read_forget(fc, cs, nbytes);
if (fc->forget_batch <= -8)
fc->forget_batch = 16;
}
//获取请求
req = list_entry(fc->pending.next, struct fuse_req, list);
req->state = FUSE_REQ_READING;
list_move(&req->list, &fc->io);
in = &req->in;
reqsize = in->h.len;
/* If request is too large, reply with an error and restart the read */
//如果read调用给的空间不足以装下这个request,则需要重启这个调用。
if (nbytes < reqsize) {
req->out.h.error = -EIO;
/* SETXATTR is special, since it may contain too large data */
if (in->h.opcode == FUSE_SETXATTR)
req->out.h.error = -E2BIG;
request_end(fc, req);
goto restart;
}
spin_unlock(&fc->lock);
cs->req = req;
//拷贝一个请求,fuse通过将用户内存映射到内核空间,然后再进行拷贝,为啥不使用copy_to_user呢?因为copy_to_user效率太低了。然而这样的方式就会引入一个隐患。
err = fuse_copy_one(cs, &in->h, sizeof(in->h));
if (!err)
err = fuse_copy_args(cs, in->numargs, in->argpages,
(struct fuse_arg *) in->args, 0);
fuse_copy_finish(cs);
spin_lock(&fc->lock);
req->locked = 0;
if (req->aborted) {
request_end(fc, req);
return -ENODEV;
}
if (err) {
req->out.h.error = -EIO;
request_end(fc, req);
return err;
}
if (!req->isreply)
request_end(fc, req);
else {
req->state = FUSE_REQ_SENT;
list_move_tail(&req->list, &fc->processing); //将这个请求移到processing链表
if (req->interrupted)
queue_interrupt(fc, req);
spin_unlock(&fc->lock);
}
return reqsize;
err_unlock:
spin_unlock(&fc->lock);
return err;
}
可以看到fuse_dev_do_read主要就是阻塞到有一个请求到来,然后将请求拷贝到用户提供的空间中,整体没啥大毛病,问题出在它传输数据到用户空间的方式。这里没有使用copy_to_user之类的函数,而是用一种比较特殊的方式。我们看下fuse拷贝数据是怎么做的
//重点分析这个函数
static int fuse_copy_one(struct fuse_copy_state *cs, void *val, unsigned size)
{
while (size) {
if (!cs->len) { //第一次进入这里时,cs->len=0,会执行下面的分支
int err = fuse_copy_fill(cs); //这里会获取用户缓存的页并映射到内核
if (err)
return err;
}
fuse_copy_do(cs, &val, &size); //这里就是尽全力去拷贝数据了
}
return 0;
}
/*
* Get another pagefull of userspace buffer, and map it to kernel
* address space, and lock request
*/
static int fuse_copy_fill(struct fuse_copy_state *cs)
{
unsigned long offset;
int err;
unlock_request(cs->fc, cs->req);
fuse_copy_finish(cs); //当fuse read /dev/fuse时,cs->currbuf和cs->mapaddr都是0,所以这里不会有什么动作
if (cs->pipebufs) { //这里和本文主题没啥关系,跳过
。。。
} else {
if (!cs->seglen) {
BUG_ON(!cs->nr_segs);
cs->seglen = cs->iov[0].iov_len;
cs->addr = (unsigned long) cs->iov[0].iov_base;
cs->iov++;
cs->nr_segs--;
}
//获取cs->addr所在的用户页,这个调用是无锁的
err = get_user_pages_fast(cs->addr, 1, cs->write, &cs->pg);
if (err < 0)
return err;
BUG_ON(err != 1);
//获取用户缓存在用户页的偏移
offset = cs->addr % PAGE_SIZE;
//映射到内核地址
cs->mapaddr = kmap(cs->pg);
//用户缓存映射到内核的地址
cs->buf = cs->mapaddr + offset;
cs->len = min(PAGE_SIZE - offset, cs->seglen);
cs->seglen -= cs->len; //做相关计数,用于下一次调用
cs->addr += cs->len;
}
return lock_request(cs->fc, cs->req);
}
可以看到,fuse是通过将用户缓存的页,映射到内核之后,再讲请求拷贝到该页,然后再解内核映射的。这是个比copy_to_user之类高效的方法,但是这会引入一个缺陷。总结来说是它会和copy on write这个机制一起触发一个bug。说到这了,各位看官能看出到底问题出在哪了吗?
问题在于,这里有两个通道去访问同一个页,一个是用户空间的映射,一个是内核的映射,这里并不是因为会产生竞争之类的造成写丢失,而是因为fork的cow机制,当进程处于cow状态,用户空间的映射进行写入是会产生页面复制、替换页表项的,而内核映射却感知不到这个变化。
我们考虑这样一个情景:
- fuse进程一个线程A申请了一块内存mem1,这个内存小于一个页,而且地址没有对齐到页。并把这个内存传递给read读取/dev/fuse的内容,内核映射了mem1所在的页,开始数据拷贝
- fuse进程的一个线程B申请了另一块内存mem2,这个内存和A所申请的内存在同一内存页里。然后存在某个线程调用了fork,此时这个进程处于cow状态,线程A、B申请的内存所在页也在cow状态。
- 线程B对mem2进行写访问,这时触发了cow,内核创建一个新页复制mem2所在的页内容,替换掉mem2所在的页
- fuse进程对/dev/fuse的读结束了,但数据所在的页到fork的新进程去了,mem1已经是新页了,还是老的内容
以上,就是我在fuse上发现的bug。是不是很神奇呢? 以前我知道的不能在多线程中进行fork,是因为除了调用fork的线程外新进程中的其他线程都蒸发了,这样可能会导致一些锁的问题,但没想到还有如此大一个坑在。除了fork,还有会调用fork的函数是不能在多线程用的,如popen、system之类的,我们这踩坑就是因为用system执行异步命令了,后来搞了个异步命令代理服务来执行异步命令。
悄咪咪告诉你,这个坑在direct io中也有~~稍后我会出篇文章分析direct io是怎样子的。