Instant Engineering

エンジニアの仕事効率を上げる知識をシェアするWeb記事/機械設計/TPS/QC品質管理

排他制御とは?デッドロック回避とセマフォ・ミューテックスの仕組みを解説

複数のタスクが同時に同じメモリやファイルへアクセスしたとき、データは本当に正しく保たれるのでしょうか。

たとえば 2 つのタスクがカウンターに同時に 1 を加算すると、結果が 1 しか増えない「競合状態」が起こり得ます。

このような不整合を防ぐ仕組みが排他制御です。

排他制御はデータベースのトランザクション処理だけでなく、組込みリアルタイムシステムのタスク管理にも欠かせない基盤技術です。

しかし排他制御を誤ると、今度は処理が永久に止まる「デッドロック」という別の問題が発生します。

本記事では、排他制御の基本概念からセマフォ・ミューテックスの仕組み、デッドロックの原因と回避策までを体系的に解説します。

組込みシステムやデータベース設計に携わるエンジニアの方は、ぜひ最後までご覧ください。

 

1. 排他制御とは

排他制御の基本概念と共有資源アクセスの模式図

排他制御とは、複数のタスクやプロセスが共有資源に同時アクセスする際に、データの整合性を保つための仕組みです。

英語では Mutual Exclusion(相互排除)と呼ばれ、略して Mutex とも表記されます。

 

共有資源とは、メモリ上の変数、ファイル、データベースのレコードなど、複数のタスクから読み書きされ得るあらゆる対象を指します。

マルチタスク環境では、OS のスケジューラがタスクの実行タイミングを制御するため、どのタスクがいつ共有資源にアクセスするかは予測できません。

 

この不確定性がデータ不整合の原因となります。

そこで、ある瞬間に共有資源へアクセスできるタスクを 1 つだけに限定する制御を行います。

これが排他制御の基本的な考え方です。

 

クリティカルセクション

排他制御の対象となるコードの区間をクリティカルセクション(臨界領域)と呼びます。

クリティカルセクションとは、共有資源に対する読み書き処理を含む一連のコード区間のことです。

 

たとえば、共有変数を読み取って計算し、結果を書き戻す一連の処理がクリティカルセクションに該当します。

この区間に複数のタスクが同時に入ると、データの不整合が発生する危険があります。

 

排他制御の 3 つの要件

正しい排他制御は、次の 3 つの要件を同時に満たす必要があります。

 

  • 相互排除:あるタスクがクリティカルセクションを実行中のとき、他のタスクは同じ区間に入れません。これが排他制御の最も基本的な性質です
  • 進行:クリティカルセクションに入っているタスクがいないとき、入りたいタスクは有限時間内に許可されます。永遠に待たされ続けることがあってはなりません
  • 有限待ち:あるタスクが入口で待ち始めてから実際に入れるまでの待ち回数に上限があります。特定のタスクだけが延々と待たされる「飢餓(スタベーション)」を防ぐ要件です

 

ハードウェアによる原子操作の支援

排他制御を実現するには、「条件判定と値の変更」を不可分に実行する仕組みが必要です。

現代の CPU はこの目的のために、ハードウェアレベルの原子命令を提供しています。

 

代表的なものが Test-and-Set(TAS)命令です。

TAS 命令はロック変数の値を読み取り、同時に 1(ロック済み)に書き換える操作を 1 命令で実行します。

 

 \text{TAS}(L) : \; \text{old} \leftarrow L, \; L \leftarrow 1, \; \text{return old}

 

戻り値が 0 であればロック取得に成功、1 であれば既に他タスクがロック中であることを意味します。

 

もう 1 つの重要な原子命令が Compare-and-Swap(CAS)です。

CAS は「期待値と現在値が一致していれば新しい値を書き込む」操作を不可分に行います。

 

 \text{CAS}(L, \text{expected}, \text{new}) : \; L = \text{expected} \; \text{ならば} \; L \leftarrow \text{new}

 

CAS は楽観的な排他制御の基盤技術でもあり、ロックフリーデータ構造の実装にも利用されています。

Java の java.util.concurrent パッケージや C++ の std::atomic も、内部では CAS 命令を活用しています。

3 つの要件が 1 つでも崩れると、データ不整合やタスクの無限待ちが発生し、システム全体の信頼性が損なわれます。

排他制御の設計では、相互排除だけでなく進行性と有限待ちも必ず意識する必要があります。

2. 排他制御が必要になる場面

競合状態(レースコンディション)の発生例

