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

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

このコミットは、GoランタイムにおけるWindows版CPUプロファイラの不具合を修正するものです。具体的には、プロファイラが定期的にクラッシュする問題に対処し、その根本原因であるg(ゴルーチン)コンテキストの取得ミスと、それに付随する複数の二次的な問題を解決しています。

コミット

commit eca55f5ac09221155de7b45e143ad863222ed976
Author: Dmitriy Vyukov <dvyukov@google.com>
Date:   Fri Feb 14 09:20:51 2014 +0400

    runtime: fix windows cpu profiler
    Currently it periodically fails with the following message.
    The immediate cause is the wrong base register when obtaining g
    in sys_windows_amd64/386.s.
    But there are several secondary problems as well.
    
    runtime: unknown pc 0x0 after stack split
    panic: invalid memory address or nil pointer dereference
    fatal error: panic during malloc
    [signal 0xc0000005 code=0x0 addr=0x60 pc=0x42267a]
    
    runtime stack:
    runtime.panic(0x7914c0, 0xc862af)
            c:/src/perfer/work/windows-amd64-a15f344a9efa/go/src/pkg/runtime/panic.c:217 +0x2c
    runtime: unexpected return pc for runtime.externalthreadhandler called from 0x0
    
    R=rsc, alex.brainman
    CC=golang-codereviews
    https://golang.org/cl/63310043

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

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

元コミット内容

このコミットは、GoランタイムのWindows版CPUプロファイラが定期的にクラッシュする問題を修正します。クラッシュは以下のようなエラーメッセージを伴っていました。

  • runtime: unknown pc 0x0 after stack split
  • panic: invalid memory address or nil pointer dereference
  • fatal error: panic during malloc
  • [signal 0xc0000005 code=0x0 addr=0x60 pc=0x42267a]
  • runtime: unexpected return pc for runtime.externalthreadhandler called from 0x0

直接の原因は、sys_windows_amd64/386.sアセンブリコード内でg(現在のゴルーチン)を取得する際に、誤ったベースレジスタを使用していたことでした。しかし、これ以外にもいくつかの二次的な問題が存在していました。

変更の背景

GoのCPUプロファイラは、プログラムの実行中に定期的にサンプリングを行い、CPU時間の消費状況を分析するための重要なツールです。Windows環境において、このプロファイラが不安定で、特にlibcall(GoランタイムがC/C++などの外部ライブラリ関数を呼び出すメカニズム)中にクラッシュするという問題が発生していました。

クラッシュの原因は多岐にわたりますが、コミットメッセージに示されているように、主な原因はプロファイラがg(現在のゴルーチン)のコンテキストを正しく取得できていなかったことにあります。CPUプロファイラは、サンプリング時に現在の実行コンテキスト(どのゴルーチンが、どの関数を実行しているかなど)を正確に把握する必要があります。これができないと、スタックトレースの取得に失敗したり、無効なメモリにアクセスしたりして、最終的にパニックを引き起こします。

特に、libcall中はGoのスタックとCのスタックが混在する可能性があり、プロファイラがGoのスタックフレームを正しく辿るのが難しくなります。また、Goのスタックは必要に応じて動的に拡張される「スタック分割(stack split)」のメカニズムを持っていますが、この処理中にプロファイラが介入すると、pc 0x0のような不正なプログラムカウンタを検出してしまうことがありました。

このコミットは、これらの問題を解決し、Windows環境でのCPUプロファイラの安定性と信頼性を向上させることを目的としています。

前提知識の解説

