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

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

このコミットは、Go言語のWindowsネットワークポーラーにおけるパフォーマンス問題を修正するための「バンドエイド(一時的な対処)」です。特に、新しいスケジューラとの組み合わせで発生するパフォーマンス低下を改善し、GetQueuedCompletionStatus()がブロックするタイミングをGoランタイムにヒントとして与えることで、TCP接続の永続的なベンチマークにおいて大幅な性能向上を実現しています。

コミット

commit ea151041102692e52fbce353f12ca73bdc48cad7
Author: Dmitriy Vyukov <dvyukov@google.com>
Date:   Mon Mar 25 20:57:36 2013 +0400

    net: band-aid for windows network poller
    Fixes performance of the current windows network poller
    with the new scheduler.
    Gives runtime a hint when GetQueuedCompletionStatus() will block.
    Fixes #5068.
    
    benchmark                    old ns/op    new ns/op    delta
    BenchmarkTCP4Persistent        4004000        33906  -99.15%
    BenchmarkTCP4Persistent-2        21790        17513  -19.63%
    BenchmarkTCP4Persistent-4        44760        34270  -23.44%
    BenchmarkTCP4Persistent-6        45280        43000   -5.04%
    
    R=golang-dev, alex.brainman, coocood, rsc
    CC=golang-dev
    https://golang.org/cl/7612045

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

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

元コミット内容

このコミットは、Windows環境におけるGoのネットワークポーラーのパフォーマンスを改善するためのものです。特に、新しいGoスケジューラが導入された際に発生した性能低下に対処しています。GetQueuedCompletionStatus()というWindows API呼び出しがブロックする可能性があることをGoランタイムに事前に伝えるヒント機構を導入することで、この問題を解決しています。

ベンチマーク結果は以下の通り、特にBenchmarkTCP4Persistentにおいて劇的な改善が見られます。

  • BenchmarkTCP4Persistent: 4004000 ns/op から 33906 ns/op へ (-99.15%)
  • BenchmarkTCP4Persistent-2: 21790 ns/op から 17513 ns/op へ (-19.63%)
  • BenchmarkTCP4Persistent-4: 44760 ns/op から 34270 ns/op へ (-23.44%)
  • BenchmarkTCP4Persistent-6: 45280 ns/op から 43000 ns/op へ (-5.04%)

この変更は、GoのIssue #5068(現在は #3873)に関連しています。

変更の背景

Go言語は、その軽量なゴルーチンと効率的なスケジューラによって高い並行性を実現しています。しかし、特定のOS(この場合はWindows)におけるI/O操作、特にネットワークI/Oの処理は、OS固有のAPIとGoランタイムのスケジューラとの連携が重要になります。

このコミットが作成された当時、Goの新しいスケジューラが導入され、Windowsのネットワークポーラーとの間でパフォーマンス上の非効率性が発生していました。Windowsでは、非同期I/Oの完了を待つためにI/O Completion Ports (IOCP) と GetQueuedCompletionStatus APIが広く利用されます。Goのネットワークポーラーも内部的にこのAPIを使用して、I/O操作が完了した際にゴルーチンを再開させていました。

問題は、GoランタイムがGetQueuedCompletionStatusがどれくらいの期間ブロックするかを正確に予測できなかったことにあります。これにより、スケジューラがI/O待ちのゴルーチンに対して不必要なコンテキストスイッチを行ったり、CPUリソースを効率的に利用できなかったりする可能性がありました。特に、GetQueuedCompletionStatusが無限にブロックする設定(INFINITEタイムアウト)で使用される場合、ランタイムはI/O完了を待つ間、そのゴルーチンが完全にブロックされていると見なし、他のゴルーチンに切り替える必要がありました。しかし、もしI/Oがすぐに完了する場合、この切り替えはオーバーヘッドとなります。

このコミットは、GetQueuedCompletionStatusが実際にブロックする前に、ランタイムに「これからブロックする可能性がある」というヒントを与えることで、この非効率性を解消しようとしました。これにより、ランタイムはより賢明なスケジューリング判断を下せるようになり、特にネットワークI/Oが頻繁に発生するシナリオでのパフォーマンスが大幅に改善されました。

前提知識の解説

Go言語のゴルーチンとスケジューラ

Go言語の並行処理の根幹をなすのが「ゴルーチン(goroutine)」です。ゴルーチンはOSのスレッドよりもはるかに軽量な実行単位であり、数百万個を同時に実行することも可能です。Goランタイムには「スケジューラ」が組み込まれており、このスケジューラがゴルーチンをOSスレッドにマッピングし、実行を管理します。

スケジューラは、ゴルーチンがI/O操作などでブロックされる際に、そのゴルーチンを一時停止し、他の実行可能なゴルーチンにCPUを割り当てます。I/O操作が完了すると、スケジューラはブロックされていたゴルーチンを再開させます。この効率的な切り替えがGoの高い並行性を支えています。

Windows I/O Completion Ports (IOCP) と GetQueuedCompletionStatus

