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

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

このコミットは、Goランタイムにおけるfork後のスタックスプリット(スタック拡張)を検出するための変更を導入しています。具体的には、syscallパッケージがforkシステムコールを呼び出す前後に、現在のgoroutineのスタックガード値を一時的に変更し、forkされた子プロセス内でスタック拡張が試みられた場合にパニックを発生させるメカニズムを追加しています。これにより、fork後にメモリ割り当てやスタック拡張が行われることによって発生する可能性のある問題を未然に防ぐことを目的としています。

コミット

commit e678ab4e375659fea86b17557c23673033cf897c
Author: Dmitriy Vyukov <dvyukov@google.com>
Date:   Thu Mar 13 17:41:08 2014 +0400

    runtime: detect stack split after fork
    This check would allowed to easily prevent issue 7511.
    Update #7511
    
    LGTM=rsc
    R=rsc, aram
    CC=golang-codereviews
    https://golang.org/cl/75260043

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

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

元コミット内容

runtime: detect stack split after fork
This check would allowed to easily prevent issue 7511.
Update #7511

LGTM=rsc
R=rsc, aram
CC=golang-codereviews
https://golang.org/cl/75260043

変更の背景

このコミットの主な背景は、Goプログラムがforkシステムコールを使用する際に発生する可能性のある問題、特にIssue 7511への対応です。

forkシステムコールは、現在のプロセス(親プロセス)のコピーである新しいプロセス(子プロセス)を作成します。子プロセスは親プロセスのメモリ空間、ファイルディスクリプタ、レジスタの状態などをほぼ完全にコピーして開始されます。しかし、forkとそれに続くexec(通常、子プロセスで新しいプログラムを実行するために呼び出される)の間には、非常に限られた操作しか安全に行うことができません。

Goランタイムは、ガベージコレクション、スケジューラ、スタック管理など、多くの複雑な内部状態を持っています。forkが呼び出された時点でのこれらの内部状態は、子プロセスにそのままコピーされますが、子プロセスがexecを呼び出す前にGoランタイムの通常の操作(例えば、メモリ割り当てやスタックの拡張)を試みると、親プロセスと子プロセスで共有されるリソースや、コピーされた状態の不整合により、デッドロック、クラッシュ、または未定義の動作を引き起こす可能性があります。

特に、スタックの拡張(スタックスプリット)は、Goのgoroutineが実行中に必要に応じてスタックサイズを動的に増やすメカニズムです。fork後、子プロセスがスタック拡張を試みると、親プロセスと共有されているメモリ領域(例えば、ヒープ)にアクセスしようとしたり、親プロセスの状態に依存するランタイム関数を呼び出したりする可能性があり、これが問題を引き起こします。

Issue 7511は、まさにこのfork後のスタック拡張に関連する問題を示唆していると考えられます。このコミットは、このような問題を未然に防ぐための防御的なメカニズムとして導入されました。

前提知識の解説

1. forkシステムコール

forkはUnix系OSでプロセスを作成するためのシステムコールです。

  • 動作: forkが呼び出されると、現在のプロセス(親プロセス)のほぼ完全なコピーである新しいプロセス(子プロセス)が作成されます。子プロセスは親プロセスと同じコードを実行し、同じメモリ空間(ただし、通常はコピーオンライト方式で共有される)、開いているファイルディスクリプタなどを持ちます。
  • 戻り値: 親プロセスでは子プロセスのPIDが返され、子プロセスでは0が返されます。
  • fork-execモデル: 多くのアプリケーションでは、forkの直後にexecシステムコールを呼び出して、子プロセスで別のプログラムを実行します。このfork-execモデルは、新しいプロセスを起動する標準的な方法です。

2. GoランタイムとGoroutineスタック

Goは軽量な並行処理の単位である「goroutine」を使用します。

  • Goroutineスタック: 各goroutineは独自のスタックを持っています。Goのスタックは固定サイズではなく、必要に応じて動的に拡張(「スタックスプリット」または「スタック成長」)および縮小(「スタックシュリンク」)します。
  • スタックガード (Stack Guard): スタックの拡張は、スタックポインタが特定の「スタックガード」値に近づいたときにトリガーされます。このガード値は、スタックのオーバーフローを防ぎ、スタック拡張処理を開始するための閾値として機能します。
  • g (Goroutine構造体): Goランタイム内部では、各goroutineはgという構造体で表現されます。この構造体には、スタックの開始アドレス、終了アドレス、そしてスタックガード値などが含まれています。
  • m (Machine構造体): mはOSのスレッドを表す構造体です。各mは1つのOSスレッドに対応し、そのスレッド上でgoroutineが実行されます。m構造体には、現在のgoroutine (curg) や、OSスレッドに関連する情報が含まれます。

3. fork後の制約

forkexecの間の子プロセスでは、親プロセスの状態がコピーされているため、多くのGoランタイムの操作が安全ではありません。

  • メモリ割り当て: fork後、子プロセスがヒープメモリを割り当てようとすると、親プロセスと共有されているメモリ管理データ構造の不整合を引き起こす可能性があります。
  • スタック拡張: スタック拡張もメモリ割り当てを伴うため、同様の問題を引き起こす可能性があります。
  • ミューテックス/ロック: 親プロセスが保持していたロックが子プロセスにコピーされると、子プロセスがそのロックを再取得しようとしたり、親プロセスが解放するのを待ったりすることで、デッドロックが発生する可能性があります。

これらの制約のため、forkexecの間では、非同期シグナルセーフな関数(async-signal-safe functions)のみを呼び出すべきであるという一般的なUnixプログラミングの原則があります。Goランタイムの多くの関数は、この要件を満たしません。

技術的詳細

このコミットは、syscallパッケージがforkシステムコールを呼び出す前後に、Goランタイムの内部状態を一時的に変更することで、fork後の安全性を高めています。

syscall·runtime_BeforeFork()

この関数は、syscallパッケージがforkを呼び出す直前にGoランタイムによって呼び出されます。

  1. m->locks++: 現在のM(OSスレッド)が保持しているロックカウントをインクリメントします。これは、fork中にランタイムがロックを保持している状態をシミュレートし、他のランタイム操作がブロックされるようにするためかもしれません。
  2. CPUプロファイラの停止: m->profilehz != 0の場合、CPUプロファイラを停止します。fork中にプロファイラが動作していると、子プロセスで問題を引き起こす可能性があるためです。
  3. スタックガードの変更: ここが最も重要な変更点です。
    • m->forkstackguard = g->stackguard;: 現在のgoroutine (g) の元のstackguard値をm->forkstackguardに保存します。これは、runtime_AfterForkで元の値を復元するために使用されます。
    • g->stackguard0 = StackPreempt-1;: g->stackguard0は、スタック拡張のトリガーとなる値です。これをStackPreempt-1に設定します。StackPreemptは、プリエンプション(横取り)をトリガーするための特別なスタックガード値であり、非常に低い値です。StackPreempt-1に設定することで、スタックが少しでも成長しようとすると、すぐにスタックガードに到達するようにします。
    • g->stackguard = StackPreempt-1;: 同様に、g->stackguardStackPreempt-1に設定します。

この変更により、forkexecの間で子プロセスがスタック拡張を試みると、g->stackguardにすぐに到達し、後述のruntime·newstack内でパニックが発生するようになります。

syscall·runtime_AfterFork()

この関数は、親プロセスにおいてforkが完了した直後にGoランタイムによって呼び出されます。

  1. スタックガードの復元:
    • g->stackguard0 = m->forkstackguard;
    • g->stackguard = m->forkstackguard;
    • m->forkstackguard = 0; runtime_BeforeForkで保存しておいた元のstackguard値をg->stackguard0g->stackguardに復元します。これにより、親プロセスは通常のスタック管理動作に戻ります。m->forkstackguardは0にリセットされます。
  2. CPUプロファイラの再開: runtime_BeforeForkで停止したCPUプロファイラを再開します。

runtime·newstack()の変更

runtime·newstackは、goroutineのスタックが拡張される際に呼び出されるランタイム関数です。

  • if(m->forkstackguard): この条件が追加されました。m->forkstackguardは、runtime_BeforeForkで設定され、runtime_AfterForkで親プロセスではリセットされますが、子プロセスではリセットされません(子プロセスはruntime_AfterForkを呼び出さないため)。
  • runtime·throw("split stack after fork");: したがって、子プロセスがfork後にスタック拡張を試みると、この条件が真となり、"split stack after fork"というメッセージとともにパニックが発生します。

M構造体への追加

src/pkg/runtime/runtime.hM構造体にuintptr forkstackguard;フィールドが追加されました。これは、runtime_BeforeForkで元のスタックガード値を一時的に保存するために使用されます。

NOSPLITプラグマ

syscall·runtime_AfterFork関数には#pragma textflag NOSPLITが追加されています。これは、この関数自体がスタック拡張を必要としないことをコンパイラに指示します。fork後のデリケートな状況で呼び出されるため、この関数自体がスタック拡張を試みて問題を引き起こすことを避けるための重要な指示です。

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

src/pkg/runtime/proc.c

--- a/src/pkg/runtime/proc.c
+++ b/src/pkg/runtime/proc.c
@@ -1704,14 +1704,28 @@ syscall·runtime_BeforeFork(void)
 	m->locks++;
 	if(m->profilehz != 0)
 		runtime·resetcpuprofiler(0);
+
+	// This function is called before fork in syscall package.
+	// Code between fork and exec must not allocate memory nor even try to grow stack.
+	// Here we spoil g->stackguard to reliably detect any attempts to grow stack.
+	// runtime_AfterFork will undo this in parent process, but not in child.
+	m->forkstackguard = g->stackguard;
+	g->stackguard0 = StackPreempt-1;
+	g->stackguard = StackPreempt-1;
+}
+
+// Called from syscall package after fork in parent.
+#pragma textflag NOSPLIT
+void
+syscall·runtime_AfterFork(void)
+{
+	int32 hz;
+
+	// See the comment in runtime_BeforeFork.
+	g->stackguard0 = m->forkstackguard;
+	g->stackguard = m->forkstackguard;
+	m->forkstackguard = 0;
+
+	hz = runtime·sched.profilehz;
+	if(hz != 0)
+		runtime·resetcpuprofiler(hz);

src/pkg/runtime/runtime.h

--- a/src/pkg/runtime/runtime.h
+++ b/src/pkg/runtime/runtime.h
@@ -367,6 +367,7 @@ struct	M
 	bool	needextram;
 	bool	(*waitunlockf)(G*, void*);
 	void*\twaitlock;
+	uintptr	forkstackguard;
 #ifdef GOOS_windows
 	void*\tthread;\t\t// thread handle
 	// these are here because they are too large to be on the stack

src/pkg/runtime/stack.c

--- a/src/pkg/runtime/stack.c
+++ b/src/pkg/runtime/stack.c
@@ -583,6 +583,8 @@ runtime·newstack(void)\n 	Gobuf label;\n 	bool newstackcall;\n \n+\tif(m->forkstackguard)\n+\t\truntime·throw("split stack after fork");\n \tif(m->morebuf.g != m->curg) {\n \t\truntime·printf("runtime: newstack called from g=%p\\n"\n \t\t\t"\\tm=%p m->curg=%p m->g0=%p m->gsignal=%p\\n",

コアとなるコードの解説

src/pkg/runtime/proc.cの変更

  • syscall·runtime_BeforeFork:
    • m->forkstackguard = g->stackguard;: 現在のgoroutineのスタックガード値(スタック拡張の閾値)をm構造体の一時的なフィールドforkstackguardに保存します。これは、fork後に親プロセスで元の状態に戻すために必要です。
    • g->stackguard0 = StackPreempt-1; および g->stackguard = StackPreempt-1;: 現在のgoroutineのスタックガード値を非常に低い値(StackPreempt-1)に設定します。これにより、スタックが少しでも成長しようとすると、すぐにスタックガードに到達し、スタック拡張処理(runtime·newstackの呼び出し)がトリガーされるようになります。この低い値は、forkexecの間でスタック拡張を意図的に失敗させるための「罠」として機能します。
  • syscall·runtime_AfterFork:
    • #pragma textflag NOSPLIT: この関数自体がスタック拡張を試みないように指示します。これは、fork後のデリケートな状況で、この関数がさらにスタック拡張をトリガーして問題を複雑化させることを防ぐためです。
    • g->stackguard0 = m->forkstackguard; および g->stackguard = m->forkstackguard;: runtime_BeforeForkで保存しておいた元のスタックガード値を復元します。これは親プロセスでのみ実行され、親プロセスが通常のGoランタイムの動作に戻ることを保証します。
    • m->forkstackguard = 0;: m->forkstackguardをリセットします。

src/pkg/runtime/runtime.hの変更

  • struct Muintptr forkstackguard;が追加されました。これは、runtime_BeforeForkで一時的にスタックガード値を保存するためのフィールドです。uintptr型はポインタを保持できる整数型であり、アドレスやサイズを表現するのに適しています。

src/pkg/runtime/stack.cの変更

  • runtime·newstack関数(スタック拡張処理を行う関数)の冒頭に以下のチェックが追加されました。
    • if(m->forkstackguard): m->forkstackguardが非ゼロの場合(つまり、runtime_BeforeForkが呼び出され、runtime_AfterForkがまだ呼び出されていない状態、これはforkされた子プロセスで発生します)、以下の処理を実行します。
    • runtime·throw("split stack after fork");: "split stack after fork"というメッセージとともにパニックを発生させます。これにより、forkexecの間で子プロセスがスタック拡張を試みた場合に、明確なエラーでプログラムが終了するようになります。これは、未定義の動作やデッドロックを防ぐための安全策です。

これらの変更により、Goプログラムがforkを使用する際に、子プロセスがexecを呼び出す前にGoランタイムの内部状態を不適切に変更しようとする試みを検出し、早期に問題を特定できるようになります。

関連リンク

参考にした情報源リンク