**回転している間に:**  
*スピンロックでよく起こる問題をお聞きください*

2026/01/29 1:48

**回転している間に:** *スピンロックでよく起こる問題をお聞きください*

RSS: https://news.ycombinator.com/rss

要約

日本語訳:

改訂要約

記事では、C++で手作業で実装したスピンロックがエラーを起こしやすく、パフォーマンスに負担がかかる理由について説明しています。単純な

int32_t
フラグは複数のスレッドが同時にロックを取得できてしまい、これを
std::atomic<int>
に置き換え、
exchange(1)
を使用すると競合は解消されますが、依然としてバイスイート(busy‑waiting)が発生し、電力消費・温度上昇・キャッシュ整合性トラフィックを引き起こします。

スピンループ内に

_mm_pause()
(または同等の命令)を挿入するとメモリ要求が抑制され、再試行ごとにパス数を倍増させる指数的バックオフはさらに競合を減らします。ただし最適なパス数はマイクロアーキテクチャによって異なるため、
PAUSE
のレイテンシは古い Intel/AMD チップで約10サイクルから Skylake では約140サイクルに及びます。したがってバックオフカウントは固定数のパスではなく CPU サイクル単位で表現すべきです。

適切なメモリ順序(ロック取得時には

acquire
、解放時には
release
)を使用すると、不必要な完全シーケンシャル整合性バリアが除去されます。カスタムスピンロックはまた、優先度逆転・ライブロック・偽共有(ロック変数が他のデータと同じキャッシュラインを共有する場合)や
std::atomic_wait
の実装差異に悩まされることがあります。

現代の C++ 標準では

std::atomic_wait
が提供されていますが、コンパイラサポートは異なります(MSVC は OS プリミティブを直接呼び出し、libc++ は従来バックオフと yield を使用)。これらの複雑さから、記事ではほとんどの場合においてカスタムスピンロックを書くよりも、OS が提供する同期プリミティブ(ミューテックス、futex、Windows の
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()
を呼び出すケースを考えます:

  1. スレッド A が
    isLocked == 0
    を確認 | スレッド B が
    isLocked == 0
    を確認
  2. 両方ともループから抜ける |
  3. スレッド A が
    isLocked
    に 1 を書き込む | スレッド B が同じく 1 を書き込む

結果として、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 libcOS スレッド yield を直接呼び出し
Glibcデフォルトで futex を使用(ただしチューニング可)
Intel TBB16 回のシンプルバックオフと yield
WebKitハードコードされたスピンカウント + OS スレッド yield
AddressSanitizer
sched_yield
/
Sleep(0)
を使用
DotNet runtime
Sleep
を使い、
WaitOnAddress
ではない

結論:自前のロックを実装するのは難しく、テスト済みのプラットフォームプリミティブを利用しましょう。

同じ日のほかのニュース

一覧に戻る →

2026/01/28 9:57

**トリニティ・ラージ** オープンな400 B スパースMoEモデル

## Japanese Translation: ``` (combining all key points with clarity):** --- ### Trinity‑Large: A Fast, Open, State‑of‑the‑Art Sparse MoE Language Model Trinity‑Large は、1 つのトークンで約 13 B パラメータ(256 エキスパート、1.56 % ルーティング分率)しか活性化しない 400 B パラメータを持つ sparse mixture‑of‑experts モデルです。10 T、4 T、3 T の三段階で **17 T** のキュレーション済みトークンを使用して訓練されました。プログラミング・STEM・推論・多言語コンテンツをカバーする合成データが用いられ、Momentum‑based エキスパートロードバランシング、1 シーケンスあたりのバランスロス、z‑loss 正則化で LM‑head ロジットを抑制し、効率的な注意機構(HSDP)と 8‑expert 並列処理が採用されました。 **リリースされたバリアント** | バリアント | 説明 | |---------|-------------| | **Trinity‑Large‑Preview** | 軽くポストトレーニングし、チャット対応。創造的執筆・物語作成・ロールプレイ・リアルタイム音声支援・エージェントタスク(OpenCode, Cline, Kilo Code)で優れた性能を発揮します。まだ推論モデルではありません。 | | **Trinity‑Large‑Base** | 完全な 17 T 事前訓練チェックポイント。ベンチマークと研究資源として使用されます。 | | **TrueBase** | 初期の 10 T チェックポイントで、指示データや LR アニーリングが含まれていません。大規模な高品質事前訓練効果を研究するのに最適です。 | 全体の作業―6か月間にわたる4つのモデル―は約 **2,000 万ドル** の費用で、**2048 台の Nvidia B300 GPU** を使用し、**33 日間** にわたって訓練されました。 **性能** - 数学・コーディング・科学的推論・原知識ベンチマークにおいて同等またはそれ以上の性能を示します。 - 推論速度は、同じハードウェア上で比較可能な重みクラスモデルより約 2–3 倍速です。 - ベンチマーク比較(Preview vs. Llama 4 Maverick): - MMLU: 87.2 vs. 85.5 - MMLU‑Pro: 75.2 vs. 80.5 - GPQA‑Diamond: 63.3 vs. 69.8 - AIME 2025: 24.0 vs. 19.3 **技術的詳細** - ネイティブコンテキスト長:**512k トークン**。Preview API はインフラ調整中に 128k と 8‑bit 量子化で動作します。 - モデルと API は Hugging Face、OpenRouter、および Arcee.ai を通じて公開されており、Kilo Code、Cline、OpenCode 用の統合がすぐに利用可能です。 **コミュニティへの関与** チームは Trinity‑Large が最先端レベルでありながら所有権と実際の使用を念頭に置いて設計されていることを強調し、ユーザーに失敗例を報告してもらうことでオープンモデルが継続的に改善できるよう奨励しています。 ```

2026/01/28 9:18

「有名な研究者が、赤ちゃんの中毒事件を隠したのでしょうか?」

2026/01/28 23:32

エアフォイル(2024)