Windowsにおける高性能な非同期I/Oのメカニズムとして、I/O Completion Ports (IOCP) があります。IOCPは、複数の非同期I/O操作の完了通知を一元的に処理するための仕組みです。

  • 非同期I/O: I/O操作(ファイル読み書き、ネットワーク通信など)を開始した後、その完了を待たずにプログラムの実行を続行できるI/Oモデルです。完了時に通知を受け取ります。
  • I/O Completion Port (IOCP): 非同期I/O操作が完了した際に、その完了通知(完了パケット)がキューされる場所です。
  • GetQueuedCompletionStatus: IOCPから完了パケットを取得するためのAPI関数です。この関数は、完了パケットが利用可能になるまでブロックすることができます。タイムアウト値を指定することで、指定された時間だけ待機することも、すぐに利用可能なパケットがない場合に即座にリターンすることも可能です。

GoのWindowsネットワークポーラーは、このIOCPとGetQueuedCompletionStatusを利用して、ネットワークソケットからのデータ受信や送信完了を非同期に待ち受けています。

システムコールとGoランタイム

GoのプログラムがOSの機能(ファイルI/O、ネットワーク通信など)を利用する際には、「システムコール」を発行します。システムコールは、ユーザーモードのプログラムがカーネルモードのOSサービスにアクセスするためのインターフェースです。

Goランタイムは、システムコールがブロックする可能性があることを認識しています。ゴルーチンがブロックするシステムコールを実行する際、Goランタイムは通常、そのゴルーチンをOSスレッドから切り離し、そのOSスレッドを他の実行可能なゴルーチンに再利用します。これにより、システムコールがブロックしている間もCPUリソースが無駄になりません。システムコールが完了すると、ランタイムはゴルーチンを元のOSスレッド(または別の利用可能なOSスレッド)に戻し、実行を再開させます。

技術的詳細

このコミットの核心は、GoランタイムがGetQueuedCompletionStatusのブロック挙動をより正確に予測し、それに基づいてスケジューリングを最適化する点にあります。

従来のGetQueuedCompletionStatusの呼び出しは、syscall.INFINITEというタイムアウト値で設定されていました。これは、完了パケットが利用可能になるまで無限にブロックすることを意味します。この設定では、GoランタイムはI/O操作が完了するまでゴルーチンが完全にブロックされると仮定し、そのゴルーチンが実行されていたOSスレッドを解放して他のゴルーチンに割り当てていました。しかし、もしI/Oが非常に短時間で完了する場合、このスレッドの解放と再割り当てのオーバーヘッドがパフォーマンスに悪影響を与える可能性がありました。

このコミットでは、この問題を解決するために以下の戦略が導入されました。

  1. 非ブロック呼び出しの試行: src/pkg/net/fd_windows.go内のresultSrv.Run()関数において、syscall.GetQueuedCompletionStatusの呼び出しが最初にタイムアウト値0で試行されるようになりました。これは、完了パケットがすぐに利用可能であればそれを取得し、そうでなければ即座にWAIT_TIMEOUTエラーを返すことを意味します。
  2. ブロックヒントの提供: もし最初の非ブロック呼び出しがWAIT_TIMEOUTエラーを返し、かつo(Overlapped構造体へのポインタ)がnilである場合(これは、完了パケットがまだキューされていないことを示唆します)、runtime_blockingSyscallHint()という新しい関数が呼び出されます。
  3. 無限ブロック呼び出しへの移行: runtime_blockingSyscallHint()が呼び出された後、syscall.GetQueuedCompletionStatusは再びsyscall.INFINITEタイムアウトで呼び出されます。これにより、実際にブロックが必要な場合にのみ無限ブロック待機が行われます。
  4. ランタイムへのヒント伝達: runtime_blockingSyscallHint()関数は、現在のゴルーチン(g)の構造体に新しく追加されたblockingsyscallというブール値をtrueに設定します。
  5. スケジューラの最適化: src/pkg/runtime/cgocall.c内のruntime·cgocall関数(CGO呼び出し、つまりGoからC/C++コードを呼び出す際のラッパー)において、システムコールに入る直前にg->blockingsyscallの値がチェックされます。
    • もしblockingsyscalltrueであれば、runtime·entersyscallblock()が呼び出されます。この関数は、システムコールがブロックすることが予想されるため、Goスケジューラに対して、現在のゴルーチンがブロックされることを明示的に通知し、OSスレッドを他のゴルーチンに再利用する準備を促します。blockingsyscallフラグはその後falseにリセットされます。
    • blockingsyscallfalseであれば、通常のruntime·entersyscall()が呼び出されます。これは、システムコールがブロックしないか、ブロックしても非常に短時間であると予想される場合に用いられます。

このメカニズムにより、GoランタイムはGetQueuedCompletionStatusが実際にブロックする可能性が高い場合にのみ、よりコストのかかるentersyscallblockパス(OSスレッドの解放と再利用)を選択できるようになります。I/Oがすぐに完了する場合は、非ブロック呼び出しで即座に処理し、スケジューリングオーバーヘッドを回避します。これにより、特に高頻度でI/Oが発生するネットワークアプリケーションにおいて、コンテキストスイッチの最適化とCPU利用率の向上が図られ、ベンチマーク結果に見られるような大幅なパフォーマンス改善が実現されました。

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