このコミットの理解には、以下のGoランタイムの概念が不可欠です。

  1. GoのCPUプロファイラ: GoのCPUプロファイラは、プログラムの実行中に定期的に(通常はミリ秒単位で)割り込みを発生させ、その時点でのプログラムカウンタ(PC)とスタックトレースを記録することで、どの関数がCPU時間を多く消費しているかを特定するツールです。これにより、パフォーマンスのボトルネックを特定し、最適化に役立てることができます。プロファイラは、シグナルハンドラやタイマー割り込みなどのOS固有のメカニズムを利用してサンプリングを行います。Windowsでは、SetTimerCreateTimerQueueTimerなどのAPIが利用されることがあります。

  2. g (Goroutine) と m (Machine) と p (Processor): Goランタイムのスケジューラは、G-M-Pモデルに基づいています。

    • g (Goroutine): Goにおける軽量な実行単位です。Goの関数呼び出しはすべてゴルーチン上で実行されます。各ゴルーチンは独自のスタックを持ちます。
    • m (Machine): OSのスレッドに対応します。Goランタイムは、OSスレッド上でゴルーチンを実行します。mは、現在のg、スタックポインタ、プログラムカウンタなどのコンテキスト情報を保持します。
    • p (Processor): 論理プロセッサに対応し、gを実行するためのコンテキストを提供します。pは、実行可能なゴルーチンのローカルキューを持ちます。 CPUプロファイラが正確な情報を取得するためには、サンプリング時に現在実行中のgを正確に特定することが極めて重要です。
  3. libcall: GoプログラムがC言語で書かれたライブラリ関数(例えば、Windows API関数)を呼び出す際に使用されるGoランタイムのメカニズムです。libcall中は、GoのスタックフレームからCのスタックフレームへと切り替わり、Goのランタイムが直接管理しないコードが実行されます。このGoとCの境界を跨ぐ呼び出しは、プロファイラがスタックトレースを正確に辿る上で複雑さを増します。特に、非同期プロファイラは、libcall中に割り込みが発生した場合に、Goのコンテキスト(gpcsp)を正確に保存・復元できる必要があります。

  4. スタック分割 (Stack Split): Goのゴルーチンは、最初は小さなスタック(数KB)で開始し、必要に応じて自動的にスタックを拡張します。これをスタック分割と呼びます。関数呼び出しの際に、現在のスタックが不足していると判断されると、より大きな新しいスタックが割り当てられ、古いスタックの内容が新しいスタックにコピーされます。このプロセス中にプロファイラが介入すると、一時的にスタックが不安定な状態になり、pc 0x0のような不正な値が検出される可能性があります。

  5. アセンブリコード (.sファイル): Goランタイムの一部は、パフォーマンスやOSとの低レベルな連携のためにアセンブリ言語で書かれています。Windows環境では、sys_windows_amd64.ssys_windows_386.sのようなファイルがこれに該当します。これらのファイルでは、レジスタの操作やメモリへの直接アクセスが行われるため、gポインタのような重要なランタイム構造体へのアクセス方法がOSやアーキテクチャに依存します。

  6. runtime.externalthreadhandler: Goランタイムが管理していない外部スレッド(例えば、OSが作成したスレッドやCライブラリが作成したスレッド)からGoのコードが呼び出される際に使用されるハンドラです。プロファイラがこのような外部スレッドからの呼び出し中にサンプリングを行う場合、Goのスタックフレームの開始点を正しく認識する必要があります。

技術的詳細

このコミットは、Windows CPUプロファイラのクラッシュ問題に対し、複数の側面から修正を加えています。

1. gポインタの取得順序とタイミングの修正 (sys_windows_386.s, sys_windows_amd64.s)

コミットメッセージで「wrong base register when obtaining g」と指摘されているように、アセンブリコード(sys_windows_386.ssys_windows_amd64.s)におけるm->libcallgm->libcallpcm->libcallspのセット順序が問題でした。

CPUプロファイラは非同期に動作するため、これらの値が完全にセットされる前にサンプリング割り込みが発生すると、不整合なデータに基づいてプロファイリングが行われ、クラッシュの原因となります。特に、m->libcallg(現在のゴルーチンgへのポインタ)が最後にセットされていたため、プロファイラがgを読み取ろうとした際に、まだ古い値や不正な値が残っている可能性がありました。

修正では、m->libcallgのセットをm->libcallpcの直後に移動し、m->libcallsp(スタックポインタ)を最後にセットするように変更されました。コメントにも「sp must be the last, because once async cpu profiler finds all three values to be non-zero, it will use them」と明記されており、プロファイラがこれらの値を使用する際の原子性を確保するための重要な変更です。これにより、プロファイラがlibcall中にサンプリングを行った際に、常に整合性の取れたgpcspの組を取得できるようになります。

2. runtime.externalthreadhandlerの登録と認識 (os_windows.c, proc.c)

runtime.externalthreadhandlerは、Goランタイムが管理していないスレッドからGoコードが呼び出された場合の入り口となる関数です。CPUプロファイラがこのような状況でスタックトレースを正しく辿るためには、このハンドラがGoのスタックのトップとして認識される必要があります。

  • src/pkg/runtime/os_windows.cでは、runtime·externalthreadhandlerpというグローバル変数にruntime·externalthreadhandler関数のアドレスが初期化時に設定されるようになりました。これにより、ランタイムの他の部分からこのハンドラのアドレスを参照できるようになります。
  • src/pkg/runtime/proc.cruntime·topofstack関数(スタックフレームがゴルーチンのトップであるかを判定する関数)では、runtime·externalthreadhandlerpが設定されている場合に、f->entry(関数のエントリポイント)がruntime·externalthreadhandlerpと一致するかどうかもチェックするようになりました。これにより、プロファイラがexternalthreadhandlerを介してGoコードに入ったスタックを正しく認識し、スタックトレースの終端を適切に判断できるようになります。

