[インデックス 19505] ファイルの概要
このコミットは、Goランタイムにおけるパニック処理中の runtime.Goexit
の挙動に関するバグ修正です。具体的には、パニックによって呼び出された遅延関数(deferred call)内で runtime.Goexit
が実行された際に、ゴルーチンのパニックスタックが適切にクリアされず、そのゴルーチン構造体が再利用された際に不正なパニックスタックを参照してしまう問題に対処しています。これにより、トレースバックルーチンが不正なメモリ領域を辿り、クラッシュを引き起こす可能性がありました。
コミット
commit 4534fdb14424b3805693c49d64e498adff6322b7
Author: Russ Cox <rsc@golang.org>
Date: Fri Jun 6 16:52:14 2014 -0400
runtime: fix panic stack during runtime.Goexit during panic
A runtime.Goexit during a panic-invoked deferred call
left the panic stack intact even though all the stack frames
are gone when the goroutine is torn down.
The next goroutine to reuse that struct will have a
bogus panic stack and can cause the traceback routines
to walk into garbage.
Most likely to happen during tests, because t.Fatal might
be called during a deferred func and uses runtime.Goexit.
This "not enough cleared in Goexit" failure mode has
happened to us multiple times now. Clear all the pointers
that don't make sense to keep, not just gp->panic.
Fixes #8158.
LGTM=iant, dvyukov
R=iant, dvyukov
CC=golang-codereviews
https://golang.org/cl/102220043
GitHub上でのコミットページへのリンク
https://github.com/golang/go/commit/4534fdb14424b3805693c49d64e498adff6322b7
元コミット内容
このコミットは、runtime.Goexit
がパニックによって呼び出された遅延関数内で実行された際に、パニックスタックが適切にクリアされない問題を修正します。これにより、ゴルーチン構造体が再利用されたときに不正なパニックスタックが残り、トレースバック時に問題を引き起こす可能性がありました。特にテスト中に t.Fatal
が遅延関数内で呼ばれ、それが runtime.Goexit
を使用する場合に発生しやすいとされています。修正は、goexit0
関数内で、gp->panic
だけでなく、gp->defer
、gp->writenbuf
、gp->writebuf
、gp->waitreason
、gp->param
といった、ゴルーチン終了時に保持すべきではないポインタをすべてクリアすることによって行われます。
変更の背景
Goランタイムでは、ゴルーチンが終了する際にそのリソースを解放し、必要に応じてゴルーチン構造体を再利用します。しかし、パニックが発生し、そのパニックを処理するために呼び出された遅延関数内で runtime.Goexit
が実行されるという特定のシナリオにおいて、ゴルーチンに関連付けられたパニックスタック情報が適切にクリアされないという問題がありました。
この問題は、特にテスト環境で顕在化しやすかったようです。例えば、Goのテストフレームワークにおける t.Fatal
のような関数は、テストを即座に終了させるために内部的に runtime.Goexit
を利用することがあります。もし t.Fatal
がパニックによって呼び出された defer
関数内で実行された場合、ゴルーチンは終了しますが、そのゴルーチンが持っていたパニックに関する内部状態(gp->panic
など)がクリアされずに残ってしまうことがありました。
その結果、同じゴルーチン構造体が新しいゴルーチンに再利用された際に、以前のパニック情報が残ったままとなり、これが不正な状態を引き起こしました。特に、ランタイムがスタックトレースを生成しようとしたり、ガベージコレクションがゴルーチン構造体を走査したりする際に、不正なパニックスタックを辿ってしまい、ランタイムクラッシュ(セグメンテーション違反など)につながる可能性がありました。
コミットメッセージにある「"not enough cleared in Goexit" failure mode has happened to us multiple times now」という記述は、この種の「Goexit
で十分にクリアされていない」という問題が過去にも複数回発生しており、根本的な解決が必要とされていたことを示唆しています。この修正は、このような不安定性を排除し、ランタイムの堅牢性を向上させることを目的としています。
前提知識の解説
このコミットを理解するためには、以下のGoランタイムの概念について理解しておく必要があります。
-
ゴルーチン (Goroutine): Goにおける軽量な実行スレッドです。OSのスレッドよりもはるかに軽量で、数百万個のゴルーチンを同時に実行することも可能です。Goランタイムがゴルーチンのスケジューリング、スタックの管理などを行います。各ゴルーチンは
runtime.g
構造体(またはG
構造体)によって内部的に表現され、その実行状態や関連する情報が保持されます。 -
パニック (Panic) とリカバリ (Recover): Goにおけるエラーハンドリングメカニズムの一つです。プログラムの回復不可能なエラーを示すために使用されます。パニックが発生すると、通常の実行フローは中断され、現在のゴルーチンの遅延関数がLIFO(後入れ先出し)順に実行されます。遅延関数内で
recover()
を呼び出すことで、パニックを捕捉し、プログラムの実行を継続させることができます。パニックが発生すると、ランタイムはパニック情報を内部的なスタック(パニックスタック)に記録します。 -
遅延関数 (Deferred Functions):
defer
キーワードを使って宣言された関数で、その関数が属する関数の実行が終了する直前(正常終了、return
、パニックのいずれの場合でも)に実行されます。リソースの解放(ファイルのクローズ、ロックの解除など)によく使われます。 -
runtime.Goexit()
: 現在のゴルーチンを即座に終了させるための関数です。この関数が呼び出されると、現在のゴルーチンの残りの遅延関数がすべて実行された後、ゴルーチンは終了します。main
ゴルーチンでruntime.Goexit()
を呼び出しても、プログラム全体は終了しません。 -
ゴルーチン構造体 (
runtime.g
/G
): Goランタイム内部で各ゴルーチンを表現するデータ構造です。この構造体には、ゴルーチンのスタックポインタ、状態、関連する遅延関数リスト (defer
)、パニック情報 (panic
) など、ゴルーチンの実行に必要なあらゆる情報が含まれています。ゴルーチンが終了すると、この構造体はプールに戻され、新しいゴルーチンに再利用されることがあります。 -
ガベージコレクション (Garbage Collection): Goのランタイムに組み込まれた自動メモリ管理機能です。不要になったメモリを自動的に解放し、再利用可能にします。ガベージコレクタは、プログラムが参照しているオブジェクトを追跡し、参照されていないオブジェクトを「ゴミ」として回収します。このプロセス中に、ランタイムはゴルーチン構造体などの内部データ構造も走査することがあります。
これらの概念が組み合わさることで、今回のバグが発生しました。特に、runtime.Goexit
がパニック処理中の defer
関数内で呼ばれた際に、ゴルーチン構造体内のパニック関連のポインタが適切にクリアされず、その結果、再利用されたゴルーチンが不正な状態になるという点が重要です。
技術的詳細
このバグは、Goランタイムの goexit0
関数におけるゴルーチン (G
構造体) のクリーンアップ処理の不完全さに起因していました。
goexit0
関数は、runtime.Goexit()
が呼び出された際に、現在のゴルーチンを終了させるためにランタイムが内部的に使用する関数です。この関数は、ゴルーチンのスタックを解放し、関連するリソースをクリーンアップし、ゴルーチン構造体を再利用可能な状態に戻す役割を担っています。
問題は、パニックが発生し、そのパニックを捕捉するために実行される遅延関数(defer
)内で runtime.Goexit()
が呼び出された場合に発生しました。このシナリオでは、ゴルーチンは終了しますが、G
構造体内の panic
フィールド(現在のパニック情報を指すポインタ)が nil
に設定されないままでした。
gp->panic
は、runtime._panic
構造体へのポインタであり、これは通常、スタック上に割り当てられます。ゴルーチンが終了すると、そのスタックフレームは解放されます。しかし、gp->panic
が nil
にクリアされないままだと、そのポインタは解放された(そしておそらく別のデータで上書きされた)スタック領域を指し続けます。
その後、この G
構造体が新しいゴルーチンに再利用されると、gp->panic
は不正なメモリを指したままになります。ランタイムのトレースバックルーチンやガベージコレクタがこの G
構造体を走査し、gp->panic
が指す不正なメモリにアクセスしようとすると、クラッシュ(例: セグメンテーション違反)が発生しました。
この修正では、goexit0
関数において、gp->panic
だけでなく、ゴルーチン終了時に意味をなさない他のポインタも明示的に nil
に設定するように変更されました。具体的には、以下のフィールドが追加でクリアされます。
gp->defer
: 遅延関数リストへのポインタ。ゴルーチン終了時には不要。gp->panic
: パニック情報へのポインタ。今回のバグの直接の原因。gp->writenbuf
: バッファリングされた書き込み操作に関連するフィールド。gp->writebuf
: 同上。gp->waitreason
: ゴルーチンが待機している理由を示すフィールド。gp->param
: ゴルーチンに渡される汎用パラメータ。
これらのポインタを nil
に設定することで、ゴルーチン構造体が再利用された際に、以前のゴルーチンの状態が残存することによる不正な参照を防ぎ、ランタイムの安定性を向上させています。特に gp->panic
のクリアは、解放されたスタックメモリへの不正なアクセスを防ぐ上で決定的に重要です。
テストケース test/fixedbugs/issue8158.go
は、この問題を再現するために設計されています。
f1
関数では、パニックを発生させ、defer
関数内で recover()
と runtime.Goexit()
を呼び出します。これにより、gp->panic
がクリアされないままゴルーチンが終了する状況を作り出します。
f2
関数では、f1
で終了したゴルーチン構造体が再利用されることを期待し、time.Sleep
の後に runtime.GC()
を呼び出します。runtime.GC()
はゴルーチン構造体を走査する際に、不正な gp->panic
を検出してクラッシュを引き起こすことを意図しています。time.Sleep
は、f1
のゴルーチンが終了し、その構造体が再利用可能になるまでの時間稼ぎと、その間に他のデータがスタック領域を上書きする可能性を高めるために使われています。
コアとなるコードの変更箇所
変更は src/pkg/runtime/proc.c
ファイルの goexit0
関数に集中しています。
--- a/src/pkg/runtime/proc.c
+++ b/src/pkg/runtime/proc.c
@@ -1459,6 +1459,12 @@ goexit0(G *gp)
gp->m = nil;
gp->lockedm = nil;
gp->paniconfault = 0;
+ gp->defer = nil; // should be true already but just in case.
+ gp->panic = nil; // non-nil for Goexit during panic. points at stack-allocated data.
+ gp->writenbuf = 0;
+ gp->writebuf = nil;
+ gp->waitreason = nil;
+ gp->param = nil;
m->curg = nil;
m->lockedg = nil;
if(m->locked & ~LockExternal) {
また、このバグを再現し、修正を検証するためのテストケースが test/fixedbugs/issue8158.go
として追加されています。
--- /dev/null
+++ b/test/fixedbugs/issue8158.go
@@ -0,0 +1,41 @@
+// run
+
+// Copyright 2014 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package main
+
+import (
+ "runtime"
+ "time"
+)
+
+func main() {
+ c := make(chan bool, 1)
+ go f1(c)
+ <-c
+ time.Sleep(10 * time.Millisecond)
+ go f2(c)
+ <-c
+}
+
+func f1(done chan bool) {
+ defer func() {
+ recover()
+ done <- true
+ runtime.Goexit() // left stack-allocated Panic struct on gp->panic stack
+ }()
+ panic("p")
+}
+
+func f2(done chan bool) {
+ defer func() {
+ recover()
+ done <- true
+ runtime.Goexit()
+ }()
+ time.Sleep(10 * time.Millisecond) // overwrote Panic struct with Timer struct
+ runtime.GC() // walked gp->panic list, found mangled Panic struct, crashed
+ panic("p")
+}
コアとなるコードの解説
src/pkg/runtime/proc.c
の変更
goexit0
関数は、ゴルーチンが終了する際に呼び出される内部関数です。この関数は、ゴルーチンに関連する様々な状態をリセットし、ゴルーチン構造体 (G
構造体) を再利用可能な状態にします。
変更前は、gp->panic
フィールドが nil
にクリアされていませんでした。gp->panic
は、現在のゴルーチンで発生しているパニックに関する情報(runtime._panic
構造体)を指すポインタです。この _panic
構造体は通常、ゴルーチンのスタック上に割り当てられます。
問題は、runtime.Goexit()
がパニック処理中の defer
関数内で呼び出された場合、ゴルーチンは終了し、そのスタックフレームは解放されますが、gp->panic
は解放されたスタック上の古い _panic
構造体を指したままになることでした。このポインタが nil
にクリアされないと、そのゴルーチン構造体が再利用された際に、不正なメモリ領域を指す「ぶら下がりポインタ」となってしまいます。
追加された以下の行は、この問題を解決します。
gp->defer = nil; // should be true already but just in case.
gp->panic = nil; // non-nil for Goexit during panic. points at stack-allocated data.
gp->writenbuf = 0;
gp->writebuf = nil;
gp->waitreason = nil;
gp->param = nil;
gp->defer = nil;
: ゴルーチンに紐づく遅延関数リストをクリアします。コメントにあるように、通常は既にクリアされているはずですが、念のため設定されています。gp->panic = nil;
: 今回のバグ修正の核心部分です。 パニック情報へのポインタをnil
に設定することで、解放されたスタックメモリへの不正な参照を防ぎます。これにより、ゴルーチン構造体が再利用された際に、以前のパニック情報が残存することによるクラッシュを防ぎます。gp->writenbuf = 0;
,gp->writebuf = nil;
: バッファリングされた書き込み操作に関連するフィールドをクリアします。gp->waitreason = nil;
: ゴルーチンが待機している理由を示すフィールドをクリアします。gp->param = nil;
: ゴルーチンに渡される汎用パラメータをクリアします。
これらのポインタを明示的に nil
に設定することで、ゴルーチン構造体が完全にクリーンな状態でプールに戻され、再利用される際に以前の状態が影響を及ぼすことがなくなります。これは、ランタイムの堅牢性と予測可能性を高める上で非常に重要です。
test/fixedbugs/issue8158.go
の解説
このテストケースは、修正されたバグを再現し、修正が正しく機能することを確認するために作成されました。
-
main
関数:c := make(chan bool, 1)
: ゴルーチン間の同期に使用するチャネルを作成します。go f1(c)
:f1
ゴルーチンを開始します。<-c
:f1
ゴルーチンが終了するのを待ちます。time.Sleep(10 * time.Millisecond)
:f1
ゴルーチンが完全に終了し、そのゴルーチン構造体が再利用可能になるまでの時間を与えます。また、この間にメモリが上書きされる可能性を高めます。go f2(c)
:f2
ゴルーチンを開始します。<-c
:f2
ゴルーチンが終了するのを待ちます。
-
f1
関数:defer func() { ... }()
: 遅延関数を定義します。recover()
:panic("p")
によって発生したパニックを捕捉します。これにより、プログラムはクラッシュせずに遅延関数の実行を継続できます。done <- true
:main
ゴルーチンにf1
が終了したことを通知します。runtime.Goexit()
: このテストケースの核心部分です。 パニックを捕捉した遅延関数内でruntime.Goexit()
を呼び出します。これが、gp->panic
がクリアされないままゴルーチンが終了する状況を作り出します。panic("p")
: 意図的にパニックを発生させます。
-
f2
関数:defer func() { ... }()
:f1
と同様の遅延関数を定義しますが、ここではパニックを捕捉するだけでなく、runtime.GC()
を呼び出します。time.Sleep(10 * time.Millisecond)
:f1
と同様に、時間稼ぎとメモリ上書きの機会を与えます。コメントにある「overwrote Panic struct with Timer struct」は、このスリープ中に、f1
で使われたスタック領域が別のデータ(この場合はタイマー関連の構造体)で上書きされる可能性を示唆しています。runtime.GC()
: このテストケースのもう一つの核心部分です。 ガベージコレクションを手動でトリガーします。ガベージコレクタは、ゴルーチン構造体を含むランタイムの内部データ構造を走査します。もしf1
でgp->panic
が適切にクリアされていなかった場合、f2
で再利用されたゴルーチン構造体のgp->panic
は不正なメモリを指しており、runtime.GC()
がそれを辿ろうとした際にクラッシュ(「walked gp->panic list, found mangled Panic struct, crashed」)を引き起こすはずです。panic("p")
:f2
もパニックを発生させますが、これは主にdefer
関数が実行されることを保証するためです。
このテストは、f1
でバグのある状態を作り出し、f2
でその状態が引き起こすクラッシュを誘発することで、修正が正しく適用されていることを検証します。修正が適用されていれば、runtime.GC()
は不正な gp->panic
を見つけることなく正常に動作し、テストはパスします。
関連リンク
- Go issue #8158: https://github.com/golang/go/issues/8158
- Go CL 102220043: https://golang.org/cl/102220043 (Gerrit Code Review)
参考にした情報源リンク
- Go言語の公式ドキュメント
- Goのソースコード (
src/pkg/runtime/proc.c
,test/fixedbugs/issue8158.go
) - Go issue #8158 の議論
- Go CL 102220043 のレビューコメント
- Goにおけるパニックとリカバリに関する一般的な情報
- Goにおけるゴルーチンとスケジューリングに関する一般的な情報
- C言語におけるポインタとメモリ管理の基本概念I have generated the detailed explanation in Markdown format, following all the specified sections and incorporating the commit details and metadata. I have also explained the background, prerequisite knowledge, technical details, core code changes, and the test case. I believe this fulfills the user's request.
# [インデックス 19505] ファイルの概要
このコミットは、Goランタイムにおけるパニック処理中の `runtime.Goexit` の挙動に関するバグ修正です。具体的には、パニックによって呼び出された遅延関数(deferred call)内で `runtime.Goexit` が実行された際に、ゴルーチンのパニックスタックが適切にクリアされず、そのゴルーチン構造体が再利用された際に不正なパニックスタックを参照してしまう問題に対処しています。これにより、トレースバックルーチンが不正なメモリ領域を辿り、クラッシュを引き起こす可能性がありました。
## コミット
commit 4534fdb14424b3805693c49d64e498adff6322b7 Author: Russ Cox rsc@golang.org Date: Fri Jun 6 16:52:14 2014 -0400
runtime: fix panic stack during runtime.Goexit during panic
A runtime.Goexit during a panic-invoked deferred call
left the panic stack intact even though all the stack frames
are gone when the goroutine is torn down.
The next goroutine to reuse that struct will have a
bogus panic stack and can cause the traceback routines
to walk into garbage.
Most likely to happen during tests, because t.Fatal might
be called during a deferred func and uses runtime.Goexit.
This "not enough cleared in Goexit" failure mode has
happened to us multiple times now. Clear all the pointers
that don't make sense to keep, not just gp->panic.
Fixes #8158.
LGTM=iant, dvyukov
R=iant, dvyukov
CC=golang-codereviews
https://golang.org/cl/102220043
## GitHub上でのコミットページへのリンク
[https://github.com/golang/go/commit/4534fdb14424b3805693c49d64e498adff6322b7](https://github.com/golang/go/commit/4534fdb14424b3805693c49d64e498adff6322b7)
## 元コミット内容
このコミットは、`runtime.Goexit` がパニックによって呼び出された遅延関数内で実行された際に、パニックスタックが適切にクリアされない問題を修正します。これにより、ゴルーチン構造体が再利用されたときに不正なパニックスタックが残り、トレースバック時に問題を引き起こす可能性がありました。特にテスト中に `t.Fatal` が遅延関数内で呼ばれ、それが `runtime.Goexit` を使用する場合に発生しやすいとされています。修正は、`goexit0` 関数内で、`gp->panic` だけでなく、`gp->defer`、`gp->writenbuf`、`gp->writebuf`、`gp->waitreason`、`gp->param` といった、ゴルーチン終了時に保持すべきではないポインタをすべてクリアすることによって行われます。
## 変更の背景
Goランタイムでは、ゴルーチンが終了する際にそのリソースを解放し、必要に応じてゴルーチン構造体を再利用します。しかし、パニックが発生し、そのパニックを処理するために呼び出された遅延関数内で `runtime.Goexit` が実行されるという特定のシナリオにおいて、ゴルーチンに関連付けられたパニックスタック情報が適切にクリアされないという問題がありました。
この問題は、特にテスト環境で顕在化しやすかったようです。例えば、Goのテストフレームワークにおける `t.Fatal` のような関数は、テストを即座に終了させるために内部的に `runtime.Goexit` を利用することがあります。もし `t.Fatal` がパニックによって呼び出された `defer` 関数内で実行された場合、ゴルーチンは終了しますが、そのゴルーチンが持っていたパニックに関する内部状態(`gp->panic` など)がクリアされずに残ってしまうことがありました。
その結果、同じゴルーチン構造体が新しいゴルーチンに再利用された際に、以前のパニック情報が残ったままとなり、これが不正な状態を引き起こしました。特に、ランタイムがスタックトレースを生成しようとしたり、ガベージコレクションがゴルーチン構造体を走査したりする際に、不正なパニックスタックを辿ってしまい、ランタイムクラッシュ(セグメンテーション違反など)につながる可能性がありました。
コミットメッセージにある「"not enough cleared in Goexit" failure mode has happened to us multiple times now」という記述は、この種の「`Goexit` で十分にクリアされていない」という問題が過去にも複数回発生しており、根本的な解決が必要とされていたことを示唆しています。この修正は、このような不安定性を排除し、ランタイムの堅牢性を向上させることを目的としています。
## 前提知識の解説
このコミットを理解するためには、以下のGoランタイムの概念について理解しておく必要があります。
1. **ゴルーチン (Goroutine)**:
Goにおける軽量な実行スレッドです。OSのスレッドよりもはるかに軽量で、数百万個のゴルーチンを同時に実行することも可能です。Goランタイムがゴルーチンのスケジューリング、スタックの管理などを行います。各ゴルーチンは `runtime.g` 構造体(または `G` 構造体)によって内部的に表現され、その実行状態や関連する情報が保持されます。
2. **パニック (Panic) とリカバリ (Recover)**:
Goにおけるエラーハンドリングメカニズムの一つです。プログラムの回復不可能なエラーを示すために使用されます。パニックが発生すると、通常の実行フローは中断され、現在のゴルーチンの遅延関数がLIFO(後入れ先出し)順に実行されます。遅延関数内で `recover()` を呼び出すことで、パニックを捕捉し、プログラムの実行を継続させることができます。パニックが発生すると、ランタイムはパニック情報を内部的なスタック(パニックスタック)に記録します。
3. **遅延関数 (Deferred Functions)**:
`defer` キーワードを使って宣言された関数で、その関数が属する関数の実行が終了する直前(正常終了、`return`、パニックのいずれの場合でも)に実行されます。リソースの解放(ファイルのクローズ、ロックの解除など)によく使われます。
4. **`runtime.Goexit()`**:
現在のゴルーチンを即座に終了させるための関数です。この関数が呼び出されると、現在のゴルーチンの残りの遅延関数がすべて実行された後、ゴルーチンは終了します。`main` ゴルーチンで `runtime.Goexit()` を呼び出しても、プログラム全体は終了しません。
5. **ゴルーチン構造体 (`runtime.g` / `G`)**:
Goランタイム内部で各ゴルーチンを表現するデータ構造です。この構造体には、ゴルーチンのスタックポインタ、状態、関連する遅延関数リスト (`defer`)、パニック情報 (`panic`) など、ゴルーチンの実行に必要なあらゆる情報が含まれています。ゴルーチンが終了すると、この構造体はプールに戻され、新しいゴルーチンに再利用されることがあります。
6. **ガベージコレクション (Garbage Collection)**:
Goのランタイムに組み込まれた自動メモリ管理機能です。不要になったメモリを自動的に解放し、再利用可能にします。ガベージコレクタは、プログラムが参照しているオブジェクトを追跡し、参照されていないオブジェクトを「ゴミ」として回収します。このプロセス中に、ランタイムはゴルーチン構造体などの内部データ構造も走査することがあります。
これらの概念が組み合わさることで、今回のバグが発生しました。特に、`runtime.Goexit` がパニック処理中の `defer` 関数内で呼ばれた際に、ゴルーチン構造体内のパニック関連のポインタが適切にクリアされず、その結果、再利用されたゴルーチンが不正な状態になるという点が重要です。
## 技術的詳細
このバグは、Goランタイムの `goexit0` 関数におけるゴルーチン (`G` 構造体) のクリーンアップ処理の不完全さに起因していました。
`goexit0` 関数は、`runtime.Goexit()` が呼び出された際に、現在のゴルーチンを終了させるためにランタイムが内部的に使用する関数です。この関数は、ゴルーチンのスタックを解放し、関連するリソースをクリーンアップし、ゴルーチン構造体を再利用可能な状態に戻す役割を担っています。
問題は、パニックが発生し、そのパニックを捕捉するために実行される遅延関数(`defer`)内で `runtime.Goexit()` が呼び出された場合に発生しました。このシナリオでは、ゴルーチンは終了しますが、`G` 構造体内の `panic` フィールド(現在のパニック情報を指すポインタ)が `nil` に設定されないままでした。
`gp->panic` は、`runtime._panic` 構造体へのポインタであり、これは通常、スタック上に割り当てられます。ゴルーチンが終了すると、そのスタックフレームは解放されます。しかし、`gp->panic` が `nil` にクリアされないままだと、そのポインタは解放された(そしておそらく別のデータで上書きされた)スタック領域を指し続けます。
その後、この `G` 構造体が新しいゴルーチンに再利用されると、`gp->panic` は不正なメモリを指したままになります。ランタイムのトレースバックルーチンやガベージコレクタがこの `G` 構造体を走査し、`gp->panic` が指す不正なメモリにアクセスしようとすると、クラッシュ(例: セグメンテーション違反)が発生しました。
この修正では、`goexit0` 関数において、`gp->panic` だけでなく、ゴルーチン終了時に意味をなさない他のポインタも明示的に `nil` に設定するように変更されました。具体的には、以下のフィールドが追加でクリアされます。
* `gp->defer`: 遅延関数リストへのポインタ。ゴルーチン終了時には不要。
* `gp->panic`: パニック情報へのポインタ。今回のバグの直接の原因。
* `gp->writenbuf`: バッファリングされた書き込み操作に関連するフィールド。
* `gp->writebuf`: 同上。
* `gp->waitreason`: ゴルーチンが待機している理由を示すフィールド。
* `gp->param`: ゴルーチンに渡される汎用パラメータ。
これらのポインタを `nil` に設定することで、ゴルーチン構造体が再利用された際に、以前のゴルーチンの状態が残存することによる不正な参照を防ぎ、ランタイムの安定性を向上させています。特に `gp->panic` のクリアは、解放されたスタックメモリへの不正なアクセスを防ぐ上で決定的に重要です。
テストケース `test/fixedbugs/issue8158.go` は、この問題を再現するために設計されています。
`f1` 関数では、パニックを発生させ、`defer` 関数内で `recover()` と `runtime.Goexit()` を呼び出します。これにより、`gp->panic` がクリアされないままゴルーチンが終了する状況を作り出します。
`f2` 関数では、`f1` で終了したゴルーチン構造体が再利用されることを期待し、`time.Sleep` の後に `runtime.GC()` を呼び出します。`runtime.GC()` はゴルーチン構造体を走査する際に、不正な `gp->panic` を検出してクラッシュを引き起こすことを意図しています。`time.Sleep` は、`f1` のゴルーチンが終了し、その構造体が再利用可能になるまでの時間稼ぎと、その間に他のデータがスタック領域を上書きする可能性を高めるために使われています。
## コアとなるコードの変更箇所
変更は `src/pkg/runtime/proc.c` ファイルの `goexit0` 関数に集中しています。
```diff
--- a/src/pkg/runtime/proc.c
+++ b/src/pkg/runtime/proc.c
@@ -1459,6 +1459,12 @@ goexit0(G *gp)
gp->m = nil;
gp->lockedm = nil;
gp->paniconfault = 0;
+ gp->defer = nil; // should be true already but just in case.
+ gp->panic = nil; // non-nil for Goexit during panic. points at stack-allocated data.
+ gp->writenbuf = 0;
+ gp->writebuf = nil;
+ gp->waitreason = nil;
+ gp->param = nil;
m->curg = nil;
m->lockedg = nil;
if(m->locked & ~LockExternal) {
また、このバグを再現し、修正を検証するためのテストケースが test/fixedbugs/issue8158.go
として追加されています。
--- /dev/null
+++ b/test/fixedbugs/issue8158.go
@@ -0,0 +1,41 @@
+// run
+
+// Copyright 2014 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package main
+
+import (
+ "runtime"
+ "time"
+)
+
+func main() {
+ c := make(chan bool, 1)
+ go f1(c)
+ <-c
+ time.Sleep(10 * time.Millisecond)
+ go f2(c)
+ <-c
+}
+
+func f1(done chan bool) {
+ defer func() {
+ recover()
+ done <- true
+ runtime.Goexit() // left stack-allocated Panic struct on gp->panic stack
+ }()
+ panic("p")
+}
+
+func f2(done chan bool) {
+ defer func() {
+ recover()
+ done <- true
+ runtime.Goexit()
+ }()
+ time.Sleep(10 * time.Millisecond) // overwrote Panic struct with Timer struct
+ runtime.GC() // walked gp->panic list, found mangled Panic struct, crashed
+ panic("p")
+}
コアとなるコードの解説
src/pkg/runtime/proc.c
の変更
goexit0
関数は、ゴルーチンが終了する際に呼び出される内部関数です。この関数は、ゴルーチンに関連する様々な状態をリセットし、ゴルーチン構造体 (G
構造体) を再利用可能な状態にします。
変更前は、gp->panic
フィールドが nil
にクリアされていませんでした。gp->panic
は、現在のゴルーチンで発生しているパニックに関する情報(runtime._panic
構造体)を指すポインタです。この _panic
構造体は通常、ゴルーチンのスタック上に割り当てられます。
問題は、runtime.Goexit()
がパニック処理中の defer
関数内で呼び出された場合、ゴルーチンは終了し、そのスタックフレームは解放されますが、gp->panic
は解放されたスタック上の古い _panic
構造体を指したままになることでした。このポインタが nil
にクリアされないと、そのゴルーチン構造体が再利用された際に、不正なメモリ領域を指す「ぶら下がりポインタ」となってしまいます。
追加された以下の行は、この問題を解決します。
gp->defer = nil; // should be true already but just in case.
gp->panic = nil; // non-nil for Goexit during panic. points at stack-allocated data.
gp->writenbuf = 0;
gp->writebuf = nil;
gp->waitreason = nil;
gp->param = nil;
gp->defer = nil;
: ゴルーチンに紐づく遅延関数リストをクリアします。コメントにあるように、通常は既にクリアされているはずですが、念のため設定されています。gp->panic = nil;
: 今回のバグ修正の核心部分です。 パニック情報へのポインタをnil
に設定することで、解放されたスタックメモリへの不正な参照を防ぎます。これにより、ゴルーチン構造体が再利用された際に、以前のパニック情報が残存することによるクラッシュを防ぎます。gp->writenbuf = 0;
,gp->writebuf = nil;
: バッファリングされた書き込み操作に関連するフィールドをクリアします。gp->waitreason = nil;
: ゴルーチンが待機している理由を示すフィールドをクリアします。gp->param = nil;
: ゴルーチンに渡される汎用パラメータをクリアします。
これらのポインタを明示的に nil
に設定することで、ゴルーチン構造体が完全にクリーンな状態でプールに戻され、再利用される際に以前の状態が影響を及ぼすことがなくなります。これは、ランタイムの堅牢性と予測可能性を高める上で非常に重要です。
test/fixedbugs/issue8158.go
の解説
このテストケースは、修正されたバグを再現し、修正が正しく機能することを確認するために作成されました。
-
main
関数:c := make(chan bool, 1)
: ゴルーチン間の同期に使用するチャネルを作成します。go f1(c)
:f1
ゴルーチンを開始します。<-c
:f1
ゴルーチンが終了するのを待ちます。time.Sleep(10 * time.Millisecond)
:f1
ゴルーチンが完全に終了し、そのゴルーチン構造体が再利用可能になるまでの時間を与えます。また、この間にメモリが上書きされる可能性を高めます。go f2(c)
:f2
ゴルーチンを開始します。<-c
:f2
ゴルーチンが終了するのを待ちます。
-
f1
関数:defer func() { ... }()
: 遅延関数を定義します。recover()
:panic("p")
によって発生したパニックを捕捉します。これにより、プログラムはクラッシュせずに遅延関数の実行を継続できます。done <- true
:main
ゴルーチンにf1
が終了したことを通知します。runtime.Goexit()
: このテストケースの核心部分です。 パニックを捕捉した遅延関数内でruntime.Goexit()
を呼び出します。これが、gp->panic
がクリアされないままゴルーチンが終了する状況を作り出します。panic("p")
: 意図的にパニックを発生させます。
-
f2
関数:defer func() { ... }()
:f1
と同様の遅延関数を定義しますが、ここではパニックを捕捉するだけでなく、runtime.GC()
を呼び出します。time.Sleep(10 * time.Millisecond)
:f1
と同様に、時間稼ぎとメモリ上書きの機会を与えます。コメントにある「overwrote Panic struct with Timer struct」は、このスリープ中に、f1
で使われたスタック領域が別のデータ(この場合はタイマー関連の構造体)で上書きされる可能性を示唆しています。runtime.GC()
: このテストケースのもう一つの核心部分です。 ガベージコレクションを手動でトリガーします。ガベージコレクタは、ゴルーチン構造体を含むランタイムの内部データ構造を走査します。もしf1
でgp->panic
が適切にクリアされていなかった場合、f2
で再利用されたゴルーチン構造体のgp->panic
は不正なメモリを指しており、runtime.GC()
がそれを辿ろうとした際にクラッシュ(「walked gp->panic list, found mangled Panic struct, crashed」)を引き起こすはずです。panic("p")
:f2
もパニックを発生させますが、これは主にdefer
関数が実行されることを保証するためです。
このテストは、f1
でバグのある状態を作り出し、f2
でその状態が引き起こすクラッシュを誘発することで、修正が正しく適用されていることを検証します。修正が適用されていれば、runtime.GC()
は不正な gp->panic
を見つけることなく正常に動作し、テストはパスします。
関連リンク
- Go issue #8158: https://github.com/golang/go/issues/8158
- Go CL 102220043: https://golang.org/cl/102220043 (Gerrit Code Review)
参考にした情報源リンク
- Go言語の公式ドキュメント
- Goのソースコード (
src/pkg/runtime/proc.c
,test/fixedbugs/issue8158.go
) - Go issue #8158 の議論
- Go CL 102220043 のレビューコメント
- Goにおけるパニックとリカバリに関する一般的な情報
- Goにおけるゴルーチンとスケジューリングに関する一般的な情報
- C言語におけるポインタとメモリ管理の基本概念