Linux删除⽂件过程解析
⽬录
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();
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()-->ext
4_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个字节:
linux删除子目录命令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⾥做的两件事情分别是:
1. 调⽤dquot_free_space_nodirty,该函数内联展开为__dquot_free_space并最终调⽤inode_sub_bytes更新inode的两个成员i_blocks和
i_bytes。
2. 调⽤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函数中做了三件事:
1. ext4_journal_start判断⽇志执⾏状态并调⽤jbd2__journal_start来启⽤⽇志handle;
2. ext4_mark_inode_dirty会调⽤ext4_get_inode_loc函数来根据指定的inode获取inode在磁盘和内存中的位置,然后调⽤
jbd2_journal_get_write_access获取写⽇志的权限,接着对inode在磁盘上对应的raw_inode进⾏更新,最后调⽤
jbd2_journal_dirty_metadata设置元数据为脏并添加到⽇志transaction的对应链表中;
3. 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。
3. rm对I/O影响
实际上,evict调⽤链上有诸多地⽅都包含设置元数据为脏并更新⽇志这个操作(ext4_handle_dirty_metadata),例如在ext4_free_blocks中还会对存放块位图(block bitmap)的block以
及存放块组描述信息(group descriptor)的block进⾏元数据的dirty操作。由此可知,要删除的⽂件越⼤,涉及到的⽇志更新操作就越频繁,所以直接rm⼀个⼤⽂件时,⼤量的⽇志更新操作将会影响到其他进程的I/O性能。如果其他进程是I/O 密集型的程序,以为例,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]
[2]
[3]
版权声明:本站内容均来自互联网,仅供演示用,请勿用于商业和其他非法用途。如果侵犯了您的权益请与我们联系QQ:729038198,我们将在24小时内删除。
发表评论