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 的泛型编程,涵盖泛型子程序、泛型包、形式子程序参数、类型类别、正式包参数,以及实务中的设计方针。结合 Mermaid 图与实例,整理类型安全的复用与零成本抽象的理念。
Ada 语言的魅力 —— 用类型阐述设计,支撑运行数十年软件的语言
介绍 Ada 语言的魅力。从强类型、范围约束、用包分离规格与实现、契约式设计、语言内置的任务(task)、SPARK 形式化验证,到 GNAT 与 Alire 开发环境,整理支撑高可靠软件的设计理念。
SPARK形式化验证入门 ── 从Ada的契约走向数学证明
本文是使用Ada子集语言SPARK进行形式化验证的实务入门文章,整理从契约(Pre/Post)进阶到证明的方法、GNATprove的使用方式、循环不变式、数据流契约、证明等级,以及在实际项目中导入的方法。
共享内存的陷阱与实务最佳实践
整理在实务中使用共享内存时的陷阱,以及涵盖同步、可见性、生命周期、ABI、权限等降低事故率的设计方法。
常见问题
汇总了咨询这一主题时常见的问题。
- 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 软件开发、技术咨询与故障排查为中心,擅长难以复现的故障调查,以及既有资产仍在运行的项目。