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

[インデックス 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言語で書かれたランタイム部分に組み込まれています。

uintptrunsafe.Pointer

  • uintptr: 任意のポインタ値を保持できる整数型です。ポインタ演算を行う際に一時的にポインタを整数に変換するために使用されますが、ガベージコレクタは uintptr をポインタとして認識しないため、uintptr に変換されたポインタが指すメモリがガベージコレクションによって解放される可能性があります。
  • unsafe.Pointer: 任意の型のポインタを保持できる特殊なポインタ型です。Goの型システムを迂回して、異なる型のポインタ間で変換を行ったり、ポインタ演算を行ったりするために使用されます。unsafe.Pointer はガベージコレクタによってポインタとして認識されるため、unsafe.Pointer が指すメモリはガベージコレクションの対象になりません。しかし、その名の通り「unsafe」であり、誤用するとメモリ安全性の問題を引き起こす可能性があります。

このコミットでは、GoのコードからCのTSan関数を呼び出す際に、GoのポインタをCの void* に渡すために unsafe.Pointer が使用されています。

技術的詳細

このコミットの核心は、GoランタイムがTSanにメモリアクセスを報告する方法を、個別のアクセス報告から範囲アクセス報告へと変更した点にあります。

従来のGoランタイムでは、例えばスライス内の要素をループで処理する際に、各要素へのアクセスごとに runtime.racereadpcruntime.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への関数呼び出し回数を大幅に削減し、性能を向上させることが可能になります。

このコミットでは、以下の主要な変更が行われています。

  1. 新しいランタイム関数の追加:

    • runtime.racereadrangepc(addr, sz, step, callpc, pc)
    • runtime.racewriterangepc(addr, sz, step, callpc, pc) これらの関数は、指定されたメモリ範囲に対する読み書き操作をTSanに報告するためのGoランタイム側のエントリポイントとなります。
  2. TSan APIのGoラッパーの追加: src/pkg/runtime/race/race.go に、C言語のTSan関数 __tsan_read_range および __tsan_write_range をGoから呼び出すためのラッパー関数 ReadRange および WriteRange が追加されました。これにより、GoのコードからTSanの範囲アクセスAPIを安全に呼び出すことができるようになります。

  3. rangeaccess ヘルパー関数の導入: src/pkg/runtime/race.crangeaccess という静的ヘルパー関数が追加されました。この関数は、runtime.racereadrangepc および runtime.racewriterangepc から呼び出され、実際のTSanの範囲アクセス関数 (runtime∕race·ReadRange または runtime∕race·WriteRange) を呼び出す前に、必要なコンテキスト(ゴルーチンID、呼び出し元PCなど)を設定します。

  4. スライス操作の最適化: 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 呼び出しに集約されました。これにより、関数呼び出しのオーバーヘッドが劇的に削減され、データ競合検出の効率が向上しました。

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

このコミットによる主要なコード変更は以下のファイルに集中しています。

  1. src/pkg/runtime/race.c:

    • runtime∕race·ReadRangeruntime∕race·WriteRange のプロトタイプ宣言が追加されました。
    • rangeaccess という静的ヘルパー関数が追加されました。この関数は、runtime.racereadrangepcruntime.racereadrangepc から呼び出され、TSanの範囲アクセス関数を呼び出すロジックをカプセル化します。
    • runtime.racewriterangepcruntime.racereadrangepc の実装が追加されました。これらは rangeaccess を呼び出します。
  2. src/pkg/runtime/race.h:

    • runtime.racewriterangepcruntime.racereadrangepc の関数プロトタイプが追加されました。
  3. src/pkg/runtime/race/race.go:

    • C言語のTSan関数 __tsan_read_range__tsan_write_range のGo側の外部関数宣言が追加されました。
    • GoからこれらのC関数を呼び出すためのラッパー関数 ReadRangeWriteRange が追加されました。
  4. src/pkg/runtime/race0.c:

    • データ競合検出器が無効な場合にダミーとして呼び出される runtime.racewriterangepcruntime.racereadrangepc の実装が追加されました。これらは何もしません。
  5. 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.racewriterangepcruntime.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の intlongunsafe.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.racereadpcruntime.racewritepc をループで呼び出していました。これは、スライスの長さが大きくなるにつれて、関数呼び出しのオーバーヘッドが線形に増加することを意味します。

変更後では、runtime.racereadrangepcruntime.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) も同様のパターンで最適化されています。

関連リンク

参考にした情報源リンク