排他制御の必要性を実感するために、「競合状態(レースコンディション)」の具体例を見てみましょう。

競合状態とは、複数のタスクの実行順序によって結果が変わってしまう状態のことです。

 

カウンター加算の競合

共有変数の初期値が 5 であるとします。

タスク A とタスク B がそれぞれ 1 回ずつ加算を行えば、期待される最終値は 7 です。

 

しかし排他制御がないと、以下のような実行順序が起こり得ます。

 

 \text{タスクA:読み取り} \; x = 5

 

 \text{タスクB:読み取り} \; x = 5

 

 \text{タスクA:書き込み} \; x = 5 + 1 = 6

 

 \text{タスクB:書き込み} \; x = 5 + 1 = 6

 

両方のタスクが古い値 5 を読み取ってから加算するため、最終値は 6 にしかなりません。

加算が 1 回分消えてしまうこの現象が競合状態です。

 

読み取り→更新→書き込みの非原子性

上の例で問題となったのは、変数への加算が「読み取り → 更新 → 書き込み」という 3 ステップの操作であった点です。

この 3 ステップは CPU 上では複数の命令に分かれて実行されるため、途中で別タスクが割り込む余地があります。

 

このように分割不可能でない操作を「非原子的(Non-atomic)」操作と呼びます。

非原子的操作の途中で他タスクが同じ変数にアクセスすると、一貫性のない中間状態を読み取ってしまいます。

 

さまざまな場面での競合

競合状態はメモリ上の変数だけでなく、あらゆる共有資源で起こり得ます。

 

データベースでは、2 つのトランザクションが同一レコードを同時に更新するケースが典型的です。

たとえば口座残高の読み取りと出金処理が並行して走ると、残高不足のまま出金が成功してしまう危険があります。

 

組込みシステムでは、メインループと割り込みハンドラが同じグローバル変数を書き換えるケースがよく見られます。

割り込みはいつ発生するか予測できないため、メインループ内の処理のどのタイミングでも中断される可能性があります。

 

ファイルシステムでも同様の問題が起こります。

2 つのプロセスが同一ファイルに同時に書き込むと、データの一部が欠落したり混在したりする恐れがあります。

 

組込みシステムにおけるタイミング依存バグ

組込みシステムでの競合状態は、再現が極めて困難な「タイミング依存バグ」を引き起こします。

たとえば、センサー読み取りタスクがグローバル変数に温度データを書き込み、表示タスクが同じ変数を読み取るケースを考えます。

 

表示タスクが温度データの上位バイトを読み取った直後に、センサータスクが全バイトを更新してしまうと、上位バイトは古い値、下位バイトは新しい値という不整合なデータを表示してしまいます。

このような「ワードティアリング(Word Tearing)」は、32 ビットや 64 ビットのデータを 8 ビットや 16 ビットの CPU で扱う際に特に起きやすい問題です。

 

タイミング依存バグはテスト環境では再現しにくく、本番環境で負荷がかかったときに初めて顕在化することが多いため、設計段階での排他制御の組み込みが不可欠です。

いずれの場面でも、クリティカルセクションを排他制御で保護することが不整合防止の基本手段となります。

3. 共有ロックと排他ロックの違い

共有ロック(S)と排他ロック(X)の互換性マトリクス

排他制御の代表的な実装方式が「ロック」です。

ロックとは、共有資源へのアクセス権を取得・解放する仕組みであり、取得したタスクだけが資源を操作できます。

 

ロックには大きく分けて共有ロック排他ロックの 2 種類があります。

 

共有ロック(S ロック / Read Lock)

共有ロックは、データを読み取るときに取得するロックです。

複数のタスクが同時に共有ロックを取得できるため、読み取り同士は並行して実行されます。

 

ただし、共有ロックが掛かっている間は書き込み(排他ロック)が許可されません。

「読んでいる最中に書き換えられる」事態を防ぐためです。

 

排他ロック(X ロック / Write Lock)

排他ロックは、データを書き込むときに取得するロックです。

排他ロックを取得したタスクだけがデータに触れるため、他のすべてのロック要求(共有・排他の両方)がブロックされます。

書き込み操作は完全に直列化されるため、データの一貫性が確実に保たれます。

 

ロック互換性マトリクス

2 つのロックの組み合わせによる互換性は、次のように整理できます。

 

要求 \ 保持 共有ロック 排他ロック
共有ロック 互換(同時取得可) 競合(待機)
排他ロック 競合(待機) 競合(待機)

 

