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

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

このコミットは、Go言語のレース検出器(Race Detector)が配列へのアクセスを適切に検出できるようにするための修正です。以前の実装では、配列のアドレスへのアクセスのみを記録していましたが、配列全体が占めるメモリ範囲へのアクセスを記録するように変更されました。これにより、配列に対する並行アクセス競合(データ競合)をより正確に検出できるようになります。

コミット

commit 3be794cdc2c7fc78a43b6a619ddf281b7271b520
Author: Rémy Oudompheng <oudomphe@phare.normalesup.org>
Date:   Fri Jun 14 11:14:45 2013 +0200

    cmd/gc: instrument arrays properly in race detector.
    
    The previous implementation would only record access to
    the address of the array but the memory access to the whole
    memory range must be recorded instead.
    
    R=golang-dev, dvyukov, r
    CC=golang-dev
    https://golang.org/cl/8053044

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

https://github.com/golang/go/commit/3be794cdc2c7fc78a43b6a619ddf281b7271b520

元コミット内容

cmd/gc: instrument arrays properly in race detector.

The previous implementation would only record access to
the address of the array but the memory access to the whole
memory range must be recorded instead.

変更の背景

Go言語のレース検出器は、並行処理におけるデータ競合(data race)を検出するための強力なツールです。データ競合は、複数のゴルーチンが同時に同じメモリ位置にアクセスし、少なくとも1つのアクセスが書き込みであり、かつそれらのアクセスが同期メカニズムによって保護されていない場合に発生します。このような競合は、プログラムの予測不能な動作やクラッシュを引き起こす可能性があります。

このコミット以前のレース検出器の実装では、配列へのアクセスを検出する際に、配列の先頭アドレスへのアクセスのみを記録していました。しかし、配列は連続したメモリ領域を占めるデータ構造であり、その要素へのアクセスは配列全体のメモリ範囲に対するアクセスと見なされるべきです。例えば、arr[0]への書き込みとarr[1]への読み込みが同時に行われた場合、これらは異なる要素へのアクセスであっても、配列という一つのメモリブロックに対する競合として検出されるべきです。

この不正確な検出メカニズムは、特に配列全体をコピーする操作や、配列の異なる部分に複数のゴルーチンがアクセスするシナリオにおいて、データ競合を見逃す可能性がありました。このコミットは、この問題を解決し、配列全体が占めるメモリ範囲を対象としてアクセスを記録することで、レース検出器の精度を向上させることを目的としています。

前提知識の解説

Go Race Detector (レース検出器)

Goのレース検出器は、プログラムの実行中にデータ競合を動的に検出するツールです。Goプログラムを-raceフラグ付きでビルドすると、コンパイラとランタイムが連携して、メモリアクセスを監視し、競合のパターンを特定します。

  • インストゥルメンテーション (Instrumentation): コンパイラ(cmd/gc)は、メモリへの読み書き操作が行われる箇所に、レース検出器のランタイム関数(例: raceread, racewrite)を呼び出すコードを自動的に挿入します。これにより、プログラムの実行時にどのメモリがいつ、どのようにアクセスされたかを追跡できます。
  • Happens-Before Memory Model: レース検出器は、Goのメモリモデルにおける「happens-before」関係に基づいて競合を検出します。これは、並行操作間の順序付けを定義するもので、もし2つのメモリアクセスがhappens-before関係で順序付けられていない場合、それらは競合している可能性があります。
  • 検出の仕組み: ランタイムのレース検出器は、インストゥルメントされたコードから送られてくるメモリアクセスイベントを収集し、それらを分析してデータ競合のパターン(例: 異なるゴルーチンからの非同期な読み書きアクセス)を特定します。競合が検出されると、詳細なレポート(スタックトレースなど)が出力され、開発者が問題を特定しやすくなります。

cmd/gc (Goコンパイラ)

cmd/gcはGo言語の公式コンパイラです。ソースコードを機械語に変換するだけでなく、最適化、型チェック、そしてこのコミットで関連するレース検出器のためのコードインストゥルメンテーションも行います。インストゥルメンテーションは、コンパイル時に特定のコードパターン(例: メモリアクセス)を検出し、それらをレース検出器のランタイム関数呼び出しに置き換えることで実現されます。

