
2026/01/29 1:48
**回転している間に:** *スピンロックでよく起こる問題をお聞きください*
RSS: https://news.ycombinator.com/rss
要約▶
日本語訳:
改訂要約
記事では、C++で手作業で実装したスピンロックがエラーを起こしやすく、パフォーマンスに負担がかかる理由について説明しています。単純な
フラグは複数のスレッドが同時にロックを取得できてしまい、これをint32_tに置き換え、std::atomic<int>を使用すると競合は解消されますが、依然としてバイスイート(busy‑waiting)が発生し、電力消費・温度上昇・キャッシュ整合性トラフィックを引き起こします。exchange(1)スピンループ内に
(または同等の命令)を挿入するとメモリ要求が抑制され、再試行ごとにパス数を倍増させる指数的バックオフはさらに競合を減らします。ただし最適なパス数はマイクロアーキテクチャによって異なるため、_mm_pause()のレイテンシは古い Intel/AMD チップで約10サイクルから Skylake では約140サイクルに及びます。したがってバックオフカウントは固定数のパスではなく CPU サイクル単位で表現すべきです。PAUSE適切なメモリ順序(ロック取得時には
、解放時にはacquire)を使用すると、不必要な完全シーケンシャル整合性バリアが除去されます。カスタムスピンロックはまた、優先度逆転・ライブロック・偽共有(ロック変数が他のデータと同じキャッシュラインを共有する場合)やreleaseの実装差異に悩まされることがあります。std::atomic_wait現代の C++ 標準では
が提供されていますが、コンパイラサポートは異なります(MSVC は OS プリミティブを直接呼び出し、libc++ は従来バックオフと yield を使用)。これらの複雑さから、記事ではほとんどの場合においてカスタムスピンロックを書くよりも、OS が提供する同期プリミティブ(ミューテックス、futex、Windows のstd::atomic_waitなど)を利用した方が望ましいと結論付けています。WaitOnAddress
この改訂要約はリストのすべての重要ポイントを取り込みつつ、明瞭さを保ち、未検証の推測を避けるようにしています。
本文
イントロ
これまで1年以内に3回、スピンループで問題が発生したプロジェクトを経験しました。数年間にわたりスピニングスレッドと対峙してきましたが、正直言うと、私は攻撃側でも被害者側でもあったことがあります。
同じ問題を何度も目にするのは疲れますし、ブログを書いて人々に読んでもらい、他人が犯したミスを繰り返さないようにしたいと考えました。
実際、多くの方がこの件について書き、スピンロックに関わる様々な問題(速度、公平性、優先度反転、NUMA、破損コードなど)を取り上げています。
それでも「スピンロックは制御不能になりやすいのでOSプリミティブを使うべきだ」という事実に納得できないなら、続けて読んでください。自前のスピンロックを実装するときに絶対にやってはいけないことについて触れます。再度言いますが、今時はほとんどの場合スピンロックを使うべきではありません。もし使う場合は、本当に何をしているかを理解し、思わぬタイミングで問題に直面することを覚悟してください。
これは一般的なスピンループの話であり、多数存在するロッキングアルゴリズム(5)についてではありません。
壊れたスピンロック
まず基本から:自前のスピンロックを実装したいとします。
「簡単だ!ブール値、lock() と unlock() の関数だけで OK 」
そうです…
デモ用に
bool の代わりに int を使います。実際にはメタデータ(例:スレッドID)を格納するために複雑な型になる場合があります。また、スピンロックそのものではなくポインタなど他の内容を変更するコードも多いです。
class BrokenSpinLock { // 故意に int32_t を使っているだけで、気にしないでください。 int32_t isLocked = 0; public: void lock() { while (isLocked != 0) { /* まだロックされていればループ */ } isLocked = 1; // Acquire } void unlock() { isLocked = 0; } };
マルチスレッドを扱ったことがある人はすぐに問題点に気づくでしょう。複数のスレッドがこのロックを使おうとすると、
isLocked の不正な値(例えば CPU が単語サイズで破壊する場合)が読まれる可能性があります。さらに、理論上はレースコンディションが発生します。
以下の例では、2 つのスレッドが同時に
lock() を呼び出すケースを考えます:
- スレッド A が
を確認 | スレッド B がisLocked == 0
を確認isLocked == 0 - 両方ともループから抜ける |
- スレッド A が
に 1 を書き込む | スレッド B が同じく 1 を書き込むisLocked
結果として、2 つのスレッドがロックを取得したと誤認します。
原子変数/操作に関する小話もあります。簡単に言えば:原子操作は他のスレッドが途中状態を見ることを防ぎ、レースコンディションを回避します(ただしそれ自体も危険で扱いが難しい)。
isLocked を std::atomic<int> に置き換えてみましょう。データそのものにはレースは起きませんが、誰がロックを取得したかは不明です。ただし exchange() を使えば解決できます。
void lock() { while (isLocked.exchange(1) != 0) {} }
同時に実行しても原子性のおかげでどちらかが先に完了します。
CPUを焼き尽くすスピンロック
このスピンロックは「何もしない」ループです。
CPU は待機中であることを知る手段が無いため、頻度を高く保ち続けます。現代の CPU ではコア周波数を下げて電力と温度を抑える仕組みがありますが、スピンロックはそれを妨げます。特にモバイル/組込みデバイスでは望ましくありません。
説得力が足りない場合、以下のような性能低下も起きます。多くのスレッドがロック取得を競い合うと、一つだけが勝ち、残りはメモリ書き込みを行います。これは各コア間で同期される必要があります。
Intel の Optimization Reference Manual から引用:
現代マイクロプロセッサのスーパースカラー予測実行エンジンでは、このようなループが複数の同時読みリクエストを発生させます…
このペナルティは Core Solo/Duo で小さいですが、Xeon では約 25 倍重いです。さらに SMT(ハイパースレッディング)を有効にすると、スピン待ちループが実行帯域幅の大部分を消費します。
対策
最善の方法はスピンループを避け、CPU に「メモリ変更を通知されるまで待つ」ことです。x86 では
PAUSE 命令(インテルでは _mm_pause())が設計されています。
void cpu_pause() { #if defined(__i386__) || defined(__x86_64__) _mm_pause(); #elif defined(__arm__) || defined(__aarch64__) __yield(); #else #error "unknown instruction set" #endif }
void lock() { while (isLocked.exchange(1) != 0) cpu_pause(); }
十分に待たないスピンロック
従来の手法ではバックオフ戦略を採用し、各試行で
PAUSE 命令数を増やします。Intel Optimization Manual の 2.7.4 で推奨されている指数バックオフが代表例です。
void lock() { const int maxPauses = 64; // MAX_BACKOFF int nbPauses = 1; while (isLocked.exchange(1) != 0) { for (int i = 0; i < nbPauses; ++i) cpu_pause(); nbPauses = nbPauses < maxBackoffs ? nbPauses * 2 : nbPauses; } }
Intel は「
PAUSE 命令数を 2 倍に増やし、最大値に達するまで繰り返す」と述べています。さらに rdtsc() を使ってランダム性を導入し、yield 部分を簡単に差し替えられる構造にします。
struct Yielder { static const int maxPauses = 64; // MAX_BACKOFF int nbPauses = 1; void do_yield() { const int jitter = static_cast<int>(__rdtsc() & (nbPauses - 1)); const int nbPausesThisLoop = nbPauses - jitter; for (int i = 0; i < nbPausesThisLoop; ++i) cpu_pause(); nbPauses = nbPauses < maxBackoffs ? nbPauses * 2 : nbPauses; } };
void lock() { Yielder yielder; while (isLocked.exchange(1) != 0) yielder.do_yield(); }
待ちすぎるスピンロック
maxBackoff はチューニング対象です。アーキテクチャにより PAUSE のサイクル数は大きく変わります。古い CPU では Intel が約 10 サイクル、AMD が約 3 サイクルでしたが、新しいものでは Intel が 100〜160、AMD が 60 程度です。
TSC 周期で
PAUSE の持続時間を制限する方法:
static inline bool before(uint64_t a, uint64_t b) { return ((int64_t)b - (int64_t)a) > 0; } void pollDelay(uint32_t clocks) { uint64_t endTime = _rdtsc() + clocks; for (; before(_rdtsc(), endTime); ) cpu_pause(); }
Yielder は次のように変更できます。
struct Yielder { static const int maxPauses = 64; // MAX_BACKOFF int nbPauses = 1; const int maxCycles = /* Some value */; void do_yield() { uint64_t beginTSC = __rdtsc(); uint64_t endTSC = beginTSC + maxCycles; const int jitter = static_cast<int>(beginTSC & (nbPauses - 1)); const int nbPausesThisLoop = nbPauses - jitter; for (int i = 0; i < nbPausesThisLoop && before(__rdtsc(), endTSC); ++i) cpu_pause(); nbPauses = nbPauses < maxBackoffs ? nbPauses * 2 : nbPauses; } };
void lock() { Yielder yield; while (isLocked.exchange(1) != 0) yield.do_yield(); }
優先度反転を扱わないスピンロック
優先度反転はスピンロックで最悪のケースです。低優先度スレッドがロックを取得し、高優先度スレッドが待機すると、OS スケジューラが低優先度スレッドをプリエンプトしてしまい、高優先度スレッドは CPU サイクルを無駄に消費します。
一般的な対策は指数バックオフと
std::this_thread::yield()(またはプラットフォーム等価物)を組み合わせることです。最大試行回数に達したら OS に yield し、他のスレッドが実行できるようにします。ただしリアルタイムカーネルでは同等以上優先度のスレッドのみが考慮されるため完全には解消できません。
OS に通知するスピンロック
より堅牢な解決策は、Linux の
futex や Windows の WaitOnAddress を利用し、カーネルに待機中であることを知らせます。これらのプリミティブはアドレスが変わるまでスレッドをブロックし、WakeByAddressSingle(または FUTEX_WAKE)で再開します。
void do_yield(int32_t* addr, int32_t cmpVal, uint32_t timeoutMs) { // 指数バックオフ + jitter... if (nbPauses >= maxBackoffs) { WaitOnAddress(addr, &cmpVal, sizeof(cmpVal), timeoutMs); nbPauses = 1; } }
void lock() { Yielder yield; while (isLocked.exchange(1, std::memory_order_acquire) != 0) do_yield(&isLocked, 1 /*while locked*/, 1 /*ms*/); } void unlock() { isLocked = 0; WakeByAddressSingle(&isLocked); // 可能なスレッドを通知 }
不公平なスピンロック
トークンロックなど一部のアルゴリズムは不公平です。競合時に高速なスレッドが常に優先され、他のスレッドが永遠に待つことがあります。不公平を避けたいなら OS プリミティブ(futex、セマフォ等)を利用する方が安全です。
偽共有を起こすスピンロック
異なる変数を書き込んでいても同じキャッシュラインに存在すると 偽共有 となり性能が低下します。ロックオブジェクトはアラインまたはパディングして別々のキャッシュラインに配置しましょう。
alignas(std::hardware_destructive_interference_size) MyLock lock1; alignas(std::hardware_destructive_interference_size) MyLock lock2;
特殊命令
MWAIT/TPAUSE などの特権命令も使用できますが、通常は OS API(例:Windows の WaitOnAddress)を通じて提供されます。AMD はユーザーレベルで monitorx、mwaitx を用意しており、指定 TSC カウントまで待機させることも可能です。
結論
ほとんどの場合、自前のロックを作る必要はありません。最良のロックは「使わない」ものです。自前でスピンロックを実装する場合、以下のベストプラクティスに従ってください。
- 低い競合:クリティカルセクションが小さく、競合が稀なときのみ使用
- 正しいメモリオーダリング:取得/解放で acquire/release を適用
- OS に通知:
/futex
/WaitOnAddress
があれば利用std::atomic_wait - 偽共有を避ける:アライン/パディングで別キャッシュラインに配置
実際に失敗したプロジェクト例:
| プロジェクト | 問題点 |
|---|---|
| RPMalloc | コンソールでの livelock、単一 CPU の yield ループ |
| OpenBSD libc | OS スレッド yield を直接呼び出し |
| Glibc | デフォルトで futex を使用(ただしチューニング可) |
| Intel TBB | 16 回のシンプルバックオフと yield |
| WebKit | ハードコードされたスピンカウント + OS スレッド yield |
| AddressSanitizer | / を使用 |
| DotNet runtime | を使い、 ではない |
結論:自前のロックを実装するのは難しく、テスト済みのプラットフォームプリミティブを利用しましょう。