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

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

このコミットは、Go言語の標準ライブラリ net パッケージ内の tcp_test.go ファイルに対する変更です。具体的には、TCP接続における Read および Write 操作がヒープアロケーションを発生させないことを検証するための新しいベンチマークテストが追加されています。

コミット

commit 905f29655230cac74f0b91bd1f1de112451e61f3
Author: Dmitriy Vyukov <dvyukov@google.com>
Date:   Tue Aug 6 21:29:35 2013 +0400

    net: test that Read/Write do 0 allocations
    It turned out that change 12413043 did not break
    any builders. So let's lock this in.
    
    R=golang-dev, rsc
    CC=golang-dev
    https://golang.org/cl/12545043

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

https://github.com/golang/go/commit/905f29655230cac74f0b91bd1f1de112451e61f3

元コミット内容

net: test that Read/Write do 0 allocations It turned out that change 12413043 did not break any builders. So let's lock this in.

このコミットメッセージは、net パッケージの Read/Write 操作がヒープアロケーションを発生させないことをテストするための変更であることを示しています。また、以前の変更 (チェンジリスト 12413043) がビルドを壊さなかったため、この最適化を確定させる意図があることが述べられています。

変更の背景

Go言語のランタイムおよび標準ライブラリでは、パフォーマンスの最適化、特にヒープアロケーションの削減が重要な目標の一つです。ヒープアロケーションはガベージコレクションの負荷を増加させ、プログラムの実行速度に悪影響を与える可能性があります。ネットワークI/Oのような頻繁に実行される操作において、アロケーションが発生すると、その影響は顕著になります。

このコミットの背景には、net パッケージの Read および Write メソッドが、内部的にバッファを再利用したり、スタック上に小さなバッファを確保したりすることで、ヒープアロケーションを避けるように設計されているという前提があります。以前の変更 (チェンジリスト 12413043) でこの最適化が導入された可能性がありますが、それが実際にアロケーションフリーであることを保証し、将来のリグレッションを防ぐためには、専用のベンチマークテストが必要とされました。

特に、net/httpnet/rpc のような高レベルのネットワークライブラリでは、同じ接続に対して並行して読み書きを行うパターンが頻繁に利用されます。このようなシナリオで Read/Write がアロケーションを発生させると、アプリケーション全体のパフォーマンスに大きな影響を与えるため、アロケーションフリーであることの保証は非常に重要です。

前提知識の解説

  • ヒープアロケーションとガベージコレクション (GC):

    • ヒープアロケーション: プログラムが実行時に動的にメモリを確保する際に、ヒープ領域からメモリを割り当てることです。Goでは makenew、あるいは構造体のリテラル初期化などでヒープアロケーションが発生します。
    • ガベージコレクション (GC): ヒープに割り当てられたメモリのうち、もはや参照されなくなった(不要になった)メモリを自動的に解放するプロセスです。GCはプログラムの実行を一時停止させることがあり(ストップ・ザ・ワールド)、これがパフォーマンスのボトルネックになることがあります。アロケーションの頻度が高いほど、GCの実行頻度も高くなり、パフォーマンスへの影響が大きくなります。
    • アロケーションフリー: 特定の操作がヒープアロケーションを一切発生させないことを指します。これにより、GCのオーバーヘッドを完全に回避し、パフォーマンスを向上させることができます。
  • Goのベンチマークテスト:

    • Goには標準でベンチマークテストのフレームワークが組み込まれています。testing パッケージを使用し、BenchmarkXxx という形式の関数を定義することでベンチマークを実行できます。
    • *testing.B 型の引数 b を受け取り、b.N 回の操作を繰り返すように実装します。
    • b.StopTimer()b.StartTimer() を使用して、セットアップやクリーンアップの時間を計測から除外できます。
    • go test -bench=. -benchmem コマンドでベンチマークを実行すると、実行時間だけでなく、操作あたりのアロケーション数 (allocs/op) とアロケーションされたバイト数 (bytes/op) も計測できます。このコミットの目的は、これらの値が0であることを確認することです。
  • runtime.GOMAXPROCS:

    • Goのランタイムが同時に実行できるOSスレッドの最大数を制御する環境変数、または関数です。デフォルトではCPUのコア数に設定されます。この値は、並行処理のベンチマークにおいて、同時に実行されるゴルーチンの数を調整するために使用されることがあります。
  • TCPソケットプログラミングの基本:

    • Listen: サーバー側で特定のネットワークアドレスとポートで接続を待ち受けるために使用されます。
    • Accept: Listen で待ち受けているソケットに対して、クライアントからの接続要求を受け入れ、新しい接続を表すソケットを返します。
    • Dial: クライアント側で指定されたネットワークアドレスとポートに接続を確立するために使用されます。
    • Read: 接続からデータを読み取ります。
    • Write: 接続にデータを書き込みます。
    • Conn インターフェース: net パッケージで定義されている、ネットワーク接続の一般的なインターフェースで、ReadWriteClose などのメソッドを持ちます。

