Ada 的安全並行處理 ── 任務與保護物件實戰指南

· · Ada, Concurrency, Tasking, ProtectedObjects, Rendezvous, RealTime, ParallelProgramming, ProgrammingLanguage, 並行處理, 高可靠性

1. 前言 ── 內建於語言本身的並行處理

並行處理是現代軟體開發中無法迴避的課題。然而,多數語言把並行處理當作「後加」的函式庫或作業系統功能,要正確使用往往需要深厚的知識與謹慎的設計。

Ada 對這個問題有著獨特的解答:並行處理直接內建於語言規格本身

Ada 的並行處理模型:
- 任務(task) ── 獨立執行的並行單元
- 會合(rendezvous) ── 任務之間的同步通訊
- 保護物件(protected object) ── 由語言管理的排他控制
- 即時優先權 ── Annex D 即時功能

任務與會合機制自 1983 年的 Ada 83 就已存在,保護物件與 Annex D 即時功能則在 Ada 95 中加入,並在 Ada 2005、Ada 2012 中持續演進。相較於互斥鎖或旗標這類低階同步原語,「能夠直接用程式碼表達設計意圖」正是 Ada 並行處理最大的特色。

本文將透過 8 個實戰程式碼範例,逐步解說 Ada 的並行處理。每個範例都是獨立可編譯、可執行的程式片段,讀者可以親自上手嘗試。

順帶一提,本文出現的程式碼片段已依章節整理成參考程式碼集,公開於 GitHub 上。

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

2. 回顧並行處理的「危險」

在進入 Ada 的細節之前,先簡單確認一下為什麼「安全的」並行處理如此重要。

並行處理中常見的典型錯誤包括:

  • 資料競爭 (data race):多個執行緒同時存取同一記憶體位置,且至少有一方在寫入時發生。結果為未定義行為。
  • 死結 (deadlock):多個任務互相等待對方完成,導致永遠無法繼續執行。
  • 優先權反轉 (priority inversion):高優先權任務等待低優先權任務所持有的資源,而中優先權任務卻搶佔了低優先權任務。
  • 飢餓 (starvation):某個任務永遠無法取得所需資源。

Ada 的並行處理模型針對這些問題提供了語言層級的防禦機制

資料競爭 → 保護物件保證排他存取
死結 → 會合模型提供結構化的同步方式
優先權反轉 → 語言內建可用的 Priority Ceiling Protocol(優先權上限協定)
飢餓 → 透過入口屏障與排隊策略加以控制

3. 任務的基礎 ── 獨立的執行單元

Ada 中並行處理的基本單位是任務(task)。任務類似於執行緒,但不一定與作業系統執行緒一一對應,排程由 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; 同步。這個過程稱為會合(rendezvous)

任務的結束會被自動等待。 主程序結束時,若還有任務在執行中,會隱含地等待這些任務全部完成。這與 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 區塊就是關鍵區段(critical section)。在此期間,呼叫端會被阻擋,任務也不會接受其他入口呼叫。處理完成後,兩者才會同時繼續執行。

整理一下會合的特性:

特性 說明
同步 呼叫端與任務端必須同時到達會合點才會繼續
資料傳輸 透過 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 為止;若消費者太快,則要等到生產者下一次呼叫為止。這自然而然地形成了反壓(back pressure)

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)中,三個工作任務各自呼叫 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 層級其實是「讀取 → 加總 → 寫回」三個步驟。若多個任務同時執行這段程式碼,某個任務的加總結果可能追不上另一個任務的讀取,導致遞增被遺失。更嚴重的是,這在 Ada RM 9.10 中屬於錯誤執行(erroneous execution)。對未同步化的共享變數同時進行讀寫,不只會讓計數結果不準確,還可能讓整個程式的行為變得任意不可控。即使兩個任務各執行 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 + 條件變數所寫的版本做個比較。

// 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_Worker 正在執行 delay 2.0,尚未到達 accept,因此排隊中的入口呼叫會在 500 毫秒後逾時。(逾時只作用於會合被接受之前的排隊等待時間,並不會中斷會合本體的執行。)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)為每個保護物件明確設定上限優先權(ceiling priority)。若呼叫端任務的當前活躍優先權超過該上限,就會引發 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 until 而不是 delay

在並行處理中,「大概沒問題」是最危險的想法。把共享狀態、等待條件、終止條件、例外方針明確地寫在程式碼上,才是安全並行處理的第一步。

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 Profile:針對高可靠即時系統的任務限制規範。受限的任務模型使靜態死結分析成為可能。
  • Ada 2022 的平行區塊:透過 parallel ... do 語法實現資料平行處理。
  • 與 SPARK 整合:對並行程式的行為進行形式化驗證(在 Ravenscar Profile 下由 GNATprove 支援)。

即便如此,「用了 Ada 就安全」仍是錯的

最後有一個重要提醒。Ada 的並行處理語法雖然強大,但使用 Ada 並不會自動變得安全。直接觸碰共享資料而不放入保護物件、在保護物件中長時間阻塞、複雜地互相呼叫多個保護物件——這些設計錯誤在 Ada 中依然可能發生。

語言功能的設計理念是「要寫出危險的程式碼需要付出明確的努力」,但它並不會代替你完成正確的設計本身。Ada 的真正價值,在於能把安全性的討論拉近到程式碼本身——「這個狀態有沒有被保護?」「這個任務什麼時候結束?」「這個入口在什麼條件下等待?」——這些問題都能以語法的形式留在程式碼上。

用型別來表達設計的 Ada 思想,在並行處理上也是一貫的。安全的並行處理,並不是從謹慎地操作鎖開始,而是從不讓危險的共享狀態裸露存在開始的。

面對「並行處理很難」這種普遍看法,Ada 的答案是:「只要選對語法,安全性就能由編譯器保證。」這樣的設計思想,與現代的 Rust、Pony 有相通之處,但 Ada 早在 40 年前就已把它作為語言規格的一部分持續保有至今。

共用相同標籤的最新文章。能以相近的主題延伸理解。

與本文相近的主題頁面。以本文為起點,可進一步連到相關服務與其他文章。

常見問題

整理諮詢這個主題時常見的問題。

Ada 的任務(task)是什麼?
任務是 Ada 並行處理的基本單位,類似於執行緒,但不一定與作業系統執行緒一一對應,而是由 Ada 執行環境負責排程管理。任務一經宣告就會自動開始執行,主程序結束時會隱含地等待所有仍在執行中的任務完成。任務對外透過入口(entry)進行會合(rendezvous)這種同步通訊。任務與會合機制自 1983 年的 Ada 83 起就已內建於語言規格中。
Ada 的保護物件與互斥鎖(mutex)有什麼不同?
保護物件是由語言本身管理的排他控制機制,不需要手動撰寫鎖定與解鎖。函式(function)是唯讀的,可供多個任務同時呼叫;程序(procedure)可讀寫,執行期間會阻擋其他呼叫;入口(entry)則會讓呼叫端排隊等待,直到屏障條件為真為止。用 C 語言搭配 pthread 的 mutex 與條件變數才能寫出的有界緩衝區控制邏輯,在 Ada 中只需一行像 `when Count < Buffer_Size` 這樣的屏障即可濃縮完成。
Ada 的會合(rendezvous)是怎樣的機制?
會合是任務之間的同步通訊機制:呼叫端的入口呼叫與任務端的 accept 陳述式必須同時到達會合點,雙方才會互相等待彼此。透過 in、out、in out 三種參數模式,可以雙向傳遞資料。accept 主體中的 do...end 區塊即為關鍵區段(critical section),執行期間呼叫端會被阻擋,任務也不會接受其他入口呼叫。搭配 select 陳述式,還能宣告式地寫出多入口等待、逾時與防衛條件等邏輯。
在保護物件裡絕對不能做的事情是什麼?
像 delay、耗時的 I/O、呼叫沉重的外部函式庫等長時間阻塞的操作,都是絕對禁止的。在保護操作內執行 delay 或特定 I/O,依 Ada 規格屬於限定錯誤(bounded error),依實作可能導致 Program_Error 或死結,因此必須完全排除,而不是只是「盡量避免」。原則是只在保護物件內做簡短的狀態更新,將沉重的運算或 I/O 放到保護物件之外執行,最後只把結果短時間寫回。

作者檔案

本文作者的個人檔案頁面。

Go Komura

小村軟體有限公司 代表

以 Windows 軟體開發、技術諮詢與故障調查為中心,在難以重現的故障調查與既有資產仍在運作的專案上具有優勢。

回到部落格一覽