[インデックス 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.Buffer
(dec.buf
)にコピーしていました。
この「一時バッファへの読み込み」と「最終バッファへのコピー」という二段階のプロセスは、特にメッセージサイズが大きい場合に、メッセージのサイズに等しいメモリ領域を一時的に余分に消費することを意味します。例えば、1GBのメッセージをデコードする場合、一時バッファに1GB、最終バッファに1GBと、合計でピーク時に2GB近いメモリが必要となる可能性がありました。これは、メモリ使用量が重要なアプリケーションや、非常に大きなデータを扱うシステムにおいて、深刻なパフォーマンスボトルネックやリソース枯渇の原因となり得ます。
コミットメッセージに記載されているように、この変更によってRSSが1GBから775MBに、アクティブメモリが約1GBから約858MBに減少したという具体的な数値は、このメモリ効率の改善が非常に効果的であったことを示しています。これは、特にメモリフットプリントを抑えたいサーバーアプリケーションや、組み込みシステムなどにおいて重要な改善となります。
前提知識の解説
このコミットを理解するためには、以下のGo言語の概念と標準ライブラリに関する知識が必要です。
-
encoding/gob
パッケージ:- Go言語の標準ライブラリの一つで、Goのデータ構造をバイナリ形式でエンコード・デコードするためのパッケージです。
- 主にGoプログラム間でGoの値を効率的に送受信するために設計されており、RPC(Remote Procedure Call)などで利用されます。
gob
形式は自己記述的であり、エンコードされたデータには型情報が含まれるため、デコード時に受信側が事前に型を知っている必要がありません。Encoder
とDecoder
という主要な型があり、それぞれio.Writer
とio.Reader
インターフェースを介してデータの読み書きを行います。
-
io.Reader
およびio.Writer
インターフェース:- Go言語におけるI/O操作の基本的なインターフェースです。
io.Reader
はRead(p []byte) (n int, err error)
メソッドを持ち、データをバイトスライスp
に読み込みます。io.Writer
はWrite(p []byte) (n int, err error)
メソッドを持ち、バイトスライスp
のデータを書き込みます。gob.Decoder
はio.Reader
からデータを読み込み、gob.Encoder
はio.Writer
にデータを書き込みます。
-
bytes.Buffer
:bytes
パッケージに含まれる可変長のバイトバッファです。io.Reader
およびio.Writer
インターフェースを実装しており、バイトデータの読み書きを効率的に行えます。- 内部的にはバイトスライスを保持し、必要に応じてその容量を自動的に拡張します。
Write
メソッドでデータを追加し、Read
メソッドでデータを読み出すことができます。Grow(n int)
メソッドは、n
バイト分の容量を事前に確保するために使用できます。
-
io.ReadFull
関数:io
パッケージのヘルパー関数で、指定されたバイトスライスが完全に埋まるまでio.Reader
からデータを読み込もうとします。len(buf)
バイトを読み込むか、エラーが発生するまで読み込みを続けます。io.EOF
が発生した場合でも、len(buf)
バイトを読み込む前に発生した場合はio.ErrUnexpectedEOF
を返します。
-
メモリ管理とガベージコレクション (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.tmp
と dec.buf
の両方が同時にメッセージ全体を保持することになり、ピーク時のメモリ使用量がメッセージサイズの約2倍になる点です。これは、メモリの無駄であり、特にメモリが限られた環境では問題となります。
変更後の解決策:
新しい readMessage
関数では、この二重のメモリ確保を避けるために「ステージングバッファ」の概念を導入しています。
-
ステージングバッファの導入:
const maxBuf = 10 * 1024
(10KB) という定数が導入されました。これは、dec.tmp
が一度に保持するデータの最大サイズを制限するためのものです。dec.tmp
は、メッセージ全体のサイズではなく、このmaxBuf
(または残りのメッセージサイズがmaxBuf
より小さい場合はそのサイズ)の範囲で確保されるようになりました。これにより、dec.tmp
が巨大になることを防ぎます。
-
dec.buf
の事前確保:dec.buf.Grow(nbytes)
が追加されました。これは、bytes.Buffer
が最終的にnbytes
分のデータを格納するために必要なメモリを事前に確保しようと試みるものです。これにより、bytes.Buffer
がデータを書き込むたびに内部的にメモリを再割り当てする回数を減らし、効率を向上させます。
-
チャンクごとの読み込みと書き込み:
- メッセージ全体を一度に読み込むのではなく、
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
関数の主要な変更点を以下に詳述します。
-
maxBuf
定数の導入:const maxBuf = 10 * 1024
これは、一時バッファ
dec.tmp
の最大サイズを10KBに制限するための定数です。これにより、dec.tmp
がメッセージ全体を保持するような巨大なサイズになることを防ぎます。 -
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
に読み込むべきバイト数を示します。これは、残りのメッセージサイズnbytes
とmaxBuf
の小さい方になります。cap(dec.tmp) < nTmp
の条件で、dec.tmp
の現在の容量がnTmp
より小さい場合にのみ、新しいバイトスライスをmake
で作成します。これにより、不要な再割り当てを避けます。nAlloc
は、dec.tmp
を作成する際の容量で、nTmp
に少し余裕を持たせたサイズ(+100
)ですが、これもmaxBuf
を超えないように制限されます。- 最後に
dec.tmp = dec.tmp[:nTmp]
で、dec.tmp
のスライス長をnTmp
に設定します。これにより、io.ReadFull
が読み込むバイト数が制御されます。
-
dec.buf.Grow(nbytes)
の追加:dec.buf.Grow(nbytes)
これは、
bytes.Buffer
であるdec.buf
が、最終的にnbytes
分のデータを格納するために必要なメモリを事前に確保するよう指示します。これにより、後続のWrite
操作でdec.buf
が内部的にメモリを再割り当てする回数を減らし、書き込み効率が向上します。 -
チャンクごとの読み込みループ:
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
デコーダは大きなメッセージを処理する際のメモリフットプリントを大幅に削減し、より効率的なデータストリーミングを実現しています。
関連リンク
- Go issue #2539:
encoding/gob: decoder uses too much memory
- このコミットが解決した元の問題トラッカー。 - Go Code Review 6392057:
encoding/gob: reduce decoder memory
- このコミットのコードレビューページ。
参考にした情報源リンク
- Go言語公式ドキュメント
encoding/gob
パッケージ: - Go言語公式ドキュメント
bytes
パッケージ: - Go言語公式ドキュメント
io
パッケージ: - Go言語におけるメモリ管理とガベージコレクションに関する一般的な情報源(例: Goのブログ記事や公式ドキュメントのパフォーマンスに関するセクション)。
- https://go.dev/doc/effective_go#allocation_efficiency
- https://go.dev/blog/go1.5gc (Go 1.5のGC改善に関する記事だが、一般的なGCの概念理解に役立つ)
- Resident Set Size (RSS) の概念に関する一般的な情報源(オペレーティングシステムのメモリ管理に関する記事など)。
- Go言語のソースコード(
src/pkg/encoding/gob/decoder.go
)- https://github.com/golang/go/blob/master/src/encoding/gob/decoder.go (コミット当時のコードとは異なる可能性があるが、現在の実装を理解する上で参考になる)