
2026/09/13 13:09
C と Go のデータ競合と ThreadSanitizer の限界
RSS: https://news.ycombinator.com/rss
要約▶
日本語翻訳:
タイトル「C および Go の Data Races と ThreadSanitizer の限界」を著者とした Phil Eaton が指摘する通り、Clang、GCC、Go、Swift、OCaml でサポートされているツールである ThreadSanitizer (TSan) には、2012 年以来 3 つの主要な改修が施されたにもかかわらず、単純なバグではなくアーキテクチャ的な限界が存在します。特に、~255 スレッド境界を越える場合にカウンターの巻き戻りにより信頼できないように Race の検出ができず、Go ウェブサービスなどの高コンカレンシーシステムに頻繁に影響を与えます。具体的な盲点としては、Farzam Dorostkar による 2025 年の博士論文などで特定された最近の研究で指摘された問題、グラニュールあたり限られた「4 セルの予算」を使い果たすようなメモリアクセスパターン、Go の
sync.Pool のシャドウスロットハッシングなど特定の実装詳細によって Race が隠蔽されること、そして頻繁なロックによりスレッドごとに割り当てられる 14 ビットのカウンターの使用上限に達することなどが挙げられます。したがって、標準的な TSan の使用方法のみを頼りにしてプロダクション環境での安全性を図ることは不十分であり、開発者はツールの設計に伴うこれらの制約と潜在的な偽陰性について認識しておく必要があります。本文
C と Go を対象としたソフトウェアインフラ上の意思決定:データレイスと ThreadSanitizer の限界について
データレイスとは何か?レース検出器がどのように機能するのか?もしあなたのレース検出器にバグがある場合どうなるのか?それらについて詳しく見ていきます。
執筆者:Phil Eaton | 2026 年 9 月 6 日
注記: あなたは購読者としてこの記事を優先的にご閲覧いただけます。皆様のご支援により、このような記事の執筆が可能になっています。ありがとうございます。
概要と背景
コードに自信を持たせるためのテストを実行すると、並列処理(コンカレンシー)を追加するとバグが**非決定性(nondeterministic)**になります。
- レース検出器を有効にすることで、実行中のコード部分でデータレイスが発生したことを報告できます。
- テストが成功または失敗に関わらず、潜在的なバグが存在することを意味します。
主な言語(Clang、GCC、Go、Swift、OCaml)のレース検出器は、ほぼすべて LLVM の ThreadSanitizer (TSan) を使用しています。
- TSan は文書化が不十分で、バージョン 3(2021 年リリース)ではソースコードを読むことを推奨されています。
- TSan の前身には Eraser や FastTrack などがあり、それぞれ独自の限界を持っています。
この記事では、TSan が以下のようなアーキテクチャ上の選択により**過負荷状態(overloaded)**となり、明確なデータレイスを検出漏れを起こす仕組みを解説します。
- 255 を超えるスレッド境界をまたぐデータレイスの報告不可。
- Go の「リクエストごとに 1 つの goroutine」という設計では珍しくない状況。
背景:データレイスとは何か?
- C11 標準: 異なるスレッド内の不一致な動作が同時に発生し、互いに先行・遅延もない場合(非原子操作)、データレイスが未定義の動作をもたらします。
- Go 言語: 全てのアクセスが
でない場合、書き込みと読み取り(または他の書き込み)が並行して発生した時点でデータレイスです。sync/atomic
基本例:カウンター更新(C)
以下のコードは、2 つのスレッドが共有変数
counter にアクセスします。通常の実行ではバグに気づかないことがありますが、負荷が高くなると失敗します。
#include <pthread.h> #include <stdlib.h> long counter; void* bump(void* n) { for (long i = 0; i < *(long*)n; i++) { long v = counter; // Read // some logic counter = v + 1; // Write } return 0; } int main(void) { pthread_t p, q; long n = atol(getenv("N") ?: "100"); pthread_create(&p, 0, bump, &n); pthread_create(&q, 0, bump, &n); pthread_join(p, 0); pthread_join(q, 0); return (counter == n * 2) ? 0 : 1; }
実行結果の非決定性:
- ストレス低い状態(N=100): 通常成功 (
)0 - ストレス高い状態(N=10,000): 失敗 (
) が発生する可能性があります。1
レース検出器による検出
-fsanitize=thread フラグを付けてコンパイルすると、TSan が正常にレイスを検出します。
$ gcc -fsanitize=thread -g counter_data_race.c -o counter_data_race $ ./counter_data_race; echo $? ================== WARNING: ThreadSanitizer: data race (pid=704862) Read of size 8 at 0x56110c2ce018 by thread T2... Previous write of size 8 at 0x56110c2ce018 by thread T1... ================== ThreadSanitizer: reported 1 warnings 66
Go 言語における同様の挙動
Go でも
-race フラグを有効にすると同様に検出されます。
$ go build -race -o counter_data_race_go counter_data_race.go $ ./counter_data_race_go; echo $? ================== WARNING: DATA RACE Read at 0x0000005bda00 by goroutine 8... Previous write at 0x0000005bda00 by goroutine 7... ================== Found 1 data race(s) 66
誤検知と一般化されたレイス(General Race)
- ライセンスを適切に使えば、TSan は「一般化されたレイス」すら検出できません。これは TSan の限界の一つです。
C の理想化されたサブセットの解釈実行
C のコードをモデル化する Python インタープリターを作成し、FastTrack 風のベクトル時計を用いて TSan が概ねどのように動作するかを示します。
インタープリターの構築
- 機能: 関数内のステートメントをパースし、スレッドとロックの管理を行います。
- 実装方針: C の完全なコンパイラではなく、理想化されたサブセットの実行に焦点を当てます。
主要な処理ロジック:
: メモリへの値書き込み(変数名でマッピング)。write
: 2 つの値の比較。compare
/thread_create
: スレッドの作成と結合管理。thread_join
/mutex_lock
: ロックの取得と解放、ブロック処理のシミュレーション。mutex_unlock
FastTrack とベクトル時計の実装
TSan のアルゴリズムの核心である「ベクトル時計(Vector Clocks)」を模擬します。
- 各スレッド: 単調な時間カウンターと、他のすべてのスレッドに対する認識した時間を含むベクトル時計を持っています。
- グローバルマッピング: 最後に読み取った/書き込んだスレッドと時刻(タイク)を記録します。
検出ロジック (
):access
- 現在のアドレスの「最後の書き込み」を参照。
- もし現在のスレッドがその書き込みの「時間認識値」よりも遅れている場合、データレイスと判定します。
検出器との連携
インタープリターに検出器の機能をフックし、以下のステップで動作を検証します。
def link(interp, detector): # 元の関数を保存 original_step = interp.step def step(thread, statement): # 実際のステートメントを実行 original_step(thread, statement) # ロック/アンロック時の同期情報を更新 if operation == "mutex_lock": detector.acquire(...) elif operation == "mutex_unlock": detector.release(...)
検証結果:
no_sync.c(ロックなしの読み書き)をこのインタープリターで実行すると、TSan と同等にレイスが検出されます。
TSan が失敗する箇所
TSan は有用なツールですが、以下のような制限により特定のケースで検出漏れを起こします。
1. スレッド数への制限(255)
- 制限: TSan は合計 255 つのスレッド を追跡し、その枠を超えると古いスロットを破棄して新しいスロットを再利用します。
- 問題: 永続的な書き込みスレッドが長く存在し、255 を超えて新たな読み取りスレッドが作成された場合、TSan はレイスを検出できません。
C 言語例 (
):toomanythreads.c
- メインスレッドと永続スレッドを別枠で管理し、新しい作業スレッドを作成します。
- スレッド数が 254 を超えると(合計 255 の制限)、レイスを検出しないようになります。
Go 言語例:
- Go ランタイム自体もスロットを使用するため、限界はより早期に達します。
2. バジェット(Budgets)の限界
TSan はリソース使用量を制限しており、以下のようなケースで過負荷状態になります。
A. スレッド境界での追跡
- C11/GCC: 合計 255 スロット。
- Go: ランタイムのオーバーヘッドにより、スロットはより早く消費されます。
B. リリース(ロック解放)への制限
- 仕様: 各スレッド内のカウンターは最大 14 ビット で管理され、ある閾値を超えると予算が枯渇します。
- 現象: 同じスレッドで同一のロックを頻繁に解放・再取得すると、TSan の内部バッファーが飽和し、レイスを検出できなくなります。
- C 例:数百万回のロック解放を行うと報告されなくなります。
- Go 例:
はsync.Mutex
よりもコストが高く、3 倍早く予算を使い果たします。pthread_mutex_t
C. アクセス履歴の限界(8 バイト粒度)
- 仕様: TSan はメモリを 8 バイト粒度 にグループ化し、各グループに対して 4 セル の履歴しか保持しません。
- 現象: 同じ 8 バイト領域に対して多くの異なるアドレスへのアクセスが行われると、古い記録が破棄(evict)されます。これにより、レイスの検出ができなくなります。
3. sync.Pool のハッシュ衝突による誤検知回避
Go 言語の
sync.Pool は、オブジェクトを再利用するためにアドレスのハッシュ値を使用します。
- 仕組み: TSan は実際のポインタではなく、ハッシュされたスロット(128 スロット)に基づいてレイスを判定します。
- 問題点: ハッシュ衝突により、実際には無関係な 2 つのオブジェクトが同じスロットに割り当てられる可能性があります。
- その場合、TSan は「同期済み」と誤認し、データレイスを報告しません(False Negative)。
Go 言語例 (
):poolrace.go
- ハッシュ衝突するオブジェクトを意図的に作成すると、本来あるべきレイスが検出されなくなります。
$ go run -race poolrace.go collide; echo $? 0 # (本来は Data Race であるはずのプログラムが正常終了)
終焉的思考
レース検出器は非常に有用ですが、以下の点に注意が必要です:
- エッジケース: TSan は多くのバジェット制限を持っていますが、これらは公式には十分に文書化されていません。
- 追加対策:
を使用する際の欠落したレイス検出や、予算の限界を探索することが推奨されます。chan struct{}
重要: 本記事は「TSan が悪いツールである」と言うのではなく、「ツールには限界があり、それを理解しておくことが重要である」と述べています。
Happy hunting(愉快な探検)。間違いや疑問があれば、編集者に送信してください。