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

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

このコミットは、Goランタイムにおけるnil関数値をgoキーワードで呼び出した際の挙動を修正するものです。具体的には、go f()のようにfnilである場合に、以前は回復不能な致命的エラー(fatal error)を引き起こしていた問題を、パニック(panic)を発生させるように変更しています。これにより、プログラムがより予測可能な形で異常終了し、recoverメカニズムによる捕捉の可能性も生まれます。

変更が加えられたファイルは以下の通りです。

  • src/pkg/runtime/crash_test.go: go nil関数呼び出しの新しいテストケースが追加されました。このテストは、nil関数値のgo呼び出しが期待通りにパニックを発生させることを検証します。
  • src/pkg/runtime/proc.c: Goランタイムのプロシージャ(goroutineの生成など)を扱うC言語のソースファイルです。runtime·newproc1関数にnil関数値のチェックが追加され、パニックを発生させるロジックが導入されました。

コミット

commit b5caa02067e0e0d2bde9290004b15e9a226c6075
Author: Dmitriy Vyukov <dvyukov@google.com>
Date:   Wed May 28 00:00:01 2014 -0400

    runtime: fix go of nil func value
    Currently runtime derefences nil with m->locks>0,
    which causes unrecoverable fatal error.
    Panic instead.
    Fixes #8045.
    
    LGTM=rsc
    R=golang-codereviews, rsc
    CC=golang-codereviews, khr
    https://golang.org/cl/97620043

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

https://github.com/golang/go/commit/b5caa02067e0e0d2bde9290004b15e9a226c6075

元コミット内容

runtime: fix go of nil func value
Currently runtime derefences nil with m->locks>0,
which causes unrecoverable fatal error.
Panic instead.
Fixes #8045.

LGTM=rsc
R=golang-codereviews, rsc
CC=golang-codereviews, khr
https://golang.org/cl/97620043

変更の背景

この変更の背景には、Goプログラムがnil関数値をgoステートメントで新しいゴルーチンとして起動しようとした際に発生していた、回復不能な致命的エラーの問題があります。

Go言語では、関数を変数に代入したり、関数の引数として渡したりすることができます。この際、関数型の変数にnilが代入されている場合があります。通常、nil関数値を直接呼び出そうとすると、Goランタイムはパニックを発生させます。これは、プログラマが意図しないnil参照を早期に検出し、適切なエラーハンドリングを促すためのGoの設計思想に基づいています。

しかし、このコミット以前のGoランタイムでは、go f()のようにnilであるfを新しいゴルーチンとして起動しようとすると、特定の条件下(コミットメッセージによるとm->locks > 0の場合)で、ランタイムがnilポインタをデリファレンス(参照解除)しようとし、その結果として回復不能な致命的エラーが発生していました。致命的エラーは、通常のパニックとは異なり、recoverメカニズムで捕捉することができず、プログラム全体が強制終了してしまうため、デバッグやエラーからの回復が非常に困難でした。

この挙動は、Goの一般的なエラーハンドリングの期待(nil関数呼び出しはパニックする)と矛盾しており、開発者にとって予期せぬクラッシュを引き起こす可能性がありました。このコミットは、この矛盾を解消し、go nil関数呼び出しも他のnil関数呼び出しと同様にパニックを発生させるようにすることで、Goプログラムの堅牢性と予測可能性を向上させることを目的としています。パニックにすることで、開発者はdeferrecoverを使ってこの種のランタイムエラーを捕捉し、適切に処理する機会を得ることができます。

前提知識の解説

