Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

[インデックス 18021] ファイルの概要

このコミットは、Go言語の標準ライブラリosパッケージ内のreaddir関数における文字列結合の最適化に関するものです。具体的には、ディレクトリ内のファイル情報を読み取る際に発生していた不要な文字列結合を削除することで、パフォーマンスの改善を図っています。

コミット

commit ff8e45828c044665b60c37287e4f2d9e91754333
Author: Brad Fitzpatrick <bradfitz@golang.org>
Date:   Tue Dec 17 12:25:32 2013 -0800

    os: avoid a string concat in readdir
    
    R=golang-dev, crawshaw
    CC=golang-dev
    https://golang.org/cl/37690045

GitHub上でのコミットページへのリンク

https://github.com/golang/go/commit/ff8e45828c044665b60c37287e4f2d9e91754333

元コミット内容

os: avoid a string concat in readdir

R=golang-dev, crawshaw
CC=golang-dev
https://golang.org/cl/37690045

変更の背景

このコミットの背景には、Go言語における文字列結合のパフォーマンス特性と、ファイルシステム操作における効率性の追求があります。Go言語では、文字列はイミュータブル(不変)な型です。そのため、+演算子を使って文字列を結合するたびに、新しい文字列が生成され、メモリの再割り当てとコピーが発生します。ループ内で頻繁に文字列結合を行うと、このオーバーヘッドが蓄積され、パフォーマンスに大きな影響を与える可能性があります。

osパッケージのreaddir関数は、ディレクトリ内のエントリ(ファイルやサブディレクトリ)の名前を読み取り、それぞれのFileInfoを取得するためにlstatシステムコールを呼び出します。lstatには、ファイルへの完全なパスを渡す必要があります。以前の実装では、ディレクトリ名とファイル名を結合する際に、余分な文字列結合が行われていました。このコミットは、この冗長な結合を排除し、readdir関数の効率を向上させることを目的としています。特に、多数のファイルを含むディレクトリを読み取る場合に、この最適化が顕著な効果を発揮すると考えられます。

前提知識の解説

Go言語における文字列とパフォーマンス

Go言語の文字列はバイトの読み取り専用スライスであり、一度作成されると内容を変更できません。この特性は、文字列の安全な共有を可能にする一方で、文字列結合の際には注意が必要です。

  • +演算子による結合: 最も直感的ですが、複数の文字列を+で結合すると、中間的な文字列が多数生成され、ガベージコレクションの負荷が増大し、パフォーマンスが低下する可能性があります。
  • strings.Builder: 複数の文字列を効率的に構築するための推奨される方法です。内部的に可変なバイトバッファを使用し、メモリの再割り当てを最小限に抑えます。
  • strings.Join(): 文字列のスライスを特定の区切り文字で結合する場合に非常に効率的です。
  • fmt.Sprintf(): フォーマットされた文字列を生成する際に使用しますが、純粋な文字列結合目的ではstrings.Builderstrings.Join()よりも遅い傾向があります。

このコミットが行われた2013年時点では、strings.Builderはまだ存在していませんでしたが、+演算子による不要な結合を避けるという原則は当時から重要でした。

osパッケージとファイルシステム操作

osパッケージは、オペレーティングシステムとの基本的なインタラクションを提供します。ファイルやディレクトリの操作、プロセスの管理、環境変数の取得などが含まれます。

  • os.File.Readdirnames(n int): Fileオブジェクトのメソッドで、ディレクトリ内のエントリ名を文字列スライスとして返します。nが0より大きい場合、最大n個のエントリを返します。nが0以下の場合、すべてのエントリを返します。
  • os.Lstat(path string): 指定されたパスのファイル情報を返します。シンボリックリンクの場合、リンク自体ではなく、リンク先のファイル情報を返します。この関数は、ファイルが存在するかどうか、種類(ファイル、ディレクトリ、シンボリックリンクなど)、サイズ、パーミッションなどを確認するために使用されます。
  • os.IsNotExist(err error): エラーがファイルまたはディレクトリが存在しないことを示す場合にtrueを返します。

パス区切り文字

Unix系システムではパス区切り文字として/が使用されます。Windowsでは\が使用されますが、Goのfilepathパッケージはクロスプラットフォームなパス操作を提供し、内部的に適切な区切り文字を扱います。このコミットはUnix固有のファイルfile_unix.goに対する変更であるため、/が直接扱われています。

技術的詳細

このコミットは、src/pkg/os/file_unix.goファイル内のFile構造体のreaddirメソッドに対する変更です。このメソッドは、ディレクトリの内容を読み取り、それぞれのFileInfoを返す役割を担っています。

