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

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

このコミットは、Go言語のtimeパッケージにおけるタイマーの削除処理に関する重要なバグ修正です。具体的には、タイマーを削除する際にnilポインタのデリファレンスによってパニックが発生した場合に、タイマーのミューテックスがロックされたままになることを防ぎ、それによって発生しうるデッドロックの問題を解決します。

コミット

commit 0286b4738e33c5a043d454b23af88fb95127bf13
Author: Jeff R. Allen <jra@nella.org>
Date:   Mon Jul 1 21:42:29 2013 -0400

    time: prevent a panic from leaving the timer mutex held
    
    When deleting a timer, a panic due to nil deref
    would leave a lock held, possibly leading to a deadlock
    in a defer. Instead return false on a nil timer.
    
    Fixes #5745.
    
    R=golang-dev, daniel.morsing, dvyukov, rsc, iant
    CC=golang-dev
    https://golang.org/cl/10373047

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

https://github.com/golang/go/commit/0286b4738e33c5a043d454b23af88fb95127bf13

元コミット内容

このコミットは、Goのtimeパッケージにおいて、タイマーを削除する際にnilポインタのデリファレンス(参照外し)によってパニックが発生した場合に、タイマー管理用のミューテックスがロックされたままになる問題を修正します。この状態は、deferステートメント内でタイマーの停止処理(Stop())が呼ばれた際にデッドロックを引き起こす可能性がありました。修正は、ロックを取得する前にnilデリファレンスによるパニックを意図的に発生させることで、ミューテックスがロックされる前に処理を中断させるというアプローチを取っています。

変更の背景

Goのランタイムには、timeパッケージが提供するタイマーやTickerを管理するための内部的なメカニズムが存在します。これらのメカニズムは、複数のゴルーチンからの同時アクセスを安全に処理するために、ミューテックス(相互排他ロック)を使用して共有リソースを保護しています。

このコミットが修正する問題は、runtime·deltimer関数(タイマー削除の内部関数)がミューテックスを取得した後に、nilポインタのデリファレンスによってパニックが発生した場合に顕在化しました。具体的には、*Timer型の変数がnilであるにもかかわらず、そのメソッド(例: Stop())が呼び出された場合、Goランタイムはnilポインタデリファレンスとしてパニックを発生させます。

問題のシナリオは以下の通りです。

  1. あるゴルーチンがタイマーを停止しようとtimer.Stop()を呼び出す。
  2. 内部的にruntime·deltimer関数が呼ばれる。
  3. runtime·deltimer関数が、タイマー管理用のミューテックス(timers)をロックする。
  4. このロック取得後、かつロック解放前に、timernilであるためにnilデリファレンスによるパニックが発生する。
  5. パニックが発生すると、通常のコードフローは中断され、deferステートメントが実行される。
  6. もしdeferステートメント内で別のタイマーのStop()が呼ばれた場合、そのStop()は既にロックされたtimersミューテックスを再度取得しようとする。
  7. 結果として、ミューテックスが解放されないまま別のロック要求が発生し、デッドロックが発生する。

このデッドロックは、特にテストコードやエラーハンドリングでdeferを使ってリソースをクリーンアップするような場合に、プログラムがハングアップする原因となっていました。コミットメッセージにあるFixes #5745は、この問題がGoのIssueトラッカーで報告されていたことを示しています。

前提知識の解説

このコミットを理解するためには、以下のGo言語の概念とランタイムの動作に関する知識が必要です。

  1. Goのtimeパッケージとタイマー/Ticker:

    • time.Timertime.Tickerは、指定された時間後に一度だけイベントを発生させる(Timer)か、定期的にイベントを発生させる(Ticker)ためのGoの標準ライブラリ機能です。
    • これらは内部的にGoランタイムのスケジューラと連携し、効率的に時間ベースのイベントを管理します。
    • Stop()メソッドは、タイマーやTickerの動作を停止し、関連するリソースを解放するために使用されます。
  2. ミューテックス (Mutex):

    • sync.Mutexは、Goにおける相互排他ロックのプリミティブです。共有リソースへの同時アクセスを制御し、データ競合を防ぐために使用されます。
    • Lock()メソッドでロックを取得し、Unlock()メソッドでロックを解放します。
    • ミューテックスがロックされている間に別のゴルーチンが同じミューテックスをロックしようとすると、そのゴルーチンはロックが解放されるまでブロックされます。
    • Goランタイムの内部でも、タイマー管理のような共有データ構造を保護するためにミューテックスが使用されます。
  3. パニック (Panic) とリカバリー (Recover):

    • Goにおけるパニックは、プログラムの異常終了を示すメカニズムです。通常、プログラミングエラー(例: nilポインタデリファレンス、配列の範囲外アクセス)や回復不能なエラーが発生した場合に引き起こされます。
    • パニックが発生すると、現在のゴルーチンの実行は中断され、そのゴルーチン内で遅延実行されるdefer関数が順に実行されます。
    • recover()組み込み関数は、defer関数内でのみ呼び出すことができ、パニックを捕捉してプログラムの実行を再開させることができます。しかし、このコミットの文脈では、パニックを捕捉するのではなく、ミューテックスがロックされる前にパニックを発生させることが目的です。
  4. deferステートメント:

    • deferステートメントは、その関数がリターンする直前、またはパニックが発生して関数が終了する直前に実行される関数呼び出しをスケジュールします。
    • リソースの解放(ファイルクローズ、ロック解除など)やエラーハンドリングによく使用されます。
    • この問題では、defer内でStop()が呼ばれることで、ロックされたミューテックスを再度取得しようとしてデッドロックが発生するという点が重要です。
  5. nilポインタデリファレンス:

    • Goでは、ポインタがnilであるにもかかわらず、そのポインタが指す値にアクセスしようとしたり、nilポインタのメソッドを呼び出したりすると、ランタイムパニックが発生します。
    • このコミットの根本原因は、nil*Timerに対してStop()が呼ばれることでした。

