[インデックス 15022] ファイルの概要
このコミットは、Go言語のランタイムにおけるデータ競合検出器(Race Detector)の性能を向上させることを目的としています。具体的には、ThreadSanitizer (TSan) ライブラリが提供する「範囲アクセス関数(range access functions)」をGoランタイムに導入し、既存の個別のメモリアクセス監視をより効率的な範囲監視に置き換えることで、データ競合検出時のオーバーヘッドを大幅に削減しています。これにより、特にスライス操作など、連続したメモリ領域へのアクセスが多い処理において、go test -race
の実行時間が劇的に短縮されています。
コミット
commit ccc61eadd548e66c906a0a33e8a9c2d03238649a
Author: Rémy Oudompheng <oudomphe@phare.normalesup.org>
Date: Wed Jan 30 01:55:02 2013 +0100
runtime: implement range access functions in race detector.
Range access functions are already available in TSan library
but were not yet used.
Time for go test -race -short:
Before:
compress/flate 24.244s
exp/norm >200s
go/printer 78.268s
After:
compress/flate 17.760s
exp/norm 5.537s
go/printer 5.738s
Fixes #4250.
R=dvyukov, golang-dev, fullung
CC=golang-dev
https://golang.org/cl/7229044
GitHub上でのコミットページへのリンク
https://github.com/golang/go/commit/ccc61eadd548e66c906a0a33e8a9c2d03238649a
元コミット内容
Goランタイムのデータ競合検出器において、範囲アクセス関数を実装しました。
これらの範囲アクセス関数は、ThreadSanitizer (TSan) ライブラリには既に存在していましたが、これまではGoランタイムで利用されていませんでした。
go test -race -short
の実行時間における改善は以下の通りです。
変更前:
compress/flate
: 24.244秒exp/norm
: >200秒go/printer
: 78.268秒
変更後:
compress/flate
: 17.760秒exp/norm
: 5.537秒go/printer
: 5.738秒
これにより、Issue #4250 が修正されます。
変更の背景
Go言語のデータ競合検出器は、並行処理におけるデータ競合(複数のゴルーチンが同時に同じメモリ位置にアクセスし、少なくとも一方が書き込み操作である場合に発生する競合状態)を検出するために非常に重要なツールです。しかし、この検出器はプログラムの実行速度に大きなオーバーヘッドをもたらすことが課題でした。
特に、スライス操作のように連続したメモリ領域に対して多数の読み書きが発生する場面では、個々のメモリアクセスごとに検出器のフックを呼び出す従来の方式では、そのオーバーヘッドが顕著でした。このコミットの背景には、この性能ボトルネックを解消し、データ競合検出器をより実用的なものにするという明確な目的があります。
ThreadSanitizer (TSan) は、Googleが開発した高速なデータ競合検出ツールであり、Go言語のデータ競合検出器の基盤として利用されています。TSanは、単一のメモリアクセスだけでなく、メモリの「範囲」に対するアクセスを効率的に監視するためのAPI(__tsan_read_range
や __tsan_write_range
など)を提供していました。しかし、Goランタイムの実装では、これらの効率的な範囲アクセスAPIが活用されておらず、個別のメモリアクセスごとにTSanのフックを呼び出す非効率な方法が取られていました。
このコミットは、TSanが提供する既存の範囲アクセス機能をGoランタイムに統合することで、特にスライス操作におけるデータ競合検出の性能を劇的に改善し、go test -race
の実行時間を大幅に短縮することを目的としています。コミットメッセージに示されているように、exp/norm
のテスト時間が200秒以上から5.537秒へと、約36倍も高速化されていることから、この変更がもたらす性能上のメリットは非常に大きいことがわかります。
前提知識の解説
データ競合 (Data Race)
データ競合とは、並行プログラムにおいて、複数のスレッド(Goにおいてはゴルーチン)が同時に同じメモリ位置にアクセスし、そのうち少なくとも1つのアクセスが書き込み操作である場合に発生する未定義の動作です。データ競合が発生すると、プログラムの実行結果が予測不能になったり、クラッシュしたりする可能性があります。これは並行プログラミングにおける最も一般的なバグの一つであり、デバッグが非常に困難です。
Go言語のデータ競合検出器 (Go Race Detector)
Go言語には、ビルド時に -race
フラグを付けることで有効化できる組み込みのデータ競合検出器があります。
例: go run -race main.go
または go test -race ./...
この検出器は、プログラムの実行中にメモリアクセスを監視し、データ競合のパターンを検出すると警告を出力します。Goのデータ競合検出器は、Googleが開発したThreadSanitizer (TSan) というツールを基盤としています。
ThreadSanitizer (TSan)
TSanは、LLVMコンパイラインフラストラクチャの一部として提供される、高速なデータ競合検出ツールです。プログラムのコンパイル時にインストルメンテーション(計測コードの挿入)を行い、実行時にメモリアクセスの履歴を追跡することでデータ競合を検出します。TSanは、以下のような特徴を持ちます。
- インストルメンテーション: コンパイル時に、すべてのメモリアクセス(読み込み、書き込み)の直前に特別な関数呼び出しを挿入します。
- シャドウメモリ: プログラムの各メモリワードに対して、そのメモリワードへのアクセス履歴(どのスレッドがいつアクセスしたか、読み込みか書き込みかなど)を記録するための「シャドウメモリ」を維持します。
- 並行性解析: 実行時にシャドウメモリの情報を基に、並行アクセスパターンを解析し、データ競合を検出します。
- API: TSanは、
__tsan_read
や__tsan_write
といった個別のメモリアクセスを報告するAPIの他に、__tsan_read_range
や__tsan_write_range
のように、連続したメモリ領域へのアクセスを効率的に報告するためのAPIも提供しています。
Goランタイム (Go Runtime)
Goランタイムは、Goプログラムの実行を管理する低レベルのシステムです。ガベージコレクション、ゴルーチン(軽量スレッド)のスケジューリング、チャネル通信、メモリアロケーションなど、Go言語の並行処理モデルとメモリ管理のほとんどを担っています。Goランタイムの一部はC言語で書かれており、特にパフォーマンスが要求される部分や、OSとのインタフェース部分はC言語で実装されています。データ競合検出器のフックも、このC言語で書かれたランタイム部分に組み込まれています。
uintptr
と unsafe.Pointer
uintptr
: 任意のポインタ値を保持できる整数型です。ポインタ演算を行う際に一時的にポインタを整数に変換するために使用されますが、ガベージコレクタはuintptr
をポインタとして認識しないため、uintptr
に変換されたポインタが指すメモリがガベージコレクションによって解放される可能性があります。unsafe.Pointer
: 任意の型のポインタを保持できる特殊なポインタ型です。Goの型システムを迂回して、異なる型のポインタ間で変換を行ったり、ポインタ演算を行ったりするために使用されます。unsafe.Pointer
はガベージコレクタによってポインタとして認識されるため、unsafe.Pointer
が指すメモリはガベージコレクションの対象になりません。しかし、その名の通り「unsafe」であり、誤用するとメモリ安全性の問題を引き起こす可能性があります。
このコミットでは、GoのコードからCのTSan関数を呼び出す際に、GoのポインタをCの void*
に渡すために unsafe.Pointer
が使用されています。
技術的詳細
このコミットの核心は、GoランタイムがTSanにメモリアクセスを報告する方法を、個別のアクセス報告から範囲アクセス報告へと変更した点にあります。
従来のGoランタイムでは、例えばスライス内の要素をループで処理する際に、各要素へのアクセスごとに runtime.racereadpc
や runtime.racewritepc
といった関数を呼び出し、TSanに個別のメモリアクセスを報告していました。これは、N
個の要素を持つスライスに対して N
回の関数呼び出しが発生することを意味し、特に N
が大きい場合に大きなオーバーヘッドとなっていました。
TSanライブラリは、このような連続したメモリアクセスを効率的に処理するために、__tsan_read_range(goid, addr, sz, step, pc)
や __tsan_write_range(goid, addr, sz, step, pc)
といった関数を提供しています。これらの関数は、開始アドレス addr
から sz
バイトの範囲を、step
バイトごとにアクセスしたことを一度の呼び出しでTSanに報告できます。これにより、GoランタイムからTSanへの関数呼び出し回数を大幅に削減し、性能を向上させることが可能になります。
このコミットでは、以下の主要な変更が行われています。
-
新しいランタイム関数の追加:
runtime.racereadrangepc(addr, sz, step, callpc, pc)
runtime.racewriterangepc(addr, sz, step, callpc, pc)
これらの関数は、指定されたメモリ範囲に対する読み書き操作をTSanに報告するためのGoランタイム側のエントリポイントとなります。
-
TSan APIのGoラッパーの追加:
src/pkg/runtime/race/race.go
に、C言語のTSan関数__tsan_read_range
および__tsan_write_range
をGoから呼び出すためのラッパー関数ReadRange
およびWriteRange
が追加されました。これにより、GoのコードからTSanの範囲アクセスAPIを安全に呼び出すことができるようになります。 -
rangeaccess
ヘルパー関数の導入:src/pkg/runtime/race.c
にrangeaccess
という静的ヘルパー関数が追加されました。この関数は、runtime.racereadrangepc
およびruntime.racewriterangepc
から呼び出され、実際のTSanの範囲アクセス関数 (runtime∕race·ReadRange
またはruntime∕race·WriteRange
) を呼び出す前に、必要なコンテキスト(ゴルーチンID、呼び出し元PCなど)を設定します。 -
スライス操作の最適化:
src/pkg/runtime/slice.c
内のruntime.appendslice
,runtime.appendstr
,runtime.growslice
,runtime.copy
,runtime.slicestringcopy
といったスライス関連のランタイム関数において、従来の個別のメモリアクセス報告ループが、新しく導入されたruntime.racereadrangepc
およびruntime.racewriterangepc
の呼び出しに置き換えられました。これにより、これらの関数が処理するメモリ範囲全体を一度にTSanに報告できるようになり、大幅な性能向上が実現されました。
例えば、runtime.appendslice
関数では、以前は x.len
回の runtime.racereadpc
呼び出しと y.len
回の runtime.racereadpc
呼び出し、そして y.len
回の runtime.racewritepc
呼び出しが行われていましたが、この変更により、それぞれ1回の runtime.racereadrangepc
および runtime.racewriterangepc
呼び出しに集約されました。これにより、関数呼び出しのオーバーヘッドが劇的に削減され、データ競合検出の効率が向上しました。
コアとなるコードの変更箇所
このコミットによる主要なコード変更は以下のファイルに集中しています。
-
src/pkg/runtime/race.c
:runtime∕race·ReadRange
とruntime∕race·WriteRange
のプロトタイプ宣言が追加されました。rangeaccess
という静的ヘルパー関数が追加されました。この関数は、runtime.racereadrangepc
とruntime.racereadrangepc
から呼び出され、TSanの範囲アクセス関数を呼び出すロジックをカプセル化します。runtime.racewriterangepc
とruntime.racereadrangepc
の実装が追加されました。これらはrangeaccess
を呼び出します。
-
src/pkg/runtime/race.h
:runtime.racewriterangepc
とruntime.racereadrangepc
の関数プロトタイプが追加されました。
-
src/pkg/runtime/race/race.go
:- C言語のTSan関数
__tsan_read_range
と__tsan_write_range
のGo側の外部関数宣言が追加されました。 - GoからこれらのC関数を呼び出すためのラッパー関数
ReadRange
とWriteRange
が追加されました。
- C言語のTSan関数
-
src/pkg/runtime/race0.c
:- データ競合検出器が無効な場合にダミーとして呼び出される
runtime.racewriterangepc
とruntime.racereadrangepc
の実装が追加されました。これらは何もしません。
- データ競合検出器が無効な場合にダミーとして呼び出される
-
src/pkg/runtime/slice.c
:runtime.appendslice
,runtime.appendstr
,runtime.growslice
,runtime.copy
,runtime.slicestringcopy
関数内で、従来の個別のメモリアクセス報告(runtime.racereadpc
,runtime.racewritepc
をループで呼び出す部分)が削除され、新しく追加されたruntime.racereadrangepc
およびruntime.racewriterangepc
の呼び出しに置き換えられました。これにより、スライス操作におけるデータ競合検出の効率が大幅に向上しました。
コアとなるコードの解説
src/pkg/runtime/race.c
の変更
// 新しいプロトタイプ宣言
void runtime∕race·ReadRange(int32 goid, void *addr, uintptr sz, uintptr step, void *pc);
void runtime∕race·WriteRange(int32 goid, void *addr, uintptr sz, uintptr step, void *pc);
// rangeaccess ヘルパー関数
static void
rangeaccess(void *addr, uintptr size, uintptr step, uintptr callpc, uintptr pc, bool write)
{
int64 goid;
if(!onstack((uintptr)addr)) { // アドレスがスタック上にある場合は無視
m->racecall = true; // 競合検出器の呼び出し中であることを示すフラグ
goid = g->goid-1; // 現在のゴルーチンIDを取得
if(callpc) { // 呼び出し元PCが指定されている場合
// 特定のランタイム関数からの呼び出しの場合、実際の呼び出し元を特定
if(callpc == (uintptr)runtime·lessstack ||
(callpc >= (uintptr)runtime·mheap.arena_start && callpc < (uintptr)runtime·mheap.arena_used))
runtime·callers(3, &callpc, 1); // 実際の呼び出し元PCを取得
runtime∕race·FuncEnter(goid, (void*)callpc); // TSanにファンクションエントリを報告
}
if(write)
runtime∕race·WriteRange(goid, addr, size, step, (void*)pc); // 書き込み範囲を報告
else
runtime∕race·ReadRange(goid, addr, size, step, (void*)pc); // 読み込み範囲を報告
if(callpc)
runtime∕race·FuncExit(goid); // TSanにファンクションイグジットを報告
m->racecall = false; // フラグをリセット
}
}
// 範囲書き込みアクセスを報告するランタイム関数
void
runtime·racewriterangepc(void *addr, uintptr sz, uintptr step, void *callpc, void *pc)
{
rangeaccess(addr, sz, step, (uintptr)callpc, (uintptr)pc, true);
}
// 範囲読み込みアクセスを報告するランタイム関数
void
runtime·racereadrangepc(void *addr, uintptr sz, uintptr step, void *callpc, void *pc)
{
rangeaccess(addr, sz, step, (uintptr)callpc, (uintptr)pc, false);
}
rangeaccess
関数は、runtime.racewriterangepc
と runtime.racereadrangepc
の共通ロジックをカプセル化しています。この関数は、メモリアドレスがスタック上にあるかどうかをチェックし、スタック上にない場合にのみTSanに報告します。また、呼び出し元PCの調整や、TSanの FuncEnter
/FuncExit
フックの呼び出しも行います。最終的に、write
フラグに基づいて runtime∕race·WriteRange
または runtime∕race·ReadRange
を呼び出し、実際の範囲アクセスをTSanに報告します。
src/pkg/runtime/race/race.go
の変更
// C言語のTSan範囲アクセス関数のGo側の宣言
void __tsan_read_range(int goid, void *addr, long sz, long step, void *pc);
void __tsan_write_range(int goid, void *addr, long sz, long step, void *pc);
// GoからTSanの範囲読み込み関数を呼び出すラッパー
func ReadRange(goid int32, addr, sz, step, pc uintptr) {
C.__tsan_read_range(C.int(goid), unsafe.Pointer(addr),
C.long(sz), C.long(step), unsafe.Pointer(pc))
}
// GoからTSanの範囲書き込み関数を呼び出すラッパー
func WriteRange(goid int32, addr, sz, step, pc uintptr) {
C.__tsan_write_range(C.int(goid), unsafe.Pointer(addr),
C.long(sz), C.long(step), unsafe.Pointer(pc))
}
これらのGo関数は、Goの uintptr
型の引数をCの int
や long
、unsafe.Pointer
に変換し、C言語で実装されたTSanの範囲アクセス関数を呼び出します。これにより、Goのランタイムコードから型安全性を保ちつつ、TSanの低レベルAPIを利用できるようになります。
src/pkg/runtime/slice.c
の変更例 (runtime.appendslice
)
// 変更前 (抜粋)
// if(raceenabled) {
// pc = runtime·getcallerpc(&t);
// for(i=0; i<x.len; i++)
// runtime·racereadpc(x.array + i*t->elem->size, pc, runtime·appendslice);
// for(i=x.len; i<x.cap; i++)
// runtime·racewritepc(x.array + i*t->elem->size, pc, runtime·appendslice);
// for(i=0; i<y.len; i++)
// runtime·racereadpc(y.array + i*t->elem->size, pc, runtime·appendslice);
// }
// 変更後 (抜粋)
if(raceenabled) {
// Don't mark read/writes on the newly allocated slice.
pc = runtime·getcallerpc(&t);
// read x[:len]
if(m > x.cap) // 新しい容量が必要な場合(再アロケーションが発生する場合)
runtime·racereadrangepc(x.array, x.len*w, w, pc, runtime·appendslice);
// read y
runtime·racereadrangepc(y.array, y.len*w, w, pc, runtime·appendslice);
// write x[len(x):len(x)+len(y)]
if(m <= x.cap) // 新しい容量が不要な場合(既存のバッファに収まる場合)
runtime·racewriterangepc(ret.array+ret.len*w, y.len*w, w, pc, runtime·appendslice);
}
この変更は、runtime.appendslice
関数におけるデータ競合検出のロジックを示しています。変更前は、スライスの各要素に対して個別に runtime.racereadpc
や runtime.racewritepc
をループで呼び出していました。これは、スライスの長さが大きくなるにつれて、関数呼び出しのオーバーヘッドが線形に増加することを意味します。
変更後では、runtime.racereadrangepc
と runtime.racewriterangepc
を使用することで、スライス全体またはその一部の範囲に対する読み書き操作を、それぞれ1回の関数呼び出しでTSanに報告できるようになりました。
x[:len]
の読み込み:runtime·racereadrangepc(x.array, x.len*w, w, pc, runtime·appendslice)
x.array
: 読み込みを開始するアドレスx.len*w
: 読み込むバイト数(x.len
は要素数、w
は要素のサイズ)w
: 各要素のステップサイズ
y
の読み込み:runtime·racereadrangepc(y.array, y.len*w, w, pc, runtime·appendslice)
x[len(x):len(x)+len(y)]
への書き込み:runtime·racewriterangepc(ret.array+ret.len*w, y.len*w, w, pc, runtime·appendslice)
ret.array+ret.len*w
: 書き込みを開始するアドレス(既存のスライスの末尾)
これにより、ループによる多数の関数呼び出しが不要になり、データ競合検出の性能が大幅に向上します。他のスライス関連関数 (runtime.appendstr
, runtime.growslice
, runtime.copy
, runtime.slicestringcopy
) も同様のパターンで最適化されています。
関連リンク
- GitHubコミットページ: https://github.com/golang/go/commit/ccc61eadd548e66c906a0a33e8a9c2d03238649a
- Go CL (Code Review): https://golang.org/cl/7229044
- 関連Issue: https://golang.org/issue/4250
参考にした情報源リンク
- Go Race Detector: https://go.dev/blog/race-detector
- ThreadSanitizer (TSan): https://clang.llvm.org/docs/ThreadSanitizer.html
- Go Runtime Source Code: https://github.com/golang/go/tree/master/src/runtime
- Go
unsafe
パッケージ: https://pkg.go.dev/unsafe - Go
uintptr
型: https://pkg.go.dev/builtin#uintptr - Go
slice
の内部構造: https://go.dev/blog/slices - Go
append
の実装: https://go.dev/doc/effective_go#append - Go
copy
の実装: https://go.dev/doc/effective_go#copy - Go
growslice
の実装 (Go 1.20 runtime/slice.go): https://github.com/golang/go/blob/go1.20/src/runtime/slice.go#L100 (コミット当時のバージョンとは異なる可能性がありますが、概念は同じです) - Go
memmove
の実装 (Go 1.20 runtime/memmove_amd64.s): https://github.com/golang/go/blob/go1.20/src/runtime/memmove_amd64.s (コミット当時のバージョンとは異なる可能性がありますが、概念は同じです) - Go
getcallerpc
の実装 (Go 1.20 runtime/stack.go): https://github.com/golang/go/blob/go1.20/src/runtime/stack.go#L100 (コミット当時のバージョンとは異なる可能性がありますが、概念は同じです) - Go
callers
の実装 (Go 1.20 runtime/trace.go): https://github.com/golang/go/blob/go1.20/src/runtime/trace.go#L100 (コミット当時のバージョンとは異なる可能性がありますが、概念は同じです) - Go
onstack
の実装 (Go 1.20 runtime/stack.go): https://github.com/golang/go/blob/go1.20/src/runtime/stack.go#L100 (コミット当時のバージョンとは異なる可能性がありますが、概念は同じです) - Go
mheap
の実装 (Go 1.20 runtime/mheap.go): https://github.com/golang/go/blob/go1.20/src/runtime/mheap.go#L100 (コミット当時のバージョンとは異なる可能性がありますが、概念は同じです) - Go
lessstack
の実装 (Go 1.20 runtime/stack.go): https://github.com/golang/go/blob/go1.20/src/runtime/stack.go#L100 (コミット当時のバージョンとは異なる可能性がありますが、概念は同じです)