このコミットの変更内容を理解するためには、以下のGo言語およびGoランタイムに関する基本的な知識が必要です。

  1. ゴルーチン (Goroutine): Go言語における軽量な実行スレッドです。goキーワードを使って関数呼び出しの前に置くことで、その関数を新しいゴルーチンとして並行して実行させることができます。ゴルーチンはOSのスレッドよりもはるかに軽量であり、数千、数万のゴルーチンを同時に実行することが可能です。

  2. goステートメント: goキーワードは、関数呼び出しを新しいゴルーチンで実行するために使用されます。例: go myFunction()

  3. nil値と関数型: Goでは、関数も第一級オブジェクトであり、変数に代入したり、引数として渡したり、戻り値として返したりできます。関数型の変数にはnilを代入することができます。nil関数値を直接呼び出そうとすると、通常はランタイムパニックが発生します。

  4. パニック (Panic) とリカバリー (Recover):

    • パニック: Goプログラムが回復不能なエラーに遭遇した際に発生するランタイムエラーです。パニックが発生すると、現在のゴルーチンの通常の実行フローは停止し、遅延関数(deferで登録された関数)が実行され、その後、呼び出しスタックを遡ってパニックが伝播します。
    • リカバリー: defer関数内でrecover()を呼び出すことで、パニックを捕捉し、パニックが発生したゴルーチンの実行を再開することができます。これにより、プログラム全体がクラッシュするのを防ぎ、エラーを適切に処理する機会が得られます。
  5. Goランタイム (Go Runtime): Goプログラムの実行を管理するシステムです。ゴルーチンのスケジューリング、メモリ管理(ガベージコレクション)、チャネル通信、システムコールなど、Goプログラムの低レベルな側面を扱います。ランタイムの多くの部分はC言語(またはGo言語自体)で実装されています。

  6. m (Machine/Thread Context): Goランタイムの内部では、mはOSのスレッド(マシン)を表す構造体です。各mは、Goのスケジューラによってゴルーチンを実行するために使用されます。m構造体には、現在のスレッドの状態やロックに関する情報などが含まれています。

  7. m->locks: m構造体内のフィールドで、現在のOSスレッドが保持しているランタイムロックの数を表します。この値が0より大きい場合、そのスレッドは重要なランタイム操作を実行中であり、プリエンプション(横取り)が一時的に無効になっていることを示唆します。プリエンプションとは、スケジューラが実行中のゴルーチンを中断し、別のゴルーチンにCPUを割り当てることです。m->locks > 0の状態でnilデリファレンスが発生すると、ランタイムの内部状態が不安定になり、回復不能な致命的エラーにつながる可能性がありました。

  8. runtime·throw: Goランタイム内部で使用される関数で、回復不能なランタイムエラーが発生した際にパニックを発生させるために使用されます。これは通常のpanic()関数とは異なり、より低レベルで、ランタイムの内部状態を考慮してパニックをトリガーします。

技術的詳細

このコミットの技術的な核心は、src/pkg/runtime/proc.c内のruntime·newproc1関数における変更です。runtime·newproc1は、新しいゴルーチンを作成し、その実行を開始するためのランタイム内部関数です。

変更前は、fn(新しいゴルーチンで実行される関数の値)がnilである場合、runtime·newproc1nilチェックを行っていませんでした。そのため、後続の処理でfnがデリファレンスされる際に、nilポインタデリファレンスが発生し、特にm->locks > 0のような特定のランタイム状態下では、回復不能な致命的エラーを引き起こしていました。

このコミットでは、runtime·newproc1関数の冒頭に以下のnilチェックが追加されました。

if(fn == nil) {
    m->throwing = -1;  // do not dump full stacks
    runtime·throw("go of nil func value");
}

このコードブロックの動作は以下の通りです。

  1. if(fn == nil): 新しいゴルーチンとして起動しようとしている関数値fnnilであるかどうかをチェックします。
  2. m->throwing = -1;: mは現在のOSスレッドのコンテキストを表す構造体です。m->throwingは、ランタイムがパニックを処理している状態を示すフラグです。-1を設定することで、パニック発生時に完全なスタックトレースをダンプしないように指示しています。これは、この特定のケース(go nil)では、スタックトレースが冗長になる可能性があり、問題の根本原因がnil関数値のgo呼び出しであると明確であるため、簡潔なエラーメッセージを優先するための最適化と考えられます。
  3. runtime·throw("go of nil func value");: runtime·throw関数を呼び出し、指定されたメッセージ「go of nil func value」と共にパニックを発生させます。これにより、プログラムは致命的エラーでクラッシュする代わりに、通常のパニックフローに入り、deferrecoverによって捕捉可能になります。

この変更により、go nil関数呼び出しは、他のnil関数呼び出しと同様に、一貫してパニックを発生させるようになり、Goプログラムの堅牢性とデバッグのしやすさが向上しました。

また、src/pkg/runtime/crash_test.goには、この修正を検証するための新しいテストケースTestGoNilが追加されました。このテストは、goNilSourceという文字列定数で定義されたGoプログラムを実行します。このプログラムは、var f func()nil関数fを宣言し、go f()でそれをゴルーチンとして起動しようとします。テストは、この実行が「go of nil func value」というメッセージを含む出力を生成することを確認し、修正が正しく機能していることを保証します。

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

src/pkg/runtime/proc.c

--- a/src/pkg/runtime/proc.c
+++ b/src/pkg/runtime/proc.c
@@ -1816,6 +1816,10 @@ runtime·newproc1(FuncVal *fn, byte *argp, int32 narg, int32 nret, void *callerp
 
 //runtime·printf("newproc1 %p %p narg=%d nret=%d\n", fn->fn, argp, narg, nret);
+	if(fn == nil) {
+		m->throwing = -1;  // do not dump full stacks
+		runtime·throw("go of nil func value");
+	}
 m->locks++;  // disable preemption because it can be holding p in a local var
 siz = narg + nret;
 siz = (siz+7) & ~7;

src/pkg/runtime/crash_test.go

--- a/src/pkg/runtime/crash_test.go
+++ b/src/pkg/runtime/crash_test.go
@@ -158,6 +158,14 @@ func TestGoexitCrash(t *testing.T) {
 	}
 }
 
