Безопасная конкурентность в Ada — практическое руководство по задачам и защищённым объектам

· Обновлено: · · Ada, Конкурентность, Таскинг, Защищённые объекты, Рандеву, Реальное время, Параллельное программирование, Язык программирования, Параллелизм, Высокая надёжность

История изменений (6 обновлений, последнее 30 Aug 2026)

Журнал изменений этой статьи. Там, где версия до правки была заархивирована, она остаётся доступной для чтения по постоянной ссылке с DOI.

Восстановлен пример guarded-buffer Ada. Утверждения статьи не менялись.
Восстановлены диаграммы последовательности рандеву Ada. Утверждения статьи не менялись.
Восстановлены пошаговые разборы выполнения Ada. Утверждения статьи не менялись.
Переведены названия источников в списке литературы. Утверждения статьи не менялись.
Карта знаний: раздел knowledge map перегенерирован после слияния #412. Утверждения статьи не менялись.
Добавлен автоматически сгенерированный раздел knowledge map. Текст статьи не менялся.
Первая публикация
Цитирование статьи(DOI (зарегистрированный архив): 10.5281/zenodo.21619913)

Приведённые ниже DOI относятся к ранее зарегистрированным архивным версиям, которые могут отличаться от текущего текста. Для ссылки на текущий текст используйте URL этой страницы.

Го Комура (2026). Безопасная конкурентность в Ada — практическое руководство по задачам и защищённым объектам. KomuraSoft LLC. https://comcomponent.com/ru/blog/ada-task-concurrency/

DOI (зарегистрированный архив)
10.5281/zenodo.21619913
DOI (последняя зарегистрированная версия)
10.5281/zenodo.21619914

1. Введение — конкурентность, встроенная в язык

Конкурентность — неизбежная тема современной разработки программного обеспечения. Однако во многих языках конкурентность является «довеском»: она зависит от библиотек или возможностей ОС, и чтобы использовать её правильно, требуются глубокие знания и осторожное проектирование.

У Ada есть собственный ответ на эту проблему. Конкурентность встроена непосредственно в спецификацию языка.

Модель конкурентности Ada:
- Задача (task) — независимо выполняемая единица конкурентности
- Рандеву (rendezvous) — синхронный обмен между задачами
- Защищённый объект (protected object) — управляемое языком взаимное исключение
- Приоритеты реального времени — возможности Annex D для реального времени

Задачи и рандеву существуют начиная с Ada 83 (1983 год), защищённые объекты и возможности реального времени Annex D были добавлены в Ada 95, а затем эволюционировали в Ada 2005 и Ada 2012. Ada опирается не на низкоуровневые примитивы синхронизации вроде мьютексов и семафоров — её главная особенность в том, что «замысел проектирования можно выразить прямо в коде».

В этой статье мы последовательно разберём конкурентность в Ada на 8 практических примерах кода. Каждый пример — самостоятельный фрагмент, который можно скомпилировать и выполнить, и вы можете попробовать его у себя.

Фрагменты кода, встречающиеся в этой статье, опубликованы на GitHub в виде справочного набора, упорядоченного по главам.

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

Запустить у себя — сборка и выполнение

Раз уж сказано «можно скомпилировать и выполнить», сначала покажу и саму процедуру.

Подготовить GNAT

GNAT — компилятор Ada из состава GCC. В Linux его ставят командой apt install gnat-13, в Windows — пакетом MSYS2 mingw-w64-x86_64-gcc-ada, либо через Alire (менеджер пакетов Ada / SPARK).

Собрать и запустить

Каждый фрагмент содержит в одном файле несколько единиц компиляции (спецификацию задачи, тело задачи, главную процедуру), поэтому его сначала разделяют через gnatchop, а затем собирают через gnatmake. gnatchop — инструмент, который режет файл по соглашению об именовании GNAT: «имя единицы = имя файла».

mkdir work && cd work
gnatchop ../src/snippets/01_hello_task.ada
gnatmake hello_task_demo
./hello_task_demo

Имя главной процедуры после разбиения становится именем исполняемого файла.

Соответствие глав и файлов