3. m->libcallgのセット位置の修正 (os_windows.c)

src/pkg/runtime/os_windows.cruntime·stdcall関数内でも、m->libcallgのセット位置が変更されています。元々はm->libcallspの後にセットされていましたが、これもアセンブリコードと同様に、m->libcallpcの直後に移動されました。これにより、libcallのコンテキスト情報がより早く、かつ一貫した順序でm構造体に保存されるようになり、非同期プロファイラがより正確な情報を取得できるようになります。

これらの変更により、Windows環境でのCPUプロファイラがlibcall中やスタック分割処理中に発生する可能性のあるコンテキストの不整合を回避し、安定して動作するようになります。

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

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

  1. src/pkg/runtime/os_windows.c

    • runtime·externalthreadhandlerp変数の宣言と、runtime·externalthreadhandler関数のプロトタイプ宣言が追加されました。
    • runtime·osinit関数内で、runtime·externalthreadhandlerpruntime·externalthreadhandlerのアドレスが代入されるようになりました。
    • runtime·stdcall関数内で、m->libcallgの代入位置がm->libcallpcの直後に移動されました。
  2. src/pkg/runtime/proc.c

    • runtime·externalthreadhandlerp変数の宣言が追加されました。
    • runtime·topofstack関数において、f->entryruntime·externalthreadhandlerpと一致する場合もスタックのトップと見なす条件が追加されました。
  3. src/pkg/runtime/sys_windows_386.s (32-bit x86 Windowsアセンブリ)

    • TEXT runtime·usleep1(SB)内のCPUプロファイラ関連のコードで、m_libcallg(BP)へのgの代入が、m_libcallpc(BP)の代入後、m_libcallsp(BP)の代入前に行われるように順序が変更されました。
  4. src/pkg/runtime/sys_windows_amd64.s (64-bit x86 Windowsアセンブリ)

    • TEXT runtime·usleep1(SB)内のCPUプロファイラ関連のコードで、m_libcallg(R13)へのgの代入が、m_libcallpc(R13)の代入後、m_libcallsp(R13)の代入前に行われるように順序が変更されました。

コアとなるコードの解説

src/pkg/runtime/os_windows.c

@@ -71,6 +71,9 @@ extern void *runtime·WriteFile;
 
 void *runtime·GetQueuedCompletionStatusEx;
 
+extern uintptr runtime·externalthreadhandlerp;
+void runtime·externalthreadhandler(void);
+
 static int32
 getproccount(void)
 {
@@ -86,6 +89,8 @@ runtime·osinit(void)
 	void *kernel32;
 	void *SetProcessPriorityBoost;
 
+\truntime·externalthreadhandlerp = (uintptr)runtime·externalthreadhandler;
+\
 	runtime·stdcall(runtime·SetConsoleCtrlHandler, 2, runtime·ctrlhandler, (uintptr)1);
 	runtime·stdcall(runtime·timeBeginPeriod, 1, (uintptr)1);
 	runtime·ncpu = getproccount();
@@ -293,9 +298,11 @@ runtime·stdcall(void *fn, int32 count, ...)
 	m->libcall.args = (uintptr*)&count + 1;
 	if(m->profilehz != 0) {
 		// leave pc/sp for cpu profiler
+\t\tm->libcallg = g;
 	\t\tm->libcallpc = (uintptr)runtime·getcallerpc(&fn);
+\t\t// sp must be the last, because once async cpu profiler finds
+\t\t// all three values to be non-zero, it will use them
 	\t\tm->libcallsp = (uintptr)runtime·getcallersp(&fn);
-\t\t\tm->libcallg = g;
 	}
 	runtime·asmcgocall(runtime·asmstdcall, &m->libcall);
 	m->libcallsp = 0;
  • runtime·externalthreadhandlerpの追加と初期化: runtime·externalthreadhandlerのアドレスをグローバル変数に保存することで、ランタイムの他の部分(特にスタックウォーカー)がこの特別なエントリポイントを認識できるようにします。
  • runtime·stdcall内のm->libcallgの移動: libcall中にCPUプロファイラがサンプリングを行う際、m->libcallpcm->libcallgm->libcallspの順で情報がセットされるように変更されました。これにより、プロファイラがこれらの値を読み取る際に、より整合性の取れたスナップショットが得られるようになります。特にspが最後にセットされることで、プロファイラがこれらの値のセットが完了したことを判断するトリガーとして利用できます。

src/pkg/runtime/proc.c

@@ -3029,6 +3029,7 @@ runtime·testSchedLocalQueueSteal(void)
 }
 
 extern void runtime·morestack(void);