uintptr

uintptrは、Go言語におけるポインタを整数として表現する型です。これは、メモリのアドレスを数値として扱う際に使用されます。レース検出器では、メモリアクセスの対象となるアドレスをuintptrとしてランタイム関数に渡すことで、どのメモリ位置がアクセスされたかを特定します。

配列と構造体のメモリレイアウト

Goにおける配列は、同じ型の要素が連続したメモリ領域に配置されるデータ構造です。例えば、[5]int型の配列は、5つのint型変数がメモリ上で隣接して配置されます。構造体も同様に、そのフィールドがメモリ上で連続して配置されることが一般的です。この連続性が、配列や構造体全体へのアクセスを単一のメモリ範囲として扱う必要がある理由です。

技術的詳細

このコミットの核心は、Goコンパイラ(cmd/gc)が配列および構造体(特に固定サイズの配列を含む場合)のメモリアクセスをインストゥルメントする方法を変更した点にあります。

変更前

以前のレース検出器は、配列の個々の要素へのアクセスに対してはracereadracewriteを呼び出していましたが、配列全体を対象とする操作(例: 配列の代入、関数への配列の受け渡し)の場合、配列の先頭アドレスのみを記録していました。これは、配列の異なる要素への並行アクセスが、レース検出器によって単一のメモリ位置へのアクセスとして扱われず、結果としてデータ競合を見逃す可能性がありました。特に、構造体の場合、そのフィールドへのアクセスは個別にインストゥルメントされていましたが、構造体全体へのアクセス(例: 構造体の代入)は適切に扱われていませんでした。

src/cmd/gc/racewalk.ccallinstr関数には、TSTRUCT(構造体)型に対する特別な処理がありましたが、これは配列には適用されていませんでした。また、構造体の各フィールドを個別に走査してインストゥルメントするロジックがありましたが、これは構造体全体が連続したメモリブロックとして扱われるべきケースには不十分でした。

変更後

このコミットでは、配列(isfixedarray(t))および構造体(t->etype == TSTRUCT)に対して、新しいランタイム関数であるracereadrangeracewriterangeを呼び出すように変更されました。これらの関数は、アクセスされたメモリの開始アドレス(uintptraddr(n))だけでなく、アクセスされたメモリ範囲のサイズ(t->width)も引数として受け取ります。

これにより、レース検出器は、配列や構造体全体が占めるメモリ範囲に対するアクセスを正確に記録できるようになります。例えば、a = bのような配列や構造体の代入操作は、そのデータ型が占める全バイト数にわたる書き込みとして記録されます。これにより、配列や構造体の異なる部分への並行アクセスが、適切にデータ競合として検出されるようになります。

src/cmd/gc/racewalk.cから、構造体の各フィールドを個別にインストゥルメントする古いロジックが削除され、配列と同様にracereadrange/racewriterangeを使用する統一されたアプローチが採用されました。これは、構造体もまた連続したメモリブロックとして扱われるべきであるという考えに基づいています。

新しいランタイム関数の導入

  • src/cmd/gc/builtin.csrc/cmd/gc/runtime.go: コンパイラが利用する組み込み関数として、racereadrangeracewriterangeの宣言が追加されました。これらは、それぞれメモリ範囲の読み込みと書き込みをレース検出器に通知するための関数です。
  • src/pkg/runtime/race.c: 実際にレース検出器のロジックを呼び出すruntime·racereadrangeruntime·racewriterangeが実装されました。これらの関数は、Goランタイムの内部関数であるruntime∕race·ReadRangeruntime∕race·WriteRangeを呼び出し、アクセスされたアドレス、サイズ、そして呼び出し元のPC(プログラムカウンタ)を渡します。m->racecallフラグは、レース検出器の内部処理中にさらなるレース検出器の呼び出しが発生するのを防ぐためのものです。

テストの強化

