[インデックス 14544] ファイルの概要
このコミットは、Go言語の標準ライブラリ bytes
パッケージ内の Buffer
型における ReadString
メソッドのパフォーマンス改善を目的としています。具体的には、文字列を読み取る際のメモリ割り当てとデータコピーの重複を排除することで、処理速度の向上とガベージコレクションの負荷軽減を実現しています。
コミット
commit b1c4a8efa998e0d6b6eb423ad441b349968233be
Author: Rémy Oudompheng <oudomphe@phare.normalesup.org>
Date: Mon Dec 3 14:04:18 2012 +0100
bytes: avoid duplicate malloc/copy in Buffer.ReadString
Twice faster and twice less garbage.
R=golang-dev, dave, daniel.morsing, bradfitz
CC=golang-dev
https://golang.org/cl/6849128
GitHub上でのコミットページへのリンク
https://github.com/golang/go/commit/b1c4a8efa998e0d6b6eb423ad441b349968233be
元コミット内容
bytes: avoid duplicate malloc/copy in Buffer.ReadString
このコミットは、bytes.Buffer
の ReadString
メソッドにおいて、重複するメモリ割り当て(malloc
)とデータコピーを回避することを目的としています。これにより、パフォーマンスが2倍向上し、ガベージ(不要なメモリ)の発生が半分に削減されるとされています。
変更の背景
bytes.Buffer
は、可変長のバイトシーケンスを効率的に操作するためのGo言語の基本的なデータ構造です。ファイルからの読み込み、ネットワークストリームの処理、文字列の構築など、様々なI/O操作で頻繁に利用されます。
このコミットが行われた2012年当時、Go言語はまだ比較的新しい言語であり、パフォーマンスの最適化とメモリ効率の改善が活発に行われていました。特に、標準ライブラリのコアコンポーネントにおけるボトルネックの特定と解消は、言語全体の性能向上に直結する重要な課題でした。
ReadString
メソッドは、特定の区切り文字(delim
)までデータを読み込み、それを文字列として返す機能を提供します。従来の ReadString
の実装では、内部的に ReadBytes
メソッドを呼び出し、その結果として得られたバイトスライスをさらに string()
関数で文字列に変換していました。このプロセスには、以下の非効率性が存在しました。
ReadBytes
内でのメモリ割り当てとコピー:ReadBytes
は、読み込んだバイトデータを格納するために新しいバイトスライスをmake
で割り当て、そこにデータをコピーしていました。string()
変換時のメモリ割り当てとコピー:ReadBytes
が返したバイトスライスをstring()
に変換する際、Go言語の仕様上、新しい文字列データがメモリに割り当てられ、バイトスライスの内容がそこに再度コピーされます。
このように、同じデータに対して「バイトスライスへのコピー」と「文字列へのコピー」という2回のメモリ割り当てとデータコピーが発生していました。これは、特に大量の文字列を読み込むようなシナリオにおいて、不必要なオーバーヘッドとなり、ガベージコレクションの頻度増加や実行速度の低下を招いていました。
このコミットは、この重複する処理を排除し、より効率的なデータフローを実現することで、ReadString
の性能を大幅に改善することを目的としています。
前提知識の解説
このコミットの変更内容を理解するためには、以下のGo言語の基本的な概念と bytes.Buffer
の内部動作に関する知識が必要です。
-
Go言語における文字列とバイトスライス:
- Go言語において、
string
型は不変(immutable)なバイトのシーケンスです。一度作成された文字列の内容は変更できません。 []byte
(バイトスライス)型は可変(mutable)なバイトのシーケンスです。スライスの要素は変更可能であり、スライスの長さも動的に変更できます(ただし、基盤となる配列の容量を超える場合は再割り当てが発生します)。string
と[]byte
の間には直接的な型変換は存在せず、string(byteSlice)
や[]byte(str)
のように明示的な変換が必要です。この変換は、多くの場合、元のデータのコピーを伴います。これは、文字列が不変であるという特性を維持するためです。
- Go言語において、
-
bytes.Buffer
の内部構造:bytes.Buffer
は、内部的に[]byte
型のバッファ(buf
フィールド)と、読み込み位置を示すオフセット(off
フィールド)を保持しています。- データは
buf
に書き込まれ、off
からlen(buf)
までの範囲が読み取り可能なデータとして扱われます。 Read
やWrite
などの操作は、この内部バッファとオフセットを操作することで行われます。バッファの容量が不足した場合は、自動的に拡張されます。
-
ガベージコレクション (GC):
- Go言語は自動メモリ管理(ガベージコレクション)を採用しています。プログラムが動的に割り当てたメモリのうち、もはや参照されなくなったメモリ領域をGCが自動的に解放します。
- 新しいメモリを頻繁に割り当てると、GCが実行される頻度が増加し、プログラムの実行が一時的に停止する「GCストップ・ザ・ワールド」が発生する可能性があります。これは、特に低レイテンシが求められるアプリケーションにおいてパフォーマンスのボトルネックとなることがあります。
- メモリ割り当ての回数を減らすことは、GCの負荷を軽減し、全体的なパフォーマンスを向上させるための重要な最適化手法です。
-
io.Reader
とio.ByteReader
インターフェース:bytes.Buffer
はio.Reader
およびio.ByteReader
インターフェースを実装しており、Go言語のI/Oエコシステムとシームレスに連携できます。ReadBytes
やReadString
は、これらのインターフェースを介して提供される高レベルな読み取り操作です。
これらの知識を前提として、従来の ReadString
がなぜ非効率であったのか、そして新しい実装がどのようにその問題を解決しているのかを深く理解することができます。
技術的詳細
このコミットの核心は、bytes.Buffer
の ReadString
メソッドが、内部バッファから直接データを参照する新しいヘルパーメソッド readSlice
を利用するように変更された点にあります。
変更前と変更後のデータフローを比較することで、最適化のメカニズムが明確になります。
変更前 (ReadString
の非効率性):
ReadString(delim byte)
が呼び出される。ReadString
はb.ReadBytes(delim)
を呼び出す。ReadBytes
は、内部バッファb.buf
からdelim
までのデータを検索し、そのサイズを特定する。ReadBytes
は、make([]byte, size)
を使用して新しいバイトスライスを割り当てる。ReadBytes
は、copy(line, b.buf[b.off:])
を使用して、内部バッファから新しいバイトスライスへデータをコピーする。ReadBytes
は、この新しく割り当てられたバイトスライスを返す。ReadString
は、string(bytes)
を使用して、ReadBytes
が返したバイトスライスを新しい文字列に変換する。この変換は、新しい文字列データのためのメモリ割り当てと、バイトスライスから文字列へのデータコピーを伴う。ReadString
は、この新しく割り当てられた文字列を返す。
このプロセスでは、データが内部バッファから新しいバイトスライスへ、そしてさらに新しい文字列へと、合計2回のメモリ割り当てと2回のデータコピーが発生していました。
変更後 (ReadString
の最適化):
-
新しいプライベートメソッド
readSlice(delim byte)
が導入される。このメソッドはReadBytes
と同様のロジックでデータを検索するが、新しいスライスを割り当てたりコピーしたりせず、内部バッファb.buf
の一部を直接参照するスライスを返す。line = b.buf[b.off:end]
のように、既存のバッファの特定範囲を指すスライスを生成する。b.off = end
で読み込みオフセットを更新する。readSlice
が返すスライスは、bytes.Buffer
の内部バッファへの参照であるため、後続のbytes.Buffer
操作によってその内容が変更される可能性がある点に注意が必要。
-
ReadString(delim byte)
が呼び出される。 -
ReadString
はb.readSlice(delim)
を呼び出す。 -
readSlice
は、内部バッファの該当部分を直接参照するスライスを返す。この時点では、メモリ割り当てもデータコピーも発生しない。 -
ReadString
は、string(slice)
を使用して、readSlice
が返したスライスを新しい文字列に変換する。この変換は、新しい文字列データのためのメモリ割り当てと、スライスから文字列へのデータコピーを伴う。 -
ReadString
は、この新しく割り当てられた文字列を返す。
この変更により、ReadString
の処理パスにおけるメモリ割り当てとデータコピーの回数が1回に削減されました。readSlice
が内部バッファへの参照を返すことで、中間的なバイトスライスの割り当てとコピーが不要になったためです。
ReadBytes
の変更点:
ReadBytes
メソッドも readSlice
を利用するように変更されましたが、ReadBytes
はバイトスライスを返すため、readSlice
が返した参照スライスをそのまま返すわけにはいきません。なぜなら、ReadBytes
が返すスライスは、呼び出し元が自由に利用できる独立したデータであるべきであり、bytes.Buffer
の内部状態に依存すべきではないからです。
そのため、ReadBytes
は readSlice
を呼び出した後、line = append(line, slice...)
を使用して、readSlice
が返したスライスを新しいバイトスライスにコピーしています。これにより、ReadBytes
のセマンティクス(呼び出し元に独立したバイトスライスを返す)が維持されます。結果として、ReadBytes
は以前と同様に1回のメモリ割り当てと1回のデータコピーを伴いますが、ReadString
の最適化とは独立してその動作が保証されます。
ベンチマークの追加:
BenchmarkReadString
が追加され、この最適化が実際にパフォーマンスに与える影響を測定できるようになりました。コミットメッセージにある「Twice faster and twice less garbage」という効果は、このようなベンチマークによって検証されたものと考えられます。
コアとなるコードの変更箇所
変更は主に src/pkg/bytes/buffer.go
と src/pkg/bytes/buffer_test.go
の2つのファイルで行われています。
src/pkg/bytes/buffer.go
:
--- a/src/pkg/bytes/buffer.go
+++ b/src/pkg/bytes/buffer.go
@@ -360,16 +360,24 @@ func (b *Buffer) UnreadByte() error {
// ReadBytes returns err != nil if and only if the returned data does not end in
// delim.
func (b *Buffer) ReadBytes(delim byte) (line []byte, err error) {
+\tslice, err := b.readSlice(delim)
+\t// return a copy of slice. The buffer's backing array may
+\t// be overwritten by later calls.
+\tline = append(line, slice...)
+\treturn
+}
+
+// readSlice is like readBytes but returns a reference to internal buffer data.
+func (b *Buffer) readSlice(delim byte) (line []byte, err error) {
\ti := IndexByte(b.buf[b.off:], delim)
-\tsize := i + 1
+\tend := b.off + i + 1
\tif i < 0 {
-\t\tsize = len(b.buf) - b.off
+\t\tend = len(b.buf)
\t\terr = io.EOF
\t}\
-\tline = make([]byte, size)
-\tcopy(line, b.buf[b.off:])
-\tb.off += size
-\treturn
+\tline = b.buf[b.off:end]
+\tb.off = end
+\treturn line, err
}
// ReadString reads until the first occurrence of delim in the input,
@@ -379,8 +387,8 @@ func (b *Buffer) ReadBytes(delim byte) (line []byte, err error) {
// ReadString returns err != nil if and only if the returned data does not end
// in delim.
func (b *Buffer) ReadString(delim byte) (line string, err error) {
-\tbytes, err := b.ReadBytes(delim)
-\treturn string(bytes), err
+\tslice, err := b.readSlice(delim)
+\treturn string(slice), err
}
// NewBuffer creates and initializes a new Buffer using buf as its initial
src/pkg/bytes/buffer_test.go
:
--- a/src/pkg/bytes/buffer_test.go
+++ b/src/pkg/bytes/buffer_test.go
@@ -375,6 +375,41 @@ func TestReadBytes(t *testing.T) {
}\n}\n \n+func TestReadString(t *testing.T) {\n+\tfor _, test := range readBytesTests {\n+\t\tbuf := NewBufferString(test.buffer)\n+\t\tvar err error\n+\t\tfor _, expected := range test.expected {\n+\t\t\tvar s string\n+\t\t\ts, err = buf.ReadString(test.delim)\n+\t\t\tif s != expected {\n+\t\t\t\tt.Errorf(\"expected %q, got %q\", expected, s)\n+\t\t\t}\n+\t\t\tif err != nil {\n+\t\t\t\tbreak\n+\t\t\t}\n+\t\t}\n+\t\tif err != test.err {\n+\t\t\tt.Errorf(\"expected error %v, got %v\", test.err, err)\n+\t\t}\n+\t}\n+}\n+\n+func BenchmarkReadString(b *testing.B) {\n+\tconst n = 32 << 10\n+\n+\tdata := make([]byte, n)\n+\tdata[n-1] = \'x\'\n+\tb.SetBytes(int64(n))\n+\tfor i := 0; i < b.N; i++ {\n+\t\tbuf := NewBuffer(data)\n+\t\t_, err := buf.ReadString(\'x\')\n+\t\tif err != nil {\n+\t\t\tb.Fatal(err)\n+\t\t}\n+\t}\n+}\n+\n func TestGrow(t *testing.T) {\n \tx := []byte{\'x\'}\n \ty := []byte{\'y\'}\n```
## コアとなるコードの解説
### `src/pkg/bytes/buffer.go`
1. **`func (b *Buffer) readSlice(delim byte) (line []byte, err error)` の追加**:
* この新しいプライベートメソッドが、最適化の鍵となります。
* `IndexByte(b.buf[b.off:], delim)` を使用して、現在の読み込みオフセット `b.off` から区切り文字 `delim` までのインデックス `i` を検索します。
* `end := b.off + i + 1` で、読み取るべきデータの終端インデックスを計算します。区切り文字が見つからない場合は、バッファの最後までを読み取ります。
* `line = b.buf[b.off:end]` が最も重要な部分です。ここでは、`make` や `copy` を使って新しいスライスを割り当てるのではなく、既存の内部バッファ `b.buf` の一部を直接参照する新しいスライスを作成しています。これにより、メモリ割り当てとデータコピーが不要になります。
* `b.off = end` で、バッファの読み込みオフセットを更新し、次の読み込みが正しく開始されるようにします。
* このメソッドが返す `line` スライスは、`bytes.Buffer` の内部バッファへの参照であるため、`bytes.Buffer` の後続の操作(特に書き込み操作)によって、このスライスの内容が変更される可能性があることに注意が必要です。
2. **`func (b *Buffer) ReadBytes(delim byte) (line []byte, err error)` の変更**:
* 変更前は、`make([]byte, size)` と `copy(line, b.buf[b.off:])` を使って新しいスライスを割り当て、データをコピーしていました。
* 変更後は、まず `slice, err := b.readSlice(delim)` を呼び出して、内部バッファへの参照スライスを取得します。
* 次に、`line = append(line, slice...)` を使用して、`readSlice` が返した参照スライスの内容を**新しいバイトスライスにコピー**しています。これは、`ReadBytes` が呼び出し元に独立したバイトスライスを返すというセマンティクスを維持するために必要です。もしコピーせずに参照を返してしまうと、呼び出し元が受け取ったスライスの内容が、`bytes.Buffer` の後続の操作によって意図せず変更される可能性があり、予期せぬバグにつながるためです。
3. **`func (b *Buffer) ReadString(delim byte) (line string, err error)` の変更**:
* 変更前は `bytes, err := b.ReadBytes(delim)` を呼び出し、その結果を `string(bytes)` で文字列に変換していました。これにより、`ReadBytes` 内でのコピーと、`string()` 変換時のコピーという2回のコピーが発生していました。
* 変更後は、`slice, err := b.readSlice(delim)` を直接呼び出し、内部バッファへの参照スライスを取得します。
* そして、`return string(slice), err` のように、この参照スライスを直接 `string()` 関数に渡して文字列に変換しています。Go言語の `string(byteSlice)` 変換は、バイトスライスの内容を新しい文字列データとしてメモリにコピーします。この変更により、`ReadBytes` を介した中間的なバイトスライスの割り当てとコピーが不要になり、`ReadString` の処理パスにおけるメモリ割り当てとデータコピーの回数が1回に削減されました。
### `src/pkg/bytes/buffer_test.go`
1. **`func TestReadString(t *testing.T)` の追加**:
* `ReadString` メソッドの機能が正しく動作することを確認するための新しい単体テストです。
* 既存の `readBytesTests` を再利用し、様々な入力と区切り文字に対して `ReadString` が期待される文字列を返すか、そして正しいエラーを返すかを検証しています。
2. **`func BenchmarkReadString(b *testing.B)` の追加**:
* `ReadString` メソッドのパフォーマンスを測定するためのベンチマークテストです。
* `const n = 32 << 10` で32KBのデータを生成し、そのデータに対して `ReadString` を繰り返し呼び出すことで、処理速度とメモリ割り当ての効率を測定します。
* `b.SetBytes(int64(n))` は、ベンチマークの実行中に処理されたバイト数を記録するために使用されます。これにより、1バイトあたりの処理時間などを計算できます。
* このベンチマークは、コミットメッセージにある「Twice faster and twice less garbage」という性能改善の主張を裏付けるために使用されたと考えられます。
これらの変更により、`bytes.Buffer` の `ReadString` メソッドは、より効率的なメモリ利用と高速な実行を実現し、Go言語のI/O処理の全体的なパフォーマンス向上に貢献しました。
## 関連リンク
* Go言語の `bytes` パッケージのドキュメント: [https://pkg.go.dev/bytes](https://pkg.go.dev/bytes)
* Go言語の `io` パッケージのドキュメント: [https://pkg.go.dev/io](https://pkg.go.dev/io)
* Go言語におけるスライス: [https://go.dev/blog/slices](https://go.dev/blog/slices)
* Go言語における文字列、バイト、ルーン: [https://go.dev/blog/strings](https://go.dev/blog/strings)
## 参考にした情報源リンク
* Go言語の公式ドキュメント
* Go言語のソースコード
* Go言語のコミット履歴と関連するコードレビュー(CL: Change List)
* Go言語のメモリ管理とガベージコレクションに関する一般的な情報源
* Go言語のパフォーマンス最適化に関する記事やブログポスト