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

[インデックス 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 #5517Fixes #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)にわたります。

  1. プロファイリングの停止と再開のフック: 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のプリエンプションとシグナル処理を再度許可します。
  2. runtime·setcpuprofilerateの変更: CPUプロファイリングレートを設定するruntime·setcpuprofilerate関数も変更されました。

    • この関数も、プロファイリングの停止と再開の際に、m->locksを一時的にインクリメント/デクリメントするようになりました。これは、プロファイリング設定の変更自体がアトミックに行われることを保証し、設定中にシグナルが割り込むことによる競合状態を防ぐためです。特に、プロファイリングを停止する際にprofロックを安全に取得するために、シグナルによる割り込みを防ぐ必要があります。
  3. syscallパッケージからの呼び出し: src/pkg/syscall/exec_bsd.gosrc/pkg/syscall/exec_linux.go内のforkAndExecInChild関数(forkexecを組み合わせた処理を行う内部関数)に、上記で追加されたランタイム関数への呼び出しが追加されました。

    • RawSyscall(SYS_FORK, ...)の直前にruntime_BeforeFork()が呼び出されます。
    • RawSyscall(SYS_FORK, ...)が完了した後、親プロセス側でruntime_AfterFork()が呼び出されます。これにより、forkのクリティカルセクションがruntime_BeforeForkruntime_AfterForkで囲まれ、その間はCPUプロファイリングが停止されることが保証されます。

この変更により、forkの実行中にプロファイリングシグナルが送信されても、ランタイムが安全な状態を保ち、ハングアップを回避できるようになりました。

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

このコミットで変更された主要なファイルと関数は以下の通りです。

  1. src/pkg/runtime/pprof/pprof_test.go:

    • TestCPUProfileWithForkという新しいテスト関数が追加されました。このテストは、forkを含むexec.Commandを複数回実行しながらCPUプロファイリングを行うことで、以前のハングアップ問題が解決されたことを検証します。大量のメモリを確保してページインさせることで、forkのコストを意図的に高くし、問題が顕在化しやすい状況を再現しています。
  2. src/pkg/runtime/proc.c:

    • syscall·runtime_BeforeFork() 関数が追加されました。
    • syscall·runtime_AfterFork() 関数が追加されました。
    • runtime·setcpuprofilerate() 関数が変更され、m->locksの操作が追加されました。
  3. src/pkg/syscall/exec_bsd.go:

    • runtime_BeforeFork()runtime_AfterFork() の外部関数宣言が追加されました。
    • forkAndExecInChild 関数内で、RawSyscall(SYS_FORK, ...) の呼び出しの前後で runtime_BeforeFork()runtime_AfterFork() が呼び出されるようになりました。
  4. 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_BeforeForksyscall·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のコストを高くし、問題が再現しやすい状況を作り出しています。このテストが正常に完了することは、コミットによる修正が効果的であることを示します。

関連リンク

参考にした情報源リンク

  • 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.