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 软件开发、技术咨询与故障排查为中心,擅长难以复现的故障调查,以及既有资产仍在运行的项目。

返回博客列表