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

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

このコミットは、Go言語の標準ライブラリであるsrc/pkg/testing/testing.goファイルに対する変更です。testingパッケージは、Goプログラムの自動テストをサポートするための機能を提供します。具体的には、テスト関数やベンチマーク関数の定義、テストの実行、結果のレポートなど、テストフレームワークの基盤となる機能が含まれています。このファイルは、テストのログ出力やエラー報告の際に使用される内部的なユーティリティ関数decorateを含んでおり、その効率性を改善することがこのコミットの目的です。

コミット

testingパッケージにおいて、多数の行を整形する際のメモリ肥大化を修正しました。

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

https://github.com/golang/go/commit/dd78f745c44f426578fbb8cddcc05c8227fb0ad5

元コミット内容

commit dd78f745c44f426578fbb8cddcc05c8227fb0ad5
Author: Rémy Oudompheng <oudomphe@phare.normalesup.org>
Date:   Tue Jul 17 07:56:25 2012 +0200

    testing: fix memory blowup when formatting many lines.
    
    Fixes #3830.
    
    R=golang-dev, r
    CC=golang-dev, remy
    https://golang.org/cl/6373047

変更の背景

この変更の背景には、Goのtestingパッケージがテスト結果をログに出力する際に、特に多数の行を整形して出力する場合に発生していたメモリ使用量の肥大化("memory blowup")問題があります。これは、Go言語における文字列の扱いに起因する一般的なパフォーマンス問題の一つです。

元のdecorate関数は、文字列の連結や部分文字列の抽出を繰り返すことで、ログメッセージの整形(ファイル名と行番号の付加、インデント、改行の調整)を行っていました。Go言語において文字列は不変(immutable)であるため、文字列を連結したり変更したりするたびに、新しい文字列オブジェクトがメモリ上に生成されます。短い文字列や少ない回数の操作であれば問題ありませんが、テストのログ出力のように大量のテキストを頻繁に整形するシナリオでは、この不必要な文字列オブジェクトの生成が繰り返され、結果として大量のメモリが一時的に消費され、ガベージコレクションの負荷が増大し、パフォーマンスが低下する原因となっていました。

この問題は、GoのIssue #3830として報告されており、このコミットはその解決を目的としています。

前提知識の解説

Go言語における文字列の不変性 (Immutability of strings in Go)

Go言語において、文字列(string型)は不変な値のシーケンスです。これは、一度作成された文字列の内容は変更できないことを意味します。例えば、s := "hello"という文字列があり、これにs += " world"のように別の文字列を連結しようとすると、既存の"hello"という文字列が変更されるのではなく、"hello world"という新しい文字列がメモリ上に作成され、sはその新しい文字列を参照するようになります。

この不変性は、文字列の安全な共有やハッシュ計算の効率化など、多くの利点をもたらしますが、頻繁な文字列連結操作を行う場合には、新しい文字列オブジェクトが繰り返し生成されるため、メモリの割り当てと解放(ガベージコレクション)のオーバーヘッドが大きくなるという欠点があります。

bytes.Bufferの役割と利点

bytes.Bufferは、Goの標準ライブラリbytesパッケージで提供される型で、可変長のバイトシーケンス(バイトスライス)を効率的に操作するためのバッファです。これは、特に文字列を効率的に構築する際に非常に有用です。

bytes.Bufferは内部的にバイトスライスを保持し、データを追加する際に必要に応じてその容量を拡張します。この際、毎回新しいメモリを割り当てるのではなく、ある程度の余裕を持ったバッファを確保し、その範囲内でデータを追加していくため、頻繁なメモリ再割り当てを避けることができます。これにより、不変な文字列の連結を繰り返すよりもはるかに少ないメモリ割り当てとガベージコレクションのオーバーヘッドで、大量の文字列データを効率的に構築することが可能になります。

Goのtestingパッケージの基本的な役割

