ロック機構とメモリ順序

このページの目次

カウンタの更新が正しくても、関連オブジェクトがまだ生存していることを意味しません。スナップショットで再試行できたとしても、不正なアクセスを取り消せるわけではありません。同期では、排他制御、順序、寿命のすべてを同時に説明する必要があります。

参照目標読書位置
ロックとコンテキスト第 1~4 節
一貫性のあるスナップショットとアトミック操作第 5~6 節
公開、待機、終了第 7~8 節

同期の目的は、2つのCPUが同じアドレスに同時に書き込むのを防ぐだけでなく、複数フィールドの不変条件の維持、オブジェクト寿命の制限、データの公開順序の保証、および同一CPU上での割り込み再帰の回避も含みます。ロックを選択する前に、すべての読み書き実行コンテキストを列挙すべきです。

例えば、キューは head、tail、count で構成されています。count を atomic に変更しても、ノードのリンクは保護されません。読者は count が更新される前に新しいノードを見るか、ノードが解放された後もアクセスを続ける可能性があります。まず、「いつキューが読者に見えるか、誰が削除できるか、誰が解放を責任を持って行うか」を明確に記述します。

アクセスコンテキストをまず列挙し、その後で排他制御とローカルマスクを決定する タスクコンテキスト スリープ可能か? softirq / hardirq 現在のロック保持者を中断できるか? 他のCPU 同じ状態を同時に読み書きしているか? 共通の同期プロトコル ロック順序、オブジェクト寿命、可視性、終了時のクリーンアップ ローカル割り込みを無効化しても、他のCPUのアクセスはロックされません。単一のアトミックフィールドも、オブジェクト全体を自動的に保護するわけではありません。

1. 制約に基づいて選択し、いわゆる「重さ」で並べ替えない

要件一般的な選択見逃せない条件
スリープ可能なタスク間での複合状態の保護mutex同一の所有者が解放する必要がある。再帰はサポートされない。
真のアトミック処理や割り込みコンテキスト内の短いクリティカルセクション適切な spinlock / raw spinlockRTセマンティクスと割り込み再帰を確認すること。
複数の読者が安定した共有状態を必要とする場合rw_semaphore保持中はブロックされる可能性があり、書き込みを遅らせることもある。
少量のスカラースナップショットの再読み取りが可能seqcount / seqlock書き込みは直列化され、読者は再試行可能。ポインタの寿命に関する脆弱性はない。
読み込みパスで低オーバーヘッドに置換可能なオブジェクトにアクセスRCU書き込み側の排他制御と遅延回収を別途処理する必要がある。
独立したアトミック状態またはカウンタatomic / refcount などアトミック性、順序、オブジェクト寿命はそれぞれ設計する。
操作の終了または条件の変化を待機completion / wait queueこれらは共有データを保護するロックに取って代わるものではない。

読み込みが多く書き込みが少ないことは統計的な特徴に過ぎず、RCUやseqlockの使用を自動的に決定するものではありません。読者が最新値を必ず見る必要があるか、スリープ可能か、再試行を許容するか、クリティカルセクションをまたいでオブジェクトを保持する必要があるかなどによって、選択は変わります。

2. spinlock とローカルマスクはそれぞれ何を行うか

PREEMPT_RT ではないカーネルでは、spin_lock() 自体が対応するクリティカルセクションのセマンティクスを維持するためにプリエンプションを無効化しますが、呼び出し元に事前にプリエンプションを無効化するよう要求はしません。ただし、ローカルの hardirq を自動的にマスクするわけではありません。

タスクがロック L を保持しており、その後、このCPUの割り込みハンドラも L を取得すると仮定します。ハンドラはタスクが解放するまでスピニング待ちをし、タスクはハンドラが戻る前に実行できないため、デッドロック(自鎖)が発生します。このような再帰を避けるために、適切なアクセス側で irqsave などのプロトコルを使用する必要があります。

  • spin_lock_irqsave() はローカル割り込みの状態を保存し、割り込みをマスクしてロックを取得し、irqrestore で元の状態を復元します。
  • _irq() / unlock_irq は元の状態を保存しないため、呼び出し元はそのエントリ状態の規約を満たす必要があります。
  • _bh() はローカルの bottom-half / softirq の競合を処理しますが、hardirq や NMI もマスクされているとみなしてはいけません。

ローカル割り込みを無効化しても、他のCPUが共有データにアクセスするのを防ぐことはできません。真のCPU間排他制御はロックによってもたらされます。NMI は通常の割り込みマスクの影響を受けず、NMI でも使用されるデータは別途設計する必要があります。

PREEMPT_RT は spinlock_t の実装とセマンティクスを変更する

RTカーネルにおける通常の spinlock_t は、ブロック可能なリアルタイムロックメカニズムに基づいており、取得後にプリエンプションが無効化されるとは限りません。その hardirq サフィックスも、非RT環境におけるローカルハード割り込みマスクとは等価ではありません。raw_spinlock_t は、厳格なスピニングと対応するアトミックセマンティクスを保持し、本当に必要な低レベルのシナリオで使用されます。