つまり、読み取り同士は並行可能ですが、書き込みが絡むと必ず直列化されます。

この仕組みにより、読み取り性能を維持しながら書き込みの安全性を確保できます。

 

ロックとデッドロックの関係

ロックは排他制御の基本ですが、複数のロックを異なる順序で取得すると、タスク同士が互いのロック解放を待ち合う「デッドロック」が発生する危険があります。

特に排他ロックは他のすべてのロックと競合するため、デッドロックの直接的な原因になりやすい傾向があります。

 

デッドロックの詳細については後述しますが、ロックの設計時にはデッドロックの可能性を常に念頭に置く必要があります。

「どの順序でロックを取得するか」を設計段階で明確に定義しておくことが、トラブル防止の鍵となります。

ロックの粒度

ロックの対象範囲を「粒度(Granularity)」と呼びます。

データベースでは、テーブル全体をロックする「テーブルロック」と、特定の行だけをロックする「行ロック」が代表的です。

 

粒度が粗い(テーブルロック)とロック管理は簡単ですが、並行性が低下します。

粒度が細かい(行ロック)と並行性は向上しますが、ロックの管理コストが増大します。

システムの特性に応じた粒度の選択が、性能チューニングの重要なポイントです。

 

2 相ロッキングプロトコル(2PL)

データベースでは「2 相ロッキング(Two-Phase Locking)」というプロトコルが広く使われます。

これは、トランザクション中のロック取得を「成長相(Growing Phase)」と「縮退相(Shrinking Phase)」の 2 段階に分けるルールです。

 

成長相ではロックの取得のみを行い、一度でもロックを解放したら以降は新たなロックを取得しません。

この制約を守ることで、トランザクションの直列化可能性(Serializability)が数学的に保証されます。

 

2PL は多くの商用 DBMS(Oracle、PostgreSQL、MySQL InnoDB など)で採用されている標準的なプロトコルです。

4. セマフォの仕組み

セマフォのP操作とV操作のフロー図

セマフォ(Semaphore)は、1965 年にオランダの計算機科学者ダイクストラ(E. W. Dijkstra)が考案した排他制御の基本メカニズムです。

名称の由来は鉄道の腕木式信号機(セマフォ信号)で、「通過してよいか」を制御する仕組みに着想を得ています。

 

セマフォは内部に非負整数のカウンター S を持ち、2 つの不可分操作で制御を行います。

不可分(Atomic)とは、操作の途中で他タスクに割り込まれないことを意味します。

 

P 操作(wait / down)

タスクがクリティカルセクションに入る前に実行する操作です。

P はオランダ語の Proberen(試す)に由来します。

 

 P(S) : \; S \geq 1 \; \text{ならば} \; S \leftarrow S - 1, \; \text{さもなくば待機}

 

カウンターが 1 以上であれば 1 を減算してタスクの処理を進めます。

カウンターが 0 の場合は、別のタスクが V 操作でカウンターを増やすまで待機状態に入ります。

この「条件判定と減算」は不可分に実行されるため、複数タスクが同時に P 操作を行っても二重に通過することはありません。

 

V 操作(signal / up)

タスクがクリティカルセクションを出た後に実行する操作です。

V はオランダ語の Verhogen(増やす)に由来します。

 

 V(S) : \; S \leftarrow S + 1

 

カウンターを 1 増やし、P 操作で待機中のタスクがあれば 1 つを起床させます。

V 操作も不可分に実行されるため、カウンターの値が不整合を起こすことはありません。

 

カウンティングセマフォ

セマフォの初期値を  S_0 = n(n は 2 以上の整数)に設定するとカウンティングセマフォになります。

最大 n 個のタスクが同時にアクセスできるため、数に限りのある共有資源の管理に適しています。

 

たとえばデータベース接続プールが 5 つある場合、初期値を 5 に設定します。

タスクが接続を取得するたびに P 操作でカウンターが減り、5 タスクが使い切るとカウンターは 0 になります。

6 つ目以降のタスクは、いずれかが接続を返却(V 操作)するまで待機します。

 

バイナリセマフォ

初期値を  S_0 = 1 とすればバイナリセマフォです。

カウンターは 0 と 1 の間で切り替わるだけなので、同時にクリティカルセクションへ入れるタスクは常に 1 つだけです。

これが最もシンプルな排他制御であり、後述するミューテックスの原型にもなっています。

 

生産者-消費者問題

