深入理解Lustre文件系统-第3篇 lustre lite

来源:互联网 发布:mac子弹头最火的色号 编辑:程序博客网 时间:2024/05/16 02:16

在这一节,我们将描述Lustre Lite怎样接入Linux VFS并与之融为一体,这对于支持VFS语义和POSIX接口非常必要。作为概要,Lustre Lite以方法表(method table)的形式提供了如下函数:

  • Lustre特有的文件操作,通过ll_file_operations表。
  • Lustre特有的dentry操作,通过ll_d_ops和它的cache。
  • Lustre特有的directory操作,通过ll_dir_operations表。
  • Lustre特有的索引节点操作,通过ll_dir_inode_operations和file_inode_operations表。
  • Lustre特有的文件映射操作,通过ll_file_vm_ops表。
  • 和其他的,诸如Lustre超级块操作、地址空间等。

3.1    VFS连接

图3给出了不同的数据结构怎样连接起来的整体视图。一些重要的事情将在下面解释。

一个进程维护了一个相关开启文件(associated open files)列表。每个打开的文件中都以一个在内存中的称为file的对象作为代表。它存储了在打开的文件和进程之间进行交互所需要的信息。对用户(To the userland),它以文件句柄fd的形式给出。这个数据结构包含一个字段(field),f_op,它类似于电话总机或者指向方法列表的指针,给出了每个文件系统各自特定的函数。这样,一个使用系统调用sys_read()的文件读操作就变为:

file->f_op->read(...);


3.1.1   Dentry对象

在file结构体中定义的另外一个字段是f_dentry,它指向一个存储在dentry cache(即所谓dcache)中的dentry对象(struct dentry)。实质上,VFS在文件和文件夹将被首次访问的时候就会创建一个dentry对象。如果这是一个不存在的文件/文件夹,那么将会创建一个无效的dentry。例如,对于如下路径名:home/bob/research08;它由四个路径部件构成:/,home,bob和research08。相应的,路径查找(lookup)将创建四个dentry,每个部件一个。每个dentry对象通过字段 d_inode指定的索引节点与各自对应的部件相联系。

索引节点对象以一个唯一的索引节点号作为标识,存储了一个特定文件的信息。ULK3为索引节点结构体提供全面的字段定义列表。非常重要的是,第一,i_sb指向了VFS超级块,第二,i_op是一个诸如如下索引节点操作的switchboard:

create(dir,dentry, mode, nameidata)

mkdir(dir, dentry,mode)

lookup(dir,dentry,nameidata)

...

第一个方法为一个常规文件创建了新的磁盘索引节点,该索引节点与某个文件夹中的dentry对象相关联。第二个方法为文件夹创建了新索引节点,该索引节点与某个文件夹下的某个dentry对象相关联。而第三个方法在一个文件夹中搜索一个索引节点,该索引节点与一个dentry对象中包含的文件名相关联。

4.1.2   Lustre超级块

VFS层定义了一个一般性的超级块对象(struct super_block),它存储了所挂载(mount)的文件系统的信息。特定的字段 s_fs_info指向属于特定文件系统的超级块信息。特别地,在Lustre中,这种文件系统特有的信息以结构体 lustre_sb_info的形式表现出来,其中存储了挂载和卸载文件系统所需要的信息。它进一步地连接到另外一个结构体ll_sb_info,其中包含了更多Lustre Lite特有的关于文件系统状态的信息,这些信息只为客户端准备。

Lustre特有的超级块操作定义在结构体变量lustre_super_operations中。初始化正确的超级块操作,是在我们在函数client_common_fill_super()中最初建立内存中超级块数据结构时进行的:

sb->s_op =&lustre_super_operations;

值得指出的是,当创建Lustre文件时,alloc_inode()超级块方法由ll_alloc_inode()函数实现。它创建了一个Lustre特有的索引节点对象ll_inode_info,并且返回嵌入其中的VFS 索引节点。请注意这里一般性的VFS 索引节点和Lustre 索引节点交互方法的特别之处:这种方法创建并且用包含Lustre文件系统所需的额外状态信息来填充VFS 索引节点结构体,但是仅返回嵌入在lli之中的VFS 索引节点结构体&lli->lli_vfs_inode。

