[インデックス 13410] ファイルの概要
このコミットは、Go言語の標準ライブラリである path
および path/filepath
パッケージにおける Clean
関数のパフォーマンス改善を目的としています。具体的には、既に正規化(クリーンアップ)されたパスに対して Clean
関数が再度呼び出された際に発生する不要なメモリ割り当てを削減します。この最適化は、特にパス操作が頻繁に行われるアプリケーションにおいて、ガベージコレクションの負荷を軽減し、全体的なパフォーマンスを向上させる効果があります。
コミット
commit 9525372e31f6bad979a5f472aecfc24af34f28d0
Author: Russ Cox <rsc@golang.org>
Date: Wed Jun 27 16:52:36 2012 -0400
path/filepath: avoid allocation in Clean of cleaned path
Alternative to https://golang.org/cl/6330044.
Fixes #3681.
R=golang-dev, r, hanwen, iant
CC=golang-dev
https://golang.org/cl/6335056
GitHub上でのコミットページへのリンク
https://github.com/golang/go/commit/9525372e31f6bad979a5f472aecfc24af34f28d0
元コミット内容
path/filepath: avoid allocation in Clean of cleaned path
このコミットは、既にクリーンアップされたパスに対して Clean
関数が呼び出された際に、メモリ割り当てを避けるための変更です。これは、以前の変更提案 https://golang.org/cl/6330044
の代替案として提出されました。
この変更は、Issue #3681 を修正します。
変更の背景
Go言語の path.Clean
および path/filepath.Clean
関数は、パス文字列を正規化(例: a/./b
を a/b
に、a/../b
を b
に)するために使用されます。しかし、これらの関数には、既に正規化されたパスが入力として与えられた場合でも、内部で新しいバイトスライス([]byte
)を割り当ててしまうという非効率性がありました。
この不要なメモリ割り当ては、特にパス操作が頻繁に行われるようなシナリオ(例: ファイルシステムの走査、URLの処理、設定ファイルの解析など)において、ガベージコレクションの頻度を増加させ、アプリケーション全体のパフォーマンスに悪影響を与える可能性がありました。
Issue #3681 はこの問題点を指摘しており、このコミットはその解決策として提案されました。目標は、入力パスが既にクリーンな形式である場合に、Clean
関数が追加のメモリ割り当てを行わないようにすることでした。これにより、パフォーマンスの向上とガベージコレクションの負荷軽減が期待されます。
前提知識の解説
Go言語のパス操作パッケージ (path
と path/filepath
)
path
パッケージ: 主にスラッシュ (/
) を区切り文字とするUnix形式のパスを操作するための関数を提供します。WebアプリケーションのURLパスや、クロスプラットフォームで統一されたパス表現を扱う際に利用されます。path/filepath
パッケージ: オペレーティングシステム固有のパス区切り文字(Windowsではバックスラッシュ\
、Unix系ではスラッシュ/
)を考慮したパス操作関数を提供します。ファイルシステム上の実際のパスを扱う際に使用されます。
両パッケージともに Clean
関数を提供しており、その基本的なロジックは似ています。
Clean
関数の役割
Clean
関数は、パス文字列を「最短かつ最もクリーンな」形式に正規化します。具体的には以下のルールを適用します。
- 連続するスラッシュを1つにまとめる (
a//b
->a/b
) .
(カレントディレクトリ) を削除する (a/./b
->a/b
)..
(親ディレクトリ) を解決する (a/b/../c
->a/c
)- パスが空の場合、
.
を返す - ルートパス (
/
) は/
のまま
メモリ割り当てとガベージコレクション (GC)
Go言語はガベージコレクタを持つ言語であり、開発者が明示的にメモリを解放する必要はありません。しかし、プログラムが頻繁に小さなオブジェクトを割り当てると、ガベージコレクタが頻繁に動作し、その分CPU時間を消費してしまいます。これは「GCのオーバーヘッド」と呼ばれ、パフォーマンスのボトルネックとなることがあります。
このコミットの目的は、不要なメモリ割り当てを避けることで、GCの頻度と負荷を減らし、パフォーマンスを向上させることにあります。
技術的詳細
このコミットの核心は、Clean
関数がパスを処理する際に、新しいバイトスライスを無条件に割り当てるのではなく、必要になるまで割り当てを遅延させる「遅延バッファ (lazy buffer)」の概念を導入した点にあります。
具体的には、lazybuf
という新しい構造体が導入されました。
type lazybuf struct {
s string // 元の入力文字列
buf []byte // 必要になった場合にのみ割り当てられるバイトスライス
w int // buf または s の書き込み位置(現在のパスの長さ)
}
lazybuf
は以下のメソッドを持ちます。
index(i int) byte
:buf
が存在すればbuf[i]
を、そうでなければs[i]
を返す。これにより、元の文字列または新しく割り当てられたバッファのどちらからでもバイトを読み取ることができる。append(c byte)
: バイトc
をパスに追加する。- もし
buf
がまだ割り当てられておらず、かつ追加しようとしているバイトc
が元の文字列s
の現在の位置b.w
と一致する場合、buf
の割り当てをスキップし、b.w
をインクリメントするだけで済ませる。これは、入力パスが既にクリーンな形式であり、変更が不要な場合にメモリ割り当てを避けるための重要な最適化。 - もし
buf
がまだ割り当てられていないが、c
がs[b.w]
と異なる場合(つまり、パスが変更される必要がある場合)、s
の内容をコピーしてbuf
を割り当て、その後にc
を追加する。 buf
が既に割り当てられている場合は、単にbuf[b.w]
にc
を追加する。
- もし
string() string
: 最終的なパス文字列を返す。buf
が存在すればstring(b.buf[:b.w])
を、そうでなければb.s[:b.w]
を返す。
Clean
関数は、パスの処理中に直接バイトスライスを操作する代わりに、この lazybuf
の append
メソッドを通じてパスを構築するように変更されました。これにより、入力パスが既にクリーンな形式である場合、lazybuf
は内部の buf
を一度も割り当てることなく処理を完了し、元の文字列のスライスを返すことができます。これにより、不要なメモリ割り当てが完全に回避されます。
テストコードでは、runtime.MemStats
を使用してメモリ割り当ての数を計測し、既にクリーンアップされたパスに対して Clean
を呼び出した際に、割り当てがゼロになることを確認しています。
コアとなるコードの変更箇所
主に src/pkg/path/filepath/path.go
と src/pkg/path/path.go
の Clean
関数、および関連するテストファイルが変更されています。
src/pkg/path/filepath/path.go
(同様の変更が src/pkg/path/path.go
にも適用)
追加された構造体とメソッド:
// A lazybuf is a lazily constructed path buffer.
// It supports append, reading previously appended bytes,
// and retrieving the final string. It does not allocate a buffer
// to hold the output until that output diverges from s.
type lazybuf struct {
s string
buf []byte
w int
}
func (b *lazybuf) index(i int) byte {
if b.buf != nil {
return b.buf[i]
}
return b.s[i]
}
func (b *lazybuf) append(c byte) {
if b.buf == nil {
if b.w < len(b.s) && b.s[b.w] == c {
b.w++
return
}
b.buf = make([]byte, len(b.s))
copy(b.buf, b.s[:b.w])
}
b.buf[b.w] = c
b.w++
}
func (b *lazybuf) string() string {
if b.buf == nil {
return b.s[:b.w]
}
return string(b.buf[:b.w])
}
Clean
関数の変更:
変更前:
func Clean(path string) string {
// ...
buf := []byte(path) // ここで常に新しいバイトスライスが割り当てられていた
r, w, dotdot := 0, 0, 0
if rooted {
buf[0] = Separator
r, w, dotdot = 1, 1, 1
}
// ... buf を直接操作
return FromSlash(vol + string(buf[0:w]))
}
変更後:
func Clean(path string) string {
// ...
out := lazybuf{s: path} // lazybuf を初期化
r, dotdot := 0, 0
if rooted {
out.append(Separator) // append メソッドを使用
r, dotdot = 1, 1
}
// ... out.append() や out.w を使用してパスを構築
// 例: buf[w] = Separator -> out.append(Separator)
// 例: w-- -> out.w--
// 例: buf[w] -> out.index(out.w)
// ...
return FromSlash(vol + out.string()) // lazybuf の string() メソッドで最終文字列を取得
}
src/pkg/path/filepath/path_test.go
(同様の変更が src/pkg/path/path_test.go
にも適用)
メモリ割り当てをテストするコードの追加:
var ms runtime.MemStats
runtime.ReadMemStats(&ms)
allocs := -ms.Mallocs
const rounds = 100
for i := 0; i < rounds; i++ {
for _, test := range tests {
filepath.Clean(test.result) // 既にクリーンなパスをテスト
}
}
runtime.ReadMemStats(&ms)
allocs += ms.Mallocs
if allocs >= rounds {
t.Errorf("Clean cleaned paths: %d allocations per test round, want zero", allocs/rounds)
}
このテストは、Clean
関数が既にクリーンなパスに対して呼び出された場合に、allocs
(割り当てられたメモリブロックの数) が rounds
(テストの繰り返し回数) よりも小さい、理想的にはゼロであることを検証します。
コアとなるコードの解説
このコミットの主要な変更点は、Clean
関数がパスを構築する際に、直接バイトスライスを操作するのではなく、lazybuf
という新しいヘルパー型を使用するように変更されたことです。
-
lazybuf
の導入:lazybuf
は、元の入力文字列s
と、必要になった場合にのみ割り当てられるバイトスライスbuf
を保持します。w
は、現在のパスの長さ、またはbuf
(あるいはs
) のどこまでが有効なパスデータであるかを示すインデックスです。
-
遅延割り当てのメカニズム (
append
メソッド):append(c byte)
メソッドがlazybuf
の核心です。- パスに新しいバイト
c
を追加しようとするとき、まずb.buf == nil
(まだバッファが割り当てられていない) かどうかをチェックします。 - もし
b.buf
がnil
で、かつb.w < len(b.s) && b.s[b.w] == c
(追加しようとしているバイトが元の文字列の現在の位置のバイトと一致する) ならば、これはパスが変更されていないことを意味します。この場合、b.w
をインクリメントするだけで、buf
の割り当てをスキップします。これがメモリ割り当てを避けるための重要なポイントです。 - もし
b.buf
がnil
で、かつb.s[b.w] != c
(パスが変更される必要がある) ならば、make([]byte, len(b.s))
で新しいバイトスライスbuf
を割り当て、元の文字列s
の内容をbuf
にコピーします。その後、c
をbuf[b.w]
に追加します。 b.buf
が既に割り当てられている場合は、単にbuf[b.w]
にc
を追加します。
-
Clean
関数での利用:Clean
関数は、out := lazybuf{s: path}
としてlazybuf
のインスタンスを初期化します。- パスの正規化処理中、以前は
buf[w] = ...
のように直接バイトスライスに書き込んでいた箇所が、out.append(...)
の呼び出しに置き換えられます。 - パスの長さを追跡していた
w
変数は、out.w
に置き換えられます。 - 最終的な結果文字列は、
out.string()
を呼び出すことで取得されます。これにより、buf
が割り当てられていなければ元の文字列のスライスが、割り当てられていればbuf
の内容が文字列として返されます。
この変更により、Clean
関数は、入力パスが既に正規化されている場合には、新しいメモリを割り当てることなく、元の文字列のスライスを返すことができるようになりました。これにより、ガベージコレクションの負荷が軽減され、パフォーマンスが向上します。
関連リンク
- Go言語の
path
パッケージ: https://pkg.go.dev/path - Go言語の
path/filepath
パッケージ: https://pkg.go.dev/path/filepath - Go Issue #3681:
path/filepath: Clean allocates even when path is already clean
(このコミットが修正したIssue) - 検索結果から確認できます。
参考にした情報源リンク
- Go言語の公式ドキュメント (path, path/filepath パッケージ)
- Go言語のIssueトラッカー (Issue #3681)
- Go言語のコードレビューシステム (Gerrit) の変更セット
https://golang.org/cl/6335056
およびhttps://golang.org/cl/6330044
(コミットメッセージに記載) - Go言語のメモリ管理とガベージコレクションに関する一般的な情報源