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

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

このコミットは、Go言語の標準ライブラリ encoding/gob パッケージにおけるデコーダのメモリ使用量を削減するための変更です。具体的には、大きなメッセージをデコードする際に発生していた一時的なメモリコピーを排除し、メモリ効率を向上させています。

コミット

commit 37519d950d24a466ea96d7a65164ceaab17a40e0
Author: Rob Pike <r@golang.org>
Date:   Thu Jul 12 20:53:17 2012 -0700

    encoding/gob: reduce decoder memory
    Gob decoding reads a whole message into memory and then
    copies it into a bytes.Buffer. For large messages this wastes
    an entire copy of the message. In this CL, we use a staging
    buffer to avoid the large temporary.
    
    Update #2539
    RSS drops to 775MB from 1GB.
    Active memory drops to 858317048 from 1027878136,
    essentially the size of one copy of the input file.
    
    R=dsymonds, nigeltao
    CC=golang-dev
    https://golang.org/cl/6392057

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

https://github.com/golang/go/commit/37519d950d24a466ea96d7a65164ceaab17a40e0

元コミット内容

encoding/gob: reduce decoder memory

Gobデコードは、メッセージ全体をメモリに読み込み、その後 bytes.Buffer にコピーしていました。大きなメッセージの場合、これはメッセージ全体のコピーを無駄にしていました。この変更リスト(CL)では、大きな一時バッファを避けるためにステージングバッファを使用しています。

Issue #2539 を更新。 RSS(Resident Set Size)が1GBから775MBに減少。 アクティブメモリが1027878136バイトから858317048バイトに減少。これは実質的に入力ファイル1つ分のサイズに相当します。

変更の背景

このコミットの背景には、encoding/gob パッケージが大きなデータをデコードする際のメモリ効率の問題がありました。従来のデコード処理では、入力ストリームから読み込んだメッセージ全体を一度一時的なバイトスライス(dec.tmp)に格納し、その後、その内容を最終的な出力バッファである bytes.Bufferdec.buf)にコピーしていました。

この「一時バッファへの読み込み」と「最終バッファへのコピー」という二段階のプロセスは、特にメッセージサイズが大きい場合に、メッセージのサイズに等しいメモリ領域を一時的に余分に消費することを意味します。例えば、1GBのメッセージをデコードする場合、一時バッファに1GB、最終バッファに1GBと、合計でピーク時に2GB近いメモリが必要となる可能性がありました。これは、メモリ使用量が重要なアプリケーションや、非常に大きなデータを扱うシステムにおいて、深刻なパフォーマンスボトルネックやリソース枯渇の原因となり得ます。

コミットメッセージに記載されているように、この変更によってRSSが1GBから775MBに、アクティブメモリが約1GBから約858MBに減少したという具体的な数値は、このメモリ効率の改善が非常に効果的であったことを示しています。これは、特にメモリフットプリントを抑えたいサーバーアプリケーションや、組み込みシステムなどにおいて重要な改善となります。

前提知識の解説

このコミットを理解するためには、以下のGo言語の概念と標準ライブラリに関する知識が必要です。

  1. encoding/gob パッケージ:

    • Go言語の標準ライブラリの一つで、Goのデータ構造をバイナリ形式でエンコード・デコードするためのパッケージです。
    • 主にGoプログラム間でGoの値を効率的に送受信するために設計されており、RPC(Remote Procedure Call)などで利用されます。
    • gob 形式は自己記述的であり、エンコードされたデータには型情報が含まれるため、デコード時に受信側が事前に型を知っている必要がありません。
    • EncoderDecoder という主要な型があり、それぞれ io.Writerio.Reader インターフェースを介してデータの読み書きを行います。
  2. io.Reader および io.Writer インターフェース:

    • Go言語におけるI/O操作の基本的なインターフェースです。
    • io.ReaderRead(p []byte) (n int, err error) メソッドを持ち、データをバイトスライス p に読み込みます。
    • io.WriterWrite(p []byte) (n int, err error) メソッドを持ち、バイトスライス p のデータを書き込みます。
    • gob.Decoderio.Reader からデータを読み込み、gob.Encoderio.Writer にデータを書き込みます。
  3. bytes.Buffer:

    • bytes パッケージに含まれる可変長のバイトバッファです。
    • io.Reader および io.Writer インターフェースを実装しており、バイトデータの読み書きを効率的に行えます。
    • 内部的にはバイトスライスを保持し、必要に応じてその容量を自動的に拡張します。
    • Write メソッドでデータを追加し、Read メソッドでデータを読み出すことができます。
    • Grow(n int) メソッドは、n バイト分の容量を事前に確保するために使用できます。
  4. io.ReadFull 関数:

    • io パッケージのヘルパー関数で、指定されたバイトスライスが完全に埋まるまで io.Reader からデータを読み込もうとします。
    • len(buf) バイトを読み込むか、エラーが発生するまで読み込みを続けます。
    • io.EOF が発生した場合でも、len(buf) バイトを読み込む前に発生した場合は io.ErrUnexpectedEOF を返します。
  5. メモリ管理とガベージコレクション (GC):

    • Goはガベージコレクタを持つ言語であり、開発者は明示的にメモリを解放する必要がありません。
    • しかし、一時的に大量のメモリを確保すると、GCの負荷が増大し、アプリケーションのパフォーマンスに影響を与える可能性があります。
    • 特に、大きなバイトスライスを確保し、それがすぐに不要になるようなパターンは、GCがそのメモリを回収するまでの間、メモリフットプリントを高く保つ原因となります。
    • RSS (Resident Set Size) は、プロセスが物理メモリに保持しているメモリの量を示し、アクティブメモリは実際に使用されているメモリの量を示します。これらの数値の減少は、メモリ効率の改善を直接的に示します。

