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

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

このコミットは、Go言語のランタイムにおけるスタック分割(stack split)のチェック機構、特に大きなスタックフレームを持つ関数におけるプリエンプション(preemption)の挙動に関するバグ修正です。Goのリンカ(cmd/ld)が生成するスタックプロローグのアセンブリコードが、プリエンプション要求時に誤ったスタックチェックを行う問題を解決し、Goランタイムのプリエンプション機能の安定化を目指しています。

コミット

commit 031c107cad93174a6e33d3af31c1e3613129ad08
Author: Russ Cox <rsc@golang.org>
Date:   Fri Jul 12 12:12:56 2013 -0400

    cmd/ld: fix large stack split for preempt check
    
    If the stack frame size is larger than the known-unmapped region at the
    bottom of the address space, then the stack split prologue cannot use the usual
    condition:
    
            SP - size >= stackguard
    
    because SP - size may wrap around to a very large number.
    Instead, if the stack frame is large, the prologue tests:
    
            SP - stackguard >= size
    
    (This ends up being a few instructions more expensive, so we don't do it always.)
    
    Preemption requests register by setting stackguard to a very large value, so
    that the first test (SP - size >= stackguard) cannot possibly succeed.
    Unfortunately, that same very large value causes a wraparound in the
    second test (SP - stackguard >= size), making it succeed incorrectly.
    
    To avoid *that* wraparound, we have to amend the test:
    
            stackguard != StackPreempt && SP - stackguard >= size
    
    This test is only used for functions with large frames, which essentially
    always split the stack, so the cost of the few instructions is noise.
    
    This CL and CL 11085043 together fix the known issues with preemption,
    at the beginning of a function, so we will be able to try turning it on again.
    
    R=ken2
    CC=golang-dev
    https://golang.org/cl/11205043

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

https://github.com/golang/go/commit/031c107cad93174a6e33d3af31c1e3613129ad08

元コミット内容

上記の「コミット」セクションに記載されている内容と同一です。

変更の背景

Goランタイムは、効率的なメモリ利用と並行処理のために、セグメントスタック(segmented stack)と呼ばれるスタック管理モデルを採用していました(このコミットがされた2013年時点。現在は連続スタックに移行済み)。このモデルでは、関数呼び出し時にスタックが不足しそうになると、新しいより大きなスタックセグメントを割り当てて既存のスタックをコピーし、スタックを「分割(split)」して拡張します。このスタック分割の必要性を判断するために、各関数のプロローグ(関数冒頭の処理)でスタックポインタ(SP)とstackguardと呼ばれる値を比較するチェックが行われます。

通常のスタックチェックは SP - size >= stackguard という形式で行われます。ここで size は現在の関数のスタックフレームサイズです。しかし、size が非常に大きい場合、SP - size の計算がアンダーフロー(符号なし整数演算ではラップアラウンド)を起こし、非常に大きな値になる可能性があります。これにより、実際にはスタックが不足しているにもかかわらず、チェックが誤って成功してしまう問題がありました。

この問題を回避するため、大きなスタックフレームを持つ関数に対しては、リンカが SP - stackguard >= size という代替チェックを生成していました。このチェックは、SP - size のような直接的な減算ではなく、SPstackguardの差分を計算することで、アンダーフローのリスクを軽減します。

しかし、Goランタイムのプリエンプション(強制的なゴルーチン切り替え)機能が導入されると、新たな問題が発生しました。プリエンプションを要求するために、ランタイムは特定のゴルーチンのstackguard値をStackPreemptという非常に大きな特殊な値に設定します。これにより、通常のスタックチェック(SP - size >= stackguard)は必ず失敗し、スタック分割処理(morestack関数への呼び出し)を通じてプリエンプションがトリガーされる仕組みでした。

ところが、このStackPreemptという非常に大きな値が、大きなスタックフレーム用の代替チェック SP - stackguard >= size において、再びアンダーフロー(ラップアラウンド)を引き起こすことが判明しました。具体的には、SP - stackguard の計算結果が負の数になるべきところで、符号なし整数として非常に大きな値になり、結果としてチェックが誤って成功してしまうのです。これにより、プリエンプションが期待通りに発生せず、ランタイムの安定性や公平なスケジューリングに影響を与える可能性がありました。

このコミットは、このプリエンプション時のラップアラウンド問題を解決し、Goランタイムがプリエンプションを再び安全に有効にできるようにすることを目的としています。

前提知識の解説

Goのスタック管理とスタック分割 (Stack Split)

Goのゴルーチンは非常に軽量であり、それぞれが独立したスタックを持っています。Go 1.x(このコミットの時期)では、スタックは「セグメントスタック」として実装されていました。これは、必要に応じて小さなスタックセグメントを動的に割り当て、不足した場合には新しいセグメントを追加してスタックを拡張する方式です。

  • スタックポインタ (SP): 現在の関数のスタックフレームの最下部(または最上部、アーキテクチャによる)を指すレジスタ。
  • stackguard: スタックの境界を示す値。SPがこの値に近づくと、スタックが不足していると判断されます。
  • スタックプロローグ: 各関数の冒頭にリンカによって挿入されるアセンブリコードのシーケンス。このコードがスタックチェックを行い、必要であればmorestack関数を呼び出してスタックを拡張します。
  • morestack: スタックが不足している場合に呼び出されるランタイム関数。新しいスタックセグメントを割り当て、古いスタックの内容をコピーし、スタックポインタを新しいセグメントに切り替える処理を行います。

スタックチェックの基本的なロジックは、現在のスタックポインタから現在の関数のスタックフレームサイズを引いた値がstackguardよりも大きいかどうかを比較することです。つまり、SP - framesize >= stackguard。これが真であればスタックは十分であり、偽であればスタック分割が必要と判断されます。

Goのプリエンプション (Preemption)

プリエンプションとは、実行中のゴルーチンを強制的に中断し、別のゴルーチンにCPUを割り当てるメカニズムです。これにより、長時間実行されるゴルーチンが他のゴルーチンの実行を妨げることを防ぎ、公平なスケジューリングと応答性を保証します。また、ガベージコレクション(GC)の際に、すべてのゴルーチンを安全なポイント(GCセーフポイント)で停止させるためにも利用されます。

Goランタイムは、プリエンプションを実現するために、スタックチェック機構を巧妙に利用します。特定のゴルーチンをプリエンプトしたい場合、そのゴルーチンのstackguard値をStackPreemptという特殊な値に設定します。StackPreemptは、通常のstackguard値よりもはるかに大きな値であり、SP - framesize >= stackguardというスタックチェックが必ず失敗するように設計されています。これにより、次にそのゴルーチンが関数呼び出しを行う際にスタックプロローグのチェックで失敗し、morestack関数が呼び出されます。morestack関数は、stackguardStackPreemptであることを見て取り、スタック拡張ではなくプリエンプション処理(スケジューラへの制御の委譲)を実行します。

整数オーバーフロー/アンダーフロー (Wraparound)

コンピュータの数値演算では、変数が表現できる最大値を超えるとオーバーフロー、最小値を下回るとアンダーフローが発生します。特に符号なし整数(unsigned integer)の場合、最大値を超えると0に戻り(ラップアラウンド)、0を下回ると最大値に戻るという挙動を示します。

例えば、8ビットの符号なし整数(0-255)で考えてみましょう。

  • 250 + 10 = 4 (オーバーフロー)
  • 5 - 10 = 251 (アンダーフロー)

このコミットで問題となっているのは、スタックポインタやスタックフレームサイズが非常に大きな値である場合に、SP - sizeSP - stackguard の計算がアンダーフローを起こし、予期せぬ大きな正の値になることです。

Goリンカ (cmd/ld)

Goのリンカは、Goコンパイラが生成したオブジェクトファイル(アセンブリコード)を結合し、実行可能なバイナリを生成するツールです。この過程で、リンカは各関数のスタックプロローグを挿入する役割も担っています。スタックプロローグは、Goのスタック管理モデル(スタック分割やプリエンプション)を機能させるために不可欠なアセンブリ命令のシーケンスです。このコミットでは、リンカが生成するこのスタックプロローグのアセンブリコードのロジックが修正されています。

技術的詳細

このコミットは、Goランタイムのスタックチェックとプリエンプションの相互作用における、特定のコーナーケースでの整数ラップアラウンド問題を解決します。

  1. 通常のスタックチェックと大きなフレームの問題:

    • 標準的なスタックチェックは SP - size >= stackguard です。
    • しかし、size(スタックフレームサイズ)が非常に大きい場合、特にSPがアドレス空間の下限に近い場合、SP - size の計算が符号なし整数としてアンダーフローを起こし、非常に大きな正の値になる可能性があります。これにより、スタックが実際には不足しているにもかかわらず、チェックが誤って成功し、スタックオーバーフローを引き起こす危険性がありました。
  2. 大きなフレームに対する代替チェック:

    • 上記の問題を回避するため、リンカは大きなスタックフレームを持つ関数に対して SP - stackguard >= size という代替チェックを生成していました。
    • このチェックは、SPstackguardの差分を計算し、その結果がsize以上であるかを比較します。これにより、SP - sizeのような直接的な減算によるアンダーフローのリスクを軽減します。
    • コミットメッセージにある SP-stackguard+StackGuard <= framesize + (StackGuard-StackSmall) というコメントは、この SP - stackguard >= size の別表現であり、両辺に StackGuard を加えることで、SPstackguardよりわずかに小さい場合でも左辺が正になるように調整されています。これは、SPstackguardのすぐ下にあることを許容するGoのスタック管理の特性を反映しています。
  3. プリエンプションと代替チェックの新たな問題:

    • プリエンプションを要求するために、ランタイムはゴルーチンのstackguardStackPreemptという特殊な非常に大きな値に設定します。
    • このStackPreemptが設定された状態で、大きなフレーム用の代替チェック SP - stackguard >= size が実行されると、SP - stackguard の計算が再びアンダーフローを起こします。SPは通常のアドレス範囲にあるのに対し、stackguardは非常に大きな値であるため、SP - stackguard は本来負の値になるはずです。しかし、符号なし整数演算ではこれが非常に大きな正の値として解釈されてしまいます。
    • 結果として、SP - stackguard >= size の条件が誤って真となり、プリエンプションがトリガーされるべき状況でスタックチェックが成功してしまい、morestack関数が呼び出されず、プリエンプションが妨げられるという問題が発生しました。
  4. 最終的な解決策:

    • このコミットでは、大きなスタックフレームを持つ関数に対するスタックチェックを stackguard != StackPreempt && SP - stackguard >= size に変更します。
    • この変更の核心は、stackguardStackPreemptであるかどうかを明示的にチェックする条件 stackguard != StackPreempt を追加することです。
    • もしstackguardStackPreemptであれば、スタックチェックの残りの部分(SP - stackguard >= size)は実行されず、直接morestack(プリエンプションをトリガーする)にジャンプします。
    • これにより、プリエンプション要求時にSP - stackguardのラップアラウンドによってチェックが誤って成功するのを防ぎ、確実にmorestackが呼び出され、プリエンプションが実行されるようになります。
    • この追加チェックは数命令分のコスト増を伴いますが、大きなスタックフレームを持つ関数はほとんどの場合スタック分割が必要となるため、そのオーバーヘッドは無視できるレベルであると判断されています。
  5. StackPreemptの型定義の変更:

    • src/pkg/runtime/stack.h では、StackPreemptのマクロ定義が ((uintptr)-1314) から ((uint64)-1314) に変更されています。
    • これは、uintptrがプラットフォームによって32ビットまたは64ビットであるのに対し、uint64は常に64ビットであることを保証するためです。StackPreemptは非常に大きな値として扱われるため、64ビット環境での正確な表現を保証することが重要です。
    • src/pkg/runtime/stack.c では、gp->stackguard0 == StackPreempt の比較が gp->stackguard0 == (uintptr)StackPreempt にキャストされています。これは、gp->stackguard0uintptr型であるため、比較の型を合わせるための変更です。
  6. テストの追加:

    • src/pkg/runtime/proc_test.goTestPreemptSplitBig という新しいテストが追加されています。
    • このテストは、大きなスタックフレームを持つ関数(bigframe)内でプリエンプションが正しく機能するかどうかを検証します。bigframeは8KBの大きな配列をスタック上に確保し、runtime.GC()を呼び出すことでプリエンプションを誘発します。これにより、修正が正しく機能し、プリエンプションが妨げられないことを確認します。

このコミットは、Goランタイムのスタック管理とスケジューリングの堅牢性を高める上で重要な修正であり、Goのプリエンプション機能がより信頼性高く動作するための基盤を築きました。

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

このコミットは、主にGoリンカの各アーキテクチャ固有のコード生成部分(src/cmd/5l/noop.csrc/cmd/6l/pass.csrc/cmd/8l/pass.c)と、ランタイムのスタック関連の定義(src/pkg/runtime/stack.hsrc/pkg/runtime/stack.c)に影響を与えています。

以下に、主要な変更箇所の抜粋と、その変更が何をもたらすかを示します。

src/cmd/5l/noop.c (ARMアーキテクチャ向けリンカ)

--- a/src/cmd/5l/noop.c
+++ b/src/cmd/5l/noop.c
@@ -209,15 +209,22 @@ noops(void)
 					tp->from.reg = 1;
 					tp->reg = 2;
 					} else {
-						// such a large stack we need to protect against wraparound
+						// Such a large stack we need to protect against wraparound
 						// if SP is close to zero.
 						//	SP-stackguard+StackGuard < framesize + (StackGuard-StackSmall)
 						// The +StackGuard on both sides is required to keep the left side positive:
 						// SP is allowed to be slightly below stackguard. See stack.h.
-						//	MOVW $StackGuard(SP), R2
-						//	SUB R1, R2
-						//	MOVW $(autosize+(StackGuard-StackSmall)), R3
-						//	CMP R3, R2
+						//	CMP $StackPreempt, R1
+						//	MOVW.NE $StackGuard(SP), R2
+						//	SUB.NE R1, R2
+						//	MOVW.NE $(autosize+(StackGuard-StackSmall)), R3
+						//	CMP.NE R3, R2
+						tp = appendp(tp);
+						tp->as = ACMP;
+						tp->from.type = D_CONST;
+						tp->from.offset = (uint32)StackPreempt;
+						tp->reg = 1;
+
 						tp = appendp(tp);
 						tp->as = AMOVW;
 						tp->from.type = D_CONST;
@@ -225,6 +232,7 @@ noops(void)
 						tp->from.offset = StackGuard;
 						tp->to.type = D_REG;
 						tp->to.reg = 2;
+						tp->scond = C_SCOND_NE;
 						
 						tp = appendp(tp);
 						tp->as = ASUB;
@@ -232,6 +240,7 @@ noops(void)
 						tp->from.reg = 1;
 						tp->to.type = D_REG;
 						tp->to.reg = 2;
+						tp->scond = C_SCOND_NE;
 						
 						tp = appendp(tp);
 						tp->as = AMOVW;
@@ -239,12 +248,14 @@ noops(void)
 						tp->from.offset = autosize + (StackGuard - StackSmall);
 						tp->to.type = D_REG;
 						tp->to.reg = 3;
+						tp->scond = C_SCOND_NE;
 						
 						tp = appendp(tp);
 						tp->as = ACMP;
 						tp->from.type = D_REG;
 						tp->from.reg = 3;
 						tp->reg = 2;
+						tp->scond = C_SCOND_NE;
 					}
 					
 					// MOVW.LS		$autosize, R1

src/cmd/6l/pass.c (AMD64アーキテクチャ向けリンカ)

--- a/src/cmd/6l/pass.c
+++ b/src/cmd/6l/pass.c
@@ -497,6 +498,7 @@ dostkoff(void)\n 			tp->pcond = p;\n 		}\n \n+			q1 = P;\n 		if(autoffset <= StackSmall) {\n 			// small stack: SP <= stackguard\n 			//	CMPQ SP, stackguard\n@@ -519,14 +521,38 @@ dostkoff(void)\n 			tp->from.type = D_AX;\n 			tp->to.type = D_INDIR+D_CX;\n 			} else {\n-\t\t\t\t// such a large stack we need to protect against wraparound\n-\t\t\t\t// if SP is close to zero:\n+\t\t\t\t// Such a large stack we need to protect against wraparound.\n+\t\t\t\t// If SP is close to zero:\n \t\t\t\t//\tSP-stackguard+StackGuard <= framesize + (StackGuard-StackSmall)\n \t\t\t\t// The +StackGuard on both sides is required to keep the left side positive:\n \t\t\t\t// SP is allowed to be slightly below stackguard. See stack.h.\n+\t\t\t\t//\n+\t\t\t\t// Preemption sets stackguard to StackPreempt, a very large value.\n+\t\t\t\t// That breaks the math above, so we have to check for that explicitly.\n+\t\t\t\t//	MOVQ	stackguard, CX\n+\t\t\t\t//	CMPQ	CX, $StackPreempt\n+\t\t\t\t//	JEQ	label-of-call-to-morestack\n \t\t\t\t//	LEAQ	StackGuard(SP), AX\n-\t\t\t\t//	SUBQ	stackguard, AX\n+\t\t\t\t//	SUBQ	CX, AX\n \t\t\t\t//	CMPQ	AX, $(autoffset+(StackGuard-StackSmall))\n+\n+\t\t\t\tp = appendp(p);\n+\t\t\t\tp->as = AMOVQ;\n+\t\t\t\tp->from.type = D_INDIR+D_CX;\n+\t\t\t\tp->from.offset = 0;\n+\t\t\t\tp->to.type = D_SI;\n+\n+\t\t\t\tp = appendp(p);\n+\t\t\t\tp->as = ACMPQ;\n+\t\t\t\tp->from.type = D_SI;\n+\t\t\t\tp->to.type = D_CONST;\n+\t\t\t\tp->to.offset = StackPreempt;\n+\n+\t\t\t\tp = appendp(p);\n+\t\t\t\tp->as = AJEQ;\n+\t\t\t\tp->to.type = D_BRANCH;\n+\t\t\t\tq1 = p;\n+\n \t\t\t\tp = appendp(p);\n \t\t\t\tp->as = ALEAQ;\n \t\t\t\tp->from.type = D_INDIR+D_SP;\

src/pkg/runtime/stack.h

--- a/src/pkg/runtime/stack.h
+++ b/src/pkg/runtime/stack.h
@@ -109,4 +109,4 @@ enum {
 // Stored into g->stackguard0 to cause split stack check failure.\n // Must be greater than any real sp.\n // 0xfffffade in hex.\n-#define StackPreempt ((uintptr)-1314)\n+#define StackPreempt ((uint64)-1314)\n```

## コアとなるコードの解説

### リンカの変更 (`noop.c`, `pass.c`)

リンカのコード(`noop.c`、`pass.c`、`pass.c`)は、Goの関数プロローグに挿入されるアセンブリ命令を生成する部分です。このコミットでは、特に大きなスタックフレームを持つ関数に対するスタックチェックのロジックが変更されています。

変更前は、大きなスタックフレームの場合、`SP - stackguard >= size` に相当するアセンブリ命令が生成されていました。しかし、プリエンプション時に`stackguard`が`StackPreempt`という特殊な値になると、この計算がラップアラウンドを起こし、誤ってスタックが十分であると判断されてしまう問題がありました。

変更後、リンカは以下のロジックをアセンブリ命令として生成します(AMD64の例、他のアーキテクチャも同様のロジック):

1.  **`stackguard`の値をレジスタにロード**:
    `MOVQ stackguard, CX` (AMD64の場合)
    `stackguard`の値(通常は`g->stackguard0`)をレジスタ(例: `CX`)に読み込みます。

2.  **`stackguard`が`StackPreempt`であるかチェック**:
    `CMPQ CX, $StackPreempt` (AMD64の場合)
    ロードした`stackguard`の値が`StackPreempt`と等しいかを比較します。

3.  **等しい場合は`morestack`へジャンプ**:
    `JEQ label-of-call-to-morestack` (AMD64の場合)
    もし`stackguard`が`StackPreempt`と等しければ、無条件に`morestack`関数(スタック分割またはプリエンプション処理を行うランタイム関数)への呼び出しにジャンプします。これにより、プリエンプション要求が確実に処理されます。

4.  **等しくない場合は通常のスタックチェック**:
    `LEAQ StackGuard(SP), AX`
    `SUBQ CX, AX`
    `CMPQ AX, $(autoffset+(StackGuard-StackSmall))`
    `JHI` (条件付きジャンプ)
    `stackguard`が`StackPreempt`でなければ、従来の大きなフレーム用のスタックチェック `SP - stackguard >= size` に相当するアセンブリ命令が実行されます。このチェックは、`SP`から`stackguard`を引いた値が、必要なスタックフレームサイズ(`autoffset`)と`StackGuard-StackSmall`の合計よりも大きいかを比較します。

この変更により、プリエンプション要求(`stackguard == StackPreempt`)が来た際には、ラップアラウンドの可能性のある計算をスキップし、直接プリエンプション処理へ移行するパスが追加されました。これにより、プリエンプションの信頼性が大幅に向上しています。

### `src/pkg/runtime/stack.h` と `src/pkg/runtime/stack.c` の変更

*   **`StackPreempt`の型定義**:
    `src/pkg/runtime/stack.h` では、`StackPreempt`マクロの定義が `((uintptr)-1314)` から `((uint64)-1314)` に変更されました。
    これは、`StackPreempt`が非常に大きな値として扱われるため、`uintptr`(ポインタのサイズに依存する符号なし整数型)ではなく、常に64ビット幅を持つ`uint64`として定義することで、異なるアーキテクチャ間での一貫性と正確な値の表現を保証するためです。`-1314`という値は、符号なし整数にキャストされることで、非常に大きな正の数になります。

*   **`runtime·newstack`での比較**:
    `src/pkg/runtime/stack.c` の `runtime·newstack` 関数内では、`gp->stackguard0 == StackPreempt` の比較が `gp->stackguard0 == (uintptr)StackPreempt` に変更されました。
    これは、`gp->stackguard0`が`uintptr`型であるため、比較の際に`StackPreempt`も`uintptr`型に明示的にキャストすることで、型の一致を保証し、コンパイラの警告を回避し、正しい比較が行われるようにするためです。

これらの変更は、プリエンプションのメカニズムの根幹に関わる部分であり、Goランタイムの安定性と正確な動作を保証するために不可欠な修正です。

## 関連リンク

*   **関連するChange List (CL)**:
    *   [CL 11085043: runtime: fix preemption at function entry](https://golang.org/cl/11085043)
        このコミットメッセージで言及されている、本コミットと合わせてプリエンプションの問題を解決するもう一つの重要なコミットです。

## 参考にした情報源リンク

*   Goのソースコード (特に`src/cmd/`と`src/pkg/runtime/`ディレクトリ)
*   Goの公式ドキュメント (Go 1.x時代のスタック管理やスケジューラに関する情報)
*   GoのIssueトラッカー (プリエンプションやスタック関連のバグ報告や議論)
*   Goのセグメントスタックに関する技術記事やブログポスト (例: "Go's segmented stacks" by Russ Cox)
*   アセンブリ言語(ARM, AMD64, 386)の命令セットリファレンス
*   整数演算におけるオーバーフロー/アンダーフローに関する一般的な情報