Adaにおける安全な並行処理 ── タスクと保護オブジェクトの実践ガイド
· 更新日: · 小村 豪 · Ada, Concurrency, Tasking, ProtectedObjects, Rendezvous, RealTime, ParallelProgramming, ProgrammingLanguage, 並行処理, 高信頼性
更新履歴(3件・最終更新 2026年08月02日)
この記事に加えた変更の記録です。アーカイブした更新前のバージョンは、DOI付きの固定URLから読めます。
- 記事の冒頭に「この記事の知識マップ」節を追加しました。本文で扱っている概念とその関係を、要約・図・詳細ページへのリンクにまとめたものです。本文の主張は変えていません。
- 外部レビュー(1283件)への対応として本文を更新しました。個々の変更内容は、この下の履歴を参照してください。
- 手元でビルドして動かすまでの手順と、章とサンプルファイルの対応表を追加しました。あわせてランデブーとバリアの動きを図にし、ガード条件の断片を動く形に拡張し、優先度が実際に効くかを確かめる方法を追加しています。実測値は載せず、読者が自分で確認する手順を示しています。
- 初版公開
この記事を引用する(DOI: 10.5281/zenodo.21589879)
この記事はZenodoにアーカイブされています。常に最新版へ解決されるDOIと、いま表示している版に固定されたDOIの両方を下に示します。
小村 豪(2026)「Adaにおける安全な並行処理 ── タスクと保護オブジェクトの実践ガイド」合同会社小村ソフト. https://doi.org/10.5281/zenodo.21589879 https://staging.comcomponent.com/blog/ada-task-concurrency/
- DOI(最新版)
- 10.5281/zenodo.21589879
- DOI(この版)
- 10.5281/zenodo.21732876
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)
手元で動かす ── ビルドと実行
「コンパイル・実行可能」と書いた以上、その手順も先に示しておきます。
GNAT を用意する
GNAT は GCC の Ada コンパイラです。Linux では apt install gnat-13、Windows では MSYS2 の mingw-w64-x86_64-gcc-ada、あるいは Alire(Ada / SPARK のパッケージマネージャ)から導入できます。
ビルドして実行する
各スニペットは 1 ファイルに複数のコンパイル単位(タスク仕様、タスク本体、メイン手続き)を含むため、gnatchop で分割してから gnatmake します。gnatchop は「ユニット名 = ファイル名」という GNAT の命名規則に沿ってファイルを分割するツールです。
mkdir work && cd work
gnatchop ../src/snippets/01_hello_task.ada
gnatmake hello_task_demo
./hello_task_demo
分割後のメイン手続きの名前が、そのまま実行ファイル名になります。
章とファイルの対応
章番号とファイル番号は 1 つずれます(3 章が 01_)。対応は次の通りです。
| 章 | ファイル | 実行ファイル | 内容 |
|---|---|---|---|
| 3章 | 01_hello_task.ada |
hello_task_demo |
タスクの基本形 |
| 4章 | 02_rendezvous_intro.ada |
rendezvous_demo |
ランデブーによる双方向データ転送 |
| 5章 | 03_selective_accept.ada |
selective_accept_demo |
選択的アクセプトとサーバータスク |
| 6章 | 04_producer_consumer.ada |
producer_consumer_demo |
プロデューサー・コンシューマー |
| 7章 | 05_protected_counter.ada |
protected_counter_demo |
保護オブジェクトによる排他制御 |
| 8章 | 06_bounded_buffer.ada |
bounded_buffer_demo |
バリア付き保護エントリ(境界付きバッファ) |
| 9章 | 07_timed_entry.ada |
timed_entry_demo |
タイムアウト付き select 呼び出し |
| 10章 | 08_task_priorities.ada |
task_priorities_demo |
タスク優先度とリアルタイムスケジューリング |
本文中のコード断片は、説明のために必要な部分だけを切り出したものです。そのままでは動かないので、手を動かすときは上のファイルを使ってください。
この記事の知識マップ
Adaのタスクはentryとacceptによるランデブーで外部と同期通信し、選択的アクセプト(select文)は複数のエントリをガード条件付きで待ち受けつつor terminateでデッドロックの原因になる待ち続けるサーバータスクを避けられる。保護オブジェクトは言語管理の排他制御で、エントリのバリア条件が真になるまで呼び出し側を待たせて共有データへの排他アクセスを保証しデータ競合を防ぐ一方、保護操作内でdelayなど禁止された処理を行うと限定エラーに該当し、処理系によってはデッドロックにつながる。優先度上限プロトコルは保護オブジェクトにシーリング優先度を設定して優先度逆転を防ぎ、Rate Monotonic Schedulingの理論的背景のもとでリアルタイム性が支えられる。Ravenscarプロファイルはタスクモデルを制限して静的なデッドロック解析を可能にする。
flowchart LR
accTitle: Adaのタスクと保護オブジェクトの知識マップ
accDescr: Adaのタスクがランデブーで同期通信し選択的アクセプトがデッドロックを避けつつ複数エントリを待ち受けること、保護オブジェクトがバリアで排他制御しデータ競合を防ぐこと、優先度上限プロトコルとRavenscarプロファイルがリアルタイム性を支えることを示す図。
ada_task["Adaのタスク(並行処理)"]
protected_object["保護オブジェクト(protected object)"]
ada["Ada(プログラミング言語)"]
rendezvous["ランデブー(rendezvous)"]
selective_accept["選択的アクセプト(select文)"]
deadlock["デッドロック(deadlock)"]
barrier["バリア(保護エントリのwhen条件)"]
data_race["データ競合(data race)"]
erroneous_execution["誤った実行(erroneous execution)"]
bounded_error["限定エラー(bounded error)"]
priority_ceiling_protocol["優先度上限プロトコル(Priority Ceiling Protocol)"]
priority_inversion["優先度逆転(priority inversion)"]
task_priority["タスク優先度(pragma Priority)"]
rate_monotonic_scheduling["Rate Monotonic Scheduling(RMS)"]
ravenscar_profile["Ravenscarプロファイル"]
ada_task -->|"前提とする"| ada
protected_object -->|"前提とする"| ada
ada_task -->|"利用する"| rendezvous
selective_accept -->|"利用する"| rendezvous
selective_accept -.->|"防止する"| deadlock
protected_object -.->|"利用する"| barrier
protected_object -->|"防止する"| data_race
data_race -->|"原因になり得る"| erroneous_execution
bounded_error -.->|"原因になり得る"| deadlock
protected_object -.->|"原因になり得る"| bounded_error
priority_ceiling_protocol -->|"防止する"| priority_inversion
priority_ceiling_protocol -->|"前提とする"| protected_object
priority_ceiling_protocol -->|"利用する"| task_priority
rate_monotonic_scheduling -->|"利用する"| task_priority
rate_monotonic_scheduling -.->|"推奨される対応"| ada_task
ravenscar_profile -.->|"軽減する"| deadlock
ravenscar_profile -->|"前提とする"| ada_task
ada_task -.->|"原因になり得る"| priority_inversion
ada_task -.->|"で構成できる"| task_priority
rendezvous -.->|"軽減する"| deadlock
図の実線は常に成り立つ関係、破線は条件付きの関係です(成立条件は詳細ページの各関係の説明に記載)。関係すべての一覧(全20件、根拠・確度つき)と主要概念の定義は知識マップ詳細ページにまとめています。データ: JSON-LD / Turtle
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 の呼び忘れによるクラッシュとは対照的です。
実行例
gnatchop ../src/snippets/01_hello_task.ada
gnatmake hello_task_demo
./hello_task_demo
Main: starting task...
Hello from a task!
Main: task has completed.
ここで 1 つ注意があります。accept Start; には do ... end ブロックがありません。つまりランデブーが成立した瞬間に両者が解放され、そのあとは並行に進みます。したがって後半 2 行の順序は保証されません。 環境によっては Main: task has completed. が先に出ます。順序まで固定したいなら、順番を守りたい処理を accept Start do ... end Start; の do ... end の中に置きます。1 行目だけは必ず先頭に来ます。Greeter は Greeter.Start; が呼ばれるまで accept で止まっているからです。
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 ブロックがクリティカルセクションになります。この間、呼び出し側はブロックされ、タスクは他のエントリを受け付けません。処理が完了すると両者が再開します。
時系列で見ると、待ち合わせの様子は次のようになります。
sequenceDiagram
participant Main as 呼び出し側
participant W as Workerタスク
Main->>W: Worker.Compute を呼ぶ
Note over Main: accept に達するまでブロック
W->>W: accept Compute ... do に到達
Note over Main,W: ランデブー成立 / accept ボディが実行される
W-->>Main: out パラメータ Result に結果を書く
Note over Main,W: end Compute で両者が同時に再開
Main->>Main: 続きの処理
W->>W: 続きの処理
先に到達したほうが待ちます。呼び出し側が先なら accept に達するまで、タスク側が先なら誰かが呼ぶまで、それぞれ止まります。どちらが先でも、do ... end の中は必ず 2 者が揃った状態で実行されます。
ランデブーの特徴をまとめると:
| 特徴 | 説明 |
|---|---|
| 同期 | 呼び出し側とタスク側が同時にランデブーポイントに達するまで待つ |
| データ転送 | in / out / in out パラメータで双方向に値を渡せる |
| 排他制御 | accept ボディ実行中はタスクの他のエントリはブロックされる |
| 構造化 | どのエントリがいつ受け付けられるかがタスクボディに明示される |
実行例
gnatchop ../src/snippets/02_rendezvous_intro.ada
gnatmake rendezvous_demo
./rendezvous_demo
Main: calling Worker.Compute...
Main: result = 25
3 * 3 + 4 * 4 = 25 が out パラメータ経由で戻ってきています。= の右に空白が 1 つ入るのは、整数型の 'Image が負でない値の前に空白を 1 文字置く仕様のためです。3 章と違い、こちらは 2 行の順序が保証されます。Worker.Compute の呼び出しは end Compute; まで戻らないからです。
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 の部分だけを切り出すと Count や Head がどこから来たのか分からなくなるので、宣言部から通しで示します。同じ考え方を保護オブジェクトで書き直した形は、8 章で扱います。
task Buffer_Task is
entry Put_Item (Item : Integer);
entry Get_Item (Item : out Integer);
end Buffer_Task;
task body Buffer_Task is
Max : constant := 8;
Data : array (0 .. Max - 1) of Integer;
Head : Integer := 0; -- 次に取り出す位置
Tail : Integer := 0; -- 次に書き込む位置
Count : Integer := 0; -- 現在の要素数
begin
loop
select
when Count > 0 =>
accept Get_Item (Item : out Integer) do
Item := Data (Head);
Head := (Head + 1) mod Max;
Count := Count - 1;
end Get_Item;
or
when Count < Max =>
accept Put_Item (Item : Integer) do
Data (Tail) := Item;
Tail := (Tail + 1) mod Max;
Count := Count + 1;
end Put_Item;
or
terminate;
end select;
end loop;
end Buffer_Task;
ガード条件が偽の分岐は、その場では選択対象から外れます。これにより「バッファが空なら Get は待たせ、満杯なら Put は待たせる」といった制御が宣言的に書けます。Head と Tail を mod Max で進めているのがリングバッファの本体で、ガード条件はその添字が有効な範囲に収まることを保証する役目も担っています。
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);
実行例
ここで 1 つ問題があります。メイン手続きがそのまま Counter.Value を読むと、まだワーカーが回っている途中の値を読んでしまいます。そこで完全版(05_protected_counter.ada)では、完了を数えるプロシージャと、全員の完了を待つエントリを足しています。
protected Counter is
procedure Increment;
procedure Mark_Done;
entry All_Done;
function Value return Integer;
private
Count : Integer := 0;
Done_Count : Integer := 0;
end Counter;
entry All_Done when Done_Count = Num_Workers というバリアを置き、各ワーカーはループを抜けたところで Counter.Mark_Done; を呼びます。メイン手続きは Counter.All_Done; で全員の完了を待ってから値を読みます。待ち合わせ用の別のフラグ変数もスリープも要りません。
gnatchop ../src/snippets/05_protected_counter.ada
gnatmake protected_counter_demo
./protected_counter_demo
Final counter value = 3000
何度実行しても 3000 です。3 つのタスクが合計 3,000 回 Increment を呼び、保護オブジェクトがその一回一回を排他的に実行するからです。
保護オブジェクトがないと何が起きるか
保護オブジェクトの価値を理解するために、保護しない場合の危険なコードを見てみましょう。
-- ⚠ 危険: 共有変数を直接操作している
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 になる保証はまったくありません。
この「保証がない」を自分の目で確かめたい場合は、上の Bad_Worker 版を作って何度も繰り返し実行し、毎回の最終値を記録してみてください。本記事では実測値を載せません。 データ競合の結果は CPU、最適化オプション、実行時のタイミングで変わるため、特定の環境で出た 1 つの数字を「こうなる」として示すと、かえって「この程度ずれるものだ」という誤った目安を与えてしまうからです。確かめるべきは「20,000 より小さい特定の値が出る」ことではなく、実行のたびに結果が変わり、しかも 1 回でも正しい値が出たことに何の意味もないという点のほうです。
保護オブジェクトは、この問題を「構文で防ぐ」仕組みです。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 を実行する)たびに、待機中のタスクのバリアが再評価されます。
再評価がいつ起きるのかは、文章だけだと追いにくいところです。空のバッファに Get が先に来た場合を時系列で並べると、次のようになります。
sequenceDiagram
participant C as Consumerタスク
participant B as 保護オブジェクト Buf
participant P as Producerタスク
C->>B: Get を呼ぶ
B->>B: バリア Count が 0 より大きいか を評価 → 偽
Note over C: Get のエントリキューで待機
P->>B: Put を呼ぶ
B->>B: バリア Count が Buffer_Size 未満か を評価 → 真
B->>B: Put のボディを実行 / Count は 1 になる
Note over B: 保護操作が終わる時点で待機中エントリのバリアを再評価
B->>B: Get のバリアが真になった
B-->>C: Get のボディを実行して Consumer を解放
ポイントは、バリアの再評価が保護操作の終わり際にまとめて行われることです。Put のボディが終わってから Buf のロックが解放されるまでの間に、待機中のエントリのバリアが評価され、真になったものがそのまま実行されます。C の条件変数のように「誰かが signal を呼ぶのを忘れると永久に起きない」という失敗の仕方がありません。
このパターン(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(¬_full, &mutex); // バリア待ち
}
data[tail] = item;
tail = (tail + 1) % BUFFER_SIZE;
count++;
pthread_cond_signal(¬_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_Worker が delay 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 をサポートしている場合、タスク優先度とスケジューリングポリシーを指定できます。
自分の環境で使えるかを確かめる
Annex D は Specialized Needs Annex(特定分野向け附属書)の一つで、対応状況は処理系と実行環境に依存します。手元で使えるかどうかは、次の 3 段階で切り分けます。
1. 優先度の範囲を見る
with Ada.Text_IO; use Ada.Text_IO;
with System;
procedure Check_Priority is
begin
Put_Line ("Priority range :"
& Integer'Image (System.Priority'First)
& " .."
& Integer'Image (System.Priority'Last));
Put_Line ("Default_Priority :"
& Integer'Image (System.Default_Priority));
end Check_Priority;
System.Priority の範囲と既定値は処理系依存なので、具体的な数値はここでは示しません。表示された範囲が十分に広ければ、pragma Priority (System.Default_Priority + 5) のような指定が意味を持つ環境です。
2. ポリシー指定がコンパイルを通るか見る
pragma Task_Dispatching_Policy (FIFO_Within_Priorities);
pragma Locking_Policy (Ceiling_Locking);
これらを付けてビルドが通れば、少なくとも構文としては受け付けられています。
3. 実際に優先度どおりに動くかを見る
ここが一番の落とし穴です。コンパイルが通ることと、OS のスケジューラが優先度どおりに動かしてくれることは別です。Linux や Windows のような汎用 OS 上では、リアルタイム優先度を実際にスケジューラへ反映させるために OS 側の権限設定が必要になる場合があります。本記事のサンプル集の README にも、Annex D が完全にはサポートされていない環境では 08_task_priorities.ada が通常のタスクとして動作する、と注記されています。
つまり、優先度が効かなくてもプログラムは動いてしまうということです。ハードリアルタイムを要求する用途では、机上の優先度設計だけでなく、ターゲット環境で実際に順序を測って確かめる工程が必須になります。
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) の理論的背景に基づいており、航空機のフライトコントロールや医療機器のようなハードリアルタイムシステムで実績があります。RMS は「周期の短いタスクほど高い優先度を割り当てる」という固定優先度の方式で、周期タスクの集合が締切を守れるかどうかを実行前に解析できる点が、ハードリアルタイム用途で重視されます。
11. 実践のための設計指針
ここまでタスクと保護オブジェクトの基本構文を見てきました。最後に、実務で Ada の並行処理を使うときに意識すべき設計指針を整理します。
保護オブジェクトでやってはいけないこと
保護オブジェクトの中では、状態の更新だけを短く行い、重い処理は外で実行するのが鉄則です。保護オブジェクトの操作は内部的に排他制御されているため、その中で長時間ブロックすると、同じ保護オブジェクトを使う他のタスクをすべて止めてしまいます。
具体的に避けるべき処理:
delayや時間のかかる I/O- 別の保護オブジェクトへの複雑な呼び出し
- 外部ライブラリの重い呼び出し
なお、保護操作内での delay や特定の I/O は、単なるパフォーマンス問題ではなく、Ada 規格上の限定エラー(bounded error)です。限定エラーとは、「起こりうる結果の範囲は規格が定めるが、その範囲のどれになるかは決まっていない」という種類のエラーです。誤った実行(erroneous execution)ほど無制限ではないものの、正しく動く保証もありません。実際、処理系によっては Program_Error が発生したりデッドロックに陥る可能性があるため、「控える」ではなく完全に排除する必要があります。
よい設計は「保護オブジェクトから必要な値を短時間で取り出す → 外で重い計算や I/O を行う → 結果だけを保護オブジェクトに短時間で書き戻す」というパターンです。
バリア条件は単純に保つ
entry ... when <condition> のバリアは強力ですが、複雑になりすぎると読みにくくなり、なぜタスクが解放されないのかを調べるのが難しくなります。
when Count < Buffer_Size や when 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 年前から言語仕様として持ち続けているのです。
関連する記事
同じタグを共有する最新の記事です。さらに近い話題で知識を深められます。
Adaによるリアルタイムシステムプログラミング ── 優先度・周期・実行時間制御の実践
AdaのAnnex D(リアルタイムシステム)を8つの実践的なコード例で学びます。タスク優先度、Ceiling_Locking、delay untilによる周期実行、Ravenscarプロファイル、タスク別実行時間計測まで段階的に整理します。
Adaのジェネリックプログラミング ── 型で契約を書き、再利用をゼロコストで実現する
Adaのジェネリックプログラミングを、総称サブプログラム、総称パッケージ、仮サブプログラム、型カテゴリ、実務上の設計指針まで体系的に解説します。型安全な再利用とゼロコスト抽象化の考え方を整理します。
SPARKによる形式検証入門 ── Adaの契約から数学的証明へ
Adaのサブセット言語SPARKによる形式検証の実践的な入門記事です。契約(Pre/Post)から証明へステップアップする方法、GNATproveの使い方、ループ不変条件、データフロー契約、証明レベル、そして実プロジェクトへの適用方法までを整理します。
Ada言語の魅力 ── 型で設計を語り、数十年動き続けるソフトウェアを支える言語
Ada言語の魅力を紹介します。強い型付け、範囲制約、パッケージによる仕様と実装の分離、契約による設計、言語組み込みのタスク、SPARKによる形式検証、GNATとAlireでの開発環境まで、高信頼ソフトウェアを支える設計思想を整理します。
共有メモリの落とし穴と実務ベストプラクティス
共有メモリを実務で使うときの落とし穴と、同期、可視性、寿命、ABI、権限まで含めた事故率を下げる設計を整理します。
関連トピック
このテーマと近いトピックページです。記事を起点に、関連するサービスや他の記事へ進めます。
Windows技術トピック
Windows 開発、不具合調査、既存資産活用の技術トピックをまとめた入口です。
よくある質問
この記事のテーマについて、相談時によくある質問をまとめています。
- Adaのタスクとは何ですか?
- タスクはAdaにおける並行処理の基本単位で、スレッドに似ていますがOSスレッドと1対1で対応するとは限らず、Adaランタイムがスケジューリングを管理します。タスクは宣言されると自動的に実行を開始し、メイン手続きが終了するときには実行中のタスクの完了が暗黙的に待たれます。外部とはエントリ(entry)を通じたランデブーで同期通信を行います。タスクとランデブーは1983年のAda 83から言語仕様に組み込まれています。
- Adaの保護オブジェクトはミューテックスと何が違いますか?
- 保護オブジェクトは言語管理の排他制御の仕組みで、ロック・アンロックを手動で書く必要がありません。関数(function)は読み取り専用で複数タスクが同時に呼び出せ、プロシージャ(procedure)は読み書き用で実行中は他の呼び出しがブロックされ、エントリ(entry)はバリア条件が真になるまで呼び出し側をキューで待たせます。C言語でpthreadのmutexと条件変数を組み合わせて書く境界付きバッファの制御が、Adaでは「when Count < Buffer_Size」のようなバリア1行に集約されます。
- Adaのランデブーとはどういう仕組みですか?
- ランデブーはタスク間の同期通信の仕組みで、呼び出し側のエントリ呼び出しとタスク側のaccept文が同時にランデブーポイントに達するまで互いに待ち合わせます。in/out/in outのパラメータモードで双方向にデータを受け渡せます。acceptボディのdo〜endブロックがクリティカルセクションとなり、実行中は呼び出し側がブロックされ、タスクは他のエントリを受け付けません。select文と組み合わせれば複数エントリの待ち受けやタイムアウト、ガード条件も宣言的に書けます。
- 保護オブジェクトの中でやってはいけないことは何ですか?
- delayや時間のかかるI/O、外部ライブラリの重い呼び出しなど、長時間ブロックする処理です。保護操作内でのdelayや特定のI/OはAda規格上の限定エラー(bounded error)に該当し、処理系によってはProgram_Errorの発生やデッドロックにつながるため、完全に排除する必要があります。状態の更新だけを短く行い、重い計算やI/Oは保護オブジェクトの外で実行して結果だけを短時間で書き戻すのが鉄則です。