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

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

このコミットは、Go言語の標準ライブラリ os パッケージにおけるWindows環境でのファイルオープン処理の改善に関するものです。具体的には、os.OpenFile 関数がファイルとディレクトリのどちらを開こうとするかの試行順序を変更し、パフォーマンスの最適化とエラーハンドリングの改善を図っています。

コミット

commit 9a7cd11bc8f1763710a18bd90e9db00f8281d69b
Author: Patrick Mézard <patrick@mezard.eu>
Date:   Wed Mar 5 12:19:56 2014 +1100

    os: try openFile before openDir in windows os.OpenFile
    
    Logging calls when running "go install -a std" turns:
    
      547  openDir succeeded
      3593 openDir failed and fell back to openFile
      3592 openFile succeeded
      1    both failed
    
    into:
    
      3592 openFile succeeded
      548  openFile failed and fell back
      547  openDir succeeded
      1    both failed
    
    Here the change trades 3593 failed openDir for 548 failed openFile.
    
    Fix issue 7426.
    
    LGTM=alex.brainman
    R=golang-codereviews, alex.brainman, bradfitz
    CC=golang-codereviews
    https://golang.org/cl/70480044

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

https://github.com/golang/go/commit/9a7cd11bc8f1763710a18bd90e9db00f8281d69b

元コミット内容

os: try openFile before openDir in windows os.OpenFile

このコミットは、Windows環境における os.OpenFile 関数内で、ファイルを開く試行 (openFile) をディレクトリを開く試行 (openDir) よりも先に行うように変更します。

コミットメッセージには、go install -a std を実行した際のログの変化が示されています。変更前は、ディレクトリとして開こうとして失敗し、その後ファイルとして開くというパターンが3593回発生していましたが、変更後はファイルとして開こうとして失敗し、その後ディレクトリとして開くというパターンが548回に減少しています。これにより、全体として失敗する試行の回数が減り、パフォーマンスが向上することが示唆されています。

この変更は、Issue 7426 を修正するものです。

変更の背景

Go言語の os.OpenFile 関数は、指定されたパスが通常のファイルであるかディレクトリであるかを区別して開く必要があります。Windowsのファイルシステムでは、ファイルとディレクトリは異なる方法で扱われます。

従来の os.OpenFile の実装では、まずパスをディレクトリとして開こうと試み、それが失敗した場合にファイルとして開こうとフォールバックしていました。この順序は、特定のシナリオ、特に多くのファイルが存在する環境や、go install -a std のようなビルドプロセスにおいて、非効率的であることが判明しました。

コミットメッセージに示されているログデータは、この非効率性を明確に示しています。

  • 変更前:

    • openDir succeeded: 547回 (ディレクトリとして正しく開けたケース)
    • openDir failed and fell back to openFile: 3593回 (ディレクトリとして開こうとして失敗し、ファイルとして再試行したケース)
    • openFile succeeded: 3592回 (ファイルとして正しく開けたケース)
    • both failed: 1回 (両方失敗したケース) このデータから、多くのケースで実際にはファイルであるパスに対して、最初にディレクトリとして開こうとする無駄な試行が発生していたことがわかります。
  • 変更後:

    • openFile succeeded: 3592回 (ファイルとして正しく開けたケース)
    • openFile failed and fell back: 548回 (ファイルとして開こうとして失敗し、ディレクトリとして再試行したケース)
    • openDir succeeded: 547回 (ディレクトリとして正しく開けたケース)
    • both failed: 1回 (両方失敗したケース) この変更により、openDir の失敗回数が大幅に減少し、代わりに openFile の失敗回数が増加していますが、その数は openDir の失敗回数よりもはるかに少ないです。これは、ほとんどの OpenFile の呼び出しが実際にはファイルを対象としているため、ファイルとして先に試行する方が効率的であることを示しています。

この最適化は、Goのビルドプロセスなど、多数のファイル操作が行われる場面でのパフォーマンス改善に寄与します。

前提知識の解説