セマフォの典型的な応用例として「生産者-消費者問題(Producer-Consumer Problem)」があります。

これは、データを生成するタスク(生産者)とデータを消費するタスク(消費者)が、有限サイズのバッファを介してデータをやり取りする問題です。

 

この問題では 2 つのセマフォを組み合わせて解決します。

 

「空きスロット数」を表すセマフォの初期値をバッファサイズ N に設定します。

 

 S_{\text{empty}} = N, \quad S_{\text{full}} = 0

 

生産者はデータを書き込む前に空きスロットの P 操作を行い、書き込み後に使用中スロットの V 操作を行います。

消費者はその逆で、使用中スロットの P 操作でデータの到着を待ち、読み取り後に空きスロットの V 操作で空きを通知します。

 

この仕組みにより、バッファが満杯のときは生産者が自動的に待機し、バッファが空のときは消費者が自動的に待機します。

OS のパイプ処理やメッセージキューの内部実装にもこのパターンが使われています。

セマフォは OS カーネルやリアルタイム OS が提供する基本機能として、あらゆるマルチタスクシステムで利用されています。

 

関連記事

instant.engineer

5. ミューテックスとセマフォの違い

ミューテックスとセマフォの比較表

バイナリセマフォとミューテックスはどちらもカウンターが 0 と 1 の 2 値で動作するため、一見すると同じ仕組みに見えます。

しかし両者には「所有権」と「優先度継承」という決定的な違いがあります。

 

所有権の有無

ミューテックスには「所有権」の概念があります。

ロックを取得したタスクだけがロックを解放できるため、別のタスクが勝手にロックを解除してしまう事故を防げます。

 

たとえば、タスク A がミューテックスを取得した場合、タスク B がそのミューテックスを解放しようとしてもエラーとなります。

これにより、プログラムのバグによる予期しないロック解除を検出できます。

 

一方、セマフォには所有権がありません。

P 操作と V 操作は別々のタスクが実行してもよいため、タスク間のシグナリング(同期通知)にも使えます。

たとえば「データ準備完了」をタスク A が V 操作で通知し、タスク B が P 操作で待ち受けるといった使い方が可能です。

 

優先度継承

多くのリアルタイム OS のミューテックス実装には「優先度継承」機能が組み込まれています。

これは後述する「優先度逆転」を防ぐための仕組みで、セマフォには通常備わっていません。

リアルタイム性が求められるシステムでは、この違いが実装方式の選択に大きく影響します。

 

再帰ロック(リカーシブミューテックス)

ミューテックスの中には「再帰ロック」をサポートする実装もあります。

これは、同一タスクが同じミューテックスを複数回取得できる機能です。

 

再帰的な関数呼び出しの中で同じ共有資源にアクセスする場合に便利ですが、取得回数と同じ回数だけ解放しないとロックが残り続けるため、使い方には注意が必要です。

 

使い分けの指針

両者の違いを表にまとめます。

 

観点 ミューテックス セマフォ
カウンター値 0 または 1 のみ 0 以上の任意の整数
所有権 あり(ロック者のみ解放可) なし(誰でも V 操作可)
優先度継承 対応(RTOS 実装) 通常なし
再帰ロック 対応(実装による) 非対応
主な用途 クリティカルセクションの保護 資源プール管理・タスク間同期

 

スピンロック

ミューテックスやセマフォの待機中、タスクを「スリープ状態」にして CPU を明け渡す方式が一般的です。

しかし、クリティカルセクションが非常に短い場合、タスクの切り替えコスト(コンテキストスイッチ)の方がロック待ち時間より大きくなることがあります。

 

この問題に対応するのがスピンロック(Spinlock)です。

スピンロックでは、ロックが取得できるまでタスクがループ(ビジーウェイト)で待機し続けます。

 

 \text{while} \; \text{TAS}(L) = 1 \; \text{do nothing}

 

CPU を専有し続けるため長時間の待機には不向きですが、数マイクロ秒以内に解放されるロックに対しては、コンテキストスイッチを回避できる分だけ高速です。

マルチコア CPU の OS カーネル内部やデバイスドライバで広く使われています。

排他制御が目的であればミューテックスを選び、リソース数の管理やタスク間のイベント通知が目的であればセマフォを選ぶのが基本方針です。

組込みシステムの開発現場では、RTOS の API ドキュメントを確認して各プリミティブの特性を把握しておくことが重要です。

6. デッドロックとは

デッドロック発生の循環待機モデル

