[インデックス 17182] ファイルの概要
このコミットは、Goランタイムにおいて、CPUプロファイリングが有効な状態でfork
システムコールが実行される際に発生しうるハングアップ(応答停止)問題を解決するためのものです。具体的には、fork
の前後でCPUプロファイリングを一時的に無効化することで、シグナル処理とfork
の競合による不安定性を回避します。
コミット
- コミットハッシュ:
e33e476e074c3f424ca5b9d14cf67acacd5250aa
- Author: Dmitriy Vyukov dvyukov@google.com
- Date: Tue Aug 13 13:01:30 2013 +0400
- コミットメッセージ:
syscall: disable cpu profiling around fork Fixes #5517. Fixes #5659. R=golang-dev, bradfitz CC=golang-dev https://golang.org/cl/12183044
GitHub上でのコミットページへのリンク
https://github.com/golang/go/commit/e33e476e074c3f424ca5b9d14cf67acacd5250aa
元コミット内容
syscall: disable cpu profiling around fork
Fixes #5517.
Fixes #5659.
R=golang-dev, bradfitz
CC=golang-dev
https://golang.org/cl/12183044
変更の背景
この変更の背景には、Goプログラムがfork
システムコール(特にexec
と組み合わせて新しいプロセスを起動する際)を使用する際に、CPUプロファイリングが有効になっているとプロセスがハングアップする可能性があったという問題があります。
Goランタイムは、CPUプロファイリングのために定期的にシグナル(通常はSIGPROF
)を送信し、実行中のゴルーチン(goroutine)のスタックトレースをサンプリングします。一方、fork
システムコールは、呼び出し元のプロセスのメモリ空間や状態を複製して新しい子プロセスを作成します。マルチスレッド環境(Goランタイムは内部的にOSのスレッドを使用します)でfork
を安全に実行することは非常に複雑です。特に、fork
が実行されている最中にシグナルが頻繁に発生すると、子プロセスの初期化が不安定になったり、デッドロックが発生したりする可能性があります。
コミットメッセージに記載されているFixes #5517
とFixes #5659
というIssue番号は、Goの公式Issueトラッカーでは直接関連する情報が見つかりませんでしたが、このコミットが解決しようとしている問題は、CPUプロファイリングのシグナルとfork
のタイミングが競合することで、子プロセスが正常に起動せずハングアップするという一般的な問題パターンを示唆しています。特に、fork
は呼び出し元のスレッドのみを子プロセスに複製するため、Goランタイムの他の重要なスレッド(スケジューラ、GCなど)が子プロセスに存在しない状態でシグナルハンドラが起動すると、予期せぬ動作やハングアップを引き起こす可能性があります。
この問題に対処するため、fork
システムコールが実行される直前にCPUプロファイリングを一時的に停止し、fork
が完了した後に再開するというアプローチが取られました。これにより、fork
のクリティカルセクション中に不要なシグナル処理が介入するのを防ぎ、プロセスの安定性を向上させています。
前提知識の解説
1. CPUプロファイリング
CPUプロファイリングは、プログラムがCPU時間をどのように消費しているかを分析するための手法です。GoのCPUプロファイラは、サンプリングプロファイラとして機能します。これは、一定の間隔(例えば、100Hz、つまり1秒間に100回)で実行中のゴルーチンのスタックトレースを収集し、どの関数がCPU時間を多く消費しているかを統計的に推定します。
- 仕組み: Goランタイムは、OSのタイマー機能とシグナル(Unix系OSでは通常
SIGPROF
)を利用して、定期的にプロファイリングシグナルを自身に送信します。このシグナルを受信すると、ランタイムは現在のゴルーチンのスタックトレースを記録し、プロファイリングデータとして蓄積します。
2. fork
システムコール
fork
はUnix系OSにおけるプロセス生成のためのシステムコールです。
- 動作:
fork
が呼び出されると、現在のプロセス(親プロセス)のほぼ完全なコピーである新しいプロセス(子プロセス)が作成されます。子プロセスは親プロセスのメモリ空間、ファイルディスクリプタ、レジスタの状態などを継承します。 - 戻り値:
fork
は親プロセスでは子プロセスのPID(プロセスID)を返し、子プロセスでは0を返します。エラーが発生した場合は親プロセスで-1を返します。 fork-exec
パターン: 多くのアプリケーションでは、fork
の直後にexec
システムコールを呼び出して、子プロセスで別のプログラムを実行します。このパターンはfork-exec
と呼ばれ、Goのos/exec
パッケージなどで利用されています。- マルチスレッド環境での
fork
の課題:fork
は、呼び出し元のスレッドのみを子プロセスに複製します。親プロセスに複数のスレッドが存在する場合、子プロセスにはfork
を呼び出したスレッド以外のスレッドは存在しません。これにより、子プロセスは親プロセスのロックの状態や、他のスレッドが管理していたリソースの状態を引き継ぐことができず、不安定になる可能性があります。特に、Goのように内部で多数のOSスレッドを使用するランタイムでは、fork
の安全な使用は非常に困難です。
3. GoのM-P-Gスケジューラモデル
Goランタイムは、ゴルーチンを効率的にスケジューリングするためにM-P-Gモデルを採用しています。
- M (Machine): OSのスレッドを表します。Goランタイムは、ゴルーチンを実行するためにOSスレッドを使用します。
- P (Processor): 論理プロセッサを表します。Goスケジューラは、Pの数だけゴルーチンを並行して実行できます。PはMにアタッチされ、MがP上でゴルーチンを実行します。
- G (Goroutine): Goの軽量な並行処理単位です。
CPUプロファイリングのシグナルは、通常M(OSスレッド)に対して送信されます。シグナルハンドラは、そのシグナルを受信したM上で実行されます。
4. シグナルとm->locks
- シグナル: OSがプロセスに非同期にイベントを通知するメカニズムです。CPUプロファイリングでは、タイマーシグナルが使用されます。
m->locks
: Goランタイムの内部的なロックカウンタです。これは、特定のM(OSスレッド)が重要な処理を実行中で、プリエンプション(スケジューラによる中断)やシグナルハンドラの実行を一時的に禁止したい場合に使用されます。m->locks
が0より大きい場合、そのMはプリエンプトされず、シグナルハンドラも遅延されるか、特定の処理が完了するまで実行されません。これにより、クリティカルセクションの保護や、ランタイムの内部状態の一貫性を保つことができます。
技術的詳細
このコミットの技術的な核心は、fork
システムコールが実行される非常に短い期間、CPUプロファイリングを一時的に停止することにあります。これにより、fork
の実行中にプロファイリングシグナルが割り込み、子プロセスの不安定化やハングアップを引き起こす可能性を排除します。
変更は主にGoランタイムのCコード(src/pkg/runtime/proc.c
)と、syscall
パッケージのGoコード(src/pkg/syscall/exec_bsd.go
, src/pkg/syscall/exec_linux.go
)にわたります。
-
プロファイリングの停止と再開のフック:
src/pkg/runtime/proc.c
に、syscall
パッケージから呼び出される新しい関数が追加されました。syscall·runtime_BeforeFork()
:fork
システムコールが呼び出される直前に実行されます。m->locks++
: 現在のM(OSスレッド)のロックカウンタをインクリメントします。これにより、このMがプリエンプトされたり、シグナルハンドラが実行されたりするのを一時的に防ぎます。これは、プロファイリングを無効にする処理自体が中断されないようにするため、およびfork
のクリティカルセクション中に他のシグナルが介入しないようにするための重要なステップです。if(m->profilehz != 0) runtime·resetcpuprofiler(0);
: もしCPUプロファイリングが現在有効であれば(m->profilehz
が0でない)、runtime·resetcpuprofiler(0)
を呼び出してプロファイリングを停止します。引数0
はプロファイリングレートを0Hzに設定することを意味し、実質的にプロファイリングを無効にします。
syscall·runtime_AfterFork()
:fork
システムコールが完了した後(親プロセス側で)実行されます。int32 hz = runtime·sched.profilehz;
: グローバルなプロファイリングレートを取得します。if(hz != 0) runtime·resetcpuprofiler(hz);
: もしプロファイリングが元々有効だった場合、元のレートでプロファイリングを再開します。m->locks--;
: ロックカウンタをデクリメントし、Mのプリエンプションとシグナル処理を再度許可します。
-
runtime·setcpuprofilerate
の変更: CPUプロファイリングレートを設定するruntime·setcpuprofilerate
関数も変更されました。- この関数も、プロファイリングの停止と再開の際に、
m->locks
を一時的にインクリメント/デクリメントするようになりました。これは、プロファイリング設定の変更自体がアトミックに行われることを保証し、設定中にシグナルが割り込むことによる競合状態を防ぐためです。特に、プロファイリングを停止する際にprof
ロックを安全に取得するために、シグナルによる割り込みを防ぐ必要があります。
- この関数も、プロファイリングの停止と再開の際に、
-
syscall
パッケージからの呼び出し:src/pkg/syscall/exec_bsd.go
とsrc/pkg/syscall/exec_linux.go
内のforkAndExecInChild
関数(fork
とexec
を組み合わせた処理を行う内部関数)に、上記で追加されたランタイム関数への呼び出しが追加されました。RawSyscall(SYS_FORK, ...)
の直前にruntime_BeforeFork()
が呼び出されます。RawSyscall(SYS_FORK, ...)
が完了した後、親プロセス側でruntime_AfterFork()
が呼び出されます。これにより、fork
のクリティカルセクションがruntime_BeforeFork
とruntime_AfterFork
で囲まれ、その間はCPUプロファイリングが停止されることが保証されます。
この変更により、fork
の実行中にプロファイリングシグナルが送信されても、ランタイムが安全な状態を保ち、ハングアップを回避できるようになりました。
コアとなるコードの変更箇所
このコミットで変更された主要なファイルと関数は以下の通りです。
-
src/pkg/runtime/pprof/pprof_test.go
:TestCPUProfileWithFork
という新しいテスト関数が追加されました。このテストは、fork
を含むexec.Command
を複数回実行しながらCPUプロファイリングを行うことで、以前のハングアップ問題が解決されたことを検証します。大量のメモリを確保してページインさせることで、fork
のコストを意図的に高くし、問題が顕在化しやすい状況を再現しています。
-
src/pkg/runtime/proc.c
:syscall·runtime_BeforeFork()
関数が追加されました。syscall·runtime_AfterFork()
関数が追加されました。runtime·setcpuprofilerate()
関数が変更され、m->locks
の操作が追加されました。
-
src/pkg/syscall/exec_bsd.go
:runtime_BeforeFork()
とruntime_AfterFork()
の外部関数宣言が追加されました。forkAndExecInChild
関数内で、RawSyscall(SYS_FORK, ...)
の呼び出しの前後でruntime_BeforeFork()
とruntime_AfterFork()
が呼び出されるようになりました。
-
src/pkg/syscall/exec_linux.go
:runtime_BeforeFork()
とruntime_AfterFork()
の外部関数宣言が追加されました。forkAndExecInChild
関数内で、RawSyscall(SYS_FORK, ...)
の呼び出しの前後でruntime_BeforeFork()
とruntime_AfterFork()
が呼び出されるようになりました。
コアとなるコードの解説
src/pkg/runtime/proc.c
// Called from syscall package before fork.
void
syscall·runtime_BeforeFork(void)
{
// Fork can hang if preempted with signals frequently enough (see issue 5517).
// Ensure that we stay on the same M where we disable profiling.
m->locks++; // Mのロックカウンタをインクリメントし、プリエンプションとシグナルを一時的に無効化
if(m->profilehz != 0) // CPUプロファイリングが有効な場合
runtime·resetcpuprofiler(0); // プロファイリングを停止 (0Hzに設定)
}
// Called from syscall package after fork in parent.
void
syscall·runtime_AfterFork(void)
{
int32 hz;
hz = runtime·sched.profilehz; // グローバルなプロファイリングレートを取得
if(hz != 0) // プロファイリングが元々有効だった場合
runtime·resetcpuprofiler(hz); // プロファイリングを再開 (元のレートに設定)
m->locks--; // Mのロックカウンタをデクリメントし、プリエンプションとシグナルを再度有効化
}
// runtime·setcpuprofilerate 関数の変更点
// ...
// Disable preemption, otherwise we can be rescheduled to another thread
// that has profiling enabled.
m->locks++; // プロファイリング設定変更中もプリエンプションとシグナルを無効化
// ...
// Stop profiler on this thread so that it is safe to lock prof.
// if a profiling signal came in while we had prof locked,
// it would deadlock.
runtime·resetcpuprofiler(0); // 現在のスレッドのプロファイラを停止
// ...
if(hz != 0)
runtime·resetcpuprofiler(hz); // プロファイラを再開
m->locks--; // ロックを解放
syscall·runtime_BeforeFork
とsyscall·runtime_AfterFork
は、fork
システムコールの前後でCPUプロファイリングを一時的に停止・再開するためのランタイム側のフックです。m->locks
の操作は、これらの処理がアトミックに行われることを保証し、fork
のクリティカルセクション中にシグナルが割り込むのを防ぎます。runtime·setcpuprofilerate
の変更も同様に、プロファイリング設定の変更自体の安全性を高めています。
src/pkg/syscall/exec_bsd.go
および src/pkg/syscall/exec_linux.go
// Implemented in runtime package.
func runtime_BeforeFork() // ランタイム側の関数を宣言
func runtime_AfterFork() // ランタイム側の関数を宣言
// forkAndExecInChild 関数内
// ...
// About to call fork.
// No more allocation or calls of non-assembly functions.
runtime_BeforeFork() // forkの直前にプロファイリングを無効化
r1, r2, err1 = RawSyscall(SYS_FORK, 0, 0, 0) // forkシステムコールを実行
if err1 != 0 {
runtime_AfterFork() // エラーの場合もプロファイリングを再開
return 0, err1
}
if r1 != 0 {
// parent; return PID
runtime_AfterFork() // 親プロセスの場合、プロファイリングを再開
return int(r1), 0
}
// ...
syscall
パッケージのforkAndExecInChild
関数は、OS固有のfork
システムコールをラップしています。この関数内で、RawSyscall(SYS_FORK, ...)
の呼び出しの直前にruntime_BeforeFork()
を、そしてfork
が完了し親プロセスに戻った後にruntime_AfterFork()
を呼び出すことで、fork
の実行中のみCPUプロファイリングが無効になるように制御しています。これにより、fork
のクリティカルな処理中にプロファイリングシグナルが割り込むことによる競合状態やハングアップを防ぎます。
src/pkg/runtime/pprof/pprof_test.go
func TestCPUProfileWithFork(t *testing.T) {
// Fork can hang if preempted with signals frequently enough (see issue 5517).
// Ensure that we do not do this.
heap := 1 << 30 // 1GBのヒープを確保
if testing.Short() {
heap = 100 << 20 // shortテストの場合は100MB
}
// This makes fork slower.
garbage := make([]byte, heap) // 大量のメモリを確保
// Need to touch the slice, otherwise it won't be paged in.
done := make(chan bool)
go func() {
for i := range garbage {
garbage[i] = 42 // メモリをページインさせるためにアクセス
}
done <- true
}()
<-done
var prof bytes.Buffer
if err := StartCPUProfile(&prof); err != nil { // CPUプロファイリングを開始
t.Fatal(err)
}
defer StopCPUProfile() // テスト終了時にプロファイリングを停止
for i := 0; i < 10; i++ {
exec.Command("go").CombinedOutput() // "go"コマンドを10回実行 (内部でfork-execが発生)
}
}
このテストは、CPUプロファイリングが有効な状態でfork-exec
を伴う外部コマンドの実行を繰り返すことで、以前のハングアップ問題が解決されたことを確認します。大量のメモリを確保してアクセスすることで、fork
のコストを高くし、問題が再現しやすい状況を作り出しています。このテストが正常に完了することは、コミットによる修正が効果的であることを示します。
関連リンク
- GoのCPUプロファイリングに関する公式ドキュメント: https://go.dev/blog/pprof
- Goのスケジューラに関する情報: https://go.dev/doc/effective_go#concurrency
参考にした情報源リンク
- Web search results for "Go issue 5517 fork hang preempted signals"
- Web search results for "Go issue 5659 cpu profiling fork"
- Stack Overflow: "Why is calling fork() in a Go program problematic?"
- Go source code (runtime/proc.c, syscall/exec_bsd.go, syscall/exec_linux.go, runtime/pprof/pprof_test.go)
- 一般的なUnix系OSにおける
fork
とシグナルの挙動に関する知識I have generated the detailed technical explanation in Markdown format, following all the specified instructions and chapter structure. I have also addressed the non-existent issue numbers by explaining the underlying technical problem. I will now output the generated content to standard output.