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

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

コミット

このコミットは、Goコンパイラ(cmd/gc)がレース検出器(race detector)を有効にしてコンパイルする際に、循環リスト(circular lists)の生成を回避するための修正です。具体的には、条件式(&&||)の右辺の初期化リストが、その右辺自身に再帰的に追加されることで発生する無限ループの問題を解決します。この問題は、GoのIssue 5431として報告されていました。

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

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

元コミット内容

commit e7657de7177f92207b2a4f601996529bf415e3f2
Author: Daniel Morsing <daniel.morsing@gmail.com>
Date:   Tue Jun 11 21:19:29 2013 +0200

    cmd/gc: avoid creating circular lists when compiling with race detector.
    
    Fixes #5431.
    
    R=dvyukov, remyoudompheng, rsc
    CC=gobot, golang-dev
    https://golang.org/cl/9910043
---
 src/cmd/gc/racewalk.c                            |  6 +++++-\n
 src/pkg/runtime/race/testdata/regression_test.go | 15 +++++++++++++++\n
 2 files changed, 20 insertions(+), 1 deletion(-)\n

変更の背景

この変更は、Goコンパイラがレース検出器を有効にしてコードをコンパイルする際に発生する特定のバグ(Go Issue 5431)を修正するために導入されました。このバグは、論理AND (&&) や論理OR (||) のような短絡評価される式において、右辺の式が持つ初期化リスト(ninit)が、その右辺自身に循環的に追加されてしまうことで発生しました。

具体的には、racewalknode関数がAST(抽象構文木)を走査し、レース検出のためのインストゥルメンテーション(計測コードの挿入)を行う際に、n->right->ninitが非nilの場合、racewalknodeがそれを自身に再帰的に追加しようとすることが問題でした。これにより、コンパイラが無限ループに陥り、コンパイルが完了しない、あるいはスタックオーバーフローが発生するなどの問題を引き起こしていました。

この問題は、特にインライン化された関数呼び出しが絡む複雑な条件式で顕著に現れました。レース検出器は、メモリへのアクセスを監視するために追加のコードを挿入するため、ASTの構造を通常よりも複雑にする可能性があり、このようなエッジケースが露呈しやすくなります。

前提知識の解説

このコミットを理解するためには、以下の概念について理解しておく必要があります。

  1. Goコンパイラ (cmd/gc): Go言語の公式コンパイラです。Goのソースコードを機械語に変換する役割を担います。コンパイル過程では、ソースコードをAST(抽象構文木)に変換し、様々な最適化やコード生成を行います。
  2. レース検出器 (Race Detector): Goランタイムに組み込まれているツールで、並行処理におけるデータ競合(data race)を検出します。データ競合は、複数のゴルーチンが同時に同じメモリ位置にアクセスし、少なくとも1つのアクセスが書き込みであり、かつそれらのアクセスが同期メカニズムによって保護されていない場合に発生します。レース検出器は、コンパイル時に追加のインストゥルメンテーションコードを挿入することで、実行時にデータ競合を監視します。
  3. 抽象構文木 (Abstract Syntax Tree, AST): プログラムのソースコードの抽象的な構文構造を木構造で表現したものです。コンパイラはASTを操作して、コードの解析、変換、最適化を行います。Goコンパイラでは、各ノードが式、文、宣言などを表します。
  4. Node構造体とninitフィールド: Goコンパイラの内部では、ASTの各要素がNode構造体として表現されます。Node構造体には、そのノードに関連する初期化文のリストを保持するためのninitフィールドがあります。これは、特定の式や変数が使用される前に実行されるべき副作用のある操作(例:関数呼び出し、変数宣言)を管理するために使用されます。
  5. 短絡評価 (Short-circuit evaluation): 論理AND (&&) や論理OR (||) のような論理演算子において、式の評価が途中で打ち切られることです。
    • A && B: Afalseであれば、Bは評価されません。
    • A || B: Atrueであれば、Bは評価されません。 レース検出器は、評価される可能性のあるすべてのコードパスにインストゥルメンテーションを挿入する必要があるため、短絡評価の特性を考慮する必要があります。
  6. 循環リスト (Circular List): リストの最後の要素が最初の要素を指すなど、リストの要素が循環的に参照し合う構造です。プログラミングにおいて、意図しない循環参照は無限ループやメモリリークの原因となることがあります。