testingパッケージは、Go言語でユニットテスト、ベンチマークテスト、および例(Example)を記述するためのフレームワークを提供します。開発者は、_test.goというサフィックスを持つファイルにテストコードを記述し、go testコマンドで実行します。このパッケージは、テストの成功/失敗の報告、ログ出力、並行テストの実行などの機能を提供し、Goアプリケーションの品質保証に不可欠な役割を果たします。

runtime.Callerの用途

runtime.Caller関数は、Goの標準ライブラリruntimeパッケージで提供され、現在のゴルーチン(Goの軽量スレッド)のコールスタックに関する情報を取得するために使用されます。具体的には、呼び出し元のファイル名、行番号、関数名、およびその情報が有効であるかどうかを示すブール値を返します。

testingパッケージでは、テストのログ出力やエラー報告の際に、どのファイルと行でログが記録されたか、またはエラーが発生したかを示すためにruntime.Callerが利用されます。これにより、開発者はテストの出力から問題の発生源を素早く特定できます。

技術的詳細

このコミットの主要な変更点は、testing.go内のdecorate関数の実装を、非効率な文字列連結からbytes.Bufferを使用した効率的なバイト操作へと全面的に書き換えたことです。

decorate関数の非効率性

変更前のdecorate関数は、以下のような非効率な処理を行っていました。

  1. addFileLineパラメータの存在: ファイルと行番号の付加を制御するブール値パラメータaddFileLineを持っていました。
  2. fmt.Sprintfと文字列連結: ファイルと行番号の情報を文字列に整形するためにfmt.Sprintfを使用し、その結果を元の文字列sに連結していました。
  3. 再帰的な呼び出しと文字列スライス: 複数行にわたる文字列のインデント処理において、改行文字(\n)を見つけるたびに、文字列をスライスし、その部分文字列に対してdecorate関数を再帰的に呼び出していました。この再帰的な呼び出しと文字列スライスは、大量の新しい文字列オブジェクトを生成し、メモリ使用量を増大させる主要な原因でした。
  4. len(s)の繰り返し計算: ループ内でlen(s)を繰り返し計算しており、これも小さなオーバーヘッドとなっていました。

これらの操作は、特に長いログメッセージや多数のログ行を処理する際に、大量のメモリ割り当てとコピーを引き起こし、パフォーマンスのボトルネックとなっていました。

decorate関数でのbytes.Bufferの導入とその効果

新しいdecorate関数は、以下の点で大幅に改善されています。

  1. bytes.Bufferの利用: bytes.Bufferを導入し、すべての文字列構築操作をこのバッファ上で行うように変更されました。これにより、文字列の連結や整形に伴う不必要なメモリ割り当てが劇的に削減されます。
  2. addFileLineパラメータの削除: decorate関数からaddFileLineパラメータが削除され、ファイル名と行番号の付加が常に実行されるようになりました。これにより、関数のインターフェースが簡素化され、呼び出し元(log関数)での引数も不要になりました。
  3. 効率的な行処理:
    • 入力文字列sstrings.Split(s, "\n")で一度にすべての行に分割します。これにより、元の実装のように改行文字を一つずつ探して再帰的に処理する必要がなくなりました。
    • 各行に対してループを回し、buf.WriteByte('\n')で改行、buf.WriteByte('\t')でインデントを追加し、buf.WriteString(line)で実際の行の内容をバッファに書き込みます。
    • 2行目以降の行には追加のタブインデント(buf.WriteByte('\t'))が適用されるロジックも、bytes.Buffer上で効率的に実装されています。
  4. 最終的な改行の追加: 入力文字列が改行で終わっていない場合、最後にbuf.WriteByte('\n')で改行を追加するロジックも、効率的に処理されます。

これらの変更により、decorate関数はメモリ効率が大幅に向上し、特に大量のログ出力を行うテストにおいて、メモリ使用量の肥大化とパフォーマンスの低下が解消されました。

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

