Adaにおける安全な並行処理 ── タスクと保護オブジェクトの実践ガイド

· · Ada, Concurrency, Tasking, ProtectedObjects, Rendezvous, RealTime, ParallelProgramming, ProgrammingLanguage, 並行処理, 高信頼性

1. はじめに ── 言語に埋め込まれた並行処理

並行処理は現代のソフトウェア開発において避けて通れないテーマです。しかし、多くの言語では並行処理は「後付け」のライブラリやOSの機能に依存しており、正しく使うために深い知識と慎重な設計が求められます。

Ada はこの問題に対する独自の答えを持っています。言語仕様そのものに並行処理が組み込まれているのです。

Ada の並行処理モデル:
- タスク(task) ── 独立して実行される並行単位
- ランデブー(rendezvous) ── タスク間の同期通信
- 保護オブジェクト(protected object) ── 言語管理の排他制御
- リアルタイム優先度 ── Annex D リアルタイム機能

タスクとランデブーは 1983 年の Ada 83 から存在し、保護オブジェクトと Annex D リアルタイム機能は Ada 95 で追加され、Ada 2005、2012 と進化を重ねてきました。ミューテックスやセマフォといった低レベルな同期プリミティブではなく、「設計意図を直接コードで表現できる」というのが Ada の並行処理の最大の特徴です。

この記事では、Ada の並行処理を 8 つの実践的なコード例で段階的に解説します。各コード例は独立したスニペットとして実際にコンパイル・実行可能であり、手元で試すことができます。

なお、この記事に登場するコード断片は、章ごとにファイルへ整理した参照用コード集として GitHub で公開しています。

ada-task-concurrency - komurasoft-blog-samples (GitHub)

2. 並行処理の「危険」を振り返る

Ada の話に入る前に、なぜ「安全な」並行処理が重要なのかを簡単に確認しておきます。

並行処理における代表的なバグには以下のようなものがあります。

  • データ競合 (data race): 複数のスレッドが同じメモリ位置に同時アクセスし、少なくとも一方が書き込みである場合。結果は未定義。
  • デッドロック (deadlock): 複数のタスクが互いの完了を待ち続け、永遠に進行しない状態。
  • 優先度逆転 (priority inversion): 高優先度タスクが低優先度タスクの保持するリソースを待ち、中優先度タスクが低優先度タスクをプリエンプトしてしまう問題。
  • 飢餓 (starvation): あるタスクが永遠にリソースを獲得できない状態。

Ada の並行処理モデルは、これらの問題に対して言語レベルでの防御策を提供します。

データ競合 → 保護オブジェクトが排他アクセスを保証
デッドロック → ランデブーモデルが構造的な同期を提供
優先度逆転 → Priority Ceiling Protocol が言語組み込みで利用可能
飢餓 → エントリバリアとキューイングポリシーで制御

3. タスクの基本 ── 独立した実行単位

Ada における並行処理の基本単位は タスク(task) です。タスクはスレッドに似ていますが、OSスレッドと1対1で対応するとは限らず、Ada ランタイムがスケジューリングを管理します。

task Greeter is
   entry Start;
end Greeter;

task body Greeter is
begin
   accept Start;
   Put_Line ("Hello from a task!");
end Greeter;

このコード(01_hello_task.ada)には重要なポイントがいくつかあります。

タスクは宣言されると自動的に実行を開始します。 Greeter タスクは、それを含む手続きの begin の時点で起動され、accept Start; で呼び出し元からのランデブー要求を待ちます。

エントリ(entry)はタスクが外部に公開するインタフェースです。 呼び出し元が Greeter.Start; と呼ぶと、タスクの accept Start; と同期します。これをランデブーと呼びます。

タスクの終了は自動的に待たれます。 メイン手続きが終了するとき、まだ実行中のタスクがあれば、それらの完了を暗黙的に待ちます。これは C++ の std::thread::join の呼び忘れによるクラッシュとは対照的です。

4. ランデブー ── データを渡す同期通信

ランデブーは単なる同期だけでなく、双方向のデータ受け渡しもできます。

task Worker is
   entry Compute (X, Y : Integer; Result : out Integer);
end Worker;

task body Worker is
   A, B   : Integer;
   Output : Integer;
begin
   accept Compute (X, Y : Integer; Result : out Integer) do
      A := X;
      B := Y;
      Output := A * A + B * B;
      Result := Output;
   end Compute;
end Worker;

呼び出し側は次のように使います(02_rendezvous_intro.ada)。

