Задачи (task): параллелизм как часть языка

Задача (task) в Ada — это поток управления, встроенный прямо в язык: конкурентность здесь не библиотека поверх ОС, а первоклассная конструкция с собственным синтаксисом и семантикой.

Задача (task) — независимая единица параллельного исполнения внутри программы Ada: объявляется как самостоятельная сущность, имеет собственный поток управления и стек, запускается автоматически и выполняется конкурентно с остальной программой.

В большинстве языков параллелизм пришёл извне — как библиотека (pthreads в C, std::thread в C++) или как более поздняя надстройка. Ada поступила радикально иначе: конкурентность встроена в само определение языка с 1983 года. Это не случайность, а необходимость предметной области — встраиваемые системы и системы реального времени по своей природе параллельны: одновременно опрашиваются датчики, обновляются дисплеи, считаются управляющие воздействия. Сделав задачи частью языка, а не платформы, Ada получила переносимую, проверяемую компилятором и независимую от конкретной ОС модель параллелизма — то, чего годами не хватало системному программированию.

Зачем встраивать параллелизм в язык

Когда конкурентность — это библиотека, компилятор её «не понимает»: для него вызовы создания потоков и блокировок — обычные функции, чью корректность он проверить не может. Гонки данных, забытые разблокировки, неверный порядок захвата мьютексов остаются на совести программиста и всплывают как редкие, невоспроизводимые сбои. Встроив задачи в язык, Ada дала компилятору знание о параллелизме: он видит, где начинается и заканчивается задача, как задачи общаются, какие данные разделяются. Это открывает дорогу к статическим проверкам и к безопасным по построению механизмам взаимодействия (рандеву и защищённые объекты — темы следующих уроков), которые исключают целые классы ошибок конкурентности на уровне конструкций языка.

Второе преимущество — переносимость и определённость. Семантика задач задана стандартом, а не нравом конкретной библиотеки потоков. Программа с задачами ведёт себя предсказуемо на разных платформах, а для жёсткого реального времени стандарт описывает специальные приложения (Real-Time Systems Annex) с гарантиями планирования, недостижимыми через сырые потоки ОС.

Объявление и запуск задачи

Как и пакет, задача имеет две части: спецификацию (объявляет задачу и её точки взаимодействия) и тело (код, который задача исполняет). Простейшая задача без точек взаимодействия объявляется и запускается автоматически — её тело начинает выполняться, как только «оживёт» окружающая область.

