[インデックス 16666] ファイルの概要
このコミットは、Goランタイムにおけるゴルーチンのステータス破損のバグを修正するものです。具体的には、システムコール中にスタックが拡張される際に、ゴルーチンのステータスが誤って Grunning
にリセットされてしまう問題に対処しています。この問題は、ガベージコレクション(GC)が実行されている最中にクラッシュを引き起こしたり、GCがスタックを正しくスキャンできなくなる可能性がありました。
コミット
commit 4eb17ecd1f1c5d130a0fe5c6bbd03714d315c41a
Author: Dmitriy Vyukov <dvyukov@google.com>
Date: Fri Jun 28 00:49:53 2013 +0400
runtime: fix goroutine status corruption
runtime.entersyscall() sets g->status = Gsyscall,
then calls runtime.lock() which causes stack split.
runtime.newstack() resets g->status to Grunning.
This will lead to crash during GC (world is not stopped) or GC will scan stack incorrectly.
R=golang-dev, rsc
CC=golang-dev
https://golang.org/cl/10696043
GitHub上でのコミットページへのリンク
https://github.com/golang/go/commit/4eb17ecd1f1c5d130a0fe5c6bbd03714d315c41a
元コミット内容
runtime: fix goroutine status corruption
runtime.entersyscall() sets g->status = Gsyscall,
then calls runtime.lock() which causes stack split.
runtime.newstack() resets g->status to Grunning.
This will lead to crash during GC (world is not stopped) or GC will scan stack incorrectly.
変更の背景
Goランタイムでは、ゴルーチンがシステムコールに入る際にそのステータスを Gsyscall
に設定します。これは、ランタイムがそのゴルーチンが現在システムコールを実行中であることを認識し、GCなどの他のランタイム操作が適切に動作するために重要です。
しかし、システムコール中に runtime.lock()
のようなランタイム関数が呼び出されると、ゴルーチンのスタックが不足している場合に「スタック分割(stack split)」が発生し、スタックを拡張するための runtime.newstack()
関数が呼び出される可能性がありました。
この runtime.newstack()
関数にはバグがあり、スタック拡張処理の完了後に、ゴルーチンのステータスを無条件に Grunning
にリセットしていました。問題は、ゴルーチンがまだシステムコール中であるにもかかわらず、そのステータスが Grunning
に戻されてしまう点にありました。
このステータス情報の不整合は、特にGoのガベージコレクタが「ワールドストップなし(world is not stopped)」で動作する(つまり、GCが実行されている間もユーザーゴルーチンが実行され続ける)場合に深刻な問題を引き起こしました。GCはゴルーチンのステータスに基づいてスタックをスキャンするかどうか、どのようにスキャンするかを決定します。Gsyscall
状態のゴルーチンは、通常、GCがスタックをスキャンする際に特別な考慮が必要です。ステータスが誤って Grunning
になっていると、GCがスタックを誤ってスキャンしたり、最悪の場合、ランタイムのクラッシュにつながる可能性がありました。
このコミットは、このゴルーチンステータスの破損を防ぎ、ランタイムの安定性とGCの正確性を確保するために導入されました。
前提知識の解説
このコミットを理解するためには、以下のGoランタイムの概念を理解しておく必要があります。
-
ゴルーチン (Goroutine): Goにおける軽量な実行スレッドです。OSのスレッドとは異なり、Goランタイムによって管理され、非常に低コストで生成・管理できます。数百万のゴルーチンを同時に実行することも可能です。
-
ゴルーチンのステータス (Goroutine Status): Goランタイムは、各ゴルーチンの現在の状態を追跡するためにステータスを使用します。主要なステータスには以下のようなものがあります。
Grunning
: ゴルーチンが現在CPU上で実行中である状態。Gsyscall
: ゴルーチンがシステムコール(OSへの呼び出し)を実行中である状態。この状態のゴルーチンは、OSのスケジューラによって管理されており、Goランタイムのスケジューラからは一時的に切り離されます。Gwaiting
: ゴルーチンが何らかのイベント(チャネル操作、タイマー、I/Oなど)を待機している状態。Gdead
: ゴルーチンが終了した状態。
-
スタック分割 (Stack Split): Goのゴルーチンは、最初は小さなスタック(通常は数KB)で開始します。関数呼び出しが深くネストしたり、大きなローカル変数が使用されたりしてスタックが不足しそうになると、Goランタイムは自動的にスタックを拡張します。このプロセスを「スタック分割」と呼びます。スタック分割は透過的に行われ、プログラマは意識する必要がありません。
-
ガベージコレクション (Garbage Collection - GC): Goは自動メモリ管理(ガベージコレクション)を採用しています。GoのGCは、並行(concurrent)かつ並列(parallel)に動作するように設計されています。これは、GCが実行されている間もユーザーゴルーチンがほとんど停止することなく実行され続けることを意味します(「ワールドストップなし」)。GCは、到達可能なオブジェクトを特定するために、実行中のゴルーチンのスタックをスキャンして、そこに保存されているポインタを識別する必要があります。
-
runtime.entersyscall()
とruntime.exitsyscall()
: Goランタイムがシステムコールに入る直前に呼び出される関数がruntime.entersyscall()
です。この関数はゴルーチンのステータスをGsyscall
に設定し、ランタイムがそのゴルーチンをシステムコール中として認識できるようにします。システムコールから戻る際にはruntime.exitsyscall()
が呼び出され、ステータスがGrunning
に戻されます。 -
runtime.lock()
: Goランタイム内部で使用される低レベルのロックプリミティブです。ランタイムの重要なデータ構造へのアクセスを保護するために使用されます。この関数は、場合によってはスタック分割を引き起こす可能性があります。 -
runtime.newstack()
: スタック分割が発生した際に、新しい(より大きな)スタックを割り当て、古いスタックの内容を新しいスタックにコピーし、ゴルーチンの実行コンテキストを新しいスタックに切り替える役割を担う関数です。
技術的詳細
このバグは、runtime.entersyscall()
によってゴルーチンのステータスが Gsyscall
に設定された後、システムコール内で runtime.lock()
が呼び出され、その結果としてスタック分割が発生した場合に顕在化しました。
通常のフローでは、ゴルーチンがシステムコールに入ると、g->status
は Gsyscall
になります。システムコールが完了すると、runtime.exitsyscall()
が g->status
を Grunning
に戻します。
しかし、システムコール中にスタック分割が必要になると、runtime.newstack()
が呼び出されます。runtime.newstack()
は、スタックの拡張とコンテキストの切り替えを行った後、ゴルーチンのステータスを Grunning
に設定するコードパスを持っていました。これは、newstack
が通常、Grunning
状態のゴルーチンに対して呼び出されることを想定していたためです。
問題は、Gsyscall
状態のゴルーチンに対して newstack
が呼び出された場合、newstack
が完了すると、ゴルーチンはまだシステムコール中であるにもかかわらず、そのステータスが Grunning
に上書きされてしまうことでした。
この誤ったステータスは、ガベージコレクタにとって致命的でした。GoのGCは、実行中のゴルーチンのスタックをスキャンして、到達可能なオブジェクトを特定します。Gsyscall
状態のゴルーチンは、OSのスタックを使用している可能性があり、GCはこれを特別に扱う必要があります。しかし、ステータスが Grunning
になっていると、GCは通常のGoスタックとしてスキャンしようとし、OSのスタック上のポインタを見逃したり、無効なメモリを読み取ろうとしてクラッシュしたりする可能性がありました。また、GCが「ワールドストップなし」で動作するため、このような不整合が実行中に発生し、ランタイムの不安定性につながりました。
このコミットの修正は、runtime.oldstack
と runtime.newstack
の両方で、スタック操作の前にゴルーチンの現在のステータスを一時変数 oldstatus
に保存し、スタック操作が完了した後にその oldstatus
をゴルーチンのステータスに復元するというシンプルなものです。これにより、スタック分割が発生しても、ゴルーチンの本来のステータス(この場合は Gsyscall
)が維持されるようになります。
コアとなるコードの変更箇所
変更は src/pkg/runtime/stack.c
ファイルに集中しています。
--- a/src/pkg/runtime/stack.c
+++ b/src/pkg/runtime/stack.c
@@ -138,6 +138,7 @@ runtime·oldstack(void)\n uintptr *src, *dst, *dstend;\n G *gp;\n int64 goid;\n+\tint32 oldstatus;\n \n gp = m->curg;\n top = (Stktop*)gp->stackbase;\
@@ -149,6 +150,10 @@ runtime·oldstack(void)\n \truntime·printf(\"runtime: oldstack gobuf={pc:%p sp:%p lr:%p} cret=%p argsize=%p\\n\",\n \t\ttop->gobuf.pc, top->gobuf.sp, top->gobuf.lr, m->cret, (uintptr)argsize);\n }\n+\n+\t// gp->status is usually Grunning, but it could be Gsyscall if a stack split\n+\t// happens during a function call inside entersyscall.\n+\toldstatus = gp->status;\n \t\n \tgp->sched = top->gobuf;\n \tgp->sched.ret = m->cret;\
@@ -174,7 +179,7 @@ runtime·oldstack(void)\n \tif(top->free != 0)\n \t\truntime·stackfree(old, top->free);\n \n-\tgp->status = Grunning;\n+\tgp->status = oldstatus;\n \truntime·gogo(&gp->sched);\
}\n \n@@ -186,7 +191,7 @@ runtime·oldstack(void)\n void\n runtime·newstack(void)\n {\n-\tint32 framesize, argsize;\n+\tint32 framesize, argsize, oldstatus;\n \tStktop *top;\n \tbyte *stk;\n \tuintptr sp;\
@@ -196,9 +201,13 @@ runtime·newstack(void)\n \tbool reflectcall;\n \tuintptr free;\n \n+\t// gp->status is usually Grunning, but it could be Gsyscall if a stack split\n+\t// happens during a function call inside entersyscall.\n+\tgp = m->curg;\n+\toldstatus = gp->status;\n+\n \tframesize = m->moreframesize;\n \targsize = m->moreargsize;\n-\tgp = m->curg;\n \tgp->status = Gwaiting;\n \tgp->waitreason = \"stack split\";\n \treflectcall = framesize==1;\
@@ -304,7 +313,7 @@ runtime·newstack(void)\n \t\truntime·gostartcall(&label, (void(*)(void))gp->sched.pc, gp->sched.ctxt);\n \t\tgp->sched.ctxt = nil;\n \t}\n-\tgp->status = Grunning;\n+\tgp->status = oldstatus;\n \truntime·gogo(&label);\
\n \t*(int32*)345 = 123;\t// never return\n```
## コアとなるコードの解説
このコミットは、`runtime·oldstack` と `runtime·newstack` の2つの関数に変更を加えています。これらの関数は、Goランタイムがゴルーチンのスタックを管理する上で中心的な役割を担っています。
1. **`runtime·oldstack` 関数への変更**:
* `int32 oldstatus;` という新しいローカル変数が追加されました。
* `oldstatus = gp->status;` という行が追加され、スタック操作が開始される前に、現在のゴルーチン `gp` のステータスが `oldstatus` に保存されます。
* `gp->status = Grunning;` という行が `gp->status = oldstatus;` に変更されました。これにより、スタック操作が完了した後、ゴルーチンのステータスは無条件に `Grunning` に設定されるのではなく、操作前の元のステータスに復元されるようになりました。
2. **`runtime·newstack` 関数への変更**:
* `int32 framesize, argsize, oldstatus;` と、ここでも `oldstatus` 変数が追加されました。
* `gp = m->curg;` の位置が変更され、`oldstatus = gp->status;` の行が追加されました。これにより、`newstack` がスタック拡張処理を開始する前に、現在のゴルーチンのステータスが `oldstatus` に保存されます。
* `gp->status = Grunning;` という行が `gp->status = oldstatus;` に変更されました。`oldstack` と同様に、スタック拡張が完了した後、ゴルーチンのステータスは元のステータスに復元されます。
これらの変更により、`runtime.entersyscall()` によって `Gsyscall` に設定されたゴルーチンが、システムコール中にスタック分割を経験しても、そのステータスが `Grunning` に誤ってリセットされることがなくなりました。代わりに、`Gsyscall` ステータスが維持されるため、ガベージコレクタはゴルーチンの真の状態を正確に認識し、適切なスタックスキャン戦略を適用できるようになります。これにより、GC中のクラッシュや不正なスタックスキャンが防止され、ランタイムの堅牢性が向上しました。
## 関連リンク
* Goのガベージコレクションに関する公式ドキュメントやブログ記事:
* [Go's Garbage Collector: A Brief History](https://go.dev/blog/go15gc)
* [The Go scheduler](https://go.dev/blog/go-scheduler)
## 参考にした情報源リンク
* Goのソースコード (`src/pkg/runtime/stack.c`)
* コミットメッセージとGerritの変更リスト (`https://golang.org/cl/10696043`)
* Goのランタイムとガベージコレクションに関する一般的な知識