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\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密集型的程序,以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