変更前のコードでは、dirname(ディレクトリのパス)が空の場合に"."に設定され、その後、常にdirname += "/"という文字列結合が行われていました。これにより、dirnameの末尾に/が追加されていました。

// 変更前
if dirname == "" {
	dirname = "."
}
dirname += "/" // ここで余分な文字列結合が発生
names, err := f.Readdirnames(n)
fi = make([]FileInfo, 0, len(names))
for _, filename := range names {
	fip, lerr := lstat(dirname + filename) // ここでも結合
	// ...
}

そして、ループ内で各filenameに対してlstat(dirname + filename)という形でパスを構築していました。このdirname + filenameの結合は、dirnameが既に/で終わっているため、結果的にdirname/filenameという形式になります。

このコミットでは、dirname += "/"という行を削除し、代わりにlstat呼び出しの中でdirname + "/" + filenameという形で明示的に/を挿入するように変更しています。

// 変更後
if dirname == "" {
	dirname = "."
}
// dirname += "/" は削除された
names, err := f.Readdirnames(n)
fi = make([]FileInfo, 0, len(names))
for _, filename := range names {
	fip, lerr := lstat(dirname + "/" + filename) // ここで結合
	// ...
}

一見すると、dirname + "/"を削除してdirname + "/" + filenameにすることで、結合回数が変わらないように見えるかもしれません。しかし、元のコードではdirname += "/"によってdirname変数自体が新しい文字列オブジェクトに置き換えられていました。これはループの外で行われるため、一度だけ発生するオーバーヘッドです。

しかし、より重要なのは、lstat(dirname + filename)という結合が、dirnameが既に/で終わっていることを前提としていた点です。この変更により、dirname/で終わっているかどうかに関わらず、常にdirnamefilenameの間に/を挿入する形になります。

この変更の真の意図は、dirname += "/"という行が不要な文字列の再構築を引き起こしていた可能性を排除すること、そしてlstatに渡すパスの構築をより直接的に行うことにあると考えられます。特に、dirname"."の場合、"./"というパスが生成され、その後に"./filename"という結合が行われていました。この変更により、dirname"."の場合でも、"." + "/" + filenameとなり、結果的に"./filename"というパスが生成されます。

この最適化は、特に多数のファイルを含むディレクトリを読み取る際に、lstat呼び出しごとに発生する文字列結合の効率をわずかに向上させることを目的としています。Goのコンパイラは文字列結合を最適化する場合がありますが、このような明示的な変更は、コンパイラが最適化しにくいケースや、より明確な意図を示すために行われることがあります。

コアとなるコードの変更箇所

src/pkg/os/file_unix.goファイルのreaddir関数内。

--- a/src/pkg/os/file_unix.go
+++ b/src/pkg/os/file_unix.go
@@ -160,11 +160,10 @@ func (f *File) readdir(n int) (fi []FileInfo, err error) {
 	if dirname == "" {
 		dirname = "."
 	}
-	dirname += "/" // この行が削除された
 	names, err := f.Readdirnames(n)
 	fi = make([]FileInfo, 0, len(names))
 	for _, filename := range names {
-		fip, lerr := lstat(dirname + filename) // 変更前
+		fip, lerr := lstat(dirname + "/" + filename) // 変更後
 		if IsNotExist(lerr) {
 			// File disappeared between readdir + stat.
 			// Just treat it as if it didn't exist.

コアとなるコードの解説

変更の核心は、dirname += "/"という行の削除と、それに伴うlstat呼び出し内のパス構築方法の変更です。

  1. dirname += "/"の削除:

    • 元のコードでは、dirnameが空文字列の場合に"."に設定された後、無条件にdirnameの末尾に/が追加されていました。これにより、dirnameは常に/で終わる文字列になっていました(例: "/path/to/dir/"または"./")。
    • この操作自体が文字列結合であり、新しい文字列オブジェクトを生成していました。このコミットでは、この余分な文字列結合を排除しています。
  2. fip, lerr := lstat(dirname + "/" + filename)への変更:

    • dirname += "/"が削除されたため、dirnameはもはや常に/で終わるわけではありません(例: "/path/to/dir"または".")。
    • そのため、lstatに渡すパスを正しく構築するために、dirnamefilenameの間に明示的にパス区切り文字/を挿入する必要があります。
    • この変更により、lstatに渡されるパスは常に"dirname/filename"の形式になります。

この変更は、dirname変数の内容を不必要に再構築するのを避け、パス構築をlstat呼び出しの直前で一度に行うことで、全体的な効率を向上させています。特に、readdirが頻繁に呼び出されるようなI/Oヘビーなアプリケーションにおいて、わずかながらもパフォーマンス上のメリットが期待できます。

関連リンク

参考にした情報源リンク