これはすべてのロックを raw spinlock に置き換えることを推奨するものではありません。長すぎる非プリエンプティブな領域はリアルタイム性を損ない、raw ロックを保持している間は、ブロックする可能性のある通常の RT spinlock の取得パスを呼び出すことはできません。型とネストのルールについてはロックタイプのドキュメントを参照してください。

3. qspinlock と mutex の競合パス

qspinlock は、コンパクトなロック状態とキュー待機を組み合わせ、無競合、pending、キューパスを含みます。競合者は MCS スタイルのノードを通じて、すべてのCPUが同じロックワードを継続的に争うオーバーヘッドを軽減できます。それは「すべての競合者が常に自分のキャッシュラインだけを触る」わけではなく、解放、キューの先頭と末尾の引き継ぎには共有状態が必要です。

実装のビットレイアウトは設定にも依存するため、include/asm-generic/qspinlock_types.h を読み、locked / pending / tail を適当にバイト単位で固定 ABI として描画してはいけません。アーキテクチャや仮想化でも異なる実装を採用する可能性があります。

mutex の無競合パスは高速に所有権を取得し、競合時には適切な条件下で楽観的スピニングまたはスリープ待機を行います。待機者を唤醒しても、即座にCPU上で実行されるとは限りません。公平性、handoff、owner 状態の処理を、単なる owner=0 の代入に簡略化することはできません。

mutex を保持している間は、許可されたコンテキストでスリープできますが、スリープパスが逆に同じロックを必要としないか考慮する必要があります。mutex_lock_interruptible() などのインターフェースは、ロックを取得できなかった結果を返す可能性があり、呼び出し元はチェックする必要があります。通常の mutex は、優先度継承を提供する rt_mutex とは等価ではありません。mutex の設計を参照してください。

4. rwsem と percpu rwsem は異なるインターフェース

rw_semaphore は複数の読者または1人の書き込みを許可します。percpu_rw_semaphore は専用のインターフェースであり、per-CPU の読側状態と対応する同期メカニズムを利用して、読み込みが多く書き込みが少ない特定のシナリオを最適化します。通常の rwsem があるバージョンに達すると自動的にすべてが per-CPU になるわけではありません。

読側ロックでも書き込みロックを待つことがあります。1人の読者が長時間スリープすると、書き込みをブロックします。RT 環境では、複数の読者と優先度継承の関係に特に注意が必要であり、任意の設定で厳格な待機遅延の上限があることを約束できません。

読側ロックから直接書き込みロックへのアップグレードを試みると、他のアップグレード試行者と相互に待つ可能性があります。インターフェースのサポート状況に応じて、解放後に条件を再確認するか、最初から書き込みロックを取得するか、または専用の変換プロトコルを設計してください。

5. Seqlock: 一貫性のあるスナップショットを取得するが、再試行の代償がある

書き込み側はシーケンスを進めて書き込み状態に入り、終了時に再度シーケンスを進めます。読者はデータをコピーした後、シーケンスが変化していないか確認します。write_seqlock() / unlock はシーケンスを管理しているため、呼び出し元はその間で手動で seq++ を行ってはいけません。

以下は、モジュール登録を含まない独立してコンパイル可能なカーネル C 翻訳単位の例です。2つのスカラーの不変条件 high = low + 1 を示しており、読み取り結果は1回の成功した検証後にのみ使用されます。

#include <linux/compiler.h>
#include <linux/seqlock.h>
#include <linux/types.h>

static DEFINE_SEQLOCK(range_lock);
static u32 range_low;
static u32 range_high = 1;

void kb_set_range(u32 low);
void kb_get_range(u32 *low, *high);

void kb_set_range(u32 low)
{
    write_seqlock(&range_lock);
    WRITE_ONCE(range_low, low);
    WRITE_ONCE(range_high, low + 1); /* u32 のオーバーフローはこの例では許容される動作 */
    write_sequnlock(&range_lock);
}

void kb_get_range(u32 *low, u32 *high)
{
    unsigned int seq;
    u32 a, b;
    do {
        seq = read_seqbegin(&range_lock);
        a = READ_ONCE(range_low);
        b = READ_ONCE(range_high);
    } while (read_seqretry(&range_lock, seq));
    *low = a;
    *high = b;
}
読者の動作一時結果呼び出し元に渡せるか
まず古い low を読み取るa = 0不可、スナップショットはまだ検証されていない
書き込み側が (10, 11) に更新し、読者が high を再読み取り(a, b) = (0, 11)不可、組み合わせが high = low + 1 を破壊する
シーケンス検証で更新を検出一時組み合わせを破棄して再読み取り検証が成功した場合のみ使用可能

再試行中は、一時値に基づいて取り消し不可能な外部副作用を実行してはいけません。