src/pkg/runtime/race/testdata/mop_test.goでは、配列コピーに関するテストが強化されました。

  • TestRaceFailingArrayCopyTestRaceArrayCopyにリネームされ、配列コピーが正しく検出されることを確認します。
  • TestRaceNestedArrayCopyが追加され、多次元配列やネストされた構造体を含む複雑なデータ構造のコピーが正しくインストゥルメントされ、競合が検出されることを検証します。これは、コンパイラが単純なケースだけでなく、より複雑なメモリレイアウトを持つデータ構造も適切に処理できることを保証するために重要です。

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

src/cmd/gc/builtin.c

--- a/src/cmd/gc/builtin.c
+++ b/src/cmd/gc/builtin.c
@@ -113,6 +113,8 @@ char *runtimeimport =\n 	\"func @\\\"\\\".racefuncexit ()\\n\"\n 	\"func @\\\"\\\".raceread (? uintptr)\\n\"\n 	\"func @\\\"\\\".racewrite (? uintptr)\\n\"\n+\t\"func @\\\"\\\".racereadrange (@\\\"\\\".addr·1 uintptr, @\\\"\\\".size·2 uintptr)\\n\"\n+\t\"func @\\\"\\\".racewriterange (@\\\"\\\".addr·1 uintptr, @\\\"\\\".size·2 uintptr)\\n\"\n \t\"\\n\"\n \t\"$$\\n\";\n char *unsafeimport =

racereadrangeracewriterangeの関数シグネチャが、コンパイラが認識する組み込み関数として追加されました。これらはアドレスとサイズをuintptrとして受け取ります。

src/cmd/gc/racewalk.c

