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(¬_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,因此排隊中的入口呼叫會在 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_Size 或 when 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 打造即時系統程式設計 ── 優先權、週期、執行時間控制實戰
本文透過 8 個實戰程式碼範例,逐步學習 Ada Annex D(即時系統)所提供的即時功能──任務優先權、Ceiling_Locking 協定、delay until 的週期執行、Ravenscar 設定檔、計時事件、保護佇列、依任務量測執行時間、多週期整合。
Ada 語言的魅力 —— 用型別闡述設計理念,支撐運作數十年軟體的語言
介紹 Ada 語言的魅力。從強型別、範圍限制、以套件分離規格與實作、契約式設計、內建於語言的工作(task)、SPARK 形式驗證,到 GNAT 與 Alire 開發環境,整理支撐高可靠軟體的設計理念。
Ada 的泛型程式設計 ── 以型別撰寫契約,零成本達成程式碼重用
系統性說明 Ada 的泛型程式設計,涵蓋泛型子程式、泛型套件、形式子程式參數、型別類別、正式套件參數,以及實務上的設計方針。搭配 Mermaid 圖與實例,整理型別安全的重用與零成本抽象化的思路。
SPARK形式驗證入門 ── 從Ada的契約到數學證明
本文是使用Ada子集語言SPARK進行形式驗證的實務入門文章,整理從契約(Pre/Post)進階到證明的方法、GNATprove的使用方式、迴圈不變條件、資料流契約、證明等級,以及在實際專案中導入的方法。
使用共享記憶體時的陷阱與最佳實踐 - 先整理同步、可見性、壽命、ABI、安全性
整理在同一機器內以共享記憶體交換大型資料時的陷阱與設計要點。把 control plane 和 data plane 分離、縮小並行模型、用固定寬度整數和標頭設計 ABI、以 offset 取代指標、明示 commit protocol、為當機復原放入 generation...
相關主題
與本文相近的主題頁面。以本文為起點,可進一步連到相關服務與其他文章。
Windows 技術主題
彙整 KomuraSoft LLC 關於 Windows 開發、故障調查與既有資產活用文章的主題中心。
常見問題
整理諮詢這個主題時常見的問題。
- 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 軟體開發、技術諮詢與故障調查為中心,在難以重現的故障調查與既有資產仍在運作的專案上具有優勢。