+func TestGoNil(t *testing.T) {
+	output := executeTest(t, goNilSource, nil)
+	want := "go of nil func value"
+	if !strings.Contains(output, want) {
+		t.Fatalf("output:\n%s\n\nwant output containing: %s", output, want)
+	}
+}
+
 const crashSource = `
 package main
 
@@ -343,3 +351,16 @@ func main() {
 	runtime.Goexit()
 }
 `
+
+const goNilSource = `
+package main
+
+func main() {
+	defer func() {
+		recover()
+	}()
+	var f func()
+	go f()
+	select{}
+}
+`

コアとなるコードの解説

src/pkg/runtime/proc.c の変更点

runtime·newproc1関数は、新しいゴルーチンを生成するGoランタイムの内部関数です。この関数は、新しいゴルーチンで実行される関数へのポインタfnを引数として受け取ります。

追加されたコードブロックは以下の通りです。

if(fn == nil) {
    m->throwing = -1;  // do not dump full stacks
    runtime·throw("go of nil func value");
}
  • if(fn == nil): これは、goステートメントに渡された関数値fnnilであるかどうかをチェックする条件分岐です。
  • m->throwing = -1;: mは現在のOSスレッド(マシン)のコンテキストを表すグローバル変数です。m->throwingは、ランタイムがパニック処理中であることを示すフラグです。この値を-1に設定することで、この特定のパニック(go nil)が発生した際に、ランタイムが完全なスタックトレースをダンプしないように指示しています。これは、スタックトレースが冗長になるのを避け、問題の根本原因(nil関数値のgo呼び出し)を明確に伝えるための最適化です。
  • runtime·throw("go of nil func value");: この関数呼び出しは、指定された文字列「go of nil func value」をパニックメッセージとして、Goランタイムレベルでパニックを発生させます。これにより、以前は回復不能な致命的エラーでプログラムがクラッシュしていた状況が、recoverで捕捉可能な通常のパニックに変わります。

この変更により、goステートメントにnil関数値が渡された場合でも、ランタイムが安全にパニックを発生させ、プログラムがより予測可能な形で終了するようになります。

src/pkg/runtime/crash_test.go の変更点

このファイルには、新しいテスト関数TestGoNilと、そのテストで使用されるソースコードを定義する文字列定数goNilSourceが追加されました。

  • TestGoNil 関数: このテスト関数は、executeTestヘルパー関数を使用して、goNilSourceで定義されたGoプログラムを実行します。 output := executeTest(t, goNilSource, nil) その後、実行結果の出力outputが、期待されるパニックメッセージ「go of nil func value」を含んでいるかどうかをstrings.Containsで確認します。 if !strings.Contains(output, want) これにより、go nil関数呼び出しが正しくパニックを発生させ、期待されるメッセージを出力することを確認しています。

  • goNilSource 定数: この文字列定数は、TestGoNilで実行されるGoプログラムのソースコードを定義しています。

    package main
    
    func main() {
    	defer func() {
    		recover()
    	}()
    	var f func()
    	go f()
    	select{}
    }
    

    このプログラムの重要な点は以下の通りです。

    • var f func(): 関数型の変数fを宣言しますが、初期値はnilです。
    • go f(): nilであるfを新しいゴルーチンとして起動しようとします。これがこのコミットで修正された問題のトリガーとなります。
    • defer func() { recover() }(): パニックが発生した場合にそれを捕捉しようとするdefer関数です。このテストでは、パニックが実際に発生し、そのメッセージが期待通りであることを確認するため、recoverが呼び出されてもテストの成功/失敗には直接影響しませんが、パニックの挙動を検証する上で重要な要素です。
    • select{}: メインゴルーチンがすぐに終了しないように、ブロッキングするselectステートメントです。これにより、go f()で起動されたゴルーチンが実行される機会が確保されます。

これらの変更により、go nil関数呼び出しの挙動が修正され、その修正がテストによって検証されるようになりました。

関連リンク

  • Go CL (Change List): https://golang.org/cl/97620043
  • Go Issue #8045: コミットメッセージにFixes #8045と記載されていますが、公式のGitHubリポジトリでは直接この番号のIssueは見つかりませんでした。これは、内部のバグトラッカーのIDであるか、古いIssueトラッキングシステムのものである可能性があります。しかし、コミットメッセージとコード変更から、問題はgo nil関数値が致命的エラーを引き起こすことであると明確です。

参考にした情報源リンク

  • Go言語公式ドキュメント (Goroutines, Panic and Recover, Functions)
  • Goランタイムのソースコード (src/pkg/runtime/proc.cruntime·newproc1関数周辺)
  • Goのm構造体に関する一般的な情報 (Goランタイムの内部構造に関するブログ記事やドキュメント)
  • Goのnil値に関する一般的な情報