--- a/src/cmd/gc/racewalk.c
+++ b/src/cmd/gc/racewalk.c
@@ -439,8 +439,8 @@ static int
 callinstr(Node **np, NodeList **init, int wr, int skip)\n {\n 	Node *f, *b, *n;\n-\tType *t, *t1;\n-\tint class, res, hascalls;\n+\tType *t;\n+\tint class, hascalls;\n \n 	n = *np;\n 	//print(\"callinstr for %+N [ %O ] etype=%E class=%d\\n\",\n@@ -451,33 +451,6 @@ callinstr(Node **np, NodeList **init, int wr, int skip)\n \tt = n->type;\n \tif(isartificial(n))\n \t\treturn 0;\n-\tif(t->etype == TSTRUCT) {\n-\t\t// TODO: instrument arrays similarly.\n-\t\t// PARAMs w/o PHEAP are not interesting.\n-\t\tif(n->class == PPARAM || n->class == PPARAMOUT)\n-\t\t\treturn 0;\n-\t\tres = 0;\n-\t\thascalls = 0;\n-\t\tforeach(n, hascallspred, &hascalls);\n-\t\tif(hascalls) {\n-\t\t\tn = detachexpr(n, init);\n-\t\t\t*np = n;\n-\t\t}\n-\t\tfor(t1=t->type; t1; t1=t1->down) {\n-\t\t\tif(t1->sym && strcmp(t1->sym->name, \"_\")) {\n-\t\t\t\tn = treecopy(n);\n-\t\t\t\tf = nod(OXDOT, n, newname(t1->sym));\n-\t\t\t\tf->type = t1;\n-\t\t\t\tif(f->type->etype == TFIELD)\n-\t\t\t\t\tf->type = f->type->type;\n-\t\t\t\tif(callinstr(&f, init, wr, 0)) {\n-\t\t\t\t\ttypecheck(&f, Erv);\n-\t\t\t\t\tres = 1;\n-\t\t\t\t}\n-\t\t\t}\n-\t\t}\n-\t\treturn res;\n-\t}\n \n \tb = basenod(n);\n \t// it skips e.g. stores to ... parameter array\n@@ -498,7 +471,11 @@ callinstr(Node **np, NodeList **init, int wr, int skip)\n \t\t}\n \t\tn = treecopy(n);\n \t\tmakeaddable(n);\n-\t\tf = mkcall(wr ? \"racewrite\" : \"raceread\", T, init, uintptraddr(n));\n+\t\tif(t->etype == TSTRUCT || isfixedarray(t)) {\n+\t\t\tf = mkcall(wr ? \"racewriterange\" : \"racereadrange\", T, init, uintptraddr(n),\n+\t\t\t\t\tnodintconst(t->width));\n+\t\t} else\n+\t\t\tf = mkcall(wr ? \"racewrite\" : \"raceread\", T, init, uintptraddr(n));\n \t\t*init = list(*init, f);\n \t\treturn 1;\n \t}\n```
`callinstr`関数内の`TSTRUCT`型に対する古い処理が削除されました。代わりに、`t->etype == TSTRUCT`(構造体)または`isfixedarray(t)`(固定長配列)の場合に、`racewriterange`または`racereadrange`を呼び出すように変更されました。この呼び出しには、アドレスと、型`t`の幅(`t->width`、つまりサイズ)が引数として渡されます。それ以外の場合は、従来通り`racewrite`または`raceread`が呼び出されます。

### `src/cmd/gc/runtime.go`

```diff
--- a/src/cmd/gc/runtime.go
+++ b/src/cmd/gc/runtime.go
@@ -149,3 +149,5 @@ func racefuncenter(uintptr)\n func racefuncexit()\n func raceread(uintptr)\n func racewrite(uintptr)\n+func racereadrange(addr, size uintptr)\n+func racewriterange(addr, size uintptr)\n```
Goのランタイムパッケージ内で、`racereadrange`と`racewriterange`のGo側の宣言が追加されました。

### `src/pkg/runtime/race.c`

```diff
--- a/src/pkg/runtime/race.c
+++ b/src/pkg/runtime/race.c
@@ -77,6 +77,17 @@ runtime·racewrite(uintptr addr)\n 	}\n }\n \n+#pragma textflag 7\n+void\n+runtime·racewriterange(uintptr addr, uintptr sz)\n+{\n+\tif(!onstack(addr)) {\n+\t\tm->racecall = true;\n+\t\truntime∕race·WriteRange(g->racectx, (void*)addr, sz, runtime·getcallerpc(&addr));\n+\t\tm->racecall = false;\n+\t}\n+}\n+\n // Called from instrumented code.\n // If we split stack, getcallerpc() can return runtime·lessstack().\n #pragma textflag 7\n@@ -90,6 +101,17 @@ runtime·raceread(uintptr addr)\n \t}\n }\n \n+#pragma textflag 7\n+void\n+runtime·racereadrange(uintptr addr, uintptr sz)\n+{\n+\tif(!onstack(addr)) {\n+\t\tm->racecall = true;\n+\t\truntime∕race·ReadRange(g->racectx, (void*)addr, sz, runtime·getcallerpc(&addr));\n+\t\tm->racecall = false;\n+\t}\n+}\n+\n // Called from runtime·racefuncenter (assembly).\n #pragma textflag 7\n void

C言語で実装されたGoランタイムのレース検出器部分に、runtime·racewriterangeruntime·racereadrange関数が追加されました。これらの関数は、onstackチェック(スタック上のアドレスでないことを確認)の後、runtime∕race·WriteRangeまたはruntime∕race·ReadRangeを呼び出し、現在のゴルーチンのレースコンテキスト(g->racectx)、アクセスされたアドレス(addr)、サイズ(sz)、そして呼び出し元のプログラムカウンタ(runtime·getcallerpc(&addr))を渡します。m->racecallフラグは、レース検出器の再帰的な呼び出しを防ぐためのものです。

src/pkg/runtime/race/testdata/mop_test.go

--- a/src/pkg/runtime/race/testdata/mop_test.go
+++ b/src/pkg/runtime/race/testdata/mop_test.go
@@ -600,8 +600,7 @@ func TestRaceSprint(t *testing.T) {\n 	<-ch\n }\n \n-// Not implemented.\n-func TestRaceFailingArrayCopy(t *testing.T) {\n+func TestRaceArrayCopy(t *testing.T) {\n \tch := make(chan bool, 1)\n \tvar a [5]int\n \tgo func() {\n@@ -612,6 +611,24 @@ func TestRaceFailingArrayCopy(t *testing.T) {\n \t<-ch\n }\n \n+// Blows up a naive compiler.\n+func TestRaceNestedArrayCopy(t *testing.T) {\n+\tch := make(chan bool, 1)\n+\ttype (\n+\t\tPoint32   [2][2][2][2][2]Point\n+\t\tPoint1024 [2][2][2][2][2]Point32\n+\t\tPoint32k  [2][2][2][2][2]Point1024\n+\t\tPoint1M   [2][2][2][2][2]Point32k\n+\t)\n+\tvar a, b Point1M\n+\tgo func() {\n+\t\ta[0][1][0][1][0][1][0][1][0][1][0][1][0][1][0][1][0][1][0][1].y = 1\n+\t\tch <- true\n+\t}()\n+\ta = b\n+\t<-ch\n+}\n+\n func TestRaceStructRW(t *testing.T) {\n \tp := Point{0, 0}\n \tch := make(chan bool, 1)\n```
テストファイルが更新され、配列コピーのテストが改善されました。特に、`TestRaceNestedArrayCopy`が追加され、非常に深くネストされた多次元配列のコピー操作が正しくレース検出器によって処理されることを確認しています。これは、コンパイラが複雑な型に対しても正確なインストゥルメンテーションを行えることを保証するために重要です。

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

このコミットの最も重要な変更は、`src/cmd/gc/racewalk.c`内の`callinstr`関数にあります。この関数は、Goコンパイラがメモリアクセスを検出してレース検出器の呼び出しを挿入する主要な場所です。

変更前は、構造体(`TSTRUCT`)に対しては、そのフィールドを個別に走査してインストゥルメントする複雑なロジックがありました。これは、構造体全体へのアクセスではなく、個々のフィールドへのアクセスを追跡しようとするものでした。しかし、配列や構造体全体がコピーされるような操作では、このアプローチでは不十分でした。

変更後、この古いロジックは削除され、代わりに以下の新しい条件分岐が導入されました。

```go
if(t->etype == TSTRUCT || isfixedarray(t)) {
    f = mkcall(wr ? "racewriterange" : "racereadrange", T, init, uintptraddr(n),
               nodintconst(t->width));
} else
    f = mkcall(wr ? "racewrite" : "raceread", T, init, uintptraddr(n));

このコードスニペットは、以下のロジックを実装しています。

  1. 型チェック: アクセスされているメモリの型tが構造体(TSTRUCT)であるか、または固定長配列(isfixedarray(t))であるかをチェックします。
  2. 範囲アクセスインストゥルメンテーション:
    • もし型が構造体または固定長配列であれば、racewriterange(書き込みの場合)またはracereadrange(読み込みの場合)を呼び出すコードを生成します。
    • これらの関数には、アクセスされたメモリの開始アドレス(uintptraddr(n))と、その型のメモリ上のサイズ(t->width)が引数として渡されます。t->widthは、Goコンパイラが型ごとに計算するバイト単位のサイズです。
  3. 単一アドレスアクセスインストゥルメンテーション:
    • それ以外の型(例: プリミティブ型、スライス、ポインタなど)の場合は、従来通りracewriteまたはracereadを呼び出すコードを生成します。これらの関数はアドレスのみを引数として受け取ります。

この変更により、配列や構造体のような連続したメモリブロックを占めるデータ構造に対する操作が、その全体的なメモリ範囲にわたるアクセスとして正確にレース検出器に報告されるようになりました。これにより、レース検出器は、配列や構造体の異なる部分への並行アクセスによって引き起こされるデータ競合を、より確実に検出できるようになります。

src/pkg/runtime/race.cに実装されたruntime·racereadrangeruntime·racewriterangeは、これらのコンパイラが挿入した呼び出しを受け取り、実際のレース検出器のコアロジック(runtime∕race·ReadRangeruntime∕race·WriteRange)にアドレスとサイズを渡します。これにより、レース検出器は、単一のメモリ位置だけでなく、指定されたメモリ範囲全体に対するアクセスを監視し、競合を検出できるようになります。

関連リンク

参考にした情報源リンク

  • Goの公式ドキュメント
  • Goのソースコードリポジトリ
  • Goのレース検出器に関するブログ記事や技術文書
  • Goのメモリモデルに関する公式仕様
  • Goのコンパイラ設計に関する資料