+uintptr runtime·externalthreadhandlerp;
 
 // Does f mark the top of a goroutine stack?
 bool
@@ -3039,7 +3040,8 @@ runtime·topofstack(Func *f)
 		f->entry == (uintptr)runtime·mcall ||
 		f->entry == (uintptr)runtime·morestack ||
 		f->entry == (uintptr)runtime·lessstack ||
-\t\tf->entry == (uintptr)_rt0_go;
+\t\tf->entry == (uintptr)_rt0_go ||
+\t\t(runtime·externalthreadhandlerp != 0 && f->entry == runtime·externalthreadhandlerp);
 }
 
 void
  • runtime·externalthreadhandlerpの宣言: os_windows.cで初期化されたこの変数を、proc.cからも参照できるようにします。
  • runtime·topofstackの修正: この関数は、与えられた関数エントリポイントがゴルーチンのスタックの最上位(つまり、ゴルーチンの開始点)であるかどうかを判定します。runtime·externalthreadhandlerpが設定されており、かつ現在の関数のエントリポイントがそれと一致する場合、そのスタックフレームもゴルーチンのトップと見なすようになりました。これにより、外部スレッドからGoコードが呼び出された場合のスタックトレースが正しく終了できるようになります。

src/pkg/runtime/sys_windows_386.s (32-bit) および src/pkg/runtime/sys_windows_amd64.s (64-bit)

// src/pkg/runtime/sys_windows_386.s
@@ -347,10 +347,12 @@ TEXT runtime·usleep1(SB),NOSPLIT,$0
 	// leave pc/sp for cpu profiler
 	MOVL	(SP), SI
 	MOVL	SI, m_libcallpc(BP)
+\tMOVL	g(CX), SI
+\tMOVL	SI, m_libcallg(BP)
+\t// sp must be the last, because once async cpu profiler finds
+\t// all three values to be non-zero, it will use them
 	LEAL	4(SP), SI
 	MOVL	SI, m_libcallsp(BP)
-\tMOVL	g(BP), SI
-\tMOVL	SI, m_libcallg(BP)
 
 	MOVL	m_g0(BP), SI
 	CMPL	g(CX), SI

// src/pkg/runtime/sys_windows_amd64.s
@@ -342,10 +342,12 @@ TEXT runtime·usleep1(SB),NOSPLIT,$0
 	// leave pc/sp for cpu profiler
 	MOVQ	(SP), R12
 	MOVQ	R12, m_libcallpc(R13)
+\tMOVQ	g(R15), R12
+\tMOVQ	R12, m_libcallg(R13)
+\t// sp must be the last, because once async cpu profiler finds
+\t// all three values to be non-zero, it will use them
 	LEAQ	8(SP), R12
 	MOVQ	R12, m_libcallsp(R13)
-\tMOVQ	g(R13), R12
-\tMOVQ	R12, m_libcallg(R13)
 
 	MOVQ	m_g0(R13), R14
 	CMPQ	g(R15), R14
  • アセンブリコードにおけるm_libcallgの代入順序の変更: 32-bit (386.s) と 64-bit (amd64.s) の両方で、m->libcallg(現在のゴルーチンgへのポインタ)のセットが、m->libcallpc(プログラムカウンタ)のセット後、かつm->libcallsp(スタックポインタ)のセット前に行われるように変更されました。
  • 以前はgの取得が遅れており、非同期プロファイラが不完全なコンテキストを読み取る可能性がありました。この変更により、pcgが先にセットされ、spが最後にセットされることで、プロファイラがこれらの値の整合性をより確実に判断できるようになります。これは、プロファイラがこれらの3つの値がすべて非ゼロになった時点で、それらを信頼できるものとして使用するという設計に基づいています。

これらの変更は、GoランタイムがWindows上で外部関数呼び出しを行う際のコンテキスト管理を強化し、CPUプロファイラがより正確かつ安定して動作するための基盤を築いています。

関連リンク

参考にした情報源リンク