Worker.Compute (3, 4, Answer);
Put_Line ("Main: result = " & Integer'Image (Answer));

ここでの重要な設計上のポイントは、パラメータモードが明示されていることです。

  • in モード: 呼び出し側からタスクへ値を渡す
  • out モード: タスクから呼び出し側へ結果を返す
  • in out モード: 双方向

accept ボディ内の do ... end ブロックがクリティカルセクションになります。この間、呼び出し側はブロックされ、タスクは他のエントリを受け付けません。処理が完了すると両者が再開します。

ランデブーの特徴をまとめると:

特徴 説明
同期 呼び出し側とタスク側が同時にランデブーポイントに達するまで待つ
データ転送 in / out / in out パラメータで双方向に値を渡せる
排他制御 accept ボディ実行中はタスクの他のエントリはブロックされる
構造化 どのエントリがいつ受け付けられるかがタスクボディに明示される

5. 選択的アクセプト ── 複数サービスを待ち受ける

実際のサーバータスクでは、複数の種類の要求を待ち受ける必要があります。Ada の select 文はこれを言語レベルで実現します。

task Server is
   entry Deposit  (Amount : Integer);
   entry Withdraw (Amount : Integer; Success : out Boolean);
   entry Balance  (Value : out Integer);
end Server;

task body Server is
   Current : Integer := 0;
begin
   loop
      select
         accept Deposit (Amount : Integer) do
            Current := Current + Amount;
         end Deposit;
      or
         accept Withdraw (Amount : Integer; Success : out Boolean) do
            if Current >= Amount then
               Current := Current - Amount;
               Success := True;
            else
               Success := False;
            end if;
         end Withdraw;
      or
         accept Balance (Value : out Integer) do
            Value := Current;
         end Balance;
      or
         terminate;
      end select;
   end loop;
end Server;

このコード(03_selective_accept.ada)の select 文には複数の or 分岐があり、呼び出しのあるエントリのうちの一つが選択されます(選択は処理系定義です)。どのエントリも呼ばれていなければ、いずれかが呼ばれるまで待機します。

or terminate; は特別な分岐で、「メイン手続きが終了し、このタスクに対して誰もエントリ呼び出しを行う可能性がなくなった」ときにタスクを安全に終了させます。デッドロックの原因となる「待ち続けるサーバータスク」問題を解決する Ada 独自の仕組みです。

選択的アクセプトの強力な点は、ガード条件も書けることです。

select
   when Count > 0 =>
      accept Get_Item (Item : out Integer) do
         Item := Data (Head);
         Count := Count - 1;
      end Get_Item;
or
   when Count < Max =>
      accept Put_Item (Item : Integer) do
         Data (Tail) := Item;
         Count := Count + 1;
      end Put_Item;
end select;

ガード条件が偽の分岐は、その場では選択対象から外れます。これにより「バッファが空なら Get は待たせ、満杯なら Put は待たせる」といった制御が宣言的に書けます。

6. プロデューサー・コンシューマー ── ランデブーで同期

ランデブーを使った典型的なパターンとして、プロデューサー・コンシューマーを見てみましょう。

task Consumer is
   entry Deliver (Item : Integer);
end Consumer;

task Producer;

task body Consumer is
   Sum : Integer := 0;
begin
   for I in 1 .. 5 loop
      accept Deliver (Item : Integer) do
         Sum := Sum + Item;
      end Deliver;
   end loop;
end Consumer;

task body Producer is
begin
   for I in 1 .. 5 loop
      Consumer.Deliver (I);
   end loop;
end Producer;

このパターン(04_producer_consumer.ada)では、プロデューサーが Deliver を呼ぶたびにコンシューマーと同期します。プロデューサーが速すぎる場合はコンシューマーが accept するまで待たされ、コンシューマーが速すぎる場合はプロデューサーの次の呼び出しまで待たされます。自然なバックプレッシャーがかかるわけです。

7. 保護オブジェクト ── ロック不要の排他制御

タスクが「能動的な動作主体」であるのに対し、保護オブジェクト(protected object)は「受動的な共有データ」のための仕組みです。

protected Counter is
   procedure Increment;
   function Value return Integer;
private
   Count : Integer := 0;
end Counter;

protected body Counter is
   procedure Increment is
   begin
      Count := Count + 1;
   end Increment;

   function Value return Integer is
   begin
      return Count;
   end Value;
end Counter;

保護オブジェクトの重要なルールは次の通りです。

  • 関数(function)は読み取り専用。複数のタスクが同時に関数を呼び出せる。
  • プロシージャ(procedure)は読み書き。プロシージャ実行中は他のプロシージャも関数もブロックされる。
  • エントリ(entry)はバリア付き。バリア条件が真になるまで呼び出し側はキューで待つ。

このコード(05_protected_counter.ada)では、3つのワーカータスクがそれぞれ 1,000 回ずつ Increment を呼び出します。保護オブジェクトが排他制御を保証するため、最終的なカウンタ値は常に 3,000 になります。ミューテックスのロック・アンロックを手動で書く必要はありません。

task type Worker (Id : Integer; Rounds : Integer);

task body Worker is
begin
   for I in 1 .. Rounds loop
      Counter.Increment;  -- 保護オブジェクトが排他を保証
   end loop;
end Worker;

W1 : Worker (1, 1_000);
W2 : Worker (2, 1_000);
W3 : Worker (3, 1_000);

保護オブジェクトがないと何が起きるか

保護オブジェクトの価値を理解するために、保護しない場合の危険なコードを見てみましょう。

-- ⚠ 危険: 共有変数を直接操作している
Shared_Counter : Integer := 0;

task body Bad_Worker is
begin
   for I in 1 .. 10_000 loop
      Shared_Counter := Shared_Counter + 1;  -- データ競合!
   end loop;
end Bad_Worker;

Shared_Counter := Shared_Counter + 1 は、CPU レベルでは「読み出し→加算→書き戻し」の 3 ステップです。複数のタスクが同時にこれを実行すると、あるタスクの加算結果が別のタスクの読み出しに追いつかず、インクリメントが失われます。さらに、これは Ada RM 9.10 における誤った実行(erroneous execution)に該当します。非同期化されていない共有変数への同時読み書きは、最終的なカウント値の不正確さにとどまらず、プログラム全体の動作が任意の結果になり得ます。2 つのタスクがそれぞれ 10,000 回実行しても、最終値が 20,000 になる保証はまったくありません。

保護オブジェクトは、この問題を「構文で防ぐ」仕組みです。Counter.Increment; と呼ぶだけで、コンパイラとランタイムが排他制御を保証してくれます。

8. 保護エントリとバリア ── 境界付きバッファ

保護オブジェクトにエントリを追加すると、条件付き同期が可能になります。古典的な境界付きバッファ(bounded buffer)で見てみましょう。

type Buffer_Array is array (0 .. Buffer_Size - 1) of Integer;

protected Buf is
   entry Put (Item : Integer);
   entry Get (Item : out Integer);
private
   Data    : Buffer_Array;
   Head    : Integer := 0;
   Tail    : Integer := 0;
   Count   : Integer := 0;
end Buf;

protected body Buf is
   entry Put (Item : Integer) when Count < Buffer_Size is
   begin
      Data (Tail) := Item;
      Tail := (Tail + 1) mod Buffer_Size;
      Count := Count + 1;
   end Put;

   entry Get (Item : out Integer) when Count > 0 is
   begin
      Item := Data (Head);
      Head := (Head + 1) mod Buffer_Size;
      Count := Count - 1;
   end Get;
end Buf;

when Count < Buffer_Sizeバリア(barrier)です。バリアはエントリ呼び出しのたびに評価され、真であれば実行、偽であれば呼び出し側のタスクはキューで待機します。バッファの状態が変わる(他のタスクが Put または Get を実行する)たびに、待機中のタスクのバリアが再評価されます。

このパターン(06_bounded_buffer.ada)は、Ada の保護オブジェクトが最も輝く場面の一つです。C 言語で pthread の mutex + condition variable を使って書く場合と比べてみてください。

// C言語 + pthread の場合(Ada との比較用)
pthread_mutex_lock(&mutex);
while (count >= BUFFER_SIZE) {           // Ada の when に相当
    pthread_cond_wait(&not_full, &mutex); // バリア待ち
}
data[tail] = item;
tail = (tail + 1) % BUFFER_SIZE;
count++;
pthread_cond_signal(&not_empty);         // 待機中タスクに通知
pthread_mutex_unlock(&mutex);

Ada ではこれらすべてが when Count < Buffer_Size の一行に集約されています。while ループの条件、シグナル送信、ロック解除のタイミングミス——これらすべてのバグの機会が消え去ります。

9. タイムアウト付き呼び出し ── 永遠に待たない

リアルタイムシステムでは「永遠に待つ」ことは許されません。Ada は select ... or delay 構文でタイムアウトをサポートします。

select
   Slow_Worker.Do_Work (Result);
   Put_Line ("Main: work completed");
or
   delay until Ada.Real_Time.Clock + Milliseconds (500);
   Put_Line ("Main: timeout after 500ms!");
end select;

このコード(07_timed_entry.ada)では、Slow_Workerdelay 2.0 の実行中でまだ accept に到達していないため、キューに入ったエントリ呼び出しが 500ms でタイムアウトします。(タイムアウトが効くのはランデブーが受け付けられる前のキュー待ち時間であり、ランデブー本体の実行を中断するものではありません。)delay until は絶対時刻での指定であり、累積ドリフトを防ぐリアルタイムプログラミングの基本技法です。

さらに Ada は 条件付き呼び出し(conditional entry call) もサポートします。

select
   Server.Process (Item);
else
   Put_Line ("Server is busy, will retry later");
end select;

else 節により、すぐにランデブーできなければ即座に代替処理に進みます。ポーリングを手動で書く必要はありません。

タイムアウト後の設計を忘れない

タイムアウトは便利ですが、「待てなかったあとに何をするか」が設計の本質です。値を本当に捨ててよいのか、再試行すべきなのか、エラーとして上位に通知すべきなのか——これらを曖昧にすると、本番環境でデータ欠落やサービス停止に変わります。タイムアウトを書くときは、タイムアウト後の責任も同じ場所で設計してください。

周期タスクと delay until

delay until はタイムアウトだけでなく、周期実行にも使えます。単純な delay 0.1 では「処理時間 + 0.1秒」が周期になってしまうのに対し、delay until は絶対時刻で次の起動点を決めるため、処理時間に左右されない安定した周期を保てます。

loop
   Next := Next + Period;
   Do_Work;
   delay until Next;
end loop;

このパターンは、センサー監視や制御ループなど、定周期処理が求められるあらゆる場面で有効です。

10. タスク優先度とリアルタイムスケジューリング

Ada のリアルタイム機能は Annex D(Real-Time Systems)で定義されています。Ada の処理系が Annex D をサポートしている場合、タスク優先度とスケジューリングポリシーを指定できます。

task High_Task is
   pragma Priority (System.Default_Priority + 5);
end High_Task;

task Low_Task is
   pragma Priority (System.Default_Priority);
end Low_Task;

より高度な設定として、スケジューリングポリシーと優先度上限プロトコルも指定できます。

pragma Task_Dispatching_Policy (FIFO_Within_Priorities);
pragma Locking_Policy (Ceiling_Locking);

Priority Ceiling Protocol(優先度上限プロトコル)は、優先度逆転を防ぐためのプロトコルです。各保護オブジェクトに pragma Priority(または Priority aspect)でシーリング優先度を明示的に設定します。呼び出し元タスクのアクティブ優先度がそのシーリングを超えると Program_Error が発生します。オブジェクトをロックしている間はシーリング優先度で実行され、中優先度タスクによるプリエンプションを防ぎます。

protected Shared_Data is
   pragma Priority (15);  -- シーリング優先度
   procedure Update (Val : Integer);
   function Read return Integer;
private
   Data : Integer := 0;
end Shared_Data;

これらの機能は、Rate Monotonic Scheduling (RMS) の理論的背景に基づいており、航空機のフライトコントロールや医療機器のようなハードリアルタイムシステムで実績があります。

11. 実践のための設計指針

ここまでタスクと保護オブジェクトの基本構文を見てきました。最後に、実務で Ada の並行処理を使うときに意識すべき設計指針を整理します。

保護オブジェクトでやってはいけないこと

保護オブジェクトの中では、状態の更新だけを短く行い、重い処理は外で実行するのが鉄則です。保護オブジェクトの操作は内部的に排他制御されているため、その中で長時間ブロックすると、同じ保護オブジェクトを使う他のタスクをすべて止めてしまいます。

具体的に避けるべき処理:

  • delay や時間のかかる I/O
  • 別の保護オブジェクトへの複雑な呼び出し
  • 外部ライブラリの重い呼び出し

なお、保護操作内での delay や特定の I/O は、単なるパフォーマンス問題ではなく、Ada 規格上の限定エラー(bounded error)です。処理系によっては Program_Error が発生したりデッドロックに陥る可能性があるため、「控える」ではなく完全に排除する必要があります。

よい設計は「保護オブジェクトから必要な値を短時間で取り出す → 外で重い計算や I/O を行う → 結果だけを保護オブジェクトに短時間で書き戻す」というパターンです。

バリア条件は単純に保つ

entry ... when <condition> のバリアは強力ですが、複雑になりすぎると読みにくくなり、なぜタスクが解放されないのかを調べるのが難しくなります。

when Count < Buffer_Sizewhen Used > 0 のように、状態の意味が一目で分かるレベルが理想的です。複数の条件が必要な場合は、列挙型で状態を表し、バリアを when State = Running のように状態名で読める形に近づけることを検討してください。

タスクの例外と停止

タスク内で例外が発生したときの方針は、明示的に決めておく必要があります。最低限、タスク本体の最上位で例外を捕捉し、何が起きたかを記録すること。

さらに重要なのは例外の設計です。そのタスクが止まったらシステムは継続できるのか、再起動してよいのか、他のタスクへどう通知するのか、共有状態をどう安全な状態に戻すのか——こうした問いに答えられるようにしておく必要があります。Ada は例外機構を言語として持っていますが、例外後の安全性はアプリケーション設計の責任です。

ミニ設計チェックリスト

観点 確認すること
共有状態 保護オブジェクトに閉じ込められているか。外部から直接触っていないか
保護操作 短いか。中でブロックしていないか
エントリ バリアは単純か。待ち続ける可能性はないか。タイムアウト方針はあるか
タスク寿命 終了条件は明確か。例外時の方針はあるか
周期処理 delay ではなく delay until を検討したか

並行処理では「たぶん大丈夫」が最も危険です。共有状態、待ち条件、終了条件、例外方針をコード上に明示することが、安全な並行処理の第一歩です。

12. まとめ ── 並行処理を「文法」にした言語

Ada の並行処理モデルが他言語と一線を画すのは、安全な並行処理が「後付けのベストプラクティス」ではなく「文法」として組み込まれている点です。

やりたいこと Ada の文法
独立した実行単位 task / task body
同期通信 entry / accept
複数要求の待受 select / or / else
排他制御 protected / function / procedure
条件付き同期 entry ... when <barrier>
タイムアウト or delay until <time>
優先度制御 pragma Priority

これらの構文は、コンパイラによる検証の対象です。たとえば、保護オブジェクトの関数内で保護オブジェクト自身のプライベート成分を書き換えようとするとコンパイルエラーになります。保護操作が完了すると、待機中のエントリのバリアが自動的に再評価されます——手動でのシグナル送信は不要です。

「型システムがメモリ安全性を保証するように、
  Ada の並行処理構文は同期の安全性を保証する」

本記事で扱った 8 つのコード例は、タスク、ランデブー、保護オブジェクト、リアルタイム機能の実践的な入門です。これらを手元で動かしながら、以下の発展的なトピックにも挑戦してみてください。

  • Ravenscar プロファイル: 高信頼リアルタイムシステム向けのタスク制限プロファイル。制限されたタスクモデルにより静的なデッドロック解析が可能になる。
  • Ada 2022 の並列ブロック: parallel ... do 構文によるデータ並列処理。
  • SPARK との統合: 並行プログラムの振る舞いを形式検証する(Ravenscar プロファイル下で GNATprove がサポート)。

それでも「Ada を使えば安全」ではない

最後に大切な注意です。Ada の並行処理構文は強力ですが、Ada を使えば自動的に安全になるわけではありません。共有データを保護オブジェクトに入れずに直接触る、保護オブジェクトの中で長時間ブロックする、複数の保護オブジェクトを複雑に呼び合う——こうした設計ミスは Ada でも起こり得ます。

言語機能は「危険な書き方をするには明示的な努力が必要になる」ように設計されていますが、正しい設計そのものを代行してくれるわけではありません。Ada の真価は、安全性の議論をコードに近い場所へ持ってこられること——「この状態は保護されているか」「このタスクはいつ終わるのか」「このエントリはどの条件で待つのか」といった問いを、構文としてコード上に残せることです。

型で設計を語る Ada の思想は、並行処理でも一貫しています。安全な並行処理は、ロックを慎重に扱うことではなく、危険な共有状態を裸で存在させないことから始まるのです。

「並行処理は難しい」という通念に対して、Ada は「構文を正しく選べば、安全性はコンパイラが保証してくれる」と答えます。その設計思想は、現代の Rust や Pony にも通じるものですが、Ada はそれを 40 年前から言語仕様として持ち続けているのです。

同じタグを共有する最新の記事です。さらに近い話題で知識を深められます。

このテーマと近いトピックページです。記事を起点に、関連するサービスや他の記事へ進めます。

著者プロフィール

記事の著者プロフィールページです。

小村 豪

合同会社小村ソフト 代表

Windows ソフト開発、技術相談、不具合調査を中心に、既存資産が残る案件や原因が見えにくい障害調査に強みがあります。

ブログ一覧に戻る