Linux删除文件过程解析
1. 概述
当我们执行rm命令删除一个文件的时候,在操作系统底层究竟会发生些什么事情呢,带着这个疑问,我们在Linux-3.10.104内核下对ext4文件系统下的rm操作进行分析。rm命令本身比较简单,但其在内核底层涉及到VFS操作、ext4块管理以及日志管理等诸多细节。
2. 源码分析
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
", 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。
3. 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负载在一段时间内突然增高的现象。
参考文献
[1] https://www.ibm.com/developerworks/cn/linux/l-cn-usagecounter/
[3] http://blog.csdn.net/luckyapple1028/article/details/61413724