golang對遍歷目錄操作的最佳化

apocelipes發表於2024-07-31

一轉眼go1.23都快釋出了,時間過得真快。

不過今天我們把時間倒流回三年半之前,來關注一個在go1.16引入的關於處理目錄時的最佳化。

對於go1.16的新變化,大家印象最深的可能是io包的大規模重構,但這個重構實際上還引進了一個最佳化,這篇文章要說的就是這個最佳化。

本文預設Linux環境,不過這個最佳化在BSD系統上也是通用的。

遍歷目錄時的最佳化

遍歷目錄是個很常見的需求,尤其是對於有大量檔案的目錄來說,遍歷的效能直接關係到了整體程式的效能。

go1.16對於遍歷目錄增加了幾個新介面:os.ReadDir(*os.File).ReadDirfilepath.WalkDir

這幾個介面最大的特徵是對目錄項使用fs.DirEntry表示而不是os.FileInfofs.DirEntry是一個介面,它提供了類似os.FileInfo的方法:

type DirEntry interface {
        Name() string
        IsDir() bool
        Type() FileMode
        Info() (FileInfo, error)
}

它還提供了一個叫Info的方法以便獲得os.FileInfo

這個介面有什麼神奇的呢?我們看下效能測試:

func IterateDir(path string) int {
    // go1.16 的 os.ReadDir 就是這麼實現的,為了測試我們把它展開成對(*os.File).ReadDir的呼叫
	f, err := os.Open(path)
	if err != nil {
		panic(err)
	}
	defer f.Close()

	files, err := f.ReadDir(-1)
	if err != nil {
		panic(err)
	}
	length := 0
	for _, finfo := range files {
		length = max(length, len(finfo.Name()))
	}
	return length
}

func IterateDir2(path string) int {
    // 1.16之前遍歷目錄的常用方法之一
	f, err := os.Open(path)
	if err != nil {
		panic(err)
	}
	defer f.Close()

	files, err := f.Readdir(-1)
	if err != nil {
		panic(err)
	}
	length := 0
	for _, finfo := range files {
		length = max(length, len(finfo.Name()))
	}
	return length
}

func BenchmarkIter1(b *testing.B) {
	for range b.N {
		IterateDir("../test")
	}
}

func BenchmarkIter2(b *testing.B) {
	for range b.N {
		IterateDir2("../test")
	}
}

test目錄是一個有5000個檔案的位於Btrfs檔案系統上的目錄,我們的測試用例會遍歷目錄並找出名字最長的檔案的檔名長度。

這是測試結果:

可以看到最佳化後的遍歷比原先的快了480%。換了個函式為什麼就會有這麼大的提升?想知道答案的話就繼續看吧。

最佳化的原理

繼續深入前我們先看看老的介面是怎麼獲取到目錄裡的檔案資訊的。答案是遍歷目錄拿到路徑,然後呼叫os.Lstat獲取完整的檔案資訊:

func (f *File) Readdir(n int) ([]FileInfo, error) {
	if f == nil {
		return nil, ErrInvalid
	}
	_, _, infos, err := f.readdir(n, readdirFileInfo)
	if infos == nil {
		// Readdir has historically always returned a non-nil empty slice, never nil,
		// even on error (except misuse with nil receiver above).
		// Keep it that way to avoid breaking overly sensitive callers.
		infos = []FileInfo{}
	}
	return infos, err
}

這個f.readdir會根據第二個引數的值來改變自己的行為,根據值不同它會遵循1.16前老程式碼的行為或者採用新的最佳化方法。這個函式不同系統上的實現也不同,我們選則*nix系統上的實現看看:

func (f *File) readdir(n int, mode readdirMode) (names []string, dirents []DirEntry, infos []FileInfo, err error) {
	...

	for n != 0 {
		// 使用系統呼叫獲得目錄項的資料
        // 目錄項的元資訊一般是儲存在目錄本身的資料裡的,所以讀這些資訊和讀普通檔案很類似
		if d.bufp >= d.nbuf {
			d.bufp = 0
			var errno error
			d.nbuf, errno = f.pfd.ReadDirent(*d.buf)
			runtime.KeepAlive(f)
			if errno != nil {
				return names, dirents, infos, &PathError{Op: "readdirent", Path: f.name, Err: errno}
			}
			if d.nbuf <= 0 {
				break // EOF
			}
		}

		buf := (*d.buf)[d.bufp:d.nbuf]
		reclen, ok := direntReclen(buf)
		if !ok || reclen > uint64(len(buf)) {
			break
		}
        // 注意這行
		rec := buf[:reclen]

		if mode == readdirName {
			names = append(names, string(name))
		} else if mode == readdirDirEntry {
			// 這裡的程式碼後面再看
		} else {
			info, err := lstat(f.name + "/" + string(name))
			if IsNotExist(err) {
				// File disappeared between readdir + stat.
				// Treat as if it didn't exist.
				continue
			}
			if err != nil {
				return nil, nil, infos, err
			}
			infos = append(infos, info)
		}
	}

	if n > 0 && len(names)+len(dirents)+len(infos) == 0 {
		return nil, nil, nil, io.EOF
	}
	return names, dirents, infos, nil
}