技術的詳細

このコミットの核心は、encoding/gob パッケージの Decoder 型の readMessage メソッドにおけるメモリ割り当てとデータ読み込みの戦略変更にあります。

変更前の問題点: 変更前の readMessage 関数は、メッセージの全長 nbytes を受け取り、まずその nbytes と同じサイズのバイトスライス dec.tmp を確保していました。そして、io.ReadFull(dec.r, dec.tmp) を使って入力ストリーム dec.r からメッセージ全体をこの dec.tmp に一度に読み込んでいました。読み込みが完了した後、dec.buf.Write(dec.tmp) を呼び出して、dec.tmp の内容を dec.buf (最終的な bytes.Buffer) にコピーしていました。

このアプローチの問題は、メッセージが非常に大きい場合(例えば数GB)、dec.tmpdec.buf の両方が同時にメッセージ全体を保持することになり、ピーク時のメモリ使用量がメッセージサイズの約2倍になる点です。これは、メモリの無駄であり、特にメモリが限られた環境では問題となります。

変更後の解決策: 新しい readMessage 関数では、この二重のメモリ確保を避けるために「ステージングバッファ」の概念を導入しています。

  1. ステージングバッファの導入:

    • const maxBuf = 10 * 1024 (10KB) という定数が導入されました。これは、dec.tmp が一度に保持するデータの最大サイズを制限するためのものです。
    • dec.tmp は、メッセージ全体のサイズではなく、この maxBuf(または残りのメッセージサイズが maxBuf より小さい場合はそのサイズ)の範囲で確保されるようになりました。これにより、dec.tmp が巨大になることを防ぎます。
  2. dec.buf の事前確保:

    • dec.buf.Grow(nbytes) が追加されました。これは、bytes.Buffer が最終的に nbytes 分のデータを格納するために必要なメモリを事前に確保しようと試みるものです。これにより、bytes.Buffer がデータを書き込むたびに内部的にメモリを再割り当てする回数を減らし、効率を向上させます。
  3. チャンクごとの読み込みと書き込み:

    • メッセージ全体を一度に読み込むのではなく、for nbytes > 0 ループを使用して、メッセージを小さなチャンクに分割して読み込むようになりました。
    • ループ内で、io.ReadFull(dec.r, dec.tmp) を使って dec.tmp にデータを読み込みます。dec.tmp のサイズは maxBuf に制限されているため、一度に読み込まれるデータ量も制限されます。
    • 読み込んだデータはすぐに dec.buf.Write(dec.tmp) を使って dec.buf に書き込まれます。
    • nbytes -= nRead で残りの読み込みバイト数を更新し、メッセージ全体が読み込まれるまでこのプロセスを繰り返します。

この変更により、dec.tmp は常に小さな(最大10KBの)一時バッファとして機能し、メッセージ全体を保持するのは dec.buf のみとなります。これにより、ピーク時のメモリ使用量が大幅に削減され、メッセージサイズの約1倍に近づきます。

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

変更は src/pkg/encoding/gob/decoder.go ファイルの readMessage 関数に集中しています。

--- a/src/pkg/encoding/gob/decoder.go
+++ b/src/pkg/encoding/gob/decoder.go
@@ -87,21 +87,38 @@ func (dec *Decoder) recvMessage() bool {
 
 // readMessage reads the next nbytes bytes from the input.
 func (dec *Decoder) readMessage(nbytes int) {
-	// Allocate the buffer.
-	if cap(dec.tmp) < nbytes {
-		dec.tmp = make([]byte, nbytes+100) // room to grow
+	// Allocate the dec.tmp buffer, up to 10KB.
+	const maxBuf = 10 * 1024
+	nTmp := nbytes
+	if nTmp > maxBuf {
+		nTmp = maxBuf
 	}
-	dec.tmp = dec.tmp[:nbytes]
+	if cap(dec.tmp) < nTmp {
+		nAlloc := nTmp + 100 // A little extra for growth.
+		if nAlloc > maxBuf {
+			nAlloc = maxBuf
+		}
+		dec.tmp = make([]byte, nAlloc)
+	}
+	dec.tmp = dec.tmp[:nTmp]
 
 	// Read the data
-	_, dec.err = io.ReadFull(dec.r, dec.tmp)
-	if dec.err != nil {
-		if dec.err == io.EOF {
-			dec.err = io.ErrUnexpectedEOF
+	dec.buf.Grow(nbytes)
+	for nbytes > 0 {
+		if nbytes < nTmp {
+			dec.tmp = dec.tmp[:nbytes]
 		}
-		return
+		var nRead int
+		nRead, dec.err = io.ReadFull(dec.r, dec.tmp)
+		if dec.err != nil {
+			if dec.err == io.EOF {
+				dec.err = io.ErrUnexpectedEOF
+			}
+			return
+		}
+		dec.buf.Write(dec.tmp)
+		nbytes -= nRead
 	}
-	dec.buf.Write(dec.tmp)
 }
 
 // toInt turns an encoded uint64 into an int, according to the marshaling rules.

コアとなるコードの解説

変更された readMessage 関数の主要な変更点を以下に詳述します。

  1. maxBuf 定数の導入:

    const maxBuf = 10 * 1024
    

    これは、一時バッファ dec.tmp の最大サイズを10KBに制限するための定数です。これにより、dec.tmp がメッセージ全体を保持するような巨大なサイズになることを防ぎます。

  2. dec.tmp のサイズ決定ロジックの変更:

    nTmp := nbytes
    if nTmp > maxBuf {
        nTmp = maxBuf
    }
    if cap(dec.tmp) < nTmp {
        nAlloc := nTmp + 100 // A little extra for growth.
        if nAlloc > maxBuf {
            nAlloc = maxBuf
        }
        dec.tmp = make([]byte, nAlloc)
    }
    dec.tmp = dec.tmp[:nTmp]
    
    • nTmp は、現在の読み込みサイクルで dec.tmp に読み込むべきバイト数を示します。これは、残りのメッセージサイズ nbytesmaxBuf の小さい方になります。
    • cap(dec.tmp) < nTmp の条件で、dec.tmp の現在の容量が nTmp より小さい場合にのみ、新しいバイトスライスを make で作成します。これにより、不要な再割り当てを避けます。
    • nAlloc は、dec.tmp を作成する際の容量で、nTmp に少し余裕を持たせたサイズ(+100)ですが、これも maxBuf を超えないように制限されます。
    • 最後に dec.tmp = dec.tmp[:nTmp] で、dec.tmp のスライス長を nTmp に設定します。これにより、io.ReadFull が読み込むバイト数が制御されます。
  3. dec.buf.Grow(nbytes) の追加:

    dec.buf.Grow(nbytes)
    

    これは、bytes.Buffer である dec.buf が、最終的に nbytes 分のデータを格納するために必要なメモリを事前に確保するよう指示します。これにより、後続の Write 操作で dec.buf が内部的にメモリを再割り当てする回数を減らし、書き込み効率が向上します。

  4. チャンクごとの読み込みループ:

    for nbytes > 0 {
        if nbytes < nTmp {
            dec.tmp = dec.tmp[:nbytes]
        }
        var nRead int
        nRead, dec.err = io.ReadFull(dec.r, dec.tmp)
        if dec.err != nil {
            if dec.err == io.EOF {
                dec.err = io.ErrUnexpectedEOF
            }
            return
        }
        dec.buf.Write(dec.tmp)
        nbytes -= nRead
    }
    
    • for nbytes > 0 ループは、メッセージのすべてのバイトが読み込まれるまで続きます。
    • if nbytes < nTmp の条件は、メッセージの残りのバイト数が nTmp(つまり maxBuf)よりも少ない場合に、dec.tmp のスライス長を残りバイト数に調整します。これにより、最後のチャンクが正確に読み込まれます。
    • io.ReadFull(dec.r, dec.tmp) で、入力ストリーム dec.r から dec.tmp にデータを読み込みます。この読み込みは、dec.tmp のサイズ(最大10KB)に制限されます。
    • エラーハンドリングは以前と同様ですが、ループ内で発生する可能性があるため、エラーが発生した場合はすぐに return します。
    • dec.buf.Write(dec.tmp) で、読み込んだチャンクを直接 dec.buf に書き込みます。これにより、メッセージ全体を一時バッファに保持することなく、直接最終バッファに転送できます。
    • nbytes -= nRead で、読み込んだバイト数を nbytes から減算し、次のループイテレーションで残りのバイト数を追跡します。

この一連の変更により、gob デコーダは大きなメッセージを処理する際のメモリフットプリントを大幅に削減し、より効率的なデータストリーミングを実現しています。

関連リンク

参考にした情報源リンク