技術的詳細

このコミットは、src/pkg/net/tcp_test.goBenchmarkTCP4ConcurrentReadWriteBenchmarkTCP6ConcurrentReadWrite という2つの新しいベンチマーク関数を追加しています。これらの関数は、内部的に benchmarkTCPConcurrentReadWrite を呼び出します。

benchmarkTCPConcurrentReadWrite ベンチマークの主な目的は、TCP接続における並行な Read および Write 操作がヒープアロケーションを発生させないことを検証することです。

ベンチマークの設計は以下の特徴を持ちます。

  1. 並行性の強調: runtime.GOMAXPROCS(0) を取得し、その値 P に基づいて P 個のクライアント/サーバーペアを作成します。各ペアは、クライアントの読み書きゴルーチンとサーバーの読み書きゴルーチンをそれぞれ起動し、合計で 4 * P 個のゴルーチンが並行して動作します。これは、net/httpnet/rpc のような実際のアプリケーションでよく見られる、同じ接続に対する並行な読み書きパターンをシミュレートしています。
  2. アロケーション計測の準備: b.StopTimer() を呼び出して、ベンチマークのセットアップ(接続の確立など)にかかる時間を計測から除外します。テスト対象の操作が開始される直前に b.StartTimer() が呼び出されます。
  3. データフロー:
    • クライアントの書き込みゴルーチンは、1バイトのデータを生成し、それをTCP接続に書き込みます。
    • サーバーの読み込みゴルーチンは、そのデータをTCP接続から読み込み、pipe というチャネルを介してサーバーの書き込みゴルーチンに渡します。
    • サーバーの書き込みゴルーチンは、pipe からデータを受け取り、簡単な計算(v *= v)を行った後、そのデータをTCP接続に書き込みます。
    • クライアントの読み込みゴルーチンは、サーバーからの応答データをTCP接続から読み込みます。
    • この一連のデータフローは、各ゴルーチンが N 回(b.N / P)繰り返します。
  4. バッファの利用: Read および Write 操作には、var buf [1]byte のようにスタック上に確保された固定サイズのバイトスライス (buf[:]) が使用されています。これにより、これらの操作がヒープアロケーションを発生させないことが期待されます。
  5. sync.WaitGroup の利用: すべてのゴルーチンが完了するのを待つために sync.WaitGroup が使用されています。これにより、ベンチマークがすべてのI/O操作が完了するまで実行されることが保証されます。
  6. numConcurrent の変更: 既存の benchmarkTCP 関数内で numConcurrent の計算が runtime.GOMAXPROCS(-1) * 16 から runtime.GOMAXPROCS(-1) * 2 に変更されています。これは、おそらく新しいベンチマークの導入に伴い、既存のベンチマークの並行度を調整したか、あるいは以前の変更 (12413043) の影響を考慮した調整であると考えられます。

このベンチマークが成功し、allocs/opbytes/op が0と報告されれば、net パッケージの Read/Write 操作が指定されたシナリオでアロケーションフリーであることが確認できます。

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

