比ls快8倍?百萬級檔案遍歷的奇技淫巧

IT技術精選文摘發表於2018-07-09

640?wx_fmt=gif

1.問題背景

在Linux下當我們操作一個檔案數較少的目錄時,例如執行ls列出當前目錄下所有的檔案,這個命令可能會瞬間執行完畢,但是當一個目錄下有上百萬個檔案時,執行ls命令會發生什麼呢,帶著疑問,我們做了如下實驗(實驗中使用的儲存裝置為NVMe介面的SSD):

[root@localhost /data1/test_ls]# for i in {1..1000000}; do echo 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' > $i.txt ; done[root@localhost /data1/test_ls]# time ls -l | wc -l1000001real    0m5.802s
user    0m2.544s
sys      0m3.328s

可以看到,統計一個包含1000000個小檔案的目錄下的檔案個數花費了將近6秒的時間,那麼檔案個數多造成ls緩慢的原因是什麼呢,且聽我們詳細分析。

2.原理分析

眾所周知,strace是分析系統呼叫的利器,所以我們用strace來分析在大目錄下執行ls命令的結果,其中這樣的輸出引起了我們的注意:

...getdents(3, /* 1024 entries */, 32768)  = 32768getdents(3, /* 1024 entries */, 32768)  = 32768getdents(3, /* 1024 entries */, 32768)  = 32768getdents(3, /* 1024 entries */, 32768)  = 32768brk(0)                                  = 0x12e8000brk(0x1309000)                          = 0x1309000getdents(3, /* 1024 entries */, 32768)  = 32768mremap(0x7f93b6246000, 2461696, 4919296, MREMAP_MAYMOVE) = 0x7f93b5d95000getdents(3, /* 1024 entries */, 32768)  = 32768getdents(3, /* 1024 entries */, 32768)  = 32768getdents(3, /* 1024 entries */, 32768)  = 32768brk(0)                                  = 0x1309000brk(0x132a000)                          = 0x132a000...

可以看到,在大目錄下執行ls命令會頻繁呼叫getdents這一系統呼叫,實際上我們通過檢視coreutils的ls.c原始碼可以發現:

640?wx_fmt=png

ls會首先呼叫opendir開啟一個目錄,然後迴圈呼叫readdir這個glibc中的函式直到遇到目錄流的結尾,也即讀完所有的目錄項(dentry)為止。我們首先看一下man page裡面對於readdir的定義:

struct dirent *readdir(DIR *dirp);

readdir返回一個指向dirent結構體的指標,指向目錄流dirp中的下一個目錄項,所以在print_dir的迴圈中,每次從目錄流中取出一個目錄項並賦值給next變數。既然說到目錄流(directory stream),我們順便看一下glibc中對它的定義:

640?wx_fmt=png

從上面的定義中可以看到,目錄流實則維護一個buffer,這個buffer的大小由allocation來確定,那麼問題來了,allocation值什麼時候確定,其實是在opendir過程中確定下來的。opendir的呼叫路徑如下所示:

__opendir-->__opendirat-->__alloc_dir

__alloc_dir中,

640?wx_fmt=png

會分配sizeof(DIR) + allocation大小的記憶體空間,最後將allocation賦值給目錄流dirp的allocation變數。allocation的預設值通過比較4*BUFSIZ的大小和dirent64結構體的大小(<32768)來確定,BUFSIZ的大小在以下幾個標頭檔案中定義:

stdio.h:        #define BUFSIZ _IO_BUFSIZ
libio.h:        #define _IO_BUFSIZ _G_BUFSIZ
_G_config.h:    #define _G_BUFSIZ 8192

回看一下strace中的輸出,getdents第三個引數以及返回值32768就是這麼來的。 講完目錄流的buffer大小是怎麼確定的之後,讓我們回到readdir的glibc實現。

640?wx_fmt=png

這段程式碼的邏輯還是比較清晰的,首先判斷目錄流的偏移量有沒有超過buffer的大小,如果超過,則說明已經讀完緩衝區中的所有內容,需要重新呼叫getdents讀取,getdents一次最多讀取32768個位元組(有_DIRENT_HAVE_D_RECLEN定義時為dirp->allocation),並將讀取到的buffer返回給dirp->data,讀取到的位元組數返回給dirp->size,然後重置偏移量為0。如果沒有超過buffer大小,則從dirp->offset開始讀,然後將偏移量增加reclen個位元組作為下次讀取的起點,reclen記錄在目錄項結構體direntd_reclen變數中,表示當前目錄項的長度,dirent(DIRENT_TYPE)這個結構體的定義如下所示:

struct dirent{
    __ino_t d_ino;  /* inode number */
    __off_t d_off;  /* offset to the next dirent */
    unsigned short int d_reclen;   /* length of this record */
    unsigned char d_type;   /* type of file */
    char d_name[256];   /* filename */};

總結一下以上整個過程就是,ls命令每次呼叫readdir都會從目錄流中讀取一個目錄項,如果目錄流的buffer讀完,就會重新呼叫getdents填充這一buffer,下次從新buffer的開頭開始讀,buffer的預設大小為32K,這也就意味著如果一個目錄下有大量的目錄項(目錄項的總大小可以通過ls -dl檢視),則執行ls命令時將會頻繁地呼叫getdents,導致目錄下的檔案數越多時ls的執行時間越長。

3.解決方法

既然glibc中readdir的buffer大小我們沒法控制,何不繞過readdir直接呼叫getdents,在這個系統呼叫中我們可以直接控制buffer的大小,以下就是一個簡單的例子listdir.c:

640?wx_fmt=png

640?wx_fmt=png

在這段程式碼中,我們將getdents的buffer大小設定為5M,編譯執行這段程式碼,我們得到如下結果:

[root@localhost /data1]# time ./listdir test_rm | wc -l1000016real    0m0.755s
user    0m0.432s
sys     0m0.320s

統計目錄中的檔案數由預設的5.802s縮短為0.755s,可以看到提升還是較為明顯的。

4. 總結

其實不止是ls命令,其他一些命令如rm -r等的實現中都會用到glibc中的readdir函式,所以如果遇到操作百萬級檔案的大目錄這種場景(當然實踐中不提倡一個目錄下放這麼多檔案),不妨直接呼叫getdents並加上自己的一些邏輯,這樣就可以在實現標準命令功能的基礎上,還能獲得其不具備的效能提升。

公眾號推薦:

640?wx_fmt=jpeg

640?wx_fmt=jpeg

相關文章