このコミットにおける主要なコード変更は以下の3つのファイルにわたります。

  1. src/pkg/net/fd_windows.go:

    • runtime_blockingSyscallHint()関数の前方宣言が追加されました。
    • resultSrv.Run()関数内で、syscall.GetQueuedCompletionStatusの呼び出しロジックが変更されました。
      • 最初の呼び出しがsyscall.INFINITEから0(非ブロック)に変更。
      • WAIT_TIMEOUTエラーかつo == nilの場合にruntime_blockingSyscallHint()を呼び出し、その後syscall.INFINITEで再試行するロジックが追加。
  2. src/pkg/runtime/cgocall.c:

    • net·runtime_blockingSyscallHint(void)関数が追加されました。この関数は、現在のゴルーチン(g)のblockingsyscallフィールドをtrueに設定します。
    • runtime·cgocall関数内で、システムコールに入る前の処理が変更されました。g->blockingsyscalltrueの場合にruntime·entersyscallblock()を呼び出し、そうでなければruntime·entersyscall()を呼び出す条件分岐が追加されました。
  3. src/pkg/runtime/runtime.h:

    • struct G(ゴルーチン構造体)に新しいフィールドbool blockingsyscall;が追加されました。これは、次のシステムコールがブロックする可能性があることを示すヒントとして使用されます。

コアとなるコードの解説

src/pkg/net/fd_windows.go

func runtime_blockingSyscallHint()

func (s *resultSrv) Run() {
	var o *syscall.Overlapped
	var key uint32
	var r ioResult
	for {
		// 最初の試行は非ブロック (タイムアウト 0) で行う
		r.err = syscall.GetQueuedCompletionStatus(s.iocp, &(r.qty), &key, &o, 0)
		// もしタイムアウトし、かつ完了パケットがまだない場合 (o == nil)
		if r.err == syscall.Errno(syscall.WAIT_TIMEOUT) && o == nil {
			// ランタイムに次のシステムコールがブロックするヒントを与える
			runtime_blockingSyscallHint()
			// その後、無限にブロックする呼び出しを行う
			r.err = syscall.GetQueuedCompletionStatus(s.iocp, &(r.qty), &key, &o, syscall.INFINITE)
		}
		switch {
		case r.err == nil:
			// 正常に完了したIOパケットをデキュー
			// ... (後続の処理)
		}
	}
}

この変更は、WindowsのネットワークポーラーがIOCPから完了パケットを取得する際の戦略を根本的に変えています。まず非ブロックで試行することで、I/Oが既に完了している場合にすぐに処理を進め、無駄なスケジューリングオーバーヘッドを避けます。もしI/Oがまだ完了していない(WAIT_TIMEOUT)場合は、runtime_blockingSyscallHint()を呼び出してランタイムに「これからブロックするシステムコールに入る」というヒントを与え、その後で無限ブロック待機に入ります。これにより、ランタイムはブロックが予想される場合にのみ、より積極的なスケジューリング最適化を行うことができます。

src/pkg/runtime/cgocall.c

// Gives a hint that the next syscall
// executed by the current goroutine will block.
// Currently used only on windows.
void
net·runtime_blockingSyscallHint(void)
{
	g->blockingsyscall = true;
}

void
runtime·cgocall(void (*fn)(void*), void *arg)
{
	// ... (省略)

	/*
	 * The goroutine is about to enter a system call.
	 * We need to tell the scheduler about this,
	 * so it is safe to call while "in a system call", outside
	 * the $GOMAXPROCS accounting.
	 */
	if(g->blockingsyscall) {
		g->blockingsyscall = false; // フラグをリセット
		runtime·entersyscallblock(); // ブロックするシステムコールとして通知
	} else
		runtime·entersyscall(); // 通常のシステムコールとして通知
	runtime·asmcgocall(fn, arg);
	runtime·exitsyscall();
}

net·runtime_blockingSyscallHint関数は非常にシンプルで、現在のゴルーチン構造体gblockingsyscallフラグをtrueに設定するだけです。このフラグは、runtime·cgocall内でシステムコールに入る直前にチェックされます。もしblockingsyscalltrueであれば、runtime·entersyscallblock()が呼び出され、Goスケジューラにこのシステムコールがブロックする可能性が高いことを伝えます。これにより、スケジューラは現在のOSスレッドを他のゴルーチンに割り当てるなどの最適化を行うことができます。フラグは一度使用されたらfalseにリセットされます。

src/pkg/runtime/runtime.h

struct	G
{
	// ... (省略)
	bool	ispanic;
	bool	issystem;	// do not output in stack dump
	bool	isbackground;	// ignore in deadlock detector
	bool	blockingsyscall;	// hint that the next syscall will block
	int8	traceignore;	// ignore race detection events
	// ... (省略)
};

ゴルーチン構造体Gblockingsyscallという新しいフィールドが追加されました。このフィールドは、net/fd_windows.goから設定され、runtime/cgocall.cで参照されることで、Goランタイムのスケジューラにシステムコールのブロック挙動に関するヒントを伝達する役割を担います。

関連リンク

参考にした情報源リンク