技術的詳細

このコミットの技術的な解決策は、非常に巧妙かつGoランタイムの特性を理解したものです。問題は「ミューテックスをロックした後にnilデリファレンスによるパニックが発生し、ミューテックスが解放されない」ことでした。この問題を解決するために、コミットは以下の変更をruntime·deltimer関数に導入しました。

変更前:

// runtime·deltimer(Timer *t)
// {
//     int32 i;
//     runtime·lock(&timers); // ここでロックを取得
//     // ... t を使用する処理 ...
// }

変更後 (src/pkg/runtime/time.goc):

void
runtime·deltimer(Timer *t)
{
	int32 i;

	// Dereference t so that any panic happens before the lock is held.
	// Discard result, because t might be moving in the heap.
	i = t->i; // ここで t のデリファレンスを試みる
	USED(i);

	runtime·lock(&timers); // ロックは t のデリファレンス後に行われる
	// ...
}

この変更の核心は、runtime·lock(&timers)(タイマー管理用のミューテックスをロックする処理)の前に、引数として渡されたTimer *tのメンバt->iにアクセスする行i = t->i;を追加したことです。

  • もしtが有効なTimerポインタであれば、t->iへのアクセスは問題なく成功し、iに値が代入されます。USED(i)は、コンパイラが未使用変数として警告を出さないようにするためのGoランタイムの慣用句です。
  • もしtnilポインタであった場合、i = t->i;の行でnilポインタデリファレンスが発生し、ミューテックスがロックされる前にパニックが引き起こされます。

これにより、パニックが発生してもtimersミューテックスはロックされていない状態が保証されます。結果として、deferステートメント内でStop()が呼ばれても、デッドロックが発生する可能性がなくなります。

このアプローチは、Goのパニックメカニズムを逆手に取ったものであり、エラーハンドリングの一般的なパターンとは異なりますが、ランタイムの低レベルなコードにおいてはこのような最適化や安全策が講じられることがあります。

また、src/pkg/time/sleep_test.goには、この問題が修正されたことを検証するための新しいテストケースTestIssue5745が追加されました。このテストは、意図的にnil*Timerに対してStop()を呼び出し、パニックが発生すること、そしてそのパニックがdefer内のticker.Stop()をデッドロックさせないことを確認します。

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

src/pkg/runtime/time.goc

