Безопасная конкурентность в 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 становится критической секцией. Пока он выполняется, вызывающая сторона заблокирована, а задача не принимает другие точки входа. По завершении обработки обе стороны возобновляют работу.
По времени ожидание выглядит так.
sequenceDiagram
accTitle: Хронология рандеву Worker.Compute
accDescr: Вызывающая сторона вызывает Worker.Compute и блокируется, пока не будет достигнут accept; задача достигает accept Compute, рандеву устанавливается, результат записывается в out-параметр Result, и на end Compute обе стороны возобновляются одновременно.
participant Main as Вызывающая сторона
participant W as задача Worker
Main->>W: Вызов Worker.Compute
Note over Main: Блокируется, пока не достигнут accept
W->>W: Достигает accept Compute ... do
Note over Main,W: Рандеву установлено / выполняется тело accept
W-->>Main: Записывает результат в out-параметр Result
Note over Main,W: На end Compute обе стороны возобновляются одновременно
Main->>Main: Продолжение обработки
W->>W: Продолжение обработки
Кто пришёл первым — тот и ждёт. Если первой оказывается вызывающая сторона, она останавливается, пока не будет достигнут 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, получается следующее.
sequenceDiagram
accTitle: Когда повторно вычисляются барьеры защищённых точек входа
accDescr: Consumer вызывает Get на пустом буфере и ждёт в очереди; Producer выполняет Put, Count становится 1, в конце защищённой операции барьер Get вычисляется заново и становится истинным, после чего тело Get выполняется и Consumer освобождается.
participant C as задача Consumer
participant B as защищённый объект Buf
participant P as задача Producer
C->>B: Вызов Get
B->>B: Оценка барьера «Count больше 0?» → ложь
Note over C: Ожидает в очереди точки входа Get
P->>B: Вызов Put
B->>B: Оценка барьера «Count меньше Buffer_Size?» → истина
B->>B: Выполнение тела Put / Count становится 1
Note over B: В момент завершения защищённой операции барьеры ожидающих точек входа вычисляются заново
B->>B: Барьер Get стал истинным
B-->>C: Выполнение тела 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(¬_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 позволяет немедленно перейти к альтернативной обработке, если рандеву невозможно выполнить сразу. Писать опрос (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 лет.
Похожие статьи
Недавние статьи с теми же тегами помогут подробнее изучить близкие темы.
Программирование систем реального времени на Ada — приоритеты, периодичность и контроль времени выполнения на практике
Изучаем Annex D Ada (системы реального времени) на 8 практических примерах кода: приоритеты задач, протокол Ceiling_Locking, периодическо...
Дженерики в Ada ── контракты через типы и повторное использование без накладных расходов
Систематическое руководство по обобщённому программированию в Ada: обобщённые подпрограммы и пакеты, формальные параметры-подпрограммы, к...
Введение в формальную верификацию SPARK ── от контрактов Ada к математическому доказательству
Практическое введение в формальную верификацию SPARK — подмножества Ada. Как подняться от контрактов (Pre/Post) к доказательству, как пол...
Привлекательность языка Ada ── типы выражают проект, язык, который держит ПО, работающее десятилетиями
Разбираем, чем привлекателен язык Ada. Сильная типизация, ограничения диапазона, разделение спецификации и реализации через пакеты, контр...
Разделяемая память: подводные камни и практические рекомендации
Подводные камни разделяемой памяти на практике и проектирование, которое снижает частоту сбоев: синхронизация, видимость, время жизни, AB...
Связанные темы
Эти страницы показывают тему статьи в более широком контексте услуг и решений.
Технические темы Windows
Раздел о разработке Windows, расследовании сбоев и использовании существующих активов.
Частые вопросы
Вопросы, которые часто возникают при консультациях по теме статьи.
- Что такое задача (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, техническом консалтинге и расследовании сбоев, особенно в проектах с унаследованными системами и трудно воспроизводимыми ошибками.