[インデックス 17211] ファイルの概要
このコミットは、Go言語の標準ライブラリである archive/zip
パッケージにおけるパフォーマンス改善を目的としています。具体的には、ZIPファイルの読み込み処理における不要なメモリ割り当てを削減し、特に多数のファイルを含むZIPアーカイブを扱う際のテストの実行速度を大幅に向上させています。
コミット
commit c7d352c9412de57ac5c9f5d7895540336ebaab5c
Author: Brad Fitzpatrick <bradfitz@golang.org>
Date: Tue Aug 13 14:48:08 2013 -0700
archive/zip: remove an allocation, speed up a test
Update #6138
TestOver65kFiles spends all its time garbage collecting.
Removing the 1.4 MB of allocations per each of the 65k
files brings this from 34 seconds to 0.23 seconds.
R=golang-dev, rsc
CC=golang-dev
https://golang.org/cl/12894043
GitHub上でのコミットページへのリンク
https://github.com/golang/go/commit/c7d352c9412de57ac5c9f5d7895540336ebaab5c
元コミット内容
archive/zip: remove an allocation, speed up a test
このコミットは、archive/zip
パッケージにおいて、メモリ割り当てを削減し、テストの速度を向上させることを目的としています。特に、TestOver65kFiles
というテストがガベージコレクションに多くの時間を費やしている問題を解決します。各ファイルごとに1.4MBのメモリ割り当てを削除することで、テスト実行時間を34秒から0.23秒に短縮しています。
変更の背景
この変更の背景には、Go言語の archive/zip
パッケージが、多数のファイルを含むZIPアーカイブを処理する際にパフォーマンス上のボトルネックを抱えていたという問題があります。特に、TestOver65kFiles
というテストケースは、65,000以上のファイルを扱うシナリオをシミュレートしており、このテストの実行に非常に長い時間がかかっていました。
コミットメッセージによると、このテストの実行時間の大部分がガベージコレクション(GC)に費やされていました。これは、ZIPファイルのヘッダー情報を読み取る際に、ファイルごとに約1.4MBもの不要なメモリ割り当てが発生していたためです。このような大量の短期的なメモリ割り当ては、GCの頻度と負荷を増大させ、結果としてアプリケーション全体のパフォーマンスを著しく低下させます。
この問題は、GoのIssue #6138として報告されており、このコミットはその解決策として提案されました。開発者は、io.NewSectionReader
と io.ReadFull
の組み合わせが、ZIPファイルのヘッダーを読み取る際に不必要な io.SectionReader
オブジェクトの生成と、それに伴うバッファの割り当てを引き起こしていることを特定しました。この非効率性を解消し、より直接的なファイル読み込み方法に切り替えることで、メモリ割り当てを削減し、テストだけでなく、同様のシナリオで archive/zip
パッケージを使用する実際のアプリケーションのパフォーマンスも改善することが期待されました。
また、zip_test.go
の変更では、TestOver65kFiles
テストにおいて w.Create
の代わりに w.CreateHeader
を使用し、Method: Store
を明示的に指定しています。これは、Issue 6136(圧縮メソッドに関連する問題)とIssue 6138(メモリ割り当ての問題)を回避するための措置であり、テストの信頼性と再現性を高める目的があります。
前提知識の解説
このコミットを理解するためには、以下のGo言語の概念と archive/zip
パッケージの基本的な動作に関する知識が必要です。
-
Go言語のガベージコレクション (GC): Goは自動メモリ管理(ガベージコレクション)を採用しています。プログラムがメモリを割り当てると、GCは不要になったメモリを自動的に解放します。しかし、短期間に大量の小さなオブジェクトが頻繁に割り当てられると、GCが頻繁に実行され、CPU時間を消費し、プログラムの実行を一時停止させる(ストップ・ザ・ワールド)ことで、パフォーマンスに悪影響を与えることがあります。このコミットの目的は、まさにこの不要なメモリ割り当てを減らし、GCの負荷を軽減することにあります。
-
io.Reader
インターフェース: Goのio
パッケージは、I/O操作のための基本的なインターフェースを提供します。io.Reader
はRead(p []byte) (n int, err error)
メソッドを持つインターフェースで、データを読み込むための抽象化を提供します。 -
io.ReaderAt
インターフェース:io.ReaderAt
はReadAt(p []byte, off int64) (n int, err error)
メソッドを持つインターフェースです。これは、指定されたオフセットoff
からデータを読み込むことができるランダムアクセス読み込みを可能にします。io.Reader
がシーケンシャルな読み込みに特化しているのに対し、io.ReaderAt
はファイル内の任意の位置から直接読み込むのに適しています。 -
io.SectionReader
:io.SectionReader
は、既存のio.ReaderAt
から特定のセクション(オフセットと長さで定義される範囲)をio.Reader
として提供する構造体です。これは、大きなデータストリームの一部をあたかも独立したio.Reader
であるかのように扱いたい場合に便利です。しかし、io.NewSectionReader
を呼び出すたびに新しいio.SectionReader
オブジェクトが作成され、その内部状態を管理するためのメモリが割り当てられます。 -
io.ReadFull
:io.ReadFull
は、指定されたio.Reader
から、与えられたバイトスライスが完全に埋まるまでデータを読み込もうとします。必要なバイト数を読み込む前にEOFに達した場合、エラーを返します。 -
archive/zip
パッケージの構造: Goのarchive/zip
パッケージは、ZIPアーカイブの読み書きをサポートします。ZIPファイルは、各ファイルのデータと、そのメタデータ(ファイル名、圧縮方法、サイズなど)を含む「ファイルヘッダー」で構成されています。これらのヘッダーは、ZIPファイル内の特定のオフセットに配置されています。File
構造体は、ZIPアーカイブ内の個々のファイルを表し、headerOffset
フィールドは、そのファイルのヘッダーがZIPファイル内でどこから始まるかを示します。 -
ZIP圧縮メソッド (Store vs Deflate): ZIPファイル内のデータは、様々な圧縮メソッドで保存できます。
Store
メソッドはデータを圧縮せずにそのまま保存することを意味し、Deflate
は一般的なLZ77ベースの圧縮アルゴリズムです。圧縮処理はCPUとメモリを消費するため、テストのパフォーマンスに影響を与える可能性があります。
これらの概念を理解することで、コミットがなぜ特定のAPIを使用し、それがどのようにパフォーマンスに影響を与えるのかを深く把握することができます。
技術的詳細
このコミットの技術的な核心は、archive/zip
パッケージの File
構造体の findBodyOffset
メソッドにおけるファイルヘッダーの読み込み方法の変更にあります。
変更前:
func (f *File) findBodyOffset() (int64, error) {
r := io.NewSectionReader(f.zipr, f.headerOffset, f.zipsize-f.headerOffset)
var buf [fileHeaderLen]byte
if _, err := io.ReadFull(r, buf[:]); err != nil {
return 0, err
}
// ... (ヘッダー解析ロジック)
}
変更前は、f.zipr
(これは io.ReaderAt
を実装している zip.Reader
の内部フィールド) から特定のセクションを読み取るために io.NewSectionReader
を使用していました。f.zipr
はZIPファイル全体のリーダーであり、f.headerOffset
から f.zipsize-f.headerOffset
の長さのセクションを切り出して、新しい io.SectionReader
オブジェクト r
を作成していました。その後、この r
から io.ReadFull
を使ってヘッダーデータを読み込んでいました。
このアプローチの問題点は、io.NewSectionReader
が呼び出されるたびに、新しい io.SectionReader
インスタンスがヒープに割り当てられることです。ZIPファイル内の各ファイルに対して findBodyOffset
が呼び出されるため、多数のファイルがある場合(例: 65,000ファイル)、65,000個もの io.SectionReader
オブジェクトが短期間に生成され、それぞれが約1.4MBのメモリを消費していました。これらのオブジェクトはすぐに不要になるため、ガベージコレクタが頻繁に動作し、パフォーマンスのボトルネックとなっていました。
変更後:
func (f *File) findBodyOffset() (int64, error) {
var buf [fileHeaderLen]byte
if _, err := f.zipr.ReadAt(buf[:], f.headerOffset); err != nil {
return 0, err
}
// ... (ヘッダー解析ロジック)
}
変更後では、io.NewSectionReader
の使用を完全に削除し、代わりに f.zipr.ReadAt(buf[:], f.headerOffset)
を直接呼び出すようにしました。f.zipr
は io.ReaderAt
インターフェースを実装しているため、指定されたオフセット f.headerOffset
から直接 buf
にデータを読み込むことができます。
この変更により、中間的な io.SectionReader
オブジェクトの割り当てが不要になりました。ReadAt
は、既存のリーダーから直接指定されたオフセットのデータを読み込むため、新しいオブジェクトの生成やそれに伴うメモリ割り当てが発生しません。これにより、ガベージコレクションの負荷が劇的に軽減され、TestOver65kFiles
の実行時間が34秒から0.23秒へと大幅に短縮されました。
zip_test.go
の変更点も重要です。
変更前:
func TestOver65kFiles(t *testing.T) {
if testing.Short() {
t.Skip("slow test; skipping")
}
// ...
for i := 0; i < nFiles; i++ {
_, err := w.Create(fmt.Sprintf("%d.dat", i))
// ...
}
// ...
}
変更前は、TestOver65kFiles
が testing.Short()
フラグによってスキップされる可能性がありました。また、w.Create
を使用してファイルをZIPアーカイブに追加していました。w.Create
はデフォルトで Deflate
圧縮を使用する可能性があります。
変更後:
func TestOver65kFiles(t *testing.T) {
// if testing.Short() { // コメントアウトまたは削除
// t.Skip("slow test; skipping")
// }
// ...
for i := 0; i < nFiles; i++ {
_, err := w.CreateHeader(&FileHeader{
Name: fmt.Sprintf("%d.dat", i),
Method: Store, // avoid Issue 6136 and Issue 6138
})
// ...
}
// ...
}
変更後では、testing.Short()
によるスキップ条件が削除(またはコメントアウト)され、このテストが常に実行されるようになりました。これは、パフォーマンス改善によりテストがもはや「遅いテスト」ではなくなったためです。
さらに重要なのは、w.Create
の代わりに w.CreateHeader
を使用し、Method: Store
を明示的に指定している点です。
w.CreateHeader
を使用することで、FileHeader
を直接指定してファイルを作成できます。Method: Store
を指定することで、データを圧縮せずにそのまま保存するように指示しています。これにより、Issue 6136(圧縮に関連する問題)とIssue 6138(メモリ割り当ての問題)の両方を回避し、テストの実行がより安定し、かつコミットの意図するパフォーマンス改善が明確に測定できるようになります。圧縮処理はそれ自体がCPUとメモリを消費するため、Store
を使用することで、ヘッダー読み込みのパフォーマンス改善に焦点を当てたテストが可能になります。
これらの変更は、GoのI/Oインターフェースの適切な使用と、メモリ割り当ての最適化がいかにパフォーマンスに大きな影響を与えるかを示す良い例です。
コアとなるコードの変更箇所
src/pkg/archive/zip/reader.go
--- a/src/pkg/archive/zip/reader.go
+++ b/src/pkg/archive/zip/reader.go
@@ -179,9 +179,8 @@ func (r *checksumReader) Close() error { return r.rc.Close() }\n // findBodyOffset does the minimum work to verify the file has a header\n // and returns the file body offset.\n func (f *File) findBodyOffset() (int64, error) {\n-\tr := io.NewSectionReader(f.zipr, f.headerOffset, f.zipsize-f.headerOffset)\n \tvar buf [fileHeaderLen]byte\n-\tif _, err := io.ReadFull(r, buf[:]); err != nil {\n+\tif _, err := f.zipr.ReadAt(buf[:], f.headerOffset); err != nil {\n \t\treturn 0, err\n \t}\n \tb := readBuf(buf[:])
src/pkg/archive/zip/zip_test.go
--- a/src/pkg/archive/zip/zip_test.go
+++ b/src/pkg/archive/zip/zip_test.go
@@ -17,14 +17,14 @@ import (\n )\n \n func TestOver65kFiles(t *testing.T) {\n-\tif testing.Short() {\n-\t\tt.Skip("slow test; skipping")\n-\t}\n \tbuf := new(bytes.Buffer)\n \tw := NewWriter(buf)\n \tconst nFiles = (1 << 16) + 42\n \tfor i := 0; i < nFiles; i++ {\n-\t\t_, err := w.Create(fmt.Sprintf("%d.dat", i))\n+\t\t_, err := w.CreateHeader(&FileHeader{\n+\t\t\tName: fmt.Sprintf("%d.dat", i),\n+\t\t\tMethod: Store, // avoid Issue 6136 and Issue 6138\n+\t\t})\n \t\tif err != nil {\n \t\t\tt.Fatalf("creating file %d: %v", i, err)\n \t\t}\n```
## コアとなるコードの解説
### `src/pkg/archive/zip/reader.go` の変更
`findBodyOffset` 関数は、ZIPアーカイブ内の個々のファイルのヘッダーを読み取り、そのボディ(データ)のオフセットを特定する役割を担っています。
* **削除された行**:
```go
r := io.NewSectionReader(f.zipr, f.headerOffset, f.zipsize-f.headerOffset)
```
この行は、`f.zipr` (ZIPリーダー全体) から、現在のファイルのヘッダーが始まるオフセット `f.headerOffset` からのセクションを切り出す `io.SectionReader` を作成していました。この `io.SectionReader` オブジェクトの生成が、ファイルごとに約1.4MBのメモリ割り当てを引き起こし、ガベージコレクションの頻発につながっていました。
* **変更された行**:
```diff
- if _, err := io.ReadFull(r, buf[:]); err != nil {
+ if _, err := f.zipr.ReadAt(buf[:], f.headerOffset); err != nil {
```
変更前は、新しく作成された `io.SectionReader` `r` を介して `io.ReadFull` を呼び出し、ヘッダーデータを `buf` に読み込んでいました。
変更後では、`f.zipr` が `io.ReaderAt` インターフェースを実装していることを利用し、`f.zipr.ReadAt` を直接呼び出すようにしました。これにより、`f.headerOffset` から直接 `buf` に必要なバイト数(`fileHeaderLen`)を読み込むことができます。中間的な `io.SectionReader` オブジェクトの生成が不要になったため、メモリ割り当てが削減され、パフォーマンスが大幅に向上しました。
### `src/pkg/archive/zip/zip_test.go` の変更
`TestOver65kFiles` は、65,000以上のファイルを含む大規模なZIPアーカイブの読み込みをテストするためのベンチマーク的なテストです。
* **削除された行**:
```go
if testing.Short() {
t.Skip("slow test; skipping")
}
```
この行は、`go test -short` コマンドが実行された場合に、このテストをスキップするためのものでした。コミットの目的がこのテストの速度を劇的に改善することであったため、もはや「遅いテスト」ではなくなり、常に実行されるべきであるという判断から削除されました。
* **変更された行**:
```diff
- _, err := w.Create(fmt.Sprintf("%d.dat", i))
+ _, err := w.CreateHeader(&FileHeader{\n+\t\t\tName: fmt.Sprintf("%d.dat", i),\n+\t\t\tMethod: Store, // avoid Issue 6136 and Issue 6138\n+\t\t})
```
変更前は、`w.Create` を使用してZIPアーカイブにファイルを追加していました。`w.Create` はデフォルトの圧縮メソッド(通常は `Deflate`)を使用します。
変更後では、`w.CreateHeader` を使用し、`FileHeader` を明示的に指定しています。ここで重要なのは `Method: Store` を設定している点です。
* `Method: Store` は、ファイルを圧縮せずにそのままZIPアーカイブに保存することを意味します。これにより、圧縮・解凍処理によるオーバーヘッドが排除され、テストの実行時間が短縮されます。
* コメント `// avoid Issue 6136 and Issue 6138` が示すように、この変更は、圧縮に関連する潜在的な問題(Issue 6136)と、このコミットが解決しようとしているメモリ割り当ての問題(Issue 6138)の両方を回避し、テストの信頼性とパフォーマンス測定の正確性を高めるためのものです。
これらの変更は、`archive/zip` パッケージの効率性を向上させ、特に多数のファイルを扱うシナリオでのGoアプリケーションのパフォーマンスを改善する上で重要な役割を果たしています。
## 関連リンク
* Go Issue 6138: `archive/zip: TestOver65kFiles is slow` - このコミットが解決した問題の元の報告。
* [https://github.com/golang/go/issues/6138](https://github.com/golang/go/issues/6138)
* Go Issue 6136: `archive/zip: Create doesn't set Method to Deflate` - `zip_test.go` の変更で `Method: Store` を指定する理由の一つとなった関連Issue。
* [https://github.com/golang/go/issues/6136](https://github.com/golang/go/issues/6136)
* Go CL 12894043: `archive/zip: remove an allocation, speed up a test` - このコミットのGo Code Reviewサイトでの変更リスト。
* [https://golang.org/cl/12894043](https://golang.org/cl/12894043)
## 参考にした情報源リンク
* Go言語の公式ドキュメント:
* `io` パッケージ: [https://pkg.go.dev/io](https://pkg.go.dev/io)
* `archive/zip` パッケージ: [https://pkg.go.dev/archive/zip](https://pkg.go.dev/archive/zip)
* Go言語のガベージコレクションに関する一般的な情報源 (例: Goのメモリ管理、GCの仕組みなど)
* (特定のURLは提供しませんが、GoのGCに関するブログ記事や公式ドキュメントを参照しました。)
* Go言語のテストに関する情報源 (例: `testing` パッケージ、`testing.Short()` など)
* [https://pkg.go.dev/testing](https://pkg.go.dev/testing)
* GitHubのgolang/goリポジトリ:
* [https://github.com/golang/go](https://github.com/golang/go)
* Go Code Review:
* [https://go-review.googlesource.com/](https://go-review.googlesource.com/)
* 一般的なZIPファイルフォーマットに関する情報 (ヘッダー構造など)
* (特定のURLは提供しませんが、ZIPファイルフォーマットの仕様に関する情報を参照しました。)