阿里云幸运券

  1. 概述

当我们执行rm命令删除一个文件的时候,在操作系统底层究竟会发生些什么事情呢,带着这个疑问,我们在Linux-3.10.104内核下对ext4文件系统下的rm操作进行分析。rm命令本身比较简单,但其在内核底层涉及到VFS操作、ext4块管理以及日志管理等诸多细节。

  1. 源码分析

rm命令是GNU coreutils里的一个命令,在对一个文件进行删除时,它实际上调用了Linux的unlink系统调用,unlink系统调用在内核中的定义如下:

SYSCALL_DEFINE1(unlink, const char __user *, pathname)
{
return do_unlinkat(AT_FDCWD, pathname);
}
do_unlinkat的第一个参数 fd值为AT_FDCWD时,pathname就是相对路径,用于删除当前工作目录下的文件,若pathname为绝对路径,则fd直接被忽略。接下来我们将会对do_unlinkat中一些重点函数做以详细分析。

static long do_unlinkat(int dfd, const char __user *pathname)
{
int error;
struct filename *name;
struct dentry *dentry;
struct nameidata nd;
struct inode *inode = NULL;
unsigned int lookup_flags = 0;
retry:
name = user_path_parent(dfd, pathname, &nd, lookup_flags);
if (IS_ERR(name))
return PTR_ERR(name);

error = -EISDIR;
if (nd.last_type != LAST_NORM)
    goto exit1;

nd.flags &= ~LOOKUP_PARENT;
error = mnt_want_write(nd.path.mnt);
if (error)
    goto exit1;

mutex_lock_nested(&nd.path.dentry->d_inode->i_mutex, I_MUTEX_PARENT);
dentry = lookup_hash(&nd);
error = PTR_ERR(dentry); 
 if (!IS_ERR(dentry)) {
     /* Why not before? Because we want correct error value */ 
     if (nd.last.name[nd.last.len])
         goto slashes;
     inode = dentry->d_inode;
     if (!inode)
         goto slashes;
     ihold(inode);
     error = security_path_unlink(&nd.path, dentry);
     if (error)
         goto exit2;
     error = vfs_unlink(nd.path.dentry->d_inode, dentry);

exit2:
dput(dentry);
}
mutex_unlock(&nd.path.dentry->d_inode->i_mutex);
if (inode)
iput(inode); /* truncate the inode here */

}
为了便于理解,这里简要介绍一下索引节点inode、目录项dentry以及目录项缓存dcache这几个重要概念,更具体的内容可参考Linux内核分析的相关书籍,如Robert Love的《Linux内核设计与实现》一书。

inode包含了与文件本身相关的信息,如文件大小、访问权限、MAC time等。
dentry(directory entry)包含了文件管理与组织的的信息,一方面它建立了文件名到inode的映射关系(有硬链接时为多对一关系),另一方面依靠其d_parent和d_child成员形成文件系统的目录树结构。
dcache是为了加快VFS查找文件或目录建立的,如果内核每次查找文件都要逐层遍历目录,那么将会浪费很多时间,这时候如果在访问dentry时将其缓存起来,那么此后访问就会快很多。
回到正题,首先在lookup_hash函数中,我们根据文件路径从目录项缓存dcache中查找对应的目录项dentry,然后根据dentry->d_inode找到对应的inode。
腾讯云代金券

接下来调用vfs_unlink函数,这个函数实际干了两件事,一是调用inode->i_op->unlink,i_op是inode数据结构中定义的inode_operations类型的成员,它描述了VFS操作inode的所有方法,在这个结构体中定义了一组函数指针,所以在ext4文件系统中,inode->i_op->unlink实际上调用了ext4_unlink这一函数。vfs_unlink干的另一件事是调用d_delete,这一函数的作用是当目录项的引用计数变为0即没有进程在使用该目录项时,将目录项从dcache中删除。
再往下走,dput函数将dentry->d_count引用计数减1,如果不为0,则直接返回;否则接着判断dentry是否从dcache的哈希链上删除,如果是,则可以释放dentry对应的inode;如果不是,则表明dentry对应的inode没有被释放,此时可以将该dentry加入到detry_unused这一LRU队列中。(注:dput函数以及vfs_unlink这两个函数涉及到的操作较为繁杂,本文没有详细展开,具体内容可参考dentry inode引用计数)。

接下来的iput函数作用就是就是释放inode,其调用路径为:

iput()–>iput_final()–>generic_drop_inode()
|–>inode_lru_list_del()
|–>evict()
generic_drop_inode函数中,通过inode->i_nlink硬链接计数的值来判断inode是否可以被删除。inode_lru_list_del将inode从LRU链表中删除,而evict则是真正地释放inode的操作,其调用路径为:

evict()–>ext4_evict_inode()–>ext4_truncate()–>ext4_ext_truncate()–>ext4_ext_remove_space()–>ext4_ext_rm_leaf()–>ext4_free_blocks()
(注:本文对一些函数的调用路径没有全部展开,只对一些关键路径加以描述,要想获得内核调用链上的全部信息,推荐使用Brendangregg开源的perf-tool中的funcgraph工具)

我们可以看到ext4_ext_truncate、ext4_ext4_remove_space以及ext4_ext_rm_leaf这三个函数名中间都有一个ext,其实就是extent,也就是说这三个函数都是操作extent的函数,而真正释放块是在ext4_free_blocks中。

EXT4文件系统相比于EXT2、EXT3等文件系统的一个最大的区别就是,EXT4采用extent而非间接块指针(indirect block pointer)来管理磁盘块。EXT4的inode大小为256字节,40-99这60个字节在EXT2、EXT3文件系统中用来保存间接块指针(12个直接指针和3个间接指针),而现在用来保存extent信息,其中40-51字节为extent头部信息,保存了魔数、extent个数以及深度等信息:

struct ext4_extent_header
{
__le16 eh_magic; /* probably will support different formats /
__le16 eh_entries; /
number of valid entries /
__le16 eh_max; /
capacity of store in entries /
__le16 eh_depth; /
has tree real underlying blocks? /
__le32 eh_generation; /
generation of the tree */
};
52~99这48个字节用来保存extent或者extent index信息,extent和extent index结构均为12个字节:

struct ext4_extent
{
__le32 ee_block;
__le16 ee_len;
__le16 ee_start_hi;
__le32 ee_start_lo;
};
struct ext4_extent_idx
{
__le32 ei_block;
__le32 ei_leaf_lo;
__le16 ei_leaf_hi;
__u16 ei_unused;
};
ext4_extent代表一组连续的块,ee_block为逻辑块号,ee_len代表extent中有多少个块,其最高位与ext4的预分配策略特性有关,所以一个extent最多能存2^15个块,由于物理块大小为4k,即可以存储128M的数据,ee_start_hi和ee_start_lo组成了物理块地址。

当文件较小时(<512M,即4个extent能存储的数据大小),完全可以用extent来保存块信息,但当文件较大时,就不得不借助ext4_extent_idx 这个中间结构来索引下一级结点,此时ext4_extent_header中保存的eh_depth就不为0了。ei_leaf_lo与ei_leaf_hi组合起来构成下一级节点的物理块号。所以对于大文件来说,通过inode找到index结点,进而找到叶子结点,最终通过叶子结点中存储的extent来找到实际的磁盘物理块。整个extent tree的结构如下图所示:

回到evict函数的调用路径上,ext4_ext_rm_leaf用来释放叶子结点中extent及其相关的物理块,start和end参数用来指定起始和终止的逻辑块号,例如start=1, end=4代表第一个到第四个extent。

static int
ext4_ext_rm_leaf(handle_t *handle, struct inode *inode,
struct ext4_ext_path *path,
ext4_fsblk_t *partial_cluster,
ext4_lblk_t start, ext4_lblk_t end)
实际的释放块操作是在ext4_free_blocks中,block参数指定了要释放的起始物理块号,count指定要释放的块数目。

void ext4_free_blocks(handle_t *handle,
struct inode *inode,
struct buffer_head *bh,
ext4_fsblk_t block,
unsigned long count,
int flags)
值得注意的是,释放块并不是真正地从介质中擦除数据,而是将这些块对应的位从块位图(block bitmap)中清除掉。另外,ext4_free_blocks需要更新quota(磁盘配额)信息。其调用路径为:

ext4_free_blocks()–>mb_clear_bits()
|–>dquot_free_block()–>dquot_free_space_nodirty()–>__dquot_free_space()–>inode_sub_bytes()
|–>mark_inode_dirty_sync()–>__mark_inode_dirty()
dquot_free_block里做的两件事情分别是:

调用dquot_free_space_nodirty,该函数内联展开为__dquot_free_space并最终调用inode_sub_bytes更新inode的两个成员i_blocks和i_bytes。
调用mark_inode_dirty_sync将inode标记为脏(因为第一步对inode做了修改),在ext4中执行该函数将会对日志进行更新。该函数内联展开为__mark_inode_dirty(inode, I_DIRTY_SYNC),I_DIRTY_SYNC表示要进行同步操作。
void __mark_inode_dirty(struct inode *inode, int flags)
{
struct super_block *sb = inode->i_sb;
struct backing_dev_info *bdi = NULL;
if (flags & (I_DIRTY_SYNC | I_DIRTY_DATASYNC)) {
trace_writeback_dirty_inode_start(inode, flags);
if (sb->s_op->dirty_inode)
sb->s_op->dirty_inode(inode, flags);
trace_writeback_dirty_inode(inode, flags);
}

如果设置了I_DIRTY_SYNC标志,则在__mark_inode_dirty函数中会通过函数指针dirty_inode调用文件系统特有的操作,在EXT4文件系统下,相应的函数为ext4_dirty_inode,该函数会启动一个日志原子操作将应该同步的inode元数据向jdb2日志模块提交。

void ext4_dirty_inode(struct inode *inode, int flags)
{
handle_t *handle;
handle = ext4_journal_start(inode, EXT4_HT_INODE, 2);
if (IS_ERR(handle))
goto out;
ext4_mark_inode_dirty(handle, inode);
ext4_journal_stop(handle);
out:
return;
}
ext4_dirty_inode函数中做了三件事:

ext4_journal_start判断日志执行状态并调用jbd2__journal_start来启用日志handle;
ext4_mark_inode_dirty会调用ext4_get_inode_loc函数来根据指定的inode获取inode在磁盘和内存中的位置,然后调用jbd2_journal_get_write_access获取写日志的权限,接着对inode在磁盘上对应的raw_inode进行更新,最后调用jbd2_journal_dirty_metadata设置元数据为脏并添加到日志transaction的对应链表中;
ext4_journal_stop用来结束此日志handle,这样的话日志的commit进程在被唤醒时将会对这个日志进行提交。
回到__mark_inode_dirty函数,该函数接下来会对inode的状态i_state添加flag标记,如上文所述,此处的flag为I_DIRTY_SYNC。

当inode尚未dirty时,还会进行如下操作:

 ...
 if (!was_dirty) {
     bool wakeup_bdi = false;          
     bdi = inode_to_bdi(inode);            
     if (bdi_cap_writeback_dirty(bdi)) {              
         WARN(!test_bit(BDI_registered, &bdi->state),                   
              "bdi-%s not registered\n", bdi->name);                
         if (!wb_has_dirty_io(&bdi->wb))                  
             wakeup_bdi = true;          
     }            
     spin_unlock(&inode->i_lock);          
     spin_lock(&bdi->wb.list_lock);          
     inode->dirtied_when = jiffies;          
     list_move(&inode->i_wb_list, &bdi->wb.b_dirty);          
     spin_unlock(&bdi->wb.list_lock);            
     if (wakeup_bdi)              
         bdi_wakeup_thread_delayed(bdi);          
     return;     
 }

当没有对回写进行限制(bdi_cap_writeback_dirty),且通过wb_has_dirty_io判断出inode对应的bdi没有正在处理的dirty io时(即dirty list, io list, more io list均为空),我们将wakeup_bdi设置为true。接下来设置inode的dirty时间,并将inode的i_wb_list移到bdi_writeback的dirty链表(wb.b_dirty)中。如果wakeup_bdi为真,则调用bdi_wakeup_thread_delayed将bdi添加到后台的回写队列中,回写队列中的dirty inode会被回写线程定期刷到磁盘,时间间隔由dirty_writeback_interval参数决定,默认为5s。

  1. rm对I/O影响

实际上,evict调用链上有诸多地方都包含设置元数据为脏并更新日志这个操作(ext4_handle_dirty_metadata),例如在ext4_free_blocks中还会对存放块位图(block bitmap)的block以及存放块组描述信息(group descriptor)的block进行元数据的dirty操作。由此可知,要删除的文件越大,涉及到的日志更新操作就越频繁,所以直接rm一个大文件时,大量的日志更新操作将会影响到其他进程的I/O性能。如果其他进程是I/O密集型的程序,以MySQL为例,rm大文件与之同时运行将会使得其QPS降低,响应时间也会增加。
为了验证这点,本文用Sysbench对MySQL进行压测,使用的设备为NVMe接口的SSD,实验分两组:

(1) 只运行Sysbench,测得的平均QPS为205500;

(2) 运行Sysbench的同时对一个400GB的大文件进行rm操作,测得的平均QPS为40485。

由此可见,在对大文件进行删除时,为了避免对其他I/O密集型应用的影响,不应该直接用rm对其删除,而应该采用其他方法。例如,每次将大文件truncate一部分并sleep一段时间,这样的话就可以将删除的I/O负载分散到每次truncate操作,不会出现I/O负载在一段时间内突然增高的现象。

腾讯云代金券

原文链接

https://cloud.tencent.com/developer/article/1143433

服务推荐

Logo

更多推荐