ReadDirent對應的是Linux上的系統呼叫getdents,這個系統呼叫會把目錄的目錄項資訊讀取到一塊記憶體裡,之後程式可以解析這塊記憶體裡的資料來獲得目錄項的一些資訊,這些資訊一般包括了檔名,檔案的型別,檔案是否是目錄等資訊。

老程式碼在讀取完這些資訊後會利用檔名再次呼叫lstat,這個也是系統呼叫,可以獲取更完整的檔案資訊,包括了檔案的擁有者,檔案的大小,檔案的修改日期等。

老的程式碼有啥問題呢?大的問題不存在,介面也算易用,但有些小瑕疵:

  1. 大多數時間遍歷目錄主要是要獲得目錄中檔案的名字或者型別等屬性,顯然os.FileInfo返回的資訊過多了。這些用不著的資訊會浪費不少記憶體,獲取這些資訊也需要額外花時間——lstat需要去進行磁碟io才能得到這些資訊,而目錄裡的檔案不像目錄項資訊那樣緊密的儲存在一起,它們是分散的,所以一一讀取它們的元資訊帶來的負擔會很大。
  2. 使用的系統呼叫太多了。由於我們測試目錄的檔案很多,但getdents可能要呼叫多次,這裡假設為兩次好了。對於每一個目錄項,都需要用lstat去獲取檔案的詳細資訊,這樣又有5000次系統呼叫,加起來是5002次。系統呼叫的開銷是很大的,積累到5000多次則會帶來肉眼可見的效能下降。實際上linux本身對lstat有最佳化,不會真的出現要反覆進入系統呼叫5000次的情況,但幾十到上百次還是需要的。

最佳化的程式碼其實只改了一行,是f.readdir(n, readdirDirEntry),第二個引數變了。新程式碼會走上面註釋掉的那段邏輯:

// rec := buf[:reclen] 防止你忘了rec是哪來的
de, err := newUnixDirent(f.name, string(name), direntType(rec))
if IsNotExist(err) {
	// File disappeared between readdir and stat.
	// Treat as if it didn't exist.
	continue
}
if err != nil {
	return nil, dirents, nil, err
}
dirents = append(dirents, de)

取代lstat的是函式newUnixDirent,這個函式可以不依賴額外的系統呼叫獲取檔案的一部分後設資料:

type unixDirent struct {
	parent string
	name   string
	typ    FileMode
	info   FileInfo
}

func newUnixDirent(parent, name string, typ FileMode) (DirEntry, error) {
	ude := &unixDirent{
		parent: parent,
		name:   name,
		typ:    typ,
	}
    // 檢測檔案型別資訊是否有效
	if typ != ^FileMode(0) && !testingForceReadDirLstat {
		return ude, nil
	}

	info, err := lstat(parent + "/" + name)
	if err != nil {
		return nil, err
	}

	ude.typ = info.Mode().Type()
	ude.info = info
	return ude, nil
}

檔名和型別都是在解析目錄項時就得到的,因此直接設定就行。不過不是每個檔案系統都支援在目錄項資料裡儲存檔案型別,所以程式碼裡做了回退,一旦發現檔案型別是無效資料就會使用lstat重新獲取資訊。

如果只使用檔名和檔案的型別這兩個資訊,那麼整個遍歷的邏輯流程到這就結束了,檔案系統提供支援的情況下不需要呼叫lstat。所以整個遍歷只需要兩次系統呼叫。這就是為什麼最佳化方案會快接近五倍的原因。

對於要使用其他資訊比如檔案大小的使用者,最佳化方案實際上也有好處,因為現在lstat是延遲且按需呼叫的:

func (d *unixDirent) Info() (FileInfo, error) {
	if d.info != nil {
		return d.info, nil
	}
    // 只會呼叫一次
	return lstat(d.parent + "/" + d.name)
}

這樣也能儘量減少不必要的系統呼叫。

所以整體最佳化的原理是:儘量充分利用檔案系統本身提供的資訊+減少系統呼叫。要遍歷的目錄越大最佳化的效果也越明顯。

最佳化的支援情況

上面也說了,能做到最佳化需要檔案系統把檔案型別資訊儲存在目錄的目錄項資料裡。這個需要檔案系統的支援。

如果檔案系統不支援的話最後還是需要依賴lstat去讀取具體檔案的後設資料。

