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

[インデックス 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は自動メモリ管理(ガベージコレクション)を採用しています。開発者は手動でメモリを解放する必要がありません。しかし、これはメモリ割り当てがパフォーマンスに影響しないという意味ではありません。

  • ヒープ割り当て: makenew を使って作成されたデータはヒープに割り当てられます。ヒープはプログラムが動的にメモリを確保する領域です。
  • スタック割り当て: 関数内で宣言されたローカル変数など、寿命が短いデータはスタックに割り当てられることがあります。スタック割り当てはヒープ割り当てよりも高速です。
  • ガベージコレクション: ヒープに割り当てられたメモリのうち、もはやプログラムから参照されなくなった(到達不能になった)メモリ領域を自動的に特定し、解放するプロセスです。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.Readerio.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 構造体のフィールドとして埋め込むことで、バッファの再利用を可能にしました。

  1. 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
    }
    
  2. バッファの再利用とゼロクリア: 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 に関する一般的な知識