排他制御を導入すると、新たに発生し得る深刻な問題がデッドロック(Deadlock / 死鎖)です。

デッドロックとは、複数のタスクが互いにロックの解放を待ち合い、どちらも先に進めなくなる状態を指します。

 

デッドロックの発生例

2 つのタスク A と B、2 つの資源 R1 と R2 があるとします。

 

タスク A がまず R1 をロックし、次に R2 のロックを要求します。

同時にタスク B がまず R2 をロックし、次に R1 のロックを要求します。

 

この結果、次のような循環待機が発生します。

 

 \text{A} \xrightarrow{\text{待機}} R_2 \xleftarrow{\text{保持}} \text{B} \xrightarrow{\text{待機}} R_1 \xleftarrow{\text{保持}} \text{A}

 

タスク A はタスク B が R2 を解放するのを待ち、タスク B はタスク A が R1 を解放するのを待ちます。

どちらも相手の完了を前提としているため、永久に先に進めません。

外部からの介入(タイムアウトや強制終了)がなければ、システムはこの状態で停止し続けます。

 

デッドロックの 4 つの必要条件(コフマン条件)

1971 年にコフマン(E. G. Coffman, Jr.)らが示した有名な定理によると、デッドロックは次の 4 条件がすべて同時に成立したときに限り発生します。

 

  • 相互排除(Mutual Exclusion):資源は一度に 1 タスクだけが使用できます。共有可能な資源(読み取り専用データなど)ではデッドロックは起きません
  • 保持と待機(Hold and Wait):あるタスクが 1 つ以上の資源を保持しながら、さらに別の資源の取得を待っています
  • 非横取り(No Preemption):保持中の資源を他タスクから強制的に奪うことができません。資源は保持者が自発的に解放するまで奪えないという制約です
  • 循環待機(Circular Wait):タスクと資源の間に循環的な待ち関係が成立しています。A が B を待ち、B が C を待ち、C が A を待つような環状の依存関係です

 

逆に言えば、4 条件のうち 1 つでも成立を阻止できればデッドロックは発生しません。

この考え方がデッドロック回避策の理論的根拠となります。

 

食事する哲学者問題

デッドロックを理解するための古典的な思考実験として「食事する哲学者問題(Dining Philosophers Problem)」があります。

これもダイクストラが 1965 年に提唱した問題です。

 

5 人の哲学者が円卓に座り、隣り合う 2 人の間にフォークが 1 本ずつ置かれています。

食事をするには左右両方のフォークが必要ですが、フォークは 1 本ずつしか取れません。

 

全員が同時に左のフォークを取ると、全員が右のフォークを待つ状態になり、誰も食事できないデッドロックが発生します。

この問題は、有限の共有資源(フォーク)を複数のタスク(哲学者)が奪い合う状況を抽象化したものです。

 

解決策としては、前述のロック順序の統一(番号の小さいフォークから取る)やセマフォによる同時着席者数の制限などが知られています。

リソース割当グラフ

デッドロックの分析には「リソース割当グラフ(Resource Allocation Graph)」が使われます。

タスクを丸、資源を四角で表し、保持関係と要求関係を矢印で結んだ有向グラフです。

 

このグラフにサイクル(循環)が存在すれば、デッドロックが発生している可能性があります。

各資源のインスタンスが 1 つだけの場合、サイクルの存在はデッドロック発生の必要十分条件となります。

 

関連記事

instant.engineer

7. デッドロックの回避策

デッドロック対策4分類(予防・回避・検出・回復)のフロー

デッドロックへの対処法は、大きく「予防」「回避」「検出と回復」の 3 段階に分類されます。

実務では、これらの手法を組み合わせて多重防御を行うのが一般的です。

 

予防(Prevention):コフマン条件を破る

4 つの必要条件のうち少なくとも 1 つを設計段階で構造的に排除する方法です。

 

実務で最も効果的なのは「循環待機」の排除です。

全資源に一意の番号を付与し、すべてのタスクが番号の昇順でのみロックを取得するルールを徹底します。

 

 R_1 < R_2 < \cdots < R_m \; \text{の順にのみロック取得を許可}

 

この順序を守る限り、循環待機は構造的に発生しません。

データベース設計では「テーブルをアルファベット順にロックする」などの運用ルールとして適用されます。

 

「保持と待機」を排除する方法としては、必要な資源をすべて一度に取得するアプローチがあります。

ただしこの方法は資源の利用効率が低下するため、実用的でない場合も多くあります。

 

回避(Avoidance):安全状態を維持する