Номер главы и номер файла сдвинуты на единицу (глава 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 Выборочный accept и серверная задача
Глава 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 Приоритеты задач и планирование реального времени

Фрагменты в тексте статьи вырезаны только в той части, которая нужна для объяснения. Сами по себе они не запускаются — когда хотите попробовать руками, используйте файлы выше.

На схеме сплошная линия обозначает отношение, которое выполняется всегда, а пунктирная — условное отношение (условия указаны в пояснении к каждому отношению на странице сведений). Полный список отношений (всего 20, с доказательствами и степенью уверенности) и определения основных понятий собраны на странице сведений карты знаний (на японском). Данные: JSON-LD / Turtle

2. Вспомним об «опасностях» конкурентности

Прежде чем перейти к Ada, кратко напомним, почему важна именно «безопасная» конкурентность.

К типичным ошибкам конкурентности относятся следующие.

  • Гонка данных (data race): несколько потоков одновременно обращаются к одной и той же области памяти, причём хотя бы один — на запись. Результат не определён.
  • Взаимная блокировка (deadlock): несколько задач бесконечно ждут завершения друг друга, и ни одна не может продвинуться дальше.
  • Инверсия приоритетов (priority inversion): задача с высоким приоритетом ждёт ресурс, который удерживает задача с низким приоритетом, а задача со средним приоритетом при этом вытесняет задачу с низким приоритетом.
  • Голодание (starvation): задача никогда не может получить нужный ей ресурс.

Модель конкурентности Ada предоставляет защиту на уровне языка от этих проблем.

Гонка данных        → защищённый объект гарантирует эксклюзивный доступ
Взаимная блокировка → модель рандеву обеспечивает структурированную синхронизацию
Инверсия приоритетов → протокол Priority Ceiling Protocol доступен как встроенная возможность языка
Голодание            → барьеры точек входа и политика очередей обеспечивают контроль

3. Основы задач — независимые единицы выполнения

Базовая единица конкурентности в Ada — это задача (task). Задача напоминает поток, но необязательно соответствует потоку ОС один к одному — планированием занимается runtime 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; задачи. Это называется рандеву.

Завершение задачи ожидается автоматически. Когда главная процедура завершается, если остаются ещё выполняющиеся задачи, их завершение неявно ожидается. Это разительно отличается от сбоев из-за забытого вызова std::thread::join в C++.

Пример выполнения

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.

Здесь есть одно замечание. У accept Start; нет блока do ... end. Это значит, что в момент установления рандеву обе стороны освобождаются и дальше идут параллельно. Поэтому порядок двух последних строк не гарантируется. В некоторых средах сначала может появиться Main: task has completed. Если нужно зафиксировать и порядок, поместите действия, которые должны идти строго по очереди, внутрь do ... end в accept Start do ... end Start;. Первая строка всегда идёт первой: Greeter стоит на accept, пока не вызовут Greeter.Start;.

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: передаёт данные в обе стороны

Блок do ... end внутри accept становится критической секцией. Пока он выполняется, вызывающая сторона заблокирована, а задача не принимает другие точки входа. По завершении обработки обе стороны возобновляют работу.

По времени ожидание выглядит так.

Хронология рандеву Worker.ComputeВызывающая сторона вызывает Worker.Compute и блокируется, пока не будет достигнут accept; задача достигает accept Compute, рандеву устанавливается, результат записывается в out-параметр Result, и на end Compute обе стороны возобновляются одновременно.задача WorkerВызывающая стороназадача WorkerВызывающая сторонаБлокируется, пока не достигнут acceptРандеву установлено / выполняется тело acceptНа end Compute обе стороны возобновляются одновременноВызов Worker.ComputeДостигает accept Compute ... doЗаписывает результат в out-параметр ResultПродолжение обработкиПродолжение обработки

Кто пришёл первым — тот и ждёт. Если первой оказывается вызывающая сторона, она останавливается, пока не будет достигнут accept; если первой оказывается задача — пока её кто-нибудь не вызовет. В любом случае содержимое do ... end выполняется только когда обе стороны уже на месте.

Кратко об особенностях рандеву:

Особенность Описание
Синхронность вызывающая сторона и задача ждут, пока обе не достигнут точки рандеву одновременно
Передача данных параметры 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. Пробел сразу после = — это спецификация 'Image для целых: перед неотрицательным значением ставится один пробел. В отличие от главы 3, здесь порядок двух строк гарантирован: вызов Worker.Compute не возвращается, пока не будет выполнен end Compute;.

5. Выборочный accept — ожидание нескольких сервисов

В реальных серверных задачах часто нужно ожидать запросы нескольких видов. Оператор select в Ada реализует это на уровне языка.

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;

В операторе select этого кода (03_selective_accept.ada) несколько ветвей or, и выбирается одна из точек входа, по которой уже есть вызов (сам выбор определяется реализацией). Если ни одна точка входа ещё не вызвана, задача ждёт, пока какую-либо из них не вызовут.

or terminate; — особая ветвь: она позволяет задаче безопасно завершиться, когда «главная процедура завершилась, и больше никто не может вызвать точку входа этой задачи». Это уникальный для Ada механизм, решающий проблему «сервер, ожидающий вечно», которая иначе приводит к взаимной блокировке.

Сильная сторона выборочного accept — возможность указывать защитные условия (guard conditions).

Следующий пример — задача с внутренним кольцевым буфером. Если вырезать только фрагмент 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. Producer-consumer — синхронизация через рандеву

Рассмотрим типичный паттерн, использующий рандеву, — producer-consumer.

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; если слишком быстро работает потребитель, он ждёт следующего вызова производителя. Так естественным образом возникает обратное давление (backpressure).

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) три задачи-воркера вызывают Increment по 1000 раз каждая. Защищённый объект гарантирует взаимное исключение, поэтому итоговое значение счётчика всегда равно 3000. Вручную писать блокировку/разблокировку мьютекса не нужно.

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);

Пример выполнения

Здесь есть одна проблема. Если главная процедура сразу читает 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; и только потом читает значение. Отдельная переменная-флаг для ожидания и sleep не нужны.

gnatchop ../src/snippets/05_protected_counter.ada
gnatmake protected_counter_demo
./protected_counter_demo
Final counter value = 3000

Сколько ни запускайте — будет 3000. Три задачи вызывают Increment в сумме 3000 раз, и защищённый объект выполняет каждый такой вызов взаимно исключительно.

Что произойдёт без защищённого объекта

Чтобы понять ценность защищённых объектов, рассмотрим опасный код, в котором данные не защищены.

-- ⚠ Опасно: прямая работа с разделяемой переменной
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 на уровне процессора — это три шага: «чтение → сложение → запись обратно». Когда несколько задач выполняют это одновременно, результат сложения одной задачи может не успеть попасть в память до чтения другой, и приращение теряется. Более того, это подпадает под понятие ошибочного выполнения (erroneous execution) согласно Ada RM 9.10: одновременное чтение и запись несинхронизированной разделяемой переменной может привести не просто к неточному итоговому значению счётчика, а к произвольному поведению всей программы. Даже если две задачи выполнят по 10 000 итераций каждая, итоговое значение вовсе не гарантированно окажется равным 20 000.

Защищённый объект предотвращает эту проблему «на уровне синтаксиса». Достаточно вызвать Counter.Increment; — компилятор и runtime сами гарантируют взаимное исключение.

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, получается следующее.

Когда повторно вычисляются барьеры защищённых точек входаConsumer вызывает Get на пустом буфере и ждёт в очереди; Producer выполняет Put, Count становится 1, в конце защищённой операции барьер Get вычисляется заново и становится истинным, после чего тело Get выполняется и Consumer освобождается.задача Producerзащищённый объект Bufзадача Consumerзадача Producerзащищённый объект Bufзадача ConsumerОжидает в очереди точки входа GetВ момент завершения защищённой операции барьеры ожидающих точек входа вычисляются зановоВызов GetОценка барьера «Count больше 0?» → ложьВызов PutОценка барьера «Count меньше Buffer_Size?» → истинаВыполнение тела Put / Count становится 1Барьер Get стал истиннымВыполнение тела Get и освобождение Consumer

Главное: повторное вычисление барьеров выполняется пакетом в конце защищённой операции. Между завершением тела Put и освобождением блокировки Buf оцениваются барьеры ожидающих точек входа, и те, что стали истинными, выполняются сразу. Ошибки в духе условных переменных C — «если кто-то забудет вызвать signal, никто больше не проснётся» — здесь нет.

Этот паттерн (06_bounded_buffer.ada) — один из случаев, где защищённые объекты Ada проявляют себя лучше всего. Сравните его с тем, как это пишется на C с использованием mutex + condition variable из pthread.

// Вариант на C + pthread (для сравнения с Ada)
pthread_mutex_lock(&mutex);
while (count >= BUFFER_SIZE) {           // эквивалент when в Ada
    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 позволяет немедленно перейти к альтернативной обработке, если рандеву невозможно выполнить сразу. Писать опрос (polling) вручную не требуется.

Не забывайте о проектировании после тайм-аута

Тайм-ауты удобны, но суть проектирования — в том, «что делать после того, как не дождались». Действительно ли можно отбросить значение, стоит ли повторить попытку, нужно ли сообщить об ошибке наверх — если оставить это неопределённым, в продакшене это превращается в потерю данных или остановку сервиса. Когда пишете тайм-аут, продумывайте ответственность за то, что происходит после него, в том же месте.

Периодические задачи и 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 (приложений для специальных областей); поддержка зависит от реализации и среды выполнения. Доступно ли это у вас, можно разобрать в три шага.

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);

Если сборка с этими pragma проходит, синтаксически они как минимум приняты.

3. Проверить, что приоритеты реально соблюдаются

Здесь главная ловушка. То, что код компилируется, и то, что планировщик ОС действительно соблюдает приоритеты, — разные вещи. На универсальных ОС вроде Linux и Windows, чтобы приоритеты реального времени реально дошли до планировщика, иногда нужны права на стороне ОС. В 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 (протокол верхнего предела приоритета) — это протокол, предотвращающий инверсию приоритетов. Для каждого защищённого объекта явно задаётся предельный приоритет (ceiling priority) через pragma Priority (или аспект 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 и медленного ввода-вывода;
  • сложных вызовов другого защищённого объекта;
  • тяжёлых вызовов внешних библиотек.

При этом delay и определённые операции ввода-вывода внутри защищённого действия — это не просто вопрос производительности, а ограниченная ошибка (bounded error) по стандарту Ada. В зависимости от реализации это может привести к Program_Error или к взаимной блокировке, поэтому такие операции нужно не «стараться избегать», а полностью исключать.

Хороший паттерн проектирования: «быстро извлечь из защищённого объекта нужные значения → выполнить тяжёлые вычисления или ввод-вывод снаружи → быстро записать обратно в защищённый объект только результат».

Держите барьерные условия простыми

Барьер entry ... when <условие> — мощный инструмент, но если он становится слишком сложным, его трудно читать, и сложно понять, почему задача не освобождается.

Идеал — уровень, на котором смысл состояния понятен с первого взгляда, как в 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 <барьер>
Тайм-аут or delay until <время>
Управление приоритетом pragma Priority

Эти конструкции проверяются компилятором. Например, попытка изменить приватные компоненты самого защищённого объекта внутри его функции приводит к ошибке компиляции. Когда защищённая операция завершается, барьеры ожидающих точек входа автоматически пересматриваются — вручную отправлять сигнал не нужно.

«Подобно тому как система типов гарантирует безопасность памяти,
  синтаксис конкурентности Ada гарантирует безопасность синхронизации»

Восемь примеров кода, рассмотренных в этой статье, — практическое введение в задачи, рандеву, защищённые объекты и возможности реального времени. Попробуйте запустить их у себя, а затем обратитесь к следующим более продвинутым темам.

  • Профиль Ravenscar: ограничивающий профиль для задач, предназначенный для высоконадёжных систем реального времени. Ограниченная модель задач делает возможным статический анализ взаимных блокировок.
  • Параллельные блоки Ada 2022: параллелизм данных с помощью конструкции parallel ... do.
  • Интеграция со SPARK: формальная верификация поведения конкурентных программ (поддерживается GNATprove в рамках профиля Ravenscar).

И всё же «использовать Ada» не значит «быть в безопасности»

Последнее важное замечание. Синтаксис конкурентности Ada мощный, но использование Ada само по себе не делает программу безопасной автоматически. Прямая работа с разделяемыми данными в обход защищённого объекта, долгая блокировка внутри защищённого объекта, сложные взаимные вызовы между несколькими защищёнными объектами — такие ошибки проектирования возможны и в Ada.

Возможности языка спроектированы так, что «для опасного стиля кода требуется явное усилие», но они не выполняют проектирование за вас. Настоящая ценность Ada в том, что она позволяет приблизить обсуждение безопасности к самому коду — вопросы вроде «защищено ли это состояние?», «когда завершается эта задача?», «при каком условии ждёт эта точка входа?» можно оставить прямо в коде в виде синтаксиса.

Философия Ada — выражать проектирование через типы — последовательна и в вопросах конкурентности. Безопасная конкурентность начинается не с аккуратного обращения с блокировками, а с того, чтобы не допускать существования опасного разделяемого состояния «в открытом виде».

На расхожее мнение «конкурентность — это сложно» Ada отвечает: «если правильно выбрать синтаксис, безопасность гарантирует компилятор». Эта философия проектирования перекликается с современными языками вроде Rust и Pony, но Ada удерживает её в спецификации языка уже более 40 лет.

Недавние статьи с теми же тегами помогут подробнее изучить близкие темы.

Эти страницы показывают тему статьи в более широком контексте услуг и решений.

Частые вопросы

Вопросы, которые часто возникают при консультациях по теме статьи.

Что такое задача (task) в Ada?
Задача — это базовая единица конкурентности в Ada. Она напоминает поток (thread), но необязательно соответствует потоку ОС один к одному — планированием занимается runtime Ada. Задача начинает выполняться автоматически сразу после объявления, а когда завершается главная процедура, ещё выполняющиеся задачи неявно ожидаются до завершения. С внешним миром задача взаимодействует через синхронный обмен — рандеву — с помощью точек входа (entry). Задачи и рандеву входят в спецификацию языка ещё с Ada 83 (1983 год).
Чем защищённые объекты в Ada отличаются от мьютексов?
Защищённый объект — это механизм взаимного исключения, которым управляет сам язык: вручную писать lock/unlock не нужно. Функции (function) доступны только для чтения и могут вызываться несколькими задачами одновременно; процедуры (procedure) предназначены для чтения и записи — во время их выполнения блокируются все остальные вызовы; точки входа (entry) снабжены барьерным условием и ставят вызывающую сторону в очередь, пока условие не станет истинным. То, что на C пишется как комбинация pthread-мьютекса и условной переменной для управления кольцевым буфером с ограничением, в Ada сжимается до одной строки барьера вида «when Count < Buffer_Size».
Как устроено рандеву в Ada?
Рандеву — это механизм синхронного обмена между задачами: вызов точки входа на стороне вызывающего и оператор accept на стороне задачи ждут друг друга, пока оба не достигнут точки рандеву одновременно. Параметры с режимами in/out/in out позволяют передавать данные в обе стороны. Блок do...end внутри accept становится критической секцией: пока он выполняется, вызывающая сторона заблокирована, а задача не принимает другие точки входа. В сочетании с оператором select можно декларативно описать ожидание нескольких точек входа, тайм-ауты и защитные условия (guard conditions).
Чего нельзя делать внутри защищённого объекта?
Нельзя выполнять длительные блокирующие операции — delay, медленный ввод-вывод, тяжёлые вызовы внешних библиотек. delay и определённые операции ввода-вывода внутри защищённого действия относятся к ограниченной ошибке (bounded error) по стандарту Ada и, в зависимости от реализации, могут привести к возникновению Program_Error или к взаимной блокировке, поэтому их нужно полностью исключать, а не просто избегать по возможности. Золотое правило: внутри защищённого объекта делать только короткое обновление состояния, а тяжёлые вычисления и ввод-вывод выполнять снаружи, возвращая внутрь лишь результат — быстро и коротко.

Об авторе

Страница с профилем автора статьи.

Го Комура

Представитель KomuraSoft LLC

Специализируется на разработке программного обеспечения для Windows, техническом консалтинге и расследовании сбоев, особенно в проектах с унаследованными системами и трудно воспроизводимыми ошибками.

Публичные ссылки

Вернуться в блог