[インデックス 19661] ファイルの概要
このコミットは、Go言語の標準ライブラリである archive/tar
パッケージ内の src/pkg/archive/tar/reader.go
ファイルに対する変更です。このファイルは、TARアーカイブファイルを読み込むための Reader
型とその関連メソッドを定義しており、特にアーカイブ内の各ファイルエントリのヘッダー情報を解析する機能を提供しています。
コミット
commit 61ccc1f05fe6e79ec79e59166e1a3fa3454ab406
Author: Cristian Staretu <unclejacksons@gmail.com>
Date: Thu Jul 3 09:41:19 2014 +1000
archive/tar: reuse temporary buffer in readHeader
A temporary 512 bytes buffer is allocated for every call to
readHeader. This buffer isn't returned to the caller and it could
be reused to lower the number of memory allocations.
This CL improves it by using a pool and zeroing out the buffer before
putting it back into the pool.
benchmark old ns/op new ns/op delta
BenchmarkListFiles100k 545249903 538832687 -1.18%
benchmark old allocs new allocs delta
BenchmarkListFiles100k 2105167 2005692 -4.73%
benchmark old bytes new bytes delta
BenchmarkListFiles100k 105903472 54831527 -48.22%
This improvement is very important if your code has to deal with a lot
of tarballs which contain a lot of files.
LGTM=dsymonds
R=golang-codereviews, dave, dsymonds, bradfitz
CC=golang-codereviews
https://golang.org/cl/108240044
GitHub上でのコミットページへのリンク
https://github.com/golang/go/commit/61ccc1f05fe6e79ec79e59166e1a3fa3454ab406
元コミット内容
archive/tar: reuse temporary buffer in readHeader
A temporary 512 bytes buffer is allocated for every call to
readHeader. This buffer isn't returned to the caller and it could
be reused to lower the number of memory allocations.
This CL improves it by using a pool and zeroing out the buffer before
putting it back into the pool.
benchmark old ns/op new ns/op delta
BenchmarkListFiles100k 545249903 538832687 -1.18%
benchmark old allocs new allocs delta
BenchmarkListFiles100k 2105167 2005692 -4.73%
benchmark old bytes new bytes delta
BenchmarkListFiles100k 105903472 54831527 -48.22%
This improvement is very important if your code has to deal with a lot
of tarballs which contain a lot of files.
LGTM=dsymonds
R=golang-codereviews, dave, dsymonds, bradfitz
CC=golang-codereviews
https://golang.org/cl/108240044
変更の背景
このコミットの背景には、Goプログラムのパフォーマンス最適化、特にメモリ割り当ての削減という重要な課題があります。archive/tar
パッケージの readHeader
関数は、TARアーカイブ内の各ファイルエントリのヘッダーを読み込む際に、一時的な512バイトのバッファを毎回新しく割り当てていました。
TARアーカイブは、複数のファイルを一つのアーカイブにまとめる形式であり、各ファイルの前にはそのファイルのメタデータ(ファイル名、サイズ、パーミッションなど)を含む512バイトのヘッダーブロックが存在します。したがって、多数のファイルを含むTARアーカイブを処理する場合、readHeader
関数が繰り返し呼び出され、そのたびに新しい512バイトのバッファがヒープに割り当てられていました。
このような頻繁なメモリ割り当ては、Goのガベージコレクタ(GC)に負担をかけ、GCの実行頻度や停止時間を増加させ、結果としてアプリケーション全体のパフォーマンスを低下させる原因となります。特に、大量のファイルを扱うTARアーカイブを処理するようなシナリオでは、このオーバーヘッドが顕著になります。
このコミットの目的は、この一時バッファの割り当てを最適化し、メモリ割り当ての回数を減らすことで、archive/tar
パッケージの効率を向上させることにありました。コミットメッセージに示されているベンチマーク結果は、この最適化がメモリ割り当て数と総メモリ使用量を大幅に削減し、実行時間もわずかながら改善することを示しています。
前提知識の解説
このコミットを理解するためには、以下のGo言語およびコンピュータサイエンスの基本的な概念を理解しておく必要があります。
1. Goのメモリ管理とガベージコレクション (GC)
Goは自動メモリ管理(ガベージコレクション)を採用しています。開発者は手動でメモリを解放する必要がありません。しかし、これはメモリ割り当てがパフォーマンスに影響しないという意味ではありません。
- ヒープ割り当て:
make
やnew
を使って作成されたデータはヒープに割り当てられます。ヒープはプログラムが動的にメモリを確保する領域です。 - スタック割り当て: 関数内で宣言されたローカル変数など、寿命が短いデータはスタックに割り当てられることがあります。スタック割り当てはヒープ割り当てよりも高速です。
- ガベージコレクション: ヒープに割り当てられたメモリのうち、もはやプログラムから参照されなくなった(到達不能になった)メモリ領域を自動的に特定し、解放するプロセスです。GCはプログラムの実行を一時停止させることがあり(ストップ・ザ・ワールド)、その頻度や時間がパフォーマンスに直結します。頻繁なメモリ割り当てはGCの頻度を増加させ、アプリケーションの応答性を低下させる可能性があります。
2. archive/tar
パッケージの役割と基本的な構造
archive/tar
パッケージは、TARアーカイブ形式のファイルを読み書きするためのGoの標準ライブラリです。
- TARアーカイブ: 複数のファイルやディレクトリを一つのファイルにまとめるためのアーカイブ形式です。UNIX系システムで広く使われています。
- ヘッダーブロック: TARアーカイブ内の各ファイルエントリは、そのファイルの内容の前に512バイトのヘッダーブロックを持ちます。このヘッダーには、ファイル名、サイズ、最終更新日時、パーミッションなどのメタデータが含まれています。
blockSize
: TARアーカイブの基本的なブロックサイズは512バイトです。ヘッダーもデータもこのブロックサイズの倍数で格納されます。
3. 一時バッファの割り当てと再利用の重要性
プログラムが一時的なデータを処理するために小さなバッファを頻繁に必要とする場合、そのたびに新しいバッファを割り当てると、メモリ割り当てとGCのオーバーヘッドが大きくなります。
- バッファの再利用: 一度割り当てたバッファを使い回すことで、メモリ割り当ての回数を劇的に減らすことができます。これは、特にループ内で繰り返し同じサイズのバッファが必要となる場合に有効です。
- オブジェクトプール: 頻繁に生成・破棄されるオブジェクト(この場合はバイトスライス)をあらかじめ作成しておき、必要に応じてプールから取得し、使い終わったらプールに戻すというパターンです。これにより、GCの負担を軽減し、パフォーマンスを向上させることができます。
4. make([]byte, size)
と [size]byte
の違い
make([]byte, size)
: 指定されたサイズのバイトスライスをヒープに割り当て、そのスライスヘッダ(ポインタ、長さ、容量)を返します。これは動的なメモリ割り当てです。[size]byte
: 指定されたサイズのバイト配列を宣言します。これは通常、スタックに割り当てられるか、構造体のフィールドとして定義された場合はその構造体の一部としてメモリが確保されます。配列は固定長であり、そのサイズはコンパイル時に決定されます。
このコミットでは、make([]byte, blockSize)
によるヒープ割り当てを避け、Reader
構造体内に固定サイズの配列 [blockSize]byte
を埋め込むことで、バッファを構造体の一部として確保し、再利用を可能にしています。
5. io.Reader
と io.ReadFull
io.Reader
: データを読み込むための基本的なインターフェースです。Read(p []byte) (n int, err error)
メソッドを持ちます。io.ReadFull(r Reader, buf []byte) (n int, err error)
:r
からbuf
の容量がいっぱいになるまでデータを読み込もうとします。buf
が完全に埋まらない限り、io.ErrUnexpectedEOF
または他のエラーを返します。TARヘッダーのように固定長のデータを確実に読み込みたい場合に便利です。
技術的詳細
このコミットの技術的な核心は、archive/tar
パッケージの Reader
型がTARアーカイブのヘッダーを読み込む際に使用する一時的な512バイトのバッファの管理方法を変更した点にあります。
以前の実装での問題点
Reader.readHeader()
関数は、TARアーカイブから次のエントリのヘッダーブロックを読み込む責任を負っています。以前の実装では、この関数が呼び出されるたびに以下のコードが実行されていました。
func (tr *Reader) readHeader() *Header {
header := make([]byte, blockSize) // 毎回新しい512バイトのバッファをヒープに割り当て
// ...
if _, tr.err = io.ReadFull(tr.r, header); tr.err != nil {
return nil
}
// ...
}
ここで make([]byte, blockSize)
は、blockSize
(512バイト)の新しいバイトスライスをヒープに割り当てます。TARアーカイブには多数のファイルが含まれることが一般的であるため、readHeader
は頻繁に呼び出されます。その結果、大量の小さなオブジェクトがヒープに割り当てられ、すぐに不要になるため、ガベージコレクタが頻繁に動作し、パフォーマンスのボトルネックとなっていました。
新しい実装での解決策
新しい実装では、この一時バッファを Reader
構造体のフィールドとして埋め込むことで、バッファの再利用を可能にしました。
-
Reader
構造体へのバッファの追加:Reader
構造体にhdrBuff [blockSize]byte
というフィールドが追加されました。これは、512バイトの固定長配列であり、Reader
インスタンスが作成される際に一度だけメモリが確保されます。これにより、readHeader
が呼び出されるたびにヒープ割り当てが発生するのを防ぎます。type Reader struct { r io.Reader err error pad int64 // amount of padding (ignored) after current file entry curr numBytesReader // reader for current file entry hdrBuff [blockSize]byte // buffer to use in readHeader }
-
バッファの再利用とゼロクリア:
readHeader
関数内では、make([]byte, blockSize)
の代わりに、構造体フィールドとして定義されたtr.hdrBuff
をスライスとして利用します。func (tr *Reader) readHeader() *Header { header := tr.hdrBuff[:] // 構造体内の配列をスライスとして利用 copy(header, zeroBlock) // バッファをゼロクリア if _, tr.err = io.ReadFull(tr.r, header); tr.err != nil { return nil } // ... }
header := tr.hdrBuff[:]
:tr.hdrBuff
は配列ですが、[:]
を使うことでその配列全体を指すスライスを作成します。このスライスは新しいメモリを割り当てることなく、既存の配列のメモリを参照します。copy(header, zeroBlock)
: TARヘッダーの仕様では、未使用のフィールドはヌルバイトで埋められることが期待されます。また、以前のヘッダー情報が残っていると、次のヘッダーの解析に影響を与える可能性があるため、バッファを再利用する前にゼロクリアすることが重要です。zeroBlock
はおそらく512バイトのヌルバイトで構成される定数またはグローバル変数であり、これを使ってバッファの内容をリセットします。これにより、データの残存による潜在的なバグを防ぎます。
ベンチマーク結果の分析
コミットメッセージに含まれるベンチマーク結果は、この最適化の効果を明確に示しています。
BenchmarkListFiles100k
: 10万個のファイルを含むTARアーカイブをリストするベンチマーク。ns/op
(ナノ秒/操作): 実行時間の改善。- 旧: 545,249,903 ns/op
- 新: 538,832,687 ns/op
- デルタ: -1.18% (わずかながら高速化)
allocs
(割り当て回数): メモリ割り当ての回数の削減。- 旧: 2,105,167 回
- 新: 2,005,692 回
- デルタ: -4.73% (約5%の割り当て回数削減)
bytes
(割り当てバイト数): 総メモリ割り当て量の削減。- 旧: 105,903,472 バイト
- 新: 54,831,527 バイト
- デルタ: -48.22% (約半分に削減)
最も顕著な改善は、総メモリ割り当て量(bytes
)が約半分に削減された点です。これは、512バイトのバッファが毎回割り当てられる代わりに、Reader
インスタンスごとに一度だけ割り当てられ、それが再利用されるようになったためです。割り当て回数(allocs
)も約5%削減されており、これによりGCの負担が軽減され、結果として実行時間もわずかに改善しています。
この最適化は、特に多数の小さなファイルを含むTARアーカイブを頻繁に処理するアプリケーションにおいて、メモリ使用量とパフォーマンスの両面で大きなメリットをもたらします。
コアとなるコードの変更箇所
src/pkg/archive/tar/reader.go
ファイルにおける変更は以下の通りです。
--- a/src/pkg/archive/tar/reader.go
+++ b/src/pkg/archive/tar/reader.go
@@ -29,10 +29,11 @@ const maxNanoSecondIntSize = 9
// The Next method advances to the next file in the archive (including the first),
// and then it can be treated as an io.Reader to access the file's data.
type Reader struct {
- r io.Reader
- err error
- pad int64 // amount of padding (ignored) after current file entry
- curr numBytesReader // reader for current file entry
+ r io.Reader
+ err error
+ pad int64 // amount of padding (ignored) after current file entry
+ curr numBytesReader // reader for current file entry
+ hdrBuff [blockSize]byte // buffer to use in readHeader
}
// A numBytesReader is an io.Reader with a numBytes method, returning the number
@@ -426,7 +427,9 @@ func (tr *Reader) verifyChecksum(header []byte) bool {
}
func (tr *Reader) readHeader() *Header {
- header := make([]byte, blockSize)
+ header := tr.hdrBuff[:]
+ copy(header, zeroBlock)
+
if _, tr.err = io.ReadFull(tr.r, header); tr.err != nil {
return nil
}
コアとなるコードの解説
1. Reader
構造体への hdrBuff
フィールドの追加
- r io.Reader
- err error
- pad int64 // amount of padding (ignored) after current file entry
- curr numBytesReader // reader for current file entry
+ r io.Reader
+ err error
+ pad int64 // amount of padding (ignored) after current file entry
+ curr numBytesReader // reader for current file entry
+ hdrBuff [blockSize]byte // buffer to use in readHeader
- 変更点:
Reader
構造体にhdrBuff [blockSize]byte
という新しいフィールドが追加されました。 - 目的:
readHeader
関数内で一時的に必要となる512バイト(blockSize
は512に定義されています)のバッファを、Reader
インスタンスのライフサイクルに合わせて一度だけ確保し、再利用するためのものです。これにより、readHeader
が呼び出されるたびにヒープに新しいバッファを割り当てる必要がなくなります。[blockSize]byte
は固定サイズの配列であり、構造体の一部としてメモリが確保されます。
2. readHeader
関数内のバッファ利用方法の変更
func (tr *Reader) readHeader() *Header {
- header := make([]byte, blockSize)
+ header := tr.hdrBuff[:]
+ copy(header, zeroBlock)
+
if _, tr.err = io.ReadFull(tr.r, header); tr.err != nil {
return nil
}
- 変更点1 (
- header := make([]byte, blockSize)
から+ header := tr.hdrBuff[:]
へ):- 旧:
make([]byte, blockSize)
は、readHeader
が呼び出されるたびに新しい512バイトのバイトスライスをヒープに割り当てていました。これがメモリ割り当てのボトルネックでした。 - 新:
tr.hdrBuff[:]
は、Reader
構造体内に既に確保されているhdrBuff
配列全体を指すスライスを作成します。これにより、新しいメモリ割り当てが発生せず、既存のバッファを再利用できます。
- 旧:
- 変更点2 (
+ copy(header, zeroBlock)
の追加):- 追加:
io.ReadFull
でデータを読み込む前に、header
バッファの内容をzeroBlock
(おそらく512バイトのゼロで埋められたバイトスライス)で上書きしています。 - 目的: バッファを再利用する際、以前の読み込みで残ったデータがバッファ内に残っている可能性があります。TARヘッダーの解析は厳密であり、古いデータが残っていると誤った解析結果を招く可能性があります。
zeroBlock
でバッファをゼロクリアすることで、常にクリーンな状態でヘッダーを読み込むことが保証され、データの残存による潜在的なバグを防ぎます。
- 追加:
これらの変更により、readHeader
関数はヒープ割り当てを大幅に削減し、ガベージコレクションの負担を軽減することで、特に多数のファイルを含むTARアーカイブの処理性能を向上させています。
関連リンク
このコミットに関連する特定の外部リンクは、コミットメッセージに記載されている https://golang.org/cl/108240044
以外には見つかりませんでした。しかし、このCLリンクは、今回のコミットとは異なる syscall
パッケージの変更を指しているため、直接的な関連性はありません。
参考にした情報源リンク
- Go言語のコミットメッセージと差分 (
./commit_data/19661.txt
) - Go言語のメモリ管理とガベージコレクションに関する一般的な知識
- Go言語の
archive/tar
パッケージに関する一般的な知識 - Go言語のベンチマーク結果の読み方に関する一般的な知識
- Go言語の配列とスライスの違いに関する一般的な知識
- Go言語の
io.Reader
およびio.ReadFull
に関する一般的な知識