資源割当て時に「この割当てを許可してもデッドロックに陥らないか」を動的に判定する方法です。

代表的なアルゴリズムがダイクストラの銀行家のアルゴリズム(Banker's Algorithm)です。

 

銀行家のアルゴリズムでは、各タスクの最大資源要求量を事前に宣言させます。

資源要求があるたびに、仮に割り当てた場合に「安全列」が存在するかを検査します。

安全列とは、すべてのタスクが完了できる実行順序のことです。

 

安全列が見つかれば割当てを許可し、見つからなければ要求を保留します。

安全列の存在判定は次の条件で行われます。

 

 \text{利用可能量} \geq \text{最大要求量} - \text{現在割当量}

 

ただしこの方法は、各タスクの最大要求量が事前にわかっている必要があるため、動的にタスクが生成されるシステムでは適用が困難です。

 

検出と回復(Detection and Recovery)

デッドロックの発生を許容したうえで、定期的に検出して解消する方法です。

予防や回避に比べてオーバーヘッドが小さく、多くの実システムで採用されています。

 

検出にはタスクと資源の依存関係をグラフ化した「Wait-for グラフ」を用います。

グラフに循環(サイクル)が検出されればデッドロックが発生しています。

 

回復手段としては、次の方法が一般的です。

  • タイムアウト:ロック待ち時間に上限を設け、超過したら強制的にトランザクションをロールバックします。実装が簡単で、最も広く使われている手法です
  • 犠牲者選択:デッドロックに関与するタスクの中からコストが最小のものを選び、強制終了(またはロールバック)します。残りのタスクはロックを取得でき、処理を続行できます

 

データベース管理システム(DBMS)の多くは、タイムアウト方式を標準的なデッドロック対策として実装しています。

MySQL InnoDB では、デッドロック検出アルゴリズムが自動的に動作し、ロールバックコストの小さいトランザクションを犠牲者として選択します。

 

実務でのデッドロック防止チェックリスト

ここまでの理論を実務に落とし込む際に、以下のチェックリストが役立ちます。

 

  • 複数のテーブルやリソースにまたがるトランザクションでは、常に同じ順序でロックを取得しているか確認します
  • トランザクションのスコープ(範囲)を必要最小限に絞り、ロック保持時間を短縮します
  • タイムアウト値を適切に設定し、デッドロック時の自動検出・回復を有効にします
  • アプリケーション側にリトライロジックを実装し、ロールバック後の再実行に対応します
  • テスト環境で意図的に並行アクセスを発生させ、デッドロックの可能性を事前に検証します

 

特にマイクロサービスアーキテクチャでは、複数のサービスが異なるデータベースを横断してロックを取得するケースがあります。

この場合は分散トランザクション全体でのロック順序を設計段階で定義しておくことが重要です。

アプリケーション開発者としては、デッドロックによるロールバックが発生した場合に備えて、リトライロジックを組み込んでおくことが実務上のポイントです。

8. 楽観的ロックと悲観的ロック

楽観的ロックと悲観的ロックの処理フロー比較

データベースの排他制御では、ロック取得のタイミングによって「悲観的ロック」と「楽観的ロック」の 2 つの戦略に分かれます。

どちらを選ぶかは、競合の頻度とシステムの特性によって判断します。

 

悲観的ロック(Pessimistic Locking)

悲観的ロックは「競合が頻繁に起きる」と想定し、データ読み取り時点でロックを取得する方式です。

前述の共有ロック・排他ロックがこれに該当します。

 

データの整合性は確実に保たれますが、ロック期間中は他タスクが待機するため、同時実行性が低下します。

ロック保持時間が長いほどデッドロックのリスクも高まります。

 

SQL では SELECT 文に FOR UPDATE 句を付けることで悲観的ロックを取得できます。

この文を実行すると、対象行に排他ロックが掛かり、他のトランザクションからの更新がブロックされます。

 

楽観的ロック(Optimistic Locking)

楽観的ロックは「競合はめったに起きない」と想定し、読み取り時にはロックを取得しない方式です。

代わりに、データ更新時(コミット時)に「読み取り後に他者が変更していないか」を検証します。

 

具体的には、各レコードにバージョン番号 V を持たせます。

読み取り時のバージョンを記録しておき、更新コミット時に現在のバージョンと比較します。

 

 V_{\text{読取時}} = V_{\text{現在}} \; \Rightarrow \; \text{更新成功}, \; V \leftarrow V + 1

 

 V_{\text{読取時}} \neq V_{\text{現在}} \; \Rightarrow \; \text{競合検出、更新失敗}

 

バージョンが一致すれば他者による変更がなかったことを意味するため、更新を実行してバージョンを 1 増やします。

不一致であれば競合が起きているため、更新を中断してアプリケーション側でリトライ処理を行います。

 

MVCC(多版型同時実行制御)

楽観的ロックの発展形として、MVCC(Multi-Version Concurrency Control)があります。

MVCC では、データの更新時に古いバージョンを消さず、新しいバージョンを追加で作成します。

 

読み取りトランザクションは自身の開始時点でのバージョンを参照するため、書き込みトランザクションと競合しません。

PostgreSQL や MySQL InnoDB が MVCC を採用しており、読み取り性能を大幅に向上させています。

 

使い分けの指針

観点 悲観的ロック 楽観的ロック
競合頻度が高い場合 適している リトライが頻発し非効率
競合頻度が低い場合 ロック待ちで非効率 適している
デッドロックリスク 高い 低い(ロック不要)
実装の複雑さ DBMS 側で対応 アプリ側でリトライロジック必要

 

Web アプリケーションのように多数のユーザーが同時にアクセスするが同一レコードの競合はまれな環境では、楽観的ロックが広く採用されています。

タイムスタンプ順序付け

楽観的ロックと悲観的ロック以外の同時実行制御方式として「タイムスタンプ順序付け(Timestamp Ordering)」があります。

各トランザクションに開始時刻のタイムスタンプを付与し、タイムスタンプの順序に基づいてアクセスの優先権を決定する方式です。

 

古いタイムスタンプを持つトランザクションが優先され、新しいトランザクションが古いデータを上書きしようとするとアボートされます。

ロックを使わないためデッドロックが発生しない利点がありますが、競合時のアボート率が高くなる場合があります。

一方、在庫管理や座席予約のように競合が頻繁に起こるシステムでは、悲観的ロックの方が信頼性の観点で有利です。

 

実際のシステムでは、画面表示中は楽観的ロックで競合を検知し、最終確定時に悲観的ロックで排他を保証するハイブリッド方式が採用されるケースもあります。

たとえば EC サイトの在庫管理では、商品一覧の閲覧時はバージョン番号による楽観的ロックを使い、購入確定時にのみ行ロックを取得して二重販売を防止するという設計が一般的です。

このように、ロック戦略の選択はシステムの性能と信頼性に直接影響する重要な設計判断です。

9. 組込みシステムにおける排他制御の設計ポイント

優先度逆転と優先度継承プロトコルの模式図

組込みシステムでは、リアルタイム性の要求から、排他制御の設計にデータベースとは異なる特有の注意点があります。

応答時間の遅れが安全性に直結するケースもあるため、排他制御の選択と設計は特に慎重に行う必要があります。

 

割り込み禁止による排他制御

最もシンプルな排他制御は、クリティカルセクションの前後で CPU の割り込みを禁止・許可する方法です。

割り込みを禁止すればタスク切り替えが発生しないため、確実に排他を実現できます。

 

ただし、割り込み禁止中は外部イベント(センサー入力、通信受信、タイマー割り込みなど)に応答できません。

リアルタイムシステムにおいて応答遅延は致命的な問題になり得るため、禁止時間はできる限り短くする必要があります。

 

一般的な設計指針として、割り込み禁止時間はシステムの最小応答要件を下回ることが求められます。

数マイクロ秒以上の処理が必要な場合は、割り込み禁止ではなくセマフォやミューテックスを使うべきです。

 

優先度逆転問題

リアルタイム OS 環境で特に危険な現象が「優先度逆転」(Priority Inversion)です。

 

優先度の異なる 3 つのタスクを考えます。

高優先度タスク H、中優先度タスク M、低優先度タスク L とします。

 

 \pi_H > \pi_M > \pi_L

 

まず低優先度タスク L がミューテックスを取得してクリティカルセクションに入ります。

次に高優先度タスク H が同じミューテックスを要求しますが、L が保持中のためブロックされます。

 

ここで中優先度タスク M が起動すると、L よりも優先度が高いため L を横取り(プリエンプション)して実行を開始します。

L は M の完了まで進めず、結果として H は M に実質的にブロックされます。

 

本来 H は M より優先度が高いにもかかわらず、M が先に実行されるという「逆転」が起きるのです。

1997 年の火星探査機「マーズ・パスファインダー」で実際にこの問題が発生し、システムの繰り返しリセットを引き起こしたことは有名な事例です。

 

優先度継承プロトコル

優先度逆転を防ぐ代表的な手法が優先度継承プロトコル(Priority Inheritance Protocol)です。

 

ミューテックスを保持する低優先度タスク L がいて、高優先度タスク H がそのミューテックスを待っている場合を考えます。

このとき L の実行優先度を一時的に H と同じ水準まで引き上げます。

 

これにより、中優先度タスク M は L を横取りできなくなります。

L はクリティカルセクションを速やかに完了してミューテックスを解放し、優先度は元に戻ります。

H はただちにミューテックスを取得して処理を続行できます。

 

優先度上限プロトコル

もう 1 つの手法が優先度上限プロトコル(Priority Ceiling Protocol)です。

各ミューテックスに対して「そのミューテックスを使う可能性のある全タスクの最高優先度」を上限値として事前に設定します。

 

 \pi_c = \max(\pi_1, \pi_2, \ldots, \pi_k)

 

タスクがミューテックスを取得すると、そのタスクの優先度は自動的に上限値まで引き上げられます。

優先度継承プロトコルが「必要になってから引き上げる」のに対し、優先度上限プロトコルは「取得した瞬間に引き上げる」点が異なります。

 

この方式には、デッドロック防止の効果も併せ持つ利点があります。

タスクの優先度が上限値に引き上げられるため、同時に複数のミューテックスを保持する状況が構造的に生じにくくなります。

 

割り込みハンドラとタスク間の排他制御

組込みシステムでは、タスク同士の排他制御だけでなく、割り込みハンドラとタスクの間の排他制御も重要です。

 

割り込みハンドラはタスクよりも優先度が高い特殊なコンテキストで動作するため、ミューテックスやセマフォの P 操作(待機を伴う操作)を使うことができません。

割り込みハンドラ内で待機が発生すると、他の割り込みもブロックされてシステム全体が停止する危険があります。

 

そのため、割り込みハンドラとタスクの間では、割り込み禁止やスピンロック(マルチコアの場合)を使って排他制御を行います。

割り込みハンドラ側はできるだけ短い処理(フラグのセットやバッファへのデータ書き込み程度)に留め、本格的な処理はタスク側で行う「上半分・下半分」設計パターンが推奨されます。

 

上半分(割り込みハンドラ)でデータを受け取り、V 操作でタスクを起床させ、下半分(タスク)で時間のかかる処理を行うという分担です。

この設計により、割り込み禁止時間を最小限に抑えつつ、安全な排他制御を実現できます。

クリティカルセクションの最小化

どの排他制御方式を採用する場合でも、共通する設計原則は「クリティカルセクションをできるだけ短くする」ことです。

ロック保持時間が長いほど、他タスクの待機時間が増え、スループットが低下します。

 

共有資源へのアクセスに必要な最小限の処理だけをクリティカルセクションに含めるべきです。

計算処理、ログ出力、通信処理などは、可能な限りセクション外で行います。

 

特にリアルタイムシステムでは、クリティカルセクションの長さが直接的に最悪応答時間に影響するため、コードレビューの重要な確認項目となります。

 

関連記事

instant.engineer

まとめ

本記事では、排他制御の基本概念からデッドロックの回避策、組込みシステム特有の設計ポイントまでを解説しました。

 

排他制御は、複数タスクが共有資源にアクセスするあらゆるシステムで必須の基盤技術です。

セマフォの P 操作・V 操作、ミューテックスの所有権と優先度継承、共有ロックと排他ロックの互換性など、それぞれの仕組みを正しく理解することが安全な設計の第一歩となります。

 

デッドロックはコフマンの 4 条件(相互排除・保持と待機・非横取り・循環待機)がすべて成立したときに発生します。

ロック順序の統一やタイムアウトの設定など、少なくとも 1 条件を崩す設計を心がけましょう。

 

楽観的ロックと悲観的ロックの選択は、競合頻度とスループット要件に応じて判断します。

競合が少ない Web アプリケーションでは楽観的ロック、競合が多い在庫管理や予約システムでは悲観的ロックが有効です。

 

組込みシステムでは優先度逆転に特に注意し、ミューテックスの優先度継承プロトコルや優先度上限プロトコルを活用してください。

クリティカルセクションの最小化も、リアルタイム性を確保するための鉄則です。

 

排他制御を正しく設計・実装することで、データの整合性とシステムの信頼性を両立させることができます。