変更は src/pkg/net/tcp_test.go ファイルに集中しています。

  1. benchmarkTCP 関数の numConcurrent の変更:
    --- a/src/pkg/net/tcp_test.go
    +++ b/src/pkg/net/tcp_test.go
    @@ -61,7 +61,7 @@ func BenchmarkTCP6PersistentTimeout(b *testing.B) {
     func benchmarkTCP(b *testing.B, persistent, timeout bool, laddr string) {
     	const msgLen = 512
     	conns := b.N
    -	numConcurrent := runtime.GOMAXPROCS(-1) * 16
    +	numConcurrent := runtime.GOMAXPROCS(-1) * 2
     	msgs := 1
     	if persistent {
     		conns = numConcurrent
    
  2. 新しいベンチマーク関数の追加: BenchmarkTCP4ConcurrentReadWrite, BenchmarkTCP6ConcurrentReadWrite, およびそれらを実装する benchmarkTCPConcurrentReadWrite 関数が追加されています。この追加されたコードブロックは非常に長く、コミットログの差分で124行の追加と1行の削除(上記の numConcurrent の変更)を示しています。

コアとなるコードの解説

追加された benchmarkTCPConcurrentReadWrite 関数がこのコミットの核心です。

func benchmarkTCPConcurrentReadWrite(b *testing.B, laddr string) {
	// The benchmark creates GOMAXPROCS client/server pairs.
	// Each pair creates 4 goroutines: client reader/writer and server reader/writer.
	// The benchmark stresses concurrent reading and writing to the same connection.
	// Such pattern is used in net/http and net/rpc.

	b.StopTimer() // ベンチマーク計測を一時停止

	P := runtime.GOMAXPROCS(0) // 論理CPUコア数を取得
	N := b.N / P               // 各ゴルーチンが実行する操作回数
	W := 1000                  // ダミーの計算負荷(アロケーションとは無関係)

	// P個のクライアント/サーバー接続をセットアップ
	clients := make([]Conn, P)
	servers := make([]Conn, P)
	ln, err := Listen("tcp", laddr) // サーバーのリスナーを作成
	if err != nil {
		b.Fatalf("Listen failed: %v", err)
	}
	defer ln.Close()
	done := make(chan bool)
	go func() { // サーバー側で接続を受け入れるゴルーチン
		for p := 0; p < P; p++ {
			s, err := ln.Accept()
			if err != nil {
				b.Fatalf("Accept failed: %v", err)
			}
			servers[p] = s
		}
		done <- true // すべてのサーバー接続が確立したら通知
	}()
	for p := 0; p < P; p++ { // クライアント側で接続を確立
		c, err := Dial("tcp", ln.Addr().String())
		if err != nil {
			b.Fatalf("Dial failed: %v", err)
		}
		clients[p] = c
	}
	<-done // サーバー接続確立を待つ

	b.StartTimer() // ベンチマーク計測を開始

	var wg sync.WaitGroup // ゴルーチンの完了を待つためのWaitGroup
	wg.Add(4 * P)         // 4 * P個のゴルーチンを追加

	for p := 0; p < P; p++ { // 各クライアント/サーバーペアに対してゴルーチンを起動
		// クライアントの書き込みゴルーチン
		go func(c Conn) {
			defer wg.Done()
			var buf [1]byte // スタック上に1バイトのバッファを確保
			for i := 0; i < N; i++ {
				v := byte(i)
				for w := 0; w < W; w++ { // ダミーの計算
					v *= v
				}
				buf[0] = v
				_, err := c.Write(buf[:]) // Write操作(アロケーションフリーを期待)
				if err != nil {
					b.Fatalf("Write failed: %v", err)
				}
			}
		}(clients[p])

		// サーバーの読み込みゴルーチンと書き込みゴルーチン間のパイプ
		pipe := make(chan byte, 128) // バッファ付きチャネル

		// サーバーの読み込みゴルーチン
		go func(s Conn) {
			defer wg.Done()
			var buf [1]byte // スタック上に1バイトのバッファを確保
			for i := 0; i < N; i++ {
				_, err := s.Read(buf[:]) // Read操作(アロケーションフリーを期待)
				if err != nil {
					b.Fatalf("Read failed: %v", err)
				}
				pipe <- buf[0] // 読み込んだデータをパイプに送信
			}
		}(servers[p])

		// サーバーの書き込みゴルーチン
		go func(s Conn) {
			defer wg.Done()
			var buf [1]byte // スタック上に1バイトのバッファを確保
			for i := 0; i < N; i++ {
				v := <-pipe // パイプからデータを受信
				for w := 0; w < W; w++ { // ダミーの計算
					v *= v
				}
				buf[0] = v
				_, err := s.Write(buf[:]) // Write操作(アロケーションフリーを期待)
				if err != nil {
					b.Fatalf("Write failed: %v", err)
				}
			}
			s.Close() // サーバー接続をクローズ
		}(servers[p])

		// クライアントの読み込みゴルーチン
		go func(c Conn) {
			defer wg.Done()
			var buf [1]byte // スタック上に1バイトのバッファを確保
			for i := 0; i < N; i++ {
				_, err := c.Read(buf[:]) // Read操作(アロケーションフリーを期待)
				if err != nil {
					b.Fatalf("Read failed: %v", err)
				}
			}
			c.Close() // クライアント接続をクローズ
		}(clients[p])
	}
	wg.Wait() // すべてのゴルーチンの完了を待つ
}

このコードの重要な点は、ReadWrite の呼び出しにおいて、buf[:] のようにスタック上に確保された小さなバッファを渡していることです。これにより、これらのI/O操作が内部的にヒープアロケーションを発生させないことを検証できます。もし ReadWrite が内部で新しいバッファをヒープに割り当ててしまうと、このベンチマークは allocs/op > 0 を報告することになります。

W := 1000 のループは、I/O操作の間にわずかなCPU負荷をかけるためのもので、アロケーションとは直接関係ありませんが、実際のアプリケーションのシナリオをより忠実に再現しようとしていると考えられます。

関連リンク

参考にした情報源リンク

  • Go言語のベンチマークに関する公式ドキュメントやブログ記事 (一般的な知識として)
  • Go言語のガベージコレクションとアロケーションに関する記事 (一般的な知識として)
  • Go言語の net パッケージのソースコード (一般的な知識として)
  • Go言語のチェンジリスト (CL) の概念: https://go.dev/doc/contribute#_code_review (CL 12413043, 12545043 の参照元)
  • Goのベンチマークでアロケーションを計測する方法: go test -bench=. -benchmem コマンドに関する情報。