この例では、アクセスコンテキストが通常の seqlock と一致すると仮定しています。もし割り込み読者が書き込み側を中断できる場合、書き込み側は対応するローカルマスクを行う必要があり、そうでなければ読者は自分自身によって中断された書き込み側の復帰を待つことになります。純粋な seqcount 自体は複数の書き込み側を直列化しないため、別の書き込み側ロックが必要であり、非プリエンプティブなどのルールを満たす必要があります。

書き込み側の更新中に2つのフィールドを別々に読み取ると、意図的に混合されたバージョンを組み立てることができます。シーケンス検証は、この一時結果の使用可否を決定し、すでに2つの値を読み取ったからといって一貫性のあるスナップショットと見なしてはいけません。

図を準備しています
組み立てられた2つの値を、同一のスナップショットとして扱えるか?

一時読み取り値は不一致になる可能性があります。シーケンスが変化せず、かつ偶数である場合のみ結果を使用します。再試行は一貫性のあるスナップショットを保護しますが、不正なポインタアクセスを取り消すものではなく、再現実行不可能な副作用を許可するものでもありません。

スナップショットの一貫性とポインタ寿命は別々に保証する

再試行は、すでに発生した不正なアクセスを修復できません。 読者がスナップショット内のポインタを通じて解放されたオブジェクトにアクセスし、その後シーケンスが変化したことに気づいたとしても、それは依然として UAF(Use-After-Free)です。ポインタ寿命は、RCU、参照カウント、または他のメカニズムによって保護されなければなりません。読側は、再試行が発生する可能性のある領域で再現実行不可能な外部副作用を行ってはなりません。seqcount/seqlock を参照してください。

6. アトミック操作、refcount と順序

atomic_read/set、戻り値なしの RMW、戻り値ありの RMW、および acquire/release/relaxed 変体の順序保証は異なり、すべての atomic 操作をフルバリアと見なしてはいけません。失敗する条件付き操作についても、戻り値と順序セマンティクスを別途確認する必要があります。

オブジェクト参照は、オーバーフロー/アンダーフロー保護と特定の寿命セマンティクスを利用する、適切な refcount_t などのインターフェースを優先して使用すべきです。一般的なカウンタは、「ゼロからの復活」や、すでに解放される可能性のあるオブジェクトの安全な取得を防ぐものではありません。

CAS は状態をアトミックに比較・置換できますが、ABA を自動的に解消するわけではありません。ある値が A→B→A を経た後でも、比較は成功する可能性があります。ロックフリー構造では、バージョン管理、回収プロトコル、厳格な順序設計も必要になることがよくあります。atomic 型 を参照してください。

7. データの公開には対応する観察関係が必要

1回だけ公開され、公開後にペイロードを変更しない例:書き込み側はまずペイロードを埋め、次に smp_store_release(&ready, 1) を実行します。読者は smp_load_acquire(&ready) を通じてこの公開値を観察した後、初めてペイロードを読み取ります。

release/acquire の効果は、対応する公開と観察の組み合わせによるもので、任意の2つのCPUがそれぞれ1回ずつバリアを実行しただけでは自動的に同期されません。ready が再利用され、ペイロードが循環して上書きされる場合、旧読者と新書き込み側の競合を防ぐための追加プロトコルが必要です。

ツール制約されるレベル
READ_ONCE / WRITE_ONCE単一アクセスに関連するコンパイラ動作
barrier()コンパイル順序
smp_* バリア対応するCPU間メモリ順序付け
デバイスアクセサと dma_* バリアMMIO/DMA の対応するデバイスアクセスプロトコル

これらの操作は統一された「キャッシュフラッシュ」ではなく、単一アクセスの制約だけでも完全な公開関係を独立して確立するものではありません。メモリバリアドキュメント を参照してください。

以下は、読者がすでに ready=1 を読み取った状態を固定し、公開と取得のペアリングのみを変更します。どの結果が依然として許可されるかを観察し、結果の集合から順序制約を理解します。ソースコードの上下の位置関係をそのまま CPU 間の先後として扱わないでください。

図を準備しています
ready=1 を見たのに、なぜ対応する公開関係が必要なのか?

読者が acquire を通じて対応する release 公開値を観察した場合のみ、対応する関係を通じて、それ以前のペイロード書き込みがその後の読み取りに与える制約を推論できます。READ_ONCE/WRITE_ONCE 自体はこの完全な関係を作成しません。

8. 待機は条件と寿命と組み合わせて行う必要がある

wait queue は、wait_event などのインターフェースを使用して、正しいプロトコル下で待機を登録し条件をチェックします。通知側はまず条件を更新し、その後通知を行います。単に if (!ready) schedule() と書くだけでは、チェックとスリープの間に通知を見逃してしまいます。

completion は、1回または制御された繰り返しの完了イベントに適しています。タイムアウトの返却は非同期操作をキャンセルするものではなく、待機オブジェクトおよびコールバックコンテキストは依然として有効であり続ける必要があります。リソースを解放する際は、まず新しいサブミットを阻止し、次に同期的にキャンセル/待機を行い、最後にリソースを解放します。また、待機中は完了パスに必要なロックを保持してはいけません。

これらの制約をどのように検証するかについては、同期デバッグ を参照してください。