[インデックス 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ランタイムの概念が不可欠です。
-
GoのCPUプロファイラ: GoのCPUプロファイラは、プログラムの実行中に定期的に(通常はミリ秒単位で)割り込みを発生させ、その時点でのプログラムカウンタ(PC)とスタックトレースを記録することで、どの関数がCPU時間を多く消費しているかを特定するツールです。これにより、パフォーマンスのボトルネックを特定し、最適化に役立てることができます。プロファイラは、シグナルハンドラやタイマー割り込みなどのOS固有のメカニズムを利用してサンプリングを行います。Windowsでは、
SetTimer
やCreateTimerQueueTimer
などのAPIが利用されることがあります。 -
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
を正確に特定することが極めて重要です。
-
libcall
: GoプログラムがC言語で書かれたライブラリ関数(例えば、Windows API関数)を呼び出す際に使用されるGoランタイムのメカニズムです。libcall
中は、GoのスタックフレームからCのスタックフレームへと切り替わり、Goのランタイムが直接管理しないコードが実行されます。このGoとCの境界を跨ぐ呼び出しは、プロファイラがスタックトレースを正確に辿る上で複雑さを増します。特に、非同期プロファイラは、libcall
中に割り込みが発生した場合に、Goのコンテキスト(g
、pc
、sp
)を正確に保存・復元できる必要があります。 -
スタック分割 (Stack Split): Goのゴルーチンは、最初は小さなスタック(数KB)で開始し、必要に応じて自動的にスタックを拡張します。これをスタック分割と呼びます。関数呼び出しの際に、現在のスタックが不足していると判断されると、より大きな新しいスタックが割り当てられ、古いスタックの内容が新しいスタックにコピーされます。このプロセス中にプロファイラが介入すると、一時的にスタックが不安定な状態になり、
pc 0x0
のような不正な値が検出される可能性があります。 -
アセンブリコード (
.s
ファイル): Goランタイムの一部は、パフォーマンスやOSとの低レベルな連携のためにアセンブリ言語で書かれています。Windows環境では、sys_windows_amd64.s
やsys_windows_386.s
のようなファイルがこれに該当します。これらのファイルでは、レジスタの操作やメモリへの直接アクセスが行われるため、g
ポインタのような重要なランタイム構造体へのアクセス方法がOSやアーキテクチャに依存します。 -
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.s
とsys_windows_amd64.s
)におけるm->libcallg
、m->libcallpc
、m->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
中にサンプリングを行った際に、常に整合性の取れたg
、pc
、sp
の組を取得できるようになります。
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.c
のruntime·topofstack
関数(スタックフレームがゴルーチンのトップであるかを判定する関数)では、runtime·externalthreadhandlerp
が設定されている場合に、f->entry
(関数のエントリポイント)がruntime·externalthreadhandlerp
と一致するかどうかもチェックするようになりました。これにより、プロファイラがexternalthreadhandler
を介してGoコードに入ったスタックを正しく認識し、スタックトレースの終端を適切に判断できるようになります。
3. m->libcallg
のセット位置の修正 (os_windows.c
)
src/pkg/runtime/os_windows.c
のruntime·stdcall
関数内でも、m->libcallg
のセット位置が変更されています。元々はm->libcallsp
の後にセットされていましたが、これもアセンブリコードと同様に、m->libcallpc
の直後に移動されました。これにより、libcall
のコンテキスト情報がより早く、かつ一貫した順序でm
構造体に保存されるようになり、非同期プロファイラがより正確な情報を取得できるようになります。
これらの変更により、Windows環境でのCPUプロファイラがlibcall
中やスタック分割処理中に発生する可能性のあるコンテキストの不整合を回避し、安定して動作するようになります。
コアとなるコードの変更箇所
このコミットによる主要なコード変更は以下の4つのファイルにわたります。
-
src/pkg/runtime/os_windows.c
runtime·externalthreadhandlerp
変数の宣言と、runtime·externalthreadhandler
関数のプロトタイプ宣言が追加されました。runtime·osinit
関数内で、runtime·externalthreadhandlerp
にruntime·externalthreadhandler
のアドレスが代入されるようになりました。runtime·stdcall
関数内で、m->libcallg
の代入位置がm->libcallpc
の直後に移動されました。
-
src/pkg/runtime/proc.c
runtime·externalthreadhandlerp
変数の宣言が追加されました。runtime·topofstack
関数において、f->entry
がruntime·externalthreadhandlerp
と一致する場合もスタックのトップと見なす条件が追加されました。
-
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)
の代入前に行われるように順序が変更されました。
-
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->libcallpc
、m->libcallg
、m->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
の取得が遅れており、非同期プロファイラが不完全なコンテキストを読み取る可能性がありました。この変更により、pc
とg
が先にセットされ、sp
が最後にセットされることで、プロファイラがこれらの値の整合性をより確実に判断できるようになります。これは、プロファイラがこれらの3つの値がすべて非ゼロになった時点で、それらを信頼できるものとして使用するという設計に基づいています。
これらの変更は、GoランタイムがWindows上で外部関数呼び出しを行う際のコンテキスト管理を強化し、CPUプロファイラがより正確かつ安定して動作するための基盤を築いています。
関連リンク
- Go CPU Profiling: https://go.dev/blog/pprof
- Go Runtime Scheduler (G-M-P Model): https://go.dev/doc/articles/go_scheduler.html
- Go Stack Management: https://go.dev/doc/articles/go_programming_language_book_chapter_10.html (Goのスタックに関する一般的な情報)
参考にした情報源リンク
- コミットメッセージと差分:
commit_data/18522.txt
- GitHubコミットページ: https://github.com/golang/go/commit/eca55f5ac09221155de7b45e143ad863222ed976
- Goのソースコード(関連ファイル):
src/pkg/runtime/os_windows.c
src/pkg/runtime/proc.c
src/pkg/runtime/sys_windows_386.s
src/pkg/runtime/sys_windows_amd64.s
- Goのドキュメントとブログ記事(一般的なGoランタイムの概念理解のため)
- GoのGerritコードレビューシステム: https://go-review.googlesource.com/ (CL 63310043の議論を参照)