このコミットを理解するためには、以下の概念について知っておく必要があります。

  1. os.OpenFile 関数: Go言語の os パッケージが提供する関数で、指定されたパスのファイルを開きます。ファイルが存在しない場合は作成したり、読み書きのモードを指定したり、パーミッションを設定したりできます。内部的には、オペレーティングシステム固有のシステムコールを呼び出してファイル操作を行います。

  2. Windowsファイルシステムとファイル/ディレクトリの区別: Windowsオペレーティングシステムでは、ファイルとディレクトリは異なるオブジェクトとして扱われます。ファイルを開くためのAPIとディレクトリを開くためのAPIは異なります。例えば、Win32 APIでは CreateFile 関数がファイルとディレクトリの両方を開くために使用されますが、その際に渡すフラグによって挙動が変わります。Goの os パッケージは、これらの低レベルなシステムコールを抽象化し、クロスプラットフォームなインターフェースを提供しています。

  3. openDiropenFile (Go内部関数): Goの os パッケージのWindows実装 (src/pkg/os/file_windows.go) には、内部的に openDiropenFile という関数が存在します。

    • openDir(name string): 指定されたパスをディレクトリとして開こうと試みます。成功すればディレクトリを表す *File オブジェクトを返します。
    • openFile(name string, flag int, perm FileMode): 指定されたパスを通常のファイルとして開こうと試みます。成功すればファイルを表す *File オブジェクトを返します。
  4. syscall.ENOENTsyscall.EISDIR: これらはGoの syscall パッケージで定義されているエラーコードです。

    • syscall.ENOENT (Error NO ENTry): 指定されたファイルやディレクトリが存在しない場合に返されるエラーです。
    • syscall.EISDIR (Error Is DIRectory): ファイルとして開こうとしたパスが実際にはディレクトリであった場合に返されるエラーです。os.OpenFile がファイルとして開こうとした際に、それがディレクトリであった場合にこのエラーを返すことで、呼び出し元に適切な情報を提供します。
  5. go install -a std: Goのコマンドラインツールの一つで、標準ライブラリのすべてのパッケージを強制的に再ビルドしてインストールします。このプロセスでは、Goのツールチェインが標準ライブラリ内の多数のファイルやディレクトリに対して os.OpenFile のような操作を頻繁に実行します。そのため、このコマンドの実行時のパフォーマンスは、ファイル操作の効率に大きく依存します。

技術的詳細

このコミットの技術的な核心は、os.OpenFile 関数における openFileopenDir の呼び出し順序の変更です。

変更前のコードでは、os.OpenFile はまず openDir(name) を呼び出して、指定された name がディレクトリであるかどうかを試行していました。

// 変更前
r, e := openDir(name)
if e == nil {
    // ディレクトリとして開けた場合
    // ...
    return r, nil
}
// ディレクトリとして開けなかった場合、ファイルとして試行
r, e = openFile(name, flag, perm)
if e == nil {
    return r, nil
}
return nil, &PathError{"open", name, e}

このロジックの問題点は、ほとんどの os.OpenFile の呼び出しが実際には通常のファイルを対象としているにもかかわらず、最初にディレクトリとして開こうとする無駄なシステムコールが発生していたことです。ディレクトリとして開こうとして失敗した場合(例えば、name がファイルであった場合)、openDir はエラーを返し、その後 openFile が呼び出されていました。この「試行→失敗→フォールバック」のサイクルが、特に多数のファイル操作が行われるシナリオでオーバーヘッドとなっていました。

変更後のコードでは、この順序が逆転しています。まず openFile(name, flag, perm) を呼び出して、指定された name が通常のファイルであるかどうかを試行します。

// 変更後
r, errf := openFile(name, flag, perm)
if errf == nil {
    return r, nil
}
// ファイルとして開けなかった場合、ディレクトリとして試行
r, errd := openDir(name)
if errd == nil {
    // ディレクトリとして開けた場合
    // ...
    return r, nil
}
return nil, &PathError{"open", name, errf} // 最終的なエラーはopenFileのものを返す

この変更により、もし name が通常のファイルであれば、最初の openFile の呼び出しで成功し、openDir の呼び出しはスキップされます。これは、ほとんどの OpenFile の呼び出しがファイルを対象としているという前提に基づくと、より効率的なパスとなります。