static structinode **ll_alloc_inode(struct super_block *sb)

{

structll_inode_info *lli;

...

return&lli->lli_vfs_inode;

}

为了从子结构体中找回父结构体,Lustre定义了一个帮助方法ll_i2info(),它实质上以如下方式调用了内核宏container_of:

/* parameters ofmacro: ptr, type, memeber */

returncontainer_of(inode, struct ll_inode_info, lli_vfs_inode);

3.1.3   Lustre 索引节点

初始化正确的索引节点和文件/文件夹操作是在ll_read_inode2()期间索引节点被填充的时候进行的。为此,定义了四个结构体变量。其中两个是为索引节点操作定义的:ll_file_inode_operations和ll_dir_inode_operations。另外两个是为文件/文件夹操作定义的ll_file_operations和ll_dir_operations。对每组来说,文件和文件夹都有各自的集合,而到底指定哪个是由索引节点或者文件本身决定的。如下所示,给出了一个文件操作的定义实例:

/* in dir.c */

structfile_operations ll_dir_operations = {

       .open = ll_file_open,

       .release = ll_file_release,

       .read = generic_read_dir,

       .readdir = ll_readdir,

       .ioctl = ll_dir_ioctl, ...

};

/* in file.c */

structfile_operations ll_file_operations = {

       .read = ll_file_read,

       .write = ll_file_write,

       .open = ll_file_open, ...

例如,如果将被创建的是一个文件,那么i_op将被指定为ll_file_operations;而如果将被创建的是一个文件夹,那么i_op将被指定为ll_dir_operations:

if(S_ISREG(inode->i_mode)) {

       inode->i_op =&ll_file_inode_operations;

       inode->i_fop = sbi->ll_fop;

       ...

} else if(S_ISDIR(inode->i_mode)) {

       inode->i_op =&ll_dir_inode_operations;

       inode->i_fop = &ll_dir_operations;

       ...

一个一般性的观察是:指向方法表的指针,是由创建它的新实例的当事人或者函数适当地初始化并建立起来的。

3.2    路径查找

路径查找是相对来说较为复杂的任务,而又是最重要和常用的任务之一。Linux VFS承担了大部分的重担;而我们想强调之处是Lustre做特殊操作的联结点。在这一节,我们追寻源码主线,以足够的细节总结了基本步骤,但是也跳过了许多在实际源码中所需要注意的分支,例如:

  • 包含.和..的路径名,
  • 包含软链接的路径名,软链接可能导致循环引用,
  • 访问权限和许可检查,
  • 包含另一个文件系统的挂载点的路径名,
  • 包含不再存在的文件的路径名,
  • LOOKUP_PARENT标识设置,
  • 不包含末尾斜杠(trailing slash)的路径名。

查找可能由sys_open()调用引发,由此引发的惯常的调用路径是执行filp_open()和open_namei()。就是这个函数最后初始化了path_lookup()的调用(?)。特别的,如果一个文件以O_CREAT标志作为访问模式的参数打开,那这个查找操作将设置LOOKUP_PARENT、LOOKUP_OPEN和LOOKUP_CREATE。最后的路径查找结果是如下之一:

  • 如果已存在,返回最后一个路径部件的dentry对象,
  • 如果不存在,按照创建新文件时的情形,返回倒数第二个路径部件的dentry对象。从这里,你可以通过调用父索引节点的创建方法,分配一个新的磁盘索引节点。

现在,我们来聚焦于路径查找的特别之处。如果路径以/开头,那么这是一个绝对路径:搜索以在current->fs->root中的进程的根文件目录作为开始。否则,搜索以current->fs->pwd作为开始。此时,我们也知道开始文件夹的dentry对象及其索引节点(查看Figure3,以弄清为什么)。nameidata使用字段dentry和mnt来追踪解析好的上一个路径部件。初始时,它们被指定为开始文件夹。核心的查找操作由link_path_walk(name, nd)进行,其中name是路径名,nd是nameidata结构体的地址。

1. 对于待解析的下一个部件,从它的名字中计算出32比特的哈希值,以供在dentry cache哈希表中查询使用。

2. 在nd->flags中设置LOOKUP_CONTINUE,标识仍有更多的部件尚待解析。

3. 调用do_lookup(),在dentry对象中搜索路径部件。如果找到了(跳过重新证实),则这个路径部件已被解析,继续。如果没有找到,则调用real_lookup()。在循环的最后,本地dentry变量next的dentry和mnt 字段将分别指向dentry对象和已挂载的文件系统对象,而这里的dentry对象和文件系统对象正是我们所尝试解析的路径部件的。

4. 如果上述,do_lookup()解析到了路径名的最后一个部件,而就像我们开始所假设的,假如这不是一个符号链接,那么我们就达到目的地了。我们所需要做的就是将dentry对象和mnt信息存储在传递过来的nameidata参数nd中,并无错返回。

nd->dentry =next->dentry;

nd->mnt =next->mnt;

Lustre特有的操作由real_lookup()函数处理。一个dentry由VFS创建并且传递到文件系统特有的查找函数中。Lustre查找函数的责任是定位或者创建对应的索引节点,并以正确信息对之赋值。如果不能找到索引节点,那么dentry仍然存在,只是它的索引节点指针指向NULL。这种dentry被称为无效的(negative),意即没有这个名字的文件。这种转换(switching)的代码段如下所示:

struct dentry*result;

struct inode *dir= parent->d_inode;

...

result =d_lookup(parent, name);

if (!result) {

       struct dentry *dentry = d_alloc(parent,name, nd);

       if (dentry) {

       result =dir->i_op->lookup(dir,dentry,nd);

       ...

}

现在,查找开始在Lustre端进行,为得到更多的信息,接下来的操作可能需要涉及到与MDS进行的通信。

同时还存在一个cached_lookup路径,它调用Lustre提供的->revalidate方法。这种方法证实缓存的dentry/索引节点仍然有效,无需再从服务端更新。

3.3    I/O路径

这一节将探讨Lustre所遵循的I/O路径:异步I/O、组I/O、和直接I/O。接着我们将探讨Lustre怎样在将I/O的控制权已交给VFS(大多数情况下)的情况下和VFS进行交互,其中VFS做了更多的准备工作,然后以一页一页的方式(page-by-pagebasis)通过地址空间方法(addressspace methods)读写数据,而其中的地址空间方法则由Lustre提供。所以,试着想像如下过程:VFS挂钩(hooks)到llite,然后llite调用VFS帮助处理,随后VFS将控制权交还llite——一个进进出出、相互纠缠的过程。

3.3.1   异步I/O

这也许是Lustre中最为曲折(traveled)的I/O路径,我们将自顶向下地描述一个写过程。读操作与之非常类似。在VFS中注册写操作,已经在2.1节中谈及。

1. writev()是为旧内核提供的,较新的内核使用aio_write()。我们以ll_file_write()作为分析的切入点。这个函数定义了一个iovec,其中base指向用户空间中提供的缓存,而length是要写入的字符数。

struct ioveclocal_iov = { .iov_base = (void __user *) buf,

.iov_len = count };

这里初始化的另一个结构体是kiocb,它记录了I/O的状态。传入vec和kiocb参数后,它根据内核版本的不同调用ll_file_writev()或者ll_file_aio_write()。在我这里,我们追寻后者。从源码角度看(codewise?),两个函数的实现是相同的,只是原型定义稍微有所不同:

#ifdefHAVE_FILE_WRITEV

static sszie_tll_file_writev(

       struct file *file,

       const struct iovect *iov,

       unsigned long nr_segs,

       loff_t *ppos) {

#else /*AIO stuff*/

static ssize_t ll_file_aio_write(

       struct kiocb *iocb,

       const struct iovec *iov,

       unsigned long nr_segs,

       loff_t pos)

{

       struct file *file = iocb->ki_filp;

       loff_t *ppos = &iocb->ki_pos;

#endif

2. 理解ll_file_aio_write()函数的关键是Lustre根据分条大小,将写分割为多个块,然后在一个循环中请求对每个块上锁。这种方法的目的在于避免需要对大extent上锁时的复杂性。虽然我们之前提到过LOV是处理分条信息的层,但是LustreLite对点认识得也非常清楚,正如这种情况所表现出来的一样——你可以看到它们合作得多么紧密。分条信息通过如下方法得到:

structlov_stripe_md *lsm = ll_l2inof(inode)->lli_smd;

Lustre通过创建一个原始iov控制结构体的复本iov_copy,控制了每次写入的大小,然后回过头来,请求内核中常用的惯例来驱动写入:

retval =generic_file_aio_write(iocb, iov_copy, nrsegs_copy, *pppos);

我们在*iov中记录需要写入的字节数,并用count来跟踪尚待写入的字节数。这个过程一直重复,直至出现错误,或者所有字节都已写入。

在我们进行一般的写入惯例之前,在通过ll_file_get_tree_lock_lov()来获得锁的基础上,还有一些这个函数需要处理的边角:

l  如果用户应用以O_LOV_DELAY_CREATE标志打开文件,但是未调用ioctl来创建文件,就开始写入,那么我们就必须使这个调用失败。

l  如果用户应用以O_APPEND标志打开文件,那么我们需要获取对整个上下文上的锁:lock_end需要设置OBD_OBJECT_EOF标志,或者设置为-1来标识文件的结尾。

3. generic_file_aio_write()只是__generic_file_aio_write_nolock()函数的一个封装;两个函数都在ULK3中有所描述,而我们将在此对之进行详述。由于本节不覆盖直接I/O的内容,写流程进入到generic_file_buffered_write()函数。在此期间,写入被进一步分割成页,并为之分配了页高速缓存。执行上述工作的主要内容,由如下所示的循环完成:

do {

       ...

       status = a_ops->prepare_write(file,page, offset, offset+bytes);

       ...

       copied = filemap_copy_from_user(page,offset, buf, bytes);

       ...

       status = a_ops->commit_write(file,page, offset, offset+bytes);

       ...

} while (count);

首先,我们通过使用Lustre注册的方法来准备页写入。这种准备工作包括检查初始位置是否是在开始处、检查是否需要先从磁盘中读入(当然,在Lustre中没有本地磁盘,但是VFS却是这样认为的)。然后它从用户空间中复制有价值的信息到内核中。最后,我们再次掉用Lustre特有的方法来将页写入。这样,控制就两次流入和流出Lustre代码。

这里存在一些关于页和界限管理的有趣的要点。假设需要写入的的逻辑文件位置是8193,而页大小是4KB。由于这是一个基于页的写入(写入准备和写入执行都是基于页的),它首先计算页索引(2)和页偏移量(1),而bytes是在该页中所能写入的最大字节数。然而,如果剩下的count数比bytes小,我们需要将bytes调整为所需要写入的确切的字节数。

index = pos>> PAGE_CACHE_SHIFT;

offset = (pos& (PAGE_CACHE_SIZE -1));

bytes =PAGE_CACHE_SIZE - offset;

bytes = min(bytes,count);

计算所得的页索引将被用来在页高速缓存中定位或者分配页,然后按如下方式与文件映射相关联:

structaddress_space *mapping = file->f_mapping;

page =__grab_cache_page(mapping, index, &cached_page, &lru_pvec);

稍微离题:__grab_cache_page()是一个在一般的文件写请求时使用的帮助函数。这个函数的基本流程是首先检查这个页是否存在于页高速缓存中,如果存在则返回。否则它将通过调用page_cache_alloc()来分配一个新页,并将之加入到页高速缓存中(add_to_page_cache())。然而,在上一次检查到现在的时间段内,另外一个线程可能分配了一个页,所以加入页高速缓存可能会失败。在这种情况下,我们需要再次检查并从页高速缓存中找到该页:

repeat:

       page = find_lock_page(mapping, index);

       if (!page) {

              ... page_cache_alloc(mapping);

              err = add_to_page_cache(*cachedpage, mapping, index, GFP_KERNEL);

       if (err == -EEXIST)

              goto repeat;

这里以沾污代码的代价进行了一个优化:如果像如上所说的,不能将已分配的页加入到页高速缓存中,则将它保留在cached_page中,而不是将它返回,这样下次再请求一个新页时,我们不需要再次调用分配函数。

4. 现在我们进入到准备写阶段。它按如下描述:

intll_prepare_write(struct file *file, struct page *page,

unsigned from, unsigned to)

以设置为offset的from,设置为offset + bytes的to作为参数,ll_prepare_write()被调用。这就是我们在上面提到的分界问题。总的来说。这个方法是用来保证页已经更新了,如果这不是整页的写,那么首先将读入特定的页。

这里使用了一些新的结构体,它们的含义如下:

结构体obdo是用来以线上(on thewire)的形式表示Lustre对象及其相关信息进行。

结构体brw_page用来描述待发送的页的状态。

结构体obd_info用来在Lustre各层间传输参数。

在这个方法中,我们还需要为部分页写(partial page write)之类的情况检查文件末尾(EOF):如果EOF处于页内(写超出了EOF),我们需要将未写入的部分填充为0;否则,我们需要预读它。

5. 接着,LOV通过lov_prep_async_page()初试化准备页。

各层定义了页写入需要经历的三个结构体。在Lustre Lite层,是ll_async_page(LLAP)。LOV则定义了lov_async_page(LAP)和osc_async_page(OAP)。

6. 如果写操作是异步的,它将由osc_queue_async_io()处理。为了确定我们不会超过所允许的脏页数,阻塞在试图增加额外的脏页的企图上,osc_queue_async_io()内部调用osc_enter_cache()函数进行清算。以类似的方式,它也强制实行grant。

这样,在(至少)有两种方式下,OAP将不能进入高速缓存。

  • 如果高速缓存的大小小于32MB,那么就O.K.了,将OAP放入高速缓存,返回0,否则返回错误码。
  • 如果客户端的grant不够,那么同样返回错误码。在每个OSC初试化时,我们可以假设OST服务器端为客户端预留(grant)一些空间。grant是服务器向客户端保证的能写入的数据量,客户端确信服务器能够处理这么多数据。grant的初始值是2MB,这是相当于两个RPC大小的数据。随后写请求将由客户端发起,如果需要,它能请求更多的grant。同时,在每个数据传输或者写入时,客户端需要跟踪这个grant,确保不会超额。

如果页不能进入缓冲,将会稍后重试组I/O或者异步I/O。

7. 在OAP高速缓存检查完毕之后,将调用loi_list_maint()来将OAP放在合适的链表里,并使之准备好读或写操作。

8. 函数osc_check_rpcs()对每个lov_oinfo中的对象创建RPC。注意,每个RPC只能携带一个数据对象的内容。

3.3.2   组I/O(或者同步I/O)

组I/O是当OAP不能被成功放入高速缓存中时激发的,这种不成功最有可能的情况是grant不够。在这种情形下,Lustre Lite将创建一个obd_to_group结构体来存储OAP。该页将会设置好URGENT标识,加入客户端的obd就绪列表。最后,将调用oig_wait()来等待组I/O的完成。

注意,组I/O会等待操作的完成,所以它又被称为同步I/O。其次,所有的组I/O都是urgent I/O,又同时是读操作。相比之下,在异步I/O中,OAP高速缓存则(当提交时)进入写列表

同样值得指出的是,除了直接I/O和无锁I/O,所有的读都以组I/O的形式执行。直接I/O将在下面简要介绍。无锁I/O是一种类型特别的I/O(现在不可用了,除非在liblustre中),在无锁I/O中,客户端不申请任何的锁,而是通知服务器根据客户端的行为处理锁。

3.3.3   直接I/O

对于直接I/O,VFS传递一个参数iovec用来描述所传输的数据段。这个参数由一个开始地址void * iobase和大小iov_len简单的进行定义。直接I/O要求开始地址必须是page-aligned。

Lustre Lite调用ll_direct_IO_26()和obd_brw_async()函数处理每个页。实质上,这将转化为调用osc_brw_async(),稍后它将被用于创建RPC请求。

3.3.4   与VFS的接口

不论是一般的,在VFS的上层,还是特殊的,在LustreLite里,有几个地方可以注册你自己的读写方法,所有这些都是通过地址空间操作结构体提供的。对于读操作,是readpage()及其向量形式的readpages()。对于写,事情稍微有点复杂。第一个入口点是writepage(),可能由如下事件触发:

l 当内存压力超出阈值,VM将触发脏页的刷新。

l 当用户应用调用fsync()强制执行脏页的刷新。

l 当内核线程周期性地刷新脏页。

l 当Lustre锁管理器撤销页的锁(块请求),脏页应当刷新。

readpage()和writepage()是否实现是可选的,并不是每个文件系统都支持它们。但是利用内核中默认的读写方式(即do_generic_file_read()和do_generic_file_write()的一般化实现)需要者两个方法。这简化了和内核高速缓存和VM的配合。同时,提供mmap的支持同样需要这两个方法。

同样,文件系统也以地址空间对象的形式注册prepare_write()和commit_write()函数。因此,地址空间对象可以描绘为连接文件空间和存储空间的桥梁。

Lustre的地址空间操作定义在lustre/llite/rw26.c中:

structaddress_space_operations ll_aops = {

      .readpage = ll_readpage,

      .direct_IO = ll_direct_IO_26,

      .writepage = ll_writepage_26,

      .set_page_dirty =__set_page_dirty_nobuffers,

      .sync_page = NULL,

      .prepare_write = ll_prepare_write,

      .commit_write = ll_commit_write,

      .invalidatepage = ll_invalidatepage,

      .releasepage = ll_releasepage,

      .bmap = NULL

};

在file对象中有一个*f_mapping指向文件对应的地址空间对象。这种联系是在文件对象创建的时候建立的。


3.4     预读

Lustre客户端的预读发生在页读取的情况下,由结构体ll_readahead_state控制。该结构体定义在lustre/llite/llite_internal.h中。这个结构体每个文件一个,包含了以下信息:

l 读历史。1. 发生过多少次连续读。2. 如果是跳(stride)读,那么发生过多少次连续跳读。3. 跳读间隔和跳读长度。

l 预读窗口(即预读窗口起点和终点)。读操作发生愈连续,预读窗口生长地愈长。

l 帮助监测预读模式的状态信息。

每个客户端最大可能预读40MB,预读算法如下所示:

1. 在页读取中(ll_readpage)文件的预读状态要根据当前状况进行跟新:

a) 如果页偏移量处于一个连续的窗口中(在上一次页的+8、-8窗口中),那么ras_consecutive_requests(由ll_readahead_state定义)将递增1。如果这是读的第一页,那么预读窗口将增加1MB的长度。所以如果读是连续的,那么预读窗口将与读操作的增加一起增长。

b) 如果页不处于连续的窗口中,那么将判断是否处于跳读模式。如果是,将现在的长度/步幅间隔同过去历史中的相对比。如果它们相等,那么ras_consecutive_stride_requests将加1。如果这是读的第一页,那么预读窗口也将增加1MB的长度。

c) 如果页既不处于连续的窗口中,又不处于连续跳跃窗口中,那么所有的预读状态将被重置。例如ras_consecutive_pages和ras_consecutive_requests将重置为0。

       2. 接着,页的读取将根据上一步跟新的状态进行实际的预读。

a) 增加预读的窗口,尝试覆盖此次读中所有的页。

b) 根据实际文件长度调整预读窗口,计算此次预读中将读入多少页。

c) 实际执行预读。

在proc中有一个可以查看预读的状态的预读状态文件(/proc/fs/lustre/llite/XXXXX/read_ahead_stats)。

本文章欢迎转载,请保留原始博客链接http://blog.csdn.net/fsdev/article
原创粉丝点击