不同檔案系統的資訊實在太分散,還有不少過時的,所以我花了幾天看程式碼+查文件做了下整理:

  1. btrfs,ext2,ext4:這個幾個檔案系統支援最佳化,man pages加檔案系統程式碼都能證實這一點
  2. OpenZFS:這個檔案系統不在Linux核心裡,所以man pages裡沒提到,但也支援最佳化
  3. xfs:支援最佳化,但得在建立檔案系統時使用類似mkfs.xfs -f -n ftype=1的選項才行
  4. F2FS,EROFS:文件沒提過,但看核心的程式碼裡是支援的,程式碼的位置在xxx_readdir這個函式附近。
  5. fat32,exfat:文件沒提過,但看核心程式碼發現是支援的,不過fat家族的檔案型別沒有那麼多花樣,只有目錄和普通檔案這兩種,所以程式碼裡很粗暴的判斷目錄項是否設定了dir標誌,有就是目錄沒有統統算普通檔案。這麼做倒是正常的,因為fat本來就不支援別的檔案型別,畢竟這個檔案系統連軟連結都不支援,更不用指望Unix Domain Socket和命名管道了。
  6. ntfs:支援,然而如註釋所說,因為ntfs和其他檔案系統處理type的方式不一樣,導致雖然檔案系統本身支援大部分檔案型別,但type資訊裡只能獲得檔案是不是目錄。所以它後面對於不是目錄的檔案會去磁碟上讀取檔案的inode然後再從inode裡獲取檔案型別——實際上相當於執行了一次lstat,相比lstat減少了進入系統呼叫時的一次上下文切換,所以ntfs上最佳化效果會不如其他檔案系統。

這麼一看的話基本上主流的常見的檔案系統都支援這種最佳化。

這也是為什麼go1.16會引入這個最佳化,不僅支援廣泛而且提升很大,免費的加速誰不愛呢。

別的語言裡怎麼利用這個最佳化

看到這裡,你應該發現這個最佳化其實是系統層面的,golang只不過是適配了一下而已。

確實是這樣的,所以這個最佳化不光golang能吃到,c/c++/python都行。

先說說c裡怎麼利用:直接用系統提供的readdir函式就行,這個函式會呼叫getdents,然後就能自然吃到最佳化了。注意事項和go的一樣,需要檢測檔案系統是否支援設定d_type。

c++:和c一樣,另外libstdc++的filesystem就是拿readdir實現的,所以用filesystem標準庫也能獲得最佳化:

// https://github.com/gcc-mirror/gcc/blob/master/libstdc++-v3/src/filesystem/dir-common.h#L270
inline file_type
get_file_type(const std::filesystem::__gnu_posix::dirent& d [[gnu::unused]])
{
#ifdef _GLIBCXX_HAVE_STRUCT_DIRENT_D_TYPE
  switch (d.d_type)
  {
  case DT_BLK:
    return file_type::block;
  case DT_CHR:
    return file_type::character;
  case DT_DIR:
    return file_type::directory;
  case DT_FIFO:
    return file_type::fifo;
  case DT_LNK:
    return file_type::symlink;
  case DT_REG:
    return file_type::regular;
  case DT_SOCK:
    return file_type::socket;
  case DT_UNKNOWN:
    return file_type::unknown;
  default:
    return file_type::none;
  }
#else
  return file_type::none;
#endif
}

// 如果作業系統以及檔案系統不支援,則回退到lstat
// https://github.com/gcc-mirror/gcc/blob/master/libstdc++-v3/include/bits/fs_dir.h#L342
file_type
_M_file_type() const
{
    if (_M_type != file_type::none && _M_type != file_type::symlink)
	    return _M_type;
    return status().type();
}

唯一的區別在於如果目標檔案是軟連線,也會呼叫stat。

python:使用os.scandir可以獲得最佳化,底層和c一樣使用readdir:https://github.com/python/cpython/blob/main/Modules/posixmodule.c#L16211,實現方法甚至類名都和golang很像,程式碼就不貼了。

總結

go雖然效能上一直被詬病,但在系統程式設計上倒是不含糊,基本常見的最佳化都有做,可以經常關注下新版本的release notes去看看go在這方面做的努力。

看著簡單的最佳化,背後的可行性驗證確實很複雜的,尤其是不同檔案系統在怎麼儲存額外的後設資料上很不相同,光是看程式碼就花了不少時間。

前面提到的ntfs在最佳化效果上會打點折扣,所以我特意拿Windows裝置測試了下,測試條件不變:

可以看到幾乎沒什麼區別。如果不是看了linux的ntfs驅動,我是不知道會產生這樣的結果的。所以這個最佳化Windows上效果不理想,但在Linux和MacOS上是適用的。

大膽假設,小心求證,系統程式設計和效能最佳化的樂趣也正在於此。

參考

exfat的fuse驅動填充d_type的邏輯:https://github.com/relan/exfat/blob/master/libexfat/utils.c#L28

Linux的ntfs驅動需要獲取檔案的inode才能得到正確的file type:https://github.com/torvalds/linux/blob/master/fs/ntfs3/dir.c#L337

相關文章