もし name がディレクトリであった場合、openFilesyscall.EISDIR エラーを返すか、あるいは他のエラーを返す可能性があります。その場合、コードは openDir を試行し、ディレクトリとして正しく開くことができれば、その *File オブジェクトを返します。

重要なのは、最終的にエラーを返す際に、openFile のエラー (errf) を優先して返している点です。これは、もしファイルとして開こうとして失敗し、かつディレクトリとしても開けなかった場合、ユーザーにとっては「ファイルとして開けなかった」という情報がより適切であるという判断に基づいていると考えられます。

この変更は、Windows環境における os.OpenFile の内部実装に特化したものであり、他のオペレーティングシステムには影響しません。WindowsのファイルシステムAPIの特性と、一般的なファイルアクセスのパターンを考慮した最適化と言えます。

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

変更は src/pkg/os/file_windows.go ファイルの OpenFile 関数内で行われています。

--- a/src/pkg/os/file_windows.go
+++ b/src/pkg/os/file_windows.go
@@ -134,20 +134,19 @@ func OpenFile(name string, flag int, perm FileMode) (file *File, err error) {
 	if name == "" {
 		return nil, &PathError{"open", name, syscall.ENOENT}
 	}
-	// TODO(brainman): not sure about my logic of assuming it is dir first, then fall back to file
-	r, e := openDir(name)
-	if e == nil {
+	r, errf := openFile(name, flag, perm)
+	if errf == nil {
+		return r, nil
+	}
+	r, errd := openDir(name)
+	if errd == nil {
 		if flag&O_WRONLY != 0 || flag&O_RDWR != 0 {
 			r.Close()
 			return nil, &PathError{"open", name, syscall.EISDIR}
 		}
 		return r, nil
 	}
-	r, e = openFile(name, flag, perm)
-	if e == nil {
-		return r, nil
-	}
-	return nil, &PathError{"open", name, e}
+	return nil, &PathError{"open", name, errf}
 }
 
 // Close closes the File, rendering it unusable for I/O.

コアとなるコードの解説

変更前のコードでは、OpenFile 関数は以下のロジックで動作していました。

  1. openDir(name) を呼び出し、name がディレクトリとして開けるか試行します。
  2. もし openDir が成功 (e == nil) し、かつ書き込みモード (O_WRONLY または O_RDWR) でない場合、ディレクトリとして開いた *File オブジェクトを返します。書き込みモードの場合は、ディレクトリへの書き込みは許可されないため EISDIR エラーを返して閉じます。
  3. もし openDir が失敗した場合(e != nil)、次に openFile(name, flag, perm) を呼び出し、name が通常のファイルとして開けるか試行します。
  4. もし openFile が成功 (e == nil) した場合、ファイルとして開いた *File オブジェクトを返します。
  5. 両方の試行が失敗した場合、最後に openFile が返したエラー e を含む PathError を返します。

変更後のコードでは、この順序が逆転し、エラーハンドリングも少し変更されています。

  1. openFile(name, flag, perm) を呼び出し、name が通常のファイルとして開けるか試行します。この結果は errf に格納されます。
  2. もし openFile が成功 (errf == nil) した場合、ファイルとして開いた *File オブジェクトを即座に返します。この場合、openDir は呼び出されません。
  3. もし openFile が失敗した場合(errf != nil)、次に openDir(name) を呼び出し、name がディレクトリとして開けるか試行します。この結果は errd に格納されます。
  4. もし openDir が成功 (errd == nil) し、かつ書き込みモードでない場合、ディレクトリとして開いた *File オブジェクトを返します。書き込みモードの場合は、ディレクトリへの書き込みは許可されないため EISDIR エラーを返して閉じます。
  5. 両方の試行が失敗した場合、最終的に openFile が返したエラー errf を含む PathError を返します。これは、元のコードが openFile のエラーを最終的に返していた挙動を維持しています。

この変更により、Goの os.OpenFile がWindows上で動作する際に、ファイルを開くケースが圧倒的に多いという実情に合わせて、より効率的なパスが選択されるようになりました。これにより、不要なシステムコールが減少し、特に多数のファイル操作を伴うアプリケーション(Goのビルドツールなど)のパフォーマンスが向上します。

関連リンク

参考にした情報源リンク