--- a/src/pkg/runtime/time.goc
+++ b/src/pkg/runtime/time.goc
@@ -131,6 +131,11 @@ runtime·deltimer(Timer *t)
  {\n \tint32 i;\n \n+\t// Dereference t so that any panic happens before the lock is held.\n+\t// Discard result, because t might be moving in the heap.\n+\ti = t->i;\n+\tUSED(i);\n+\n \truntime·lock(&timers);\
 \n \t// t may not be registered anymore and may have

src/pkg/time/sleep_test.go

--- a/src/pkg/time/sleep_test.go
+++ b/src/pkg/time/sleep_test.go
@@ -314,3 +314,23 @@ func TestOverflowSleep(t *testing.T) {\n \t\tt.Fatalf(\"negative timeout didn\'t fire\")\n \t}\n }\n+\n+// Test that a panic while deleting a timer does not leave\n+// the timers mutex held, deadlocking a ticker.Stop in a defer.\n+func TestIssue5745(t *testing.T) {\n+\tticker := NewTicker(Hour)\n+\tdefer func() {\n+\t\t// would deadlock here before the fix due to\n+\t\t// lock taken before the segfault.\n+\t\tticker.Stop()\n+\n+\t\tif r := recover(); r == nil {\n+\t\t\tt.Error(\"Expected panic, but none happened.\")\n+\t\t}\n+\t}()\n+\n+\t// cause a panic due to a segfault\n+\tvar timer *Timer\n+\ttimer.Stop()\n+\tt.Error(\"Should be unreachable.\")\n+}\

コアとなるコードの解説

src/pkg/runtime/time.gocの変更

runtime·deltimer関数は、Goランタイム内部でタイマーを削除する際に呼び出されるC言語で書かれた関数です(Goのソースコードには.goc拡張子のファイルがあり、これはC言語とGoのハイブリッドのような形式で、Goランタイムの低レベルな部分を実装するために使われます)。

追加された5行のコードは以下の通りです。

	// Dereference t so that any panic happens before the lock is held.
	// Discard result, because t might be moving in the heap.
	i = t->i;
	USED(i);
  • i = t->i;: ここが最も重要な変更点です。tTimer *型のポインタであり、iはそのTimer構造体内のメンバ(おそらくタイマーのインデックスやIDのようなもの)です。この行は、runtime·lock(&timers)が呼び出されるに、意図的にtをデリファレンスしようとします。
    • もしtnilであれば、この行でnilポインタデリファレンスによるパニックが発生します。
    • パニックが発生すると、それ以降のコード(runtime·lock(&timers)を含む)は実行されません。
    • これにより、timersミューテックスがロックされる前にパニックが処理されることが保証され、ミューテックスがロックされたままになることを防ぎます。
  • USED(i);: これはGoランタイムの内部マクロで、変数iが使用されていることをコンパイラに伝えるためのものです。iの値自体はこの修正のロジックには直接関係なく、単にtがデリファレンス可能かどうかをチェックするためにアクセスされています。

この変更により、nilタイマーに対するStop()呼び出しは、ミューテックスをロックする前にパニックを引き起こすようになり、デッドロックの可能性が排除されました。

src/pkg/time/sleep_test.goの変更

TestIssue5745という新しいテスト関数が追加されました。このテストは、修正が正しく機能することを確認するためのものです。

func TestIssue5745(t *testing.T) {
	ticker := NewTicker(Hour)
	defer func() {
		// would deadlock here before the fix due to
		// lock taken before the segfault.
		ticker.Stop()

		if r := recover(); r == nil {
			t.Error("Expected panic, but none happened.")
		}
	}()

	// cause a panic due to a segfault
	var timer *Timer
	timer.Stop()
	t.Error("Should be unreachable.")
}
  • ticker := NewTicker(Hour): テストのためにTickerを作成します。このTickerは、defer内でStop()が呼ばれることで、デッドロックが発生するかどうかを検証するためのものです。
  • defer func() { ... }(): 無名関数をdeferで登録しています。この関数は、TestIssue5745が終了する際に実行されます。
    • ticker.Stop(): ここがデッドロックの発生源となる可能性があった場所です。修正前は、もしメインのコードでパニックが発生し、ミューテックスがロックされたままだった場合、このStop()呼び出しがデッドロックを引き起こしました。修正後は、デッドロックが発生しないことを期待します。
    • if r := recover(); r == nil { t.Error("Expected panic, but none happened.") }: recover()を使ってパニックを捕捉し、実際にパニックが発生したことを確認しています。このテストの目的は、nilデリファレンスによるパニックが発生すること、そしてそのパニックがデッドロックを引き起こさないことを検証することです。
  • var timer *Timer: *Timer型の変数を宣言しますが、初期化しないため、その値はnilになります。
  • timer.Stop(): この行が意図的にnilポインタデリファレンスによるパニックを引き起こします。修正前は、この呼び出しがruntime·deltimer内でミューテックスをロックした後にパニックを引き起こし、デッドロックにつながっていました。修正後は、ミューテックスがロックされる前にパニックが発生します。
  • t.Error("Should be unreachable."): timer.Stop()でパニックが発生するため、この行は実行されません。もし実行された場合、テストは失敗します。

このテストは、問題の再現と修正の検証を同時に行う、非常に効果的なテストケースです。

関連リンク

  • Gerrit Change-ID: https://golang.org/cl/10373047
    • Goプロジェクトは、GitHubに移行する前はGerritというコードレビューシステムを使用していました。このリンクは、当時のGerrit上の変更セット(Change-ID)を指しています。

参考にした情報源リンク

  • Go言語の公式ドキュメント(timeパッケージ、syncパッケージ、パニックとリカバリーに関するセクション)
  • Goランタイムのソースコード(特にsrc/pkg/runtime/time.goc
  • GoのIssueトラッカー(ただし、#5745という直接のIssueは見つかりませんでした。これは古いIssueであるか、内部的なトラッキング番号である可能性があります。)