src/pkg/testing/testing.goファイルのdecorate関数が全面的に書き換えられました。

--- a/src/pkg/testing/testing.go
+++ b/src/pkg/testing/testing.go
@@ -79,6 +79,7 @@
  package testing
  
  import (
+	"bytes"
  	"flag"
  	"fmt"
  	"os"
@@ -128,37 +129,42 @@ func Short() bool {
  	return *short
  }
  
-// decorate inserts the final newline if needed and indentation tabs for formatting.
-// If addFileLine is true, it also prefixes the string with the file and line of the call site.
-func decorate(s string, addFileLine bool) string {
-	if addFileLine {
-		_, file, line, ok := runtime.Caller(3) // decorate + log + public function.
-		if ok {
-			// Truncate file name at last file name separator.
-			if index := strings.LastIndex(file, "/"); index >= 0 {
-				file = file[index+1:]
-			} else if index = strings.LastIndex(file, "\\"); index >= 0 {
-				file = file[index+1:]
-			}
-		} else {
-			file = "???"
-			line = 1
-		}
-		s = fmt.Sprintf("%s:%d: %s", file, line, s)
-	}
-	s = "\t" + s // Every line is indented at least one tab.
-	n := len(s)
-	if n > 0 && s[n-1] != '\n' {
-		s += "\n"
-		n++
-	}
-	for i := 0; i < n-1; i++ { // -1 to avoid final newline
-		if s[i] == '\n' {
-			// Second and subsequent lines are indented an extra tab.
-			return s[0:i+1] + "\t" + decorate(s[i+1:n], false)
-		}
-	}
-	return s
+// decorate prefixes the string with the file and line of the call site
+// and inserts the final newline if needed and indentation tabs for formatting.
+func decorate(s string) string {
+	_, file, line, ok := runtime.Caller(3) // decorate + log + public function.
+	if ok {
+		// Truncate file name at last file name separator.
+		if index := strings.LastIndex(file, "/"); index >= 0 {
+			file = file[index+1:]
+		} else if index = strings.LastIndex(file, "\\"); index >= 0 {
+			file = file[index+1:]
+		}
+	} else {
+		file = "???"
+		line = 1
+	}
+	buf := new(bytes.Buffer)
+	fmt.Fprintf(buf, "%s:%d: ", file, line)
+
+	lines := strings.Split(s, "\n")
+	for i, line := range lines {
+		if i > 0 {
+			buf.WriteByte('\n')
+		}
+		// Every line is indented at least one tab.
+		buf.WriteByte('\t')
+		if i > 0 {
+			// Second and subsequent lines are indented an extra tab.
+			buf.WriteByte('\t')
+		}
+		buf.WriteString(line)
+	}
+	if l := len(s); l > 0 && s[len(s)-1] != '\n' {
+		// Add final new line if needed.
+		buf.WriteByte('\n')
+	}
+	return buf.String()
  }
  
  // T is a type passed to Test functions to manage test state and support formatted test logs.
@@ -204,7 +210,7 @@ func (c *common) FailNow() {
  
  // log generates the output. It's always at the same stack depth.
  func (c *common) log(s string) {
-	c.output = append(c.output, decorate(s, true)...)
+	c.output = append(c.output, decorate(s)...)
  }
  
  // Log formats its arguments using default formatting, analogous to Println(),

コアとなるコードの解説

import "bytes" の追加

bytes.Bufferを使用するために、bytesパッケージがインポートリストに追加されました。

decorate関数のシグネチャ変更

// 旧: func decorate(s string, addFileLine bool) string
// 新: func decorate(s string) string

addFileLineというブール値パラメータが削除されました。これは、ファイル名と行番号のプレフィックスが常に付加されるようになったためです。これにより、関数の呼び出し元(log関数)も簡素化されます。

ファイルと行番号の取得と整形

	_, file, line, ok := runtime.Caller(3) // decorate + log + public function.
	if ok {
		// Truncate file name at last file name separator.
		if index := strings.LastIndex(file, "/"); index >= 0 {
			file = file[index+1:]
		} else if index = strings.LastIndex(file, "\\"); index >= 0 {
			file = file[index+1:]
		}
	} else {
		file = "???"
		line = 1
	}

この部分は変更前とほぼ同じです。runtime.Caller(3)を使って、decorate関数を呼び出した元のコード(テスト関数など)のファイル名と行番号を取得します。パスからファイル名のみを抽出する処理も同様です。

bytes.Bufferの初期化とプレフィックスの書き込み

	buf := new(bytes.Buffer)
	fmt.Fprintf(buf, "%s:%d: ", file, line)

ここで新しいbytes.Bufferが作成されます。new(bytes.Buffer)&bytes.Buffer{}と同じ意味で、bytes.Bufferのゼロ値へのポインタを返します。 次に、fmt.Fprintfを使って、取得したファイル名と行番号、そしてコロンとスペース(例: main_test.go:42: )をバッファに書き込みます。これにより、ログメッセージの冒頭部分が効率的に構築されます。

入力文字列の行分割とループ処理

	lines := strings.Split(s, "\n")
	for i, line := range lines {
		if i > 0 {
			buf.WriteByte('\n')
		}
		// Every line is indented at least one tab.
		buf.WriteByte('\t')
		if i > 0 {
			// Second and subsequent lines are indented an extra tab.
			buf.WriteByte('\t')
		}
		buf.WriteString(line)
	}
  1. lines := strings.Split(s, "\n"): 入力文字列sを改行文字\nで分割し、各行を要素とする文字列スライスlinesを作成します。これにより、元の再帰的な処理が不要になります。
  2. for i, line := range lines: 分割された各行に対してループ処理を行います。
  3. if i > 0 { buf.WriteByte('\n') }: 2行目以降の行の前に改行文字をバッファに書き込みます。これにより、各行が新しい行に表示されます。
  4. buf.WriteByte('\t'): すべての行の先頭にタブ文字(インデント)を書き込みます。
  5. if i > 0 { buf.WriteByte('\t') }: 2行目以降の行には、さらに追加のタブ文字を書き込み、より深いインデントを適用します。これは、元のdecorate関数が再帰的に行っていた「2行目以降は追加のタブ」というロジックを再現しています。
  6. buf.WriteString(line): 現在の行の内容をバッファに書き込みます。

これらの操作はすべてbytes.Buffer上で行われるため、新しい文字列オブジェクトの生成が最小限に抑えられ、非常に効率的です。

最終的な改行の追加

	if l := len(s); l > 0 && s[len(s)-1] != '\n' {
		// Add final new line if needed.
		buf.WriteByte('\n')
	}

元の入力文字列sが改行で終わっていない場合、整形された出力の最後に改行を追加します。これは、ログ出力の整形規則に合わせたものです。

結果の返却

	return buf.String()

最終的に、bytes.Bufferの内容をstring型に変換して返します。Buffer.String()メソッドは、バッファの内容を新しい文字列として返しますが、これは一度の操作であり、ループ内での繰り返し生成とは異なります。

log関数の変更

--- a/src/pkg/testing/testing.go
+++ b/src/pkg/testing/testing.go
@@ -204,7 +210,7 @@ func (c *common) FailNow() {
  
  // log generates the output. It's always at the same stack depth.
  func (c *common) log(s string) {
-	c.output = append(c.output, decorate(s, true)...)
+	c.output = append(c.output, decorate(s)...)
  }

decorate関数のシグネチャ変更に伴い、log関数での呼び出しもdecorate(s, true)からdecorate(s)へと簡素化されました。

これらの変更により、decorate関数はメモリ効率とパフォーマンスが大幅に向上し、Goのテストフレームワークの堅牢性が高まりました。

関連リンク

参考にした情報源リンク