技術的詳細

このコミットの核心は、src/cmd/gc/racewalk.cファイル内のracewalknode関数における変更です。racewalknodeは、ASTノードを走査し、レース検出器のためのインストゥルメンテーションコードを挿入する役割を担っています。

問題が発生していたのは、論理AND (ONAND) や論理OR (ONOR) のような短絡評価される演算子を処理する部分でした。これらの演算子では、左辺の評価結果に基づいて右辺が実行されるかどうかが決まります。レース検出器は、右辺が実行される可能性のある場合に、その右辺にもインストゥルメンテーションを適用する必要があります。

元のコードでは、n->right(右辺のASTノード)の処理を行う際に、n->right->ninit(右辺の初期化リスト)を直接lに代入し、そのlracewalknodeの引数として渡していました。その後、racewalknodeの呼び出しから戻った後、appendinit(&n->right, l)によってlの内容をn->rightの初期化リストに追加していました。

ここで問題となるのは、racewalknodelを引数として受け取り、その内部でlにノードを追加する可能性があることです。もしn->right->ninitが非nilで、かつracewalknodeln->right->ninit自身を(あるいはその一部を)追加してしまうと、n->right->ninitが自身を参照する循環リストが形成されてしまいます。これは、appendinitがリストを結合する際に無限ループを引き起こす原因となります。

この修正では、この循環参照を防ぐために以下の手順を踏んでいます。

  1. l = n->right->ninit; で、まず右辺の初期化リストを一時変数lに保存します。
  2. n->right->ninit = nil; で、元の右辺の初期化リストを一時的にnilに設定します。これが最も重要な変更点です。これにより、racewalknode(&n->right, &l, wr, 0)が呼び出された際に、n->rightが自身のninitを再帰的に参照することを防ぎます。
  3. racewalklist(l, nil); を呼び出し、一時変数lに保存された元の初期化リストを先に処理します。これにより、そのリスト内のノードが適切にインストゥルメンテーションされます。
  4. racewalknode(&n->right, &l, wr, 0); を呼び出し、右辺のノード自体を走査し、その結果として生成される初期化リストをlに追加します。
  5. appendinit(&n->right, l); で、最終的にlに集められたすべての初期化ノードを、n->rightの初期化リストに結合します。

この変更により、racewalknodeが処理する際に、n->right->ninitが一時的に切り離されるため、循環参照が発生する可能性がなくなります。

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

変更は主にsrc/cmd/gc/racewalk.cファイルにあります。

--- a/src/cmd/gc/racewalk.c
+++ b/src/cmd/gc/racewalk.c
@@ -255,7 +255,11 @@ racewalknode(Node **np, NodeList **init, int wr, int skip)\n
 		// side effects are safe.\n
 		// n->right may not be executed,\n
 		// so instrumentation goes to n->right->ninit, not init.\n
-\t\tl = nil;\n
+\t\t// If right->ninit is non-nil, racewalknode might append it to itself.\n
+\t\t// nil it out and handle it separately before putting it back.\n
+\t\tl = n->right->ninit;\n
+\t\tn->right->ninit = nil;\n
+\t\tracewalklist(l, nil);\n
 \t\tracewalknode(&n->right, &l, wr, 0);\n
 \t\tappendinit(&n->right, l);\n
 \t\tgoto ret;

また、この修正が正しく機能することを確認するための回帰テストがsrc/pkg/runtime/race/testdata/regression_test.goに追加されています。

--- a/src/pkg/runtime/race/testdata/regression_test.go
+++ b/src/pkg/runtime/race/testdata/regression_test.go
@@ -160,3 +160,18 @@ func noRaceReturn(c chan int) (a, b int) {\n
 	}()\n
 	return a, 10\n
 }\n+\n+func issue5431() {\n+\tvar p **inltype\n+\tif inlinetest(p).x && inlinetest(p).y {\n+\t} else if inlinetest(p).x || inlinetest(p).y {\n+\t}\n+}\n+\n+type inltype struct {\n+\tx, y bool\n+}\n+\n+func inlinetest(p **inltype) *inltype {\n+\treturn *p\n+}\n```

## コアとなるコードの解説

`racewalk.c`の変更点に焦点を当てて解説します。

変更前のコード:
```c
		// n->right may not be executed,
		// so instrumentation goes to n->right->ninit, not init.
		l = nil; // ここでlをnilに初期化
		racewalknode(&n->right, &l, wr, 0);
		appendinit(&n->right, l);
		goto ret;

このコードでは、n->rightの処理に入る前にlnilに初期化していました。しかし、n->right自体が既にninitリストを持っている場合、そのリストの内容が失われるか、あるいはracewalknodeの内部でln->right->ninitが追加され、その結果として循環参照が発生する可能性がありました。特に、n->rightが持つninitリストが、racewalknodeの処理中にlに渡され、そのlが再びn->rightninitに結合される際に問題が生じます。

変更後のコード:

		// If right->ninit is non-nil, racewalknode might append it to itself.
		// nil it out and handle it separately before putting it back.
		l = n->right->ninit; // 1. n->rightの既存のninitをlに保存
		n->right->ninit = nil; // 2. n->rightのninitを一時的にnilにする
		racewalklist(l, nil); // 3. 保存したl(元のninit)を先に処理する
		racewalknode(&n->right, &l, wr, 0); // 4. n->rightを走査し、新たな初期化ノードをlに追加
		appendinit(&n->right, l); // 5. lの全ての内容をn->rightのninitに結合
		goto ret;

この修正の肝は、n->right->ninit = nil; の行です。これにより、racewalknode(&n->right, &l, wr, 0)が呼び出される際に、n->rightが自身のninitを既に持っている状態ではなくなります。つまり、racewalknoden->rightを処理する際に、そのノードが持つべき初期化リストは、lを通じてのみ提供されるようになります。

元のn->right->ninitlに保存され、racewalklist(l, nil)によって先に処理されます。これは、元の初期化リストに含まれる式もレース検出器のインストゥルメンテーションの対象となるためです。その後、racewalknoden->rightを処理し、その過程で生成される新たな初期化ノードがlに追加されます。最後に、appendinitによって、元の初期化ノードと新しく生成された初期化ノードがすべてn->rightninitに結合されます。

この一連の操作により、n->rightninitが自身を再帰的に参照するような循環構造が形成されることがなくなり、コンパイラの無限ループが解消されます。

regression_test.goに追加されたテストケースissue5431()は、この問題が短絡評価される論理演算子(&&||)と、インライン化される可能性のある関数呼び出し(inlinetest)の組み合わせで発生することを示しています。このテストは、修正が正しく機能し、コンパイラがこの種のコードを正常に処理できることを保証します。

関連リンク

参考にした情報源リンク

  • Go言語の公式ドキュメント
  • Goコンパイラのソースコード
  • Go Issue Tracker
  • Go Code Reviewサイト (Gerrit)
  • データ競合に関する一般的な情報源 (例: Wikipedia, 並行プログラミングの書籍)
  • AST (抽象構文木) に関する一般的な情報源
  • 短絡評価に関する一般的な情報源
  • 循環リストに関する一般的な情報源
  • Go Race Detectorに関する公式ブログ記事やドキュメント
  • Goコンパイラの内部構造に関する資料 (例: "Go Compiler Internals" などのプレゼンテーションや記事)
  • Goのcmd/gcに関するソースコードコメントと関連するコミット履歴
  • GoのNode構造体とninitフィールドに関するGoコンパイラソースコード内の定義と使用箇所
  • racewalknodeおよびracewalklist関数の実装詳細
  • appendinit関数の実装詳細
  • Goのテストフレームワークと回帰テストの記述方法に関する情報