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

[インデックス 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.BufferReadString メソッドにおいて、重複するメモリ割り当て(malloc)とデータコピーを回避することを目的としています。これにより、パフォーマンスが2倍向上し、ガベージ(不要なメモリ)の発生が半分に削減されるとされています。

変更の背景

bytes.Buffer は、可変長のバイトシーケンスを効率的に操作するためのGo言語の基本的なデータ構造です。ファイルからの読み込み、ネットワークストリームの処理、文字列の構築など、様々なI/O操作で頻繁に利用されます。

このコミットが行われた2012年当時、Go言語はまだ比較的新しい言語であり、パフォーマンスの最適化とメモリ効率の改善が活発に行われていました。特に、標準ライブラリのコアコンポーネントにおけるボトルネックの特定と解消は、言語全体の性能向上に直結する重要な課題でした。

ReadString メソッドは、特定の区切り文字(delim)までデータを読み込み、それを文字列として返す機能を提供します。従来の ReadString の実装では、内部的に ReadBytes メソッドを呼び出し、その結果として得られたバイトスライスをさらに string() 関数で文字列に変換していました。このプロセスには、以下の非効率性が存在しました。

  1. ReadBytes 内でのメモリ割り当てとコピー: ReadBytes は、読み込んだバイトデータを格納するために新しいバイトスライスを make で割り当て、そこにデータをコピーしていました。
  2. string() 変換時のメモリ割り当てとコピー: ReadBytes が返したバイトスライスを string() に変換する際、Go言語の仕様上、新しい文字列データがメモリに割り当てられ、バイトスライスの内容がそこに再度コピーされます。

このように、同じデータに対して「バイトスライスへのコピー」と「文字列へのコピー」という2回のメモリ割り当てとデータコピーが発生していました。これは、特に大量の文字列を読み込むようなシナリオにおいて、不必要なオーバーヘッドとなり、ガベージコレクションの頻度増加や実行速度の低下を招いていました。

このコミットは、この重複する処理を排除し、より効率的なデータフローを実現することで、ReadString の性能を大幅に改善することを目的としています。

前提知識の解説

このコミットの変更内容を理解するためには、以下のGo言語の基本的な概念と bytes.Buffer の内部動作に関する知識が必要です。

  1. Go言語における文字列とバイトスライス:

    • Go言語において、string 型は不変(immutable)なバイトのシーケンスです。一度作成された文字列の内容は変更できません。
    • []byte(バイトスライス)型は可変(mutable)なバイトのシーケンスです。スライスの要素は変更可能であり、スライスの長さも動的に変更できます(ただし、基盤となる配列の容量を超える場合は再割り当てが発生します)。
    • string[]byte の間には直接的な型変換は存在せず、string(byteSlice)[]byte(str) のように明示的な変換が必要です。この変換は、多くの場合、元のデータのコピーを伴います。これは、文字列が不変であるという特性を維持するためです。
  2. bytes.Buffer の内部構造:

    • bytes.Buffer は、内部的に []byte 型のバッファ(buf フィールド)と、読み込み位置を示すオフセット(off フィールド)を保持しています。
    • データは buf に書き込まれ、off から len(buf) までの範囲が読み取り可能なデータとして扱われます。
    • ReadWrite などの操作は、この内部バッファとオフセットを操作することで行われます。バッファの容量が不足した場合は、自動的に拡張されます。
  3. ガベージコレクション (GC):

    • Go言語は自動メモリ管理(ガベージコレクション)を採用しています。プログラムが動的に割り当てたメモリのうち、もはや参照されなくなったメモリ領域をGCが自動的に解放します。
    • 新しいメモリを頻繁に割り当てると、GCが実行される頻度が増加し、プログラムの実行が一時的に停止する「GCストップ・ザ・ワールド」が発生する可能性があります。これは、特に低レイテンシが求められるアプリケーションにおいてパフォーマンスのボトルネックとなることがあります。
    • メモリ割り当ての回数を減らすことは、GCの負荷を軽減し、全体的なパフォーマンスを向上させるための重要な最適化手法です。
  4. io.Readerio.ByteReader インターフェース:

    • bytes.Bufferio.Reader および io.ByteReader インターフェースを実装しており、Go言語のI/Oエコシステムとシームレスに連携できます。
    • ReadBytesReadString は、これらのインターフェースを介して提供される高レベルな読み取り操作です。

これらの知識を前提として、従来の ReadString がなぜ非効率であったのか、そして新しい実装がどのようにその問題を解決しているのかを深く理解することができます。

技術的詳細

このコミットの核心は、bytes.BufferReadString メソッドが、内部バッファから直接データを参照する新しいヘルパーメソッド readSlice を利用するように変更された点にあります。

変更前と変更後のデータフローを比較することで、最適化のメカニズムが明確になります。

変更前 (ReadString の非効率性):

  1. ReadString(delim byte) が呼び出される。
  2. ReadStringb.ReadBytes(delim) を呼び出す。
  3. ReadBytes は、内部バッファ b.buf から delim までのデータを検索し、そのサイズを特定する。
  4. ReadBytes は、make([]byte, size) を使用して新しいバイトスライスを割り当てる。
  5. ReadBytes は、copy(line, b.buf[b.off:]) を使用して、内部バッファから新しいバイトスライスへデータをコピーする。
  6. ReadBytes は、この新しく割り当てられたバイトスライスを返す。
  7. ReadString は、string(bytes) を使用して、ReadBytes が返したバイトスライスを新しい文字列に変換する。この変換は、新しい文字列データのためのメモリ割り当てと、バイトスライスから文字列へのデータコピーを伴う。
  8. ReadString は、この新しく割り当てられた文字列を返す。

このプロセスでは、データが内部バッファから新しいバイトスライスへ、そしてさらに新しい文字列へと、合計2回のメモリ割り当てと2回のデータコピーが発生していました。

変更後 (ReadString の最適化):

  1. 新しいプライベートメソッド readSlice(delim byte) が導入される。このメソッドは ReadBytes と同様のロジックでデータを検索するが、新しいスライスを割り当てたりコピーしたりせず、内部バッファ b.buf の一部を直接参照するスライスを返す。

    • line = b.buf[b.off:end] のように、既存のバッファの特定範囲を指すスライスを生成する。
    • b.off = end で読み込みオフセットを更新する。
    • readSlice が返すスライスは、bytes.Buffer の内部バッファへの参照であるため、後続の bytes.Buffer 操作によってその内容が変更される可能性がある点に注意が必要。
  2. ReadString(delim byte) が呼び出される。

  3. ReadStringb.readSlice(delim) を呼び出す。

  4. readSlice は、内部バッファの該当部分を直接参照するスライスを返す。この時点では、メモリ割り当てもデータコピーも発生しない

  5. ReadString は、string(slice) を使用して、readSlice が返したスライスを新しい文字列に変換する。この変換は、新しい文字列データのためのメモリ割り当てと、スライスから文字列へのデータコピーを伴う。

  6. ReadString は、この新しく割り当てられた文字列を返す。

この変更により、ReadString の処理パスにおけるメモリ割り当てとデータコピーの回数が1回に削減されました。readSlice が内部バッファへの参照を返すことで、中間的なバイトスライスの割り当てとコピーが不要になったためです。

ReadBytes の変更点:

ReadBytes メソッドも readSlice を利用するように変更されましたが、ReadBytes はバイトスライスを返すため、readSlice が返した参照スライスをそのまま返すわけにはいきません。なぜなら、ReadBytes が返すスライスは、呼び出し元が自由に利用できる独立したデータであるべきであり、bytes.Buffer の内部状態に依存すべきではないからです。

そのため、ReadBytesreadSlice を呼び出した後、line = append(line, slice...) を使用して、readSlice が返したスライスを新しいバイトスライスにコピーしています。これにより、ReadBytes のセマンティクス(呼び出し元に独立したバイトスライスを返す)が維持されます。結果として、ReadBytes は以前と同様に1回のメモリ割り当てと1回のデータコピーを伴いますが、ReadString の最適化とは独立してその動作が保証されます。

ベンチマークの追加:

BenchmarkReadString が追加され、この最適化が実際にパフォーマンスに与える影響を測定できるようになりました。コミットメッセージにある「Twice faster and twice less garbage」という効果は、このようなベンチマークによって検証されたものと考えられます。

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

変更は主に src/pkg/bytes/buffer.gosrc/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言語のパフォーマンス最適化に関する記事やブログポスト