with Ada.Text_IO; use Ada.Text_IO;
procedure Two_Tasks is

   task Worker_A;                 -- спецификация (без точек входа)
   task body Worker_A is
   begin
      for I in 1 .. 3 loop
         Put_Line ("A:" & I'Image);
         delay 0.01;             -- уступить время, имитируя работу
      end loop;
   end Worker_A;

   task Worker_B;
   task body Worker_B is
   begin
      for I in 1 .. 3 loop
         Put_Line ("B:" & I'Image);
         delay 0.01;
      end loop;
   end Worker_B;

begin
   --  Обе задачи УЖЕ запущены параллельно с этим телом.
   Put_Line ("Главная процедура работает тоже");
end Two_Tasks;

Ключевая особенность: задачи Worker_A и Worker_B стартуют автоматически при оживлении процедуры Two_Tasks и выполняются конкурентно и друг с другом, и с основным телом. Точный порядок строк вывода не определён — он зависит от планировщика; это нормальное свойство параллельной программы. Оператор delay 0.01; приостанавливает задачу на указанное время (в секундах), давая другим задачам шанс выполниться.

Жизненный цикл задачи и зависимость

Задача проходит стадии: создание (при оживлении), активация (старт тела), выполнение, завершение. Важнейшее правило Ada — иерархия зависимости: задача, объявленная внутри блока, процедуры или другой задачи, считается зависимой от этой области, и область не может завершиться, пока не завершатся все зависимые от неё задачи. Это структурный, предсказуемый параллелизм: нельзя «случайно» оставить висящий поток, переживший родителя.

procedure Parent is
   task Child;
   task body Child is
   begin
      delay 0.05;
      Ada.Text_IO.Put_Line ("Child закончил");
   end Child;
begin
   Ada.Text_IO.Put_Line ("Parent дошёл до конца тела");
   --  Parent НЕ завершится здесь: он ЖДЁТ завершения Child.
end Parent;   -- выход произойдёт только после "Child закончил"

Это правило резко отличает Ada от языков, где поток-демон легко переживает создавшую его функцию. Здесь время жизни задачи структурно вложено во время жизни её области — как локальная переменная. Такой «структурированный параллелизм» предотвращает утечки потоков и упорядочивает завершение программы.

Как работает под капотом

За задачами стоит среда исполнения времени выполнения (runtime), включающая планировщик. В полнофункциональной конфигурации задачи Ada обычно отображаются на потоки ОС, и планирование делегируется операционной системе с учётом приоритетов, заданных средствами Real-Time Annex. Но Ada не привязана к «тяжёлым» потокам ОС: для встраиваемых систем существуют компактные runtime, реализующие задачи поверх собственного планировщика на «голом железе» без операционной системы вовсе. Спецификация задачи отделена от тела ровно для того, чтобы взаимодействие (точки входа, о которых речь в следующем уроке) можно было объявить как контракт, а реализацию скрыть. Оператор delay — это обращение к планировщику «не давай мне процессор до истечения интервала», и его относительная (delay D;) и абсолютная (delay until T;) формы — основа периодического исполнения в системах реального времени.

Почему встроенный параллелизм безопаснее библиотечного

Различие между «параллелизм как часть языка» и «параллелизм как библиотека» стоит разобрать глубже, потому что оно объясняет всю модель Ada. Когда конкурентность приходит библиотекой (pthreads в C, потоки поверх ОС), компилятор воспринимает создание потока и захват блокировки как обычные вызовы функций — для него это непрозрачные операции, чью семантику он не знает. Поэтому он не может предупредить о гонке данных, о захвате блокировок в неверном порядке (источник взаимоблокировок), о забытом освобождении мьютекса. Вся ответственность ложится на программиста, а ошибки конкурентности коварны: они проявляются редко, зависят от тайминга и почти невоспроизводимы при отладке.

Встроив задачи в язык, Ada дала компилятору понимание параллелизма. Он видит границы задач, знает, какие данные разделяются и через какие конструкции происходит взаимодействие. Это знание — фундамент, на котором строятся высокоуровневые безопасные механизмы: рандеву и защищённые объекты (темы следующих уроков) устроены так, что целые классы ошибок в них попросту невыразимы. Нельзя «забыть освободить» защищённый объект — взаимное исключение снимается автоматически при выходе из операции. Нельзя обратиться к разделяемым данным «в обход» — доступ только через защищённые операции. Язык не просто предоставляет потоки; он предоставляет дисциплину их безопасного использования.

Переносимость и независимость от платформы

Есть и второе важное следствие. Семантика задач определена стандартом языка, а не нравом конкретной библиотеки или особенностями ОС. Программа с задачами ведёт себя предсказуемо при переносе между платформами, потому что её модель параллелизма — часть языка, а не тонкого слоя над системными вызовами. Более того, Ada не привязана к «тяжёлым» потокам операционной системы: одна и та же программа с задачами может исполняться поверх потоков ОС на рабочей станции и поверх компактного собственного планировщика на «голом железе» микроконтроллера без всякой ОС. Стандартное приложение реального времени (Real-Time Systems Annex) добавляет переносимые средства управления приоритетами и планированием, недостижимые через сырые потоки. Так встроенный параллелизм даёт то, чего годами не хватало системному программированию: единую, переносимую, проверяемую компилятором модель конкурентности.

Задачи как активные объекты предметной области

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

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

Закрепим, чем встроенный параллелизм Ada отличается от привычного библиотечного. Делая задачи частью языка, Ada дала компилятору понимание конкурентности — а значит, и возможность строить безопасные по построению механизмы взаимодействия (рандеву, защищённые объекты), где целые классы ошибок невыразимы. Структурированная зависимость задач исключает висящие потоки; семантика, заданная стандартом, обеспечивает переносимость между потоками ОС и «голым железом»; приложение реального времени добавляет управляемое планирование. Программа на Ada моделирует параллельные процессы предметной области напрямую, а её структура совпадает со структурой реального параллелизма — что делает конкурентный код яснее и анализируемее, чем нагромождение ручных блокировок над разделяемой памятью. Это фундамент, на котором стоят следующие, более высокоуровневые механизмы раздела, и причина, по которой Ada десятилетиями остаётся языком выбора для встраиваемых систем и систем реального времени.

Частые ошибки

  • Думать, что задачу надо «запускать» вручную. Задача стартует автоматически при оживлении её области; отдельного «start» не требуется.
  • Полагаться на порядок вывода параллельных задач. Порядок чередования определяется планировщиком и не гарантирован; не закладывайте в логику конкретную последовательность строк.
  • Ожидать, что родитель завершится, бросив задачи. Область ждёт завершения всех зависимых задач; это структурная гарантия, а не блокировка по ошибке.
  • Использовать активное ожидание (busy-wait) вместо delay. Пустой цикл «ждать» сжигает процессор; delay и механизмы синхронизации уступают время корректно.

Итоги

  • Задача (task) — встроенная в язык единица параллельного исполнения со своим потоком и стеком.
  • Параллелизм в Ada — часть языка, а не библиотека: компилятор «понимает» его, что открывает дорогу безопасным механизмам взаимодействия.
  • Задача имеет спецификацию и тело; задачи без точек входа запускаются автоматически при оживлении области.
  • Действует структурированный параллелизм: область не завершается, пока живы зависимые от неё задачи, — нет висящих потоков.
  • Runtime отображает задачи на потоки ОС или на собственный планировщик «голого железа»; delay корректно уступает процессор.
Проверьте себя
1. Как запускается задача (task) в Ada, объявленная внутри процедуры?
AЕё нужно явно запустить вызовом Start
BОна стартует автоматически при оживлении (активации) окружающей области
CОна запускается только после завершения процедуры
DЗадачи в Ada вообще не запускаются без библиотеки потоков
2. Что гарантирует правило зависимости задач (структурированный параллелизм) в Ada?
AЧто задачи всегда выполняются строго по очереди
BЧто область (процедура/блок) не завершится, пока не завершатся все зависимые от неё задачи
CЧто задача переживает создавшую её процедуру
DЧто порядок вывода задач детерминирован