[インデックス 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
のような直接的な減算ではなく、SP
とstackguard
の差分を計算することで、アンダーフローのリスクを軽減します。
しかし、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
関数は、stackguard
がStackPreempt
であることを見て取り、スタック拡張ではなくプリエンプション処理(スケジューラへの制御の委譲)を実行します。
整数オーバーフロー/アンダーフロー (Wraparound)
コンピュータの数値演算では、変数が表現できる最大値を超えるとオーバーフロー、最小値を下回るとアンダーフローが発生します。特に符号なし整数(unsigned integer)の場合、最大値を超えると0に戻り(ラップアラウンド)、0を下回ると最大値に戻るという挙動を示します。
例えば、8ビットの符号なし整数(0-255)で考えてみましょう。
250 + 10 = 4
(オーバーフロー)5 - 10 = 251
(アンダーフロー)
このコミットで問題となっているのは、スタックポインタやスタックフレームサイズが非常に大きな値である場合に、SP - size
や SP - stackguard
の計算がアンダーフローを起こし、予期せぬ大きな正の値になることです。
Goリンカ (cmd/ld
)
Goのリンカは、Goコンパイラが生成したオブジェクトファイル(アセンブリコード)を結合し、実行可能なバイナリを生成するツールです。この過程で、リンカは各関数のスタックプロローグを挿入する役割も担っています。スタックプロローグは、Goのスタック管理モデル(スタック分割やプリエンプション)を機能させるために不可欠なアセンブリ命令のシーケンスです。このコミットでは、リンカが生成するこのスタックプロローグのアセンブリコードのロジックが修正されています。
技術的詳細
このコミットは、Goランタイムのスタックチェックとプリエンプションの相互作用における、特定のコーナーケースでの整数ラップアラウンド問題を解決します。
-
通常のスタックチェックと大きなフレームの問題:
- 標準的なスタックチェックは
SP - size >= stackguard
です。 - しかし、
size
(スタックフレームサイズ)が非常に大きい場合、特にSP
がアドレス空間の下限に近い場合、SP - size
の計算が符号なし整数としてアンダーフローを起こし、非常に大きな正の値になる可能性があります。これにより、スタックが実際には不足しているにもかかわらず、チェックが誤って成功し、スタックオーバーフローを引き起こす危険性がありました。
- 標準的なスタックチェックは
-
大きなフレームに対する代替チェック:
- 上記の問題を回避するため、リンカは大きなスタックフレームを持つ関数に対して
SP - stackguard >= size
という代替チェックを生成していました。 - このチェックは、
SP
とstackguard
の差分を計算し、その結果がsize
以上であるかを比較します。これにより、SP - size
のような直接的な減算によるアンダーフローのリスクを軽減します。 - コミットメッセージにある
SP-stackguard+StackGuard <= framesize + (StackGuard-StackSmall)
というコメントは、このSP - stackguard >= size
の別表現であり、両辺にStackGuard
を加えることで、SP
がstackguard
よりわずかに小さい場合でも左辺が正になるように調整されています。これは、SP
がstackguard
のすぐ下にあることを許容するGoのスタック管理の特性を反映しています。
- 上記の問題を回避するため、リンカは大きなスタックフレームを持つ関数に対して
-
プリエンプションと代替チェックの新たな問題:
- プリエンプションを要求するために、ランタイムはゴルーチンの
stackguard
をStackPreempt
という特殊な非常に大きな値に設定します。 - この
StackPreempt
が設定された状態で、大きなフレーム用の代替チェックSP - stackguard >= size
が実行されると、SP - stackguard
の計算が再びアンダーフローを起こします。SP
は通常のアドレス範囲にあるのに対し、stackguard
は非常に大きな値であるため、SP - stackguard
は本来負の値になるはずです。しかし、符号なし整数演算ではこれが非常に大きな正の値として解釈されてしまいます。 - 結果として、
SP - stackguard >= size
の条件が誤って真となり、プリエンプションがトリガーされるべき状況でスタックチェックが成功してしまい、morestack
関数が呼び出されず、プリエンプションが妨げられるという問題が発生しました。
- プリエンプションを要求するために、ランタイムはゴルーチンの
-
最終的な解決策:
- このコミットでは、大きなスタックフレームを持つ関数に対するスタックチェックを
stackguard != StackPreempt && SP - stackguard >= size
に変更します。 - この変更の核心は、
stackguard
がStackPreempt
であるかどうかを明示的にチェックする条件stackguard != StackPreempt
を追加することです。 - もし
stackguard
がStackPreempt
であれば、スタックチェックの残りの部分(SP - stackguard >= size
)は実行されず、直接morestack
(プリエンプションをトリガーする)にジャンプします。 - これにより、プリエンプション要求時に
SP - stackguard
のラップアラウンドによってチェックが誤って成功するのを防ぎ、確実にmorestack
が呼び出され、プリエンプションが実行されるようになります。 - この追加チェックは数命令分のコスト増を伴いますが、大きなスタックフレームを持つ関数はほとんどの場合スタック分割が必要となるため、そのオーバーヘッドは無視できるレベルであると判断されています。
- このコミットでは、大きなスタックフレームを持つ関数に対するスタックチェックを
-
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->stackguard0
がuintptr
型であるため、比較の型を合わせるための変更です。
-
テストの追加:
src/pkg/runtime/proc_test.go
にTestPreemptSplitBig
という新しいテストが追加されています。- このテストは、大きなスタックフレームを持つ関数(
bigframe
)内でプリエンプションが正しく機能するかどうかを検証します。bigframe
は8KBの大きな配列をスタック上に確保し、runtime.GC()
を呼び出すことでプリエンプションを誘発します。これにより、修正が正しく機能し、プリエンプションが妨げられないことを確認します。
このコミットは、Goランタイムのスタック管理とスケジューリングの堅牢性を高める上で重要な修正であり、Goのプリエンプション機能がより信頼性高く動作するための基盤を築きました。
コアとなるコードの変更箇所
このコミットは、主にGoリンカの各アーキテクチャ固有のコード生成部分(src/cmd/5l/noop.c
、src/cmd/6l/pass.c
、src/cmd/8l/pass.c
)と、ランタイムのスタック関連の定義(src/pkg/runtime/stack.h
、src/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)の命令セットリファレンス
* 整数演算におけるオーバーフロー/アンダーフローに関する一般的な情報