Безопасный параллелизм

В реальной жизни многие процессы протекают одновременно. Люди делают несколько вещей одновременно с поразительной легкостью. Кажется, женщины преуспевают в этом лучше мужчин, возможно потому, что им нужно баюкать малыша, одновременно готовя еду и отгоняя тигра от пещеры. Мужчины же обычно концентрируются на одной проблеме за раз, ловят кролика на обед, либо ищут большую пещеру, либо, возможно, даже изобретают колесо.

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

Операционные системы и задачи

Степень параллельности, доступная в разных операционных системах, может быть совершенно разная. Операционные системы с поддержкой POSIX позволяют программисту создать несколько потоков управления. Эти потоки исполняют программу довольно независимо друг от друга и, таким образом, реализуют параллельное программирование.

На некоторых системах есть всего один процессор и он будет исполнять различные потоки в соответствии с некоторым алгоритмом планирования. Один из вариантов — просто выделять небольшой интервал времени каждому потоку по очереди. Более сложные алгоритмы (особенно для систем реального времени) используют приоритеты и предельные сроки, чтобы гарантировать, что процессор используется эффективно.

Другие системы имеют несколько процессоров. В этом случае некоторые потоки выполняются действительно параллельно. Здесь тоже используется планировщик, который распределяет процессоры для исполнения активных потоков по возможности эффективно.

В языках программирования параллельные процессы обычно называют потоки или задачи. Здесь мы используем второй вариант, который соответствует терминологии Ады. Существуют различные подходы к вопросу параллельности в разных языках. Одни предлагают многозадачные средства, встроенные непосредственно в язык. Другие просто предоставляют доступ к соответствующим примитивам операционной системы. Есть и такие, которые полностью игнорируют этот вопрос.

Ада, Java, C и последние версии C++ среди тех языков, где поддержка параллельности встроена в язык. В C нет такой поддержки и программистам приходится пользоваться внешними библиотеками или напрямую вызывать сервисы операционной системы.

Есть, как минимум, три преимущества, когда язык имеет встроенные средства поддержки многозадачности:

  • Встроенный синтаксис существенно облегчает написание корректных программ, поскольку язык может предотвратить появление множества видов ошибок. Здесь опять проявляет себя принцип абстракции. Мы избегаем ошибок, скрывая различные низкоуровневые детали.
  • При использовании средств операционной системы напрямую существенно затрудняется переносимость, поскольку одна система может значительно отличаться от другой.
  • Операционные системы общего назначения не предоставляют средства, необходимые для различных приложений систем реального времени.

Многозадачной программе обычно нужны следующие условия:

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

В этой главе мы кратко остановимся на этих вопросах и продемонстрируем надежные способы их решения, применяемые в языке Ада. Реализация многозадачности влечет целый спектр сложных моментов, поскольку написать корректную многозадачную программу намного труднее, чем чисто последовательную. Но сначала мы рассмотрим простую концепцию задачи в языке Ада и общую структуру программы.

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

task A; --  Спецификация задачи

task body A is      --  Тело задачи
begin
   ...   --  инструкции, определяющие что делать
end A;

Бывают случаи, когда удобно иметь несколько одинаковых задач, тогда мы объявляем задачный тип:

task type Worker;

task body Worker is ...

После чего мы можем объявить несколько задач так же, как мы объявляем объекты:

Tom, Dick, Harry: Worker;

Тут мы создали три задачи Tom, Dick, Harry. Мы можем объявлять массивы задач, делать компоненты записи и прочее. Задачи можно объявлять всюду, где можно объявлять объекты, например в пакете, в подпрограмме или даже в другой задаче. Не удивительно, что задачи имеют лимитированный тип, поскольку нет смысла присваивать одной задаче другую.

Главная подпрограмма всей программы вызывается из так называемой задачи окружения. Именно эта задача выполняет предвыполнение пакетов библиотечного уровня, как описано в главе «Безопасный запуск». Таким образом, программу с тремя пакетами A, B и C и главной процедурой Main можно представить, как:

task type Environment_Task;

task body Environment_Task is
   ... -- объявления пакетов A, B, C
   ... -- и главной процедуры Main
begin
   ... -- вызов процедуры Main
end Environment_Task;

Задача становится активной сразу после объявления. Она завершается, когда исполнение доходит до конца тела задачи. Существует важное правило, локальная задача (т. е. та, что объявлена внутри подпрограммы, блока или другой задачи) должна завершиться до того, как управление покинет охватывающий ее блок. Исполнение окружающего блока приостанавливается до тех пор, пока вложенная задача не завершится. Это правило предотвращает появление висящих ссылок на несуществующие более объекты.

Защищенные объекты

Допустим, три задачи — Tom, Dick и Harry используют общий стек для временного хранения данных. Время от времени одна из них кладет элемент в стек, затем, время от времени, одна из них (возможна та же, а возможно другая) достает данные из стека.

Три задачи исполняются параллельно, возможно на многопроцессорной машине, либо под управлением планировщика на однопроцессорной машине, в которой ОС выделяет кванты процессорного времени каждой задаче. Допустим второе и кванты длинной 10мс выделяются задачам по очереди.

Пусть задачи используют стек из главы «Безопасная архитектура». Пусть квант, выделенный задаче Harry, истекает при вызове Push, затем управление передается задаче Tom, вызывающей Pop. Говоря более конкретно, пусть Harry теряет управление сразу после увеличения переменной Top

procedure Push(X: Float) is
begin
   Top := Top + 1; -- после этого Harry теряет управление
   A(Top) := X;
end Push;

В этот момент Top уже имеет новое значение, но новое значение X еще не помещено в массив. Когда задача Tom вызовет Pop, она получит старое, скорее всего бессмысленное значение, которое должно было быть переписанным новым значением X. Когда задача Harry получит управление назад (допустим к этому моменту не было других операций со стеком), она запишет значение X в элемент массива, который находится за вершиной стека. Другими словами, значение X будет потеряно.

Еще хуже обстоят дела, когда задачи переключаются посреди выполнения инструкции языка. Например, Harry считал значение Top в регистр, но новое значение Top не успел сохранить, и тут переключился контекст. Далее, пусть Dick вызывает Push, таким образом увеличивает Top на единицу. Когда Harry продолжит исполнение, он заменит Top устаревшим значением. Таким образом, два вызова Push увеличивают Top лишь на 1, вместо 2.

Такое нежелательное поведение может быть предотвращено благодаря использованию защищенного объекта для хранения стека. Такая возможность появилась в стандарте Ада 95. Мы напишем:

protected Stack is
   procedure Clear;
   procedure Push(X: in Float);
   procedure Pop(X: out Float);
private
   Max: constant := 100;
   Top: Integer range 0 .. Max := 0;
   A: Float_Array(1 .. Max);
end Stack;

protected body Stack is

   procedure Clear is
   begin
      Top := 0;
   end Clear;

   procedure Push(X: in Float) is
   begin
      Top := Top + 1;
      A(Top) := X;
   end Push;

   procedure Pop(X: out Float) is
   begin
      X := A(Top);
      Top := Top 1;
   end Pop;

end Stack;

Отметьте, как package поменялось на protected, данные из тела пакета переместились в private часть, функция Pop превратилась в процедуру. Мы предполагаем, что тип Float_Array объявлен в другом месте, как array (Integer range <>) of Float.

Три процедуры Clear, Push и Pop называются защищенными операциями и вызываются аналогично обычным процедурам. Отличие состоит в том, что только одна задача может получить доступ к операциям объекта в один момент времени. Если задача, такая как Tom, пытается вызвать процедуру Pop, пока Harry исполняет Push, то Tom будет приостановлен, пока Harry не покинет Push. Это выполняется автоматически, без каких‐то усилий со стороны программиста. Таким образом мы избежим несогласованности данных.

За кулисами защищенного объекта лежит механизм блокировок. Перед исполнением операции этого объекта, задача должна сначала захватить блокировку. Если другая задача уже захватила блокировку, первая задача будет ждать, пока другая задача закончит исполнять операцию и отпустит блокировку. (Блокировка может быть реализована с помощью примитивов операционной системы, но также возможны реализации с меньшими накладными расходами.)

Мы можем усовершенствовать наш пример, чтобы показать, как справиться с переполнением или опустошением стека. В первом варианте обе этих ситуации приводят к исключению Constraint_Error. В случае с Push, это происходит при попытке присвоить переменной Top значение Max+1; аналогичная проблема проявляется с Pop. При возбуждении исключения блокировка автоматически снимется, когда исключение завершит вызов процедуры.

Чтобы избежать переполненния и опустошения стека, мы используем барьеры:

protected Stack is

   procedure Clear;
   entry Push(X: in Float);
   entry Pop(X: out Float);
private
   Max: constant := 100;
   Top: Integer range 0 .. Max := 0;
   A: Float_Array(1 .. Max);
end Stack;

protected body Stack is

   procedure Clear is
   begin
      Top := 0;
   end Clear;

   entry Push(X: in Float) when Top < Max is
   begin
      Top := Top + 1;
      A(Top) := X;
   end Push;

   entry Pop(X: out Float) when Top > 0 is
   begin
      X := A(Top);
      Top := Top 1;
   end Pop;

end Stack;

Операции Push и Pop теперь входы (entry), а не процедуры, и у них появились барьеры, логические условия, такие как Top < Max. Вход не может принять исполнение, пока условие его барьера ложно. Заметьте, что это не значит, что такой вход нельзя вызвать. Просто вызывающая задача будет приостановлена до тех пор, пока условие не станет истинно. Например, если задача Harry пытается вызвать Push, когда стек заполнен, она должна дождаться, пока какая‐нибудь другая задача (Tom или Dick) вызовет Pop и освободит верхний элемент. После этого исполнение Harry автоматически продолжится. Это произойдет без дополнительных действий программиста.

Заметьте, что вызов входа или защищенной процедуры выполняется так же, как и вызов обычной процедуры

Stack.Push (Z);

Подведем итог. Механизм защищенных объектов языка Ада обеспечивает эксклюзивный доступ к общим данным. В видимой части защищенного объекта объявляются защищенные операции, а защищаемые объектом компоненты объявляются в приватной части. Тело защищенного объекта содержит реализацию защищенных операций. Защищенные процедуры и входы предоставляют возможность читать/писать защищаемые данные, в то время, как защищенные функции — только читать. Это ограничение позволяет нескольким задачам читать общие данные одновременно (при использовании защищенных функций), но лишь одна задача может менять их. Из‐за запрета защищенным функциям изменять данные нам пришлось переписать Pop как процедуру, хотя в изначальном варианте это была функция.

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

Было бы поучительно рассмотреть, как мы запрограммировали бы этот пример, используя низкоуровневые примитивы. Исторически сложилось, что таким примитивом считается объект семафор, у которого определены две операции P (захватить) и V (освободить). Эффект операции P(sem) состоит в захвате блокировки, соответствующей sem, если блокировка свободна, в противном случае задача приостанавливается и помещается в очередь к sem. Эффект V(sem) состоит в том, чтобы снять блокировку и разбудить одну из задач очереди, если она есть.

Чтобы обеспечить эксклюзивный доступ к данным, мы должны окружить каждую нашу операцию парой вызовов P и V. Например Push будет таким:

procedure Push(X: in Float) is
begin
   P(Stack_Lock); --  захватить блокировку
   Top := Top + 1;
   A(Top) := X;
   V(Stack_Lock); --  освободить блокировку
end Push;

Аналогично делается для подпрограмм Clear и Pop. Так обычно пишут многозадачный код на ассемблере. При этом есть множество возможностей совершить ошибку:

  • Можно пропустить одну из операций P или V, нарушив баланс блокировок.
  • Можно забыть поставить обе операции и оставить нужный код без защиты.
  • Можно перепутать имя семафора.
  • Можно случайно обойти вызов закрывающей операции V при исполнении.

Последняя ошибка могла бы возникнуть, например, если в варианте без барьеров, Push вызывается при заполненном массиве. Это приводит к возбуждению исключения Constraint_Error. Если мы не напишем обработчик исключения, где будем вызывать V, объект останется заблокированным навсегда.

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

Входы с барьерами являются механизмом более высокого уровня, чем механизм «условных переменных», который можно найти в других языках программирования. Например, в языке Java, программист обязан явно вызывать wait, notify и notifyAll для переменных, отражающих состояния объекта, такие как «стек полон» и «стек пуст». Этот подход чреват ошибками и подвержен ситуации гонки приоритетов, в отличии от механизмов Ады.

Рандеву

Еще одним средством поддержи многозадачности является возможность непосредственного обмена данными между двумя задачами. В Аде это реализовано с помощью механизма рандеву. Две взаимодействующие задачи вступают в отношение клиент‐сервер. Сервер должен быть известен клиенту, нуждающемуся в каком‐то его сервисе. В то же время серверу безразлично, какого клиента он обслуживает.

Вот общий вид сервера, предоставляющего единственный сервис:

task Server is
   entry Some_Service(Format: in out Data);
end;

task body Server is
begin
   ...
   accept Some_Service(Format: in out Data) do
      ... -- код предоставляющий сервис
   end Some_Service;
end Server;

По спецификации сервера видно, что он имеет вход Some_Service. Этот вход может быть вызван точно так же, как вход защищенного объекта. Отличие в том, что код, предоставляющий сервис, находится в соответствующей инструкции принятия (accept), которая исполняется лишь когда до нее доходит поток исполнения сервера. До этого момента вызывающая задача будет ожидать. Когда сервер достигнет инструкции принятия, она будет исполнена, используя любые параметры, переданные клиентом. Клиент будет ожидать окончания исполнения инструкции принятия, после чего все параметры out и in out получат новые значения.

Тело клиента может выглядеть так:

task body Client is
   Actual: Data;
begin
   ...
   Server.Some_Service (Actual);
   ...
end Client;

Каждый вход имеет соответствующую очередь. Если задача вызывает вход, а сервер в этот момент не ожидает на инструкции принятия, то задача ставится в очередь. С другой стороны, если сервер достигает инструкции принятия, а очередь пуста, то останавливается сервер. Инструкция принятия может находиться в любом месте в теле задачи, где допускаются инструкции, например, в одной из ветвей условной инструкции (if) или внутри цикла. Этот механизм очень гибкий.

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

Теперь приведем пример использования рандеву, в котором клиенту не нужно ожидать. Идея в том, что клиент передает серверу ссылку на вход, который необходимо вызвать, когда работа будет выполнена. Сначала мы объявим своего рода почтовый ящик, для обмена элементами некоторого типа Item, который определен где‐то ранее:

task type Mailbox is
   entry Deposit(X: in Item);
   entry Collect(X: out Item);
end Mailbox;

task Mailbox is
   Local: Item;
begin
   accept Deposit(X: in Item) do
      Local := X;
   end;
   accept Collect(X: out Item) do
      X := Local;
   end;
end Mailbox;

Мы можем положить элемент в Mailbox, чтобы забрать его позже. Клиент передаст ссылку на почтовый ящик, куда сервер положит элемент, а клиент заберет его там позже. Нам понадобится ссылочный тип:

type Mailbox_Ref is access Mailbox;

Клиент и сервер будут следующего вида:

task Server is
   entry Request(Ref: in Mailbox_Ref; X: in Item);
end;

task body Server is
   Reply: Mailbox_Ref;
   Job: Item;
begin
   loop
      accept Request(Ref: in Mailbox_Ref; X: in Item) do
         Reply := Ref;
         Job := X;
      end;
      ...
      -- выполняем работу
      Reply.Deposit(Job);
   end loop;
end Server;

task Client;

task body Client is
   My_Box: Mailbox_Ref := new Mailbox;
   -- создаем задачу-почтовый ящик
   My_Item: Item;
begin
   Server.Request(My_Box, My_Item);
   ...
   -- занимаемся чем-то пока ждем
   My_Box.Collect(My_Item);
end Client;

На практике клиент мог бы время от времени проверять почтовый ящик. Это легко реализовать, используя условный вызов входа:

select
   My_Box.Collect (My_Item);
   -- успешно получили элемент
else
   -- элемент еще не готов
end select;

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

Ограничения

Директива компилятору pragma Restrictions, используемая для запрета использования некоторых возможностей языка, уже упоминалась в главах «Безопасное ООП» и «Безопасное управление памятью».

Существует множество ограничений, касающихся многозадачности. Некоторые были известны еще со времен Ады 95, другие добавлены в Аде 2005 и 2012. Возможности Ады в этой области очень обширны. С их помощью можно создавать совершенно разные приложения реального времени. Но многие из них очень просты и не требуют использования всех возможностей языка. Вот некоторые примеры возможных ограничений:

  • No_Task_Hierarchy
  • No_Task_Termination
  • Max_Entry_Queue_Length => n

Ограничение No_Task_Hierarchy предотвращает создание задач внутри других задач или подпрограмм, таким образом, все задачи будут в пакетах библиотечного уровня. Ограничение No_Task_Termination означает, что все задачи будут исполняться вечно, это часто встречается во многих управляющих приложениях, где каждая задача содержит бесконечный цикл, исполняющий какое‐то повторяющееся действие. Следующее ограничение обуславливает максимальное количество задач, ожидающих на одном входе в любой момент времени.

Указание ограничений может дать возможность:

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

Существует множество других ограничений, касающихся многозадачности, которые мы не рассмотрели.

Ravenscar

Особенно важная группа ограничений налагается профилем Ravenscar, который был разработан в середине 1990‐х и стандартизирован, как часть языка Ада 2005. Чтобы гарантировать, что программа соответствует этому профилю, достаточно написать:

pragma Profile(Ravenscar);

Использование любой из запрещенных возможностей языка (на них мы остановимся далее) приведет к ошибке компиляции.

Главной целью профиля Ravenscar является ограничение многозадачных возможностей таким образом, чтобы эффект от программы стал предсказуемым. (Профиль был определен Международным Симпозиумом по вопросам Реального Времени языка Ада, который проходил дважды в отдаленной деревне Равенскар на побережье Йоркшира в северо‐восточной Англии.)

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

Оригинальная версия профиля Ravenscar предполагала исполнение программы на однопроцессорной машине. В Аде 2012 в профиль добавили семантику исполнения на многопроцессорной машине при условии, что задачи жестко закреплены за ЦПУ. Мы еще вернемся к этому вопросу.

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

Никакой другой язык не предлагает таких средств обеспечения надежности, какие дает язык Ада с включенным профилем Ravenscar.

Безопасное завершение

В некоторых приложениях (например СУПР) задачи исполняются бесконечно, в других задачи доходят до своего конца и завершаются. В этих ситуациях вопросов, касающихся завершения задачи, не встает. Но бывают случаи, когда задача, предназначенная для бесконечной работы, должна быть завершена, например, по причине некоторой ошибки оборудования задача больше не нужна. Встает вопрос, как немедленно и безопасно завершить задачу. Увы, эти требования противоречат друг другу, и разработчику нужно искать компромисс. К счастью, язык Ада предлагает достаточно гибкие средства, чтобы разработчик смог реализовать нужный компромисс. Но даже если предпочтение отдается быстроте завершения, семантика языка гарантирует, что критические операции будут выполнены до того, как станет возможно завершить задачу.

Важным понятием в этом подходе является концепция региона отложенного прекращения. Это такой участок кода, исполнение которого должно дойти до конца, иначе существует риск разрушения разделяемых структур данных. Примерами могут служить тела защищенных операций и операторов принятия. Заметим, что при исполнении такого региона задача может быть вытеснена задачей с более высоким приоритетом.

Чтобы проиллюстрировать эти понятия, рассмотрим следующую версию задачи сервера:

task Server is
   entry Some_Service(Formal: in out Data);
end;

task body Server is
begin
   loop
      accept Some_Service(Format: in out Data) do
         ... -- код изменения значения Format
      end Some_Service;
   end loop;
end Server;

Задача будет исполнять периодически одну и ту же работу. Когда в ней больше не будет потребности, задача зависнет на инструкции принятия. Это напоминает анабиоз без перспективы пробуждения. Не очень приятная мысль и не очень приятный стиль программирования. Программа просто виснет, вместо того, чтобы изящно завершиться.

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

task Grim_Reaper;

task body Grim_Reaper is
begin
   abort Server;
end Grim_Reaper;

Предназначение инструкции abort в том, чтобы прекратить указанную задачу (в нашем случае Server). Но это может быть рискованно — прекратить задачу немедленно, вне зависимости от того, что она делает в данный момент, будто вытащить вилку из розетки. Возможно, Server находится в процессе исполнения инструкции принятия входа Some_Service, и параметр может быть в несогласованном состоянии. Прекращение задачи привело бы к тому, что вызывающая задача получила бы искаженные данные, например, частично обработанный массив. Но, как сказано выше, инструкция принятия имеет «отложенное прекращение». Если будет попытка прекратить задачу в этот момент, то библиотека времени исполнения заметит это (формально, задача перейдет в аварийное состояние), но задача не будет завершена до тех пор, пока длится регион отложенного прекращения, т. е. в нашем случае до конца исполнения инструкции accept.

Даже если завершаемая задача не находится в регионе отложенного прекращения, эффект не обязательно будет мгновенным. Говоря коротко, завершаемая задача перейдет в аварийное состояние, в котором она рассматривается, как своего рода прокаженный. Если какая‐либо задача неблагоразумно попытается взаимодействовать с этим несчастным (например, обратившись к одному из входов задачи), то получит исключение Tasking_Error. Наконец, если/когда прерванная задача достигнет любой точки планирования, такой как, вызов входа или инструкция принятия, то ее страданию прийдет конец и она завершится. (Для реализаций, поддерживающих Приложение Систем Реального Времени, требования к прекращению более жесткие: грубо говоря, аварийная задача завершится, как только окажется вне региона отложенного прекращения.)

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

Следующая техника использует специальный вход для передачи запроса на завершение. Приняв такой вызов, задача затем завершается обычным способом. Вот иллюстрация этой техники:

task Server is
   entry Some_Service(Formal: in out Data);
   entry Shutdown;
end;

task body Server is
begin
   loop
      select
         accept Shutdown;
         exit;
      or
         accept Some_Service(Format: in out Data) do
            ... -- код изменения значения Format
         end Some_Service;
      end select;
   end loop;
end Server;

В этой версии применяется форма инструкции select, охватывающая несколько альтернатив, каждая из которых начинается инструкцией принятия входа. При исполнении такой инструкции сначала проверяется, есть ли вызовы, ожидающие приема на этих входах. Если нет, то задача приостанавливается до тех пор, пока не появится такой вызов (в этом случае управление передается на соответствующую ветку). Если такой вызов один, управление передается на нужную ветку. Если несколько, то выбор ветки зависит от политики очередей входов (если реализация поддерживает Приложение Систем Реального Времени, политика, по умолчанию, основывается на приоритетах).

Такая форма инструкции select широко применяется в серверных задачах, когда задача имеет несколько входов, и порядок их вызова (либо количество) заранее не известен. В данном примере порядок вызова входов известен, сначала вызывается Some_Service, а затем Shutdown. Но мы не знаем, сколько раз вызовется Some_Service, поэтому нам понадобился бесконечный цикл.

Как и в предыдущем примере, нам нужна отдельная задача для завершения сервера. Но в этом случае, вместо инструкции прерывания задачи будет вызываться вход Shutdown:

task Grim_Reaper;

task body Grim_Reaper is
begin
   Server.Shutdown;
end Grim_Reaper;

При условии, что Grim_Reaper написан корректно, т. е. вызывает Shutdown после всех возможных вызовов Some_Service, подход с использованием Shutdown отвечает нашим требованиям к безопасной остановке задачи. Цена, которую мы платим за это — дополнительная задержка, поскольку завершение не происходит мгновенно.

Ада предлагает еще один подход к завершению, в котором нет необходимости какой‐то из задач запускать процесс остановки. Идея состоит в том, что задача завершится автоматически (при содействии библиотеки времени исполнения), когда появится гарантия, что это можно сделать безопасно. Такую гарантию можно дать, если задача, имеющая один или несколько входов, висит на инструкции select и ни одна из ветвей этой инструкции не может быть вызвана. Чтобы это выразить, существует специальный вариант ветви инструкции select:

task Server is
   entry Some_Service(Formal: in out Data);
   entry Shutdown;
end;

task body Server is
begin
   loop
      select
         terminate;
      or
         accept Some_Service(Format: in out Data) do
            ... -- код изменения значения Format
         end Some_Service;
      end select;
   end loop;
end Server;

В этом случае, когда Server дойдет до инструкции select (или будет ожидать на ней), система времени исполнения посмотрит вокруг и определит, есть ли хоть одна живая задача, имеющая ссылку на Server (и таким образом имеющая возможность вызвать его вход). Если таких задач нет, то Server завершится безопасно и это произойдет автоматически. В этом случае нет возможности исполнить какой‐либо код для очистки структур данных перед завершением. Но в стандарте Ада 2005 была добавлена возможность определить обработчик завершения, который будет вызван в процессе завершения задачи.

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

Подведем итог: Ада предоставляет различные возможности и поддерживает различные подходы к завершению задач. Инструкция прерывания имеет наименьшую задержку (хотя и ожидает выхода из регионов с отложенным прерыванием), но могут возникнуть проблемы, когда задача не находится в подходящем для завершения состоянии. Вход для запроса завершения решает эту проблему (задача завершается только когда примет запрос), но увеличивает задержку завершения. Наконец, подход со специальной альтернативой завершения наиболее безопасен (поскольку избавляет от ручного управления завершением задачи), но вводит дополнительные накладные расходы.

Среди возможностей, которые Ада избегает сознательно, возможность асинхронно возбудить исключение в другой задаче. Подобная возможность была, например, в изначальном варианте языка Java. Метод Thread.stop() (теперь считающийся устаревшим) позволяет легко разрушить разделяемые данные, оставив их в несогласованном состоянии. Исключения в Аде всегда выполняются синхронно и не имеют этой проблемы. Конечно, нужно аккуратно подходить к использованию исключений. Например, программист должен знать, что если он не обрабатывает исключения, то задача просто завершится при возникновении исключения, а исключение пропадет. Зато сложностей с асинхронными исключениями удалось избежать.

Время и планирование

Наверное, нельзя закончить главу о многозадачности в Аде, не остановившись на времени и планировании.

Есть инструкции для синхронизации исполнения программы с часами. Мы можем приостановить программу на некоторый промежуток времени (так называемая относительная задержка исполнения), либо до наступления нужного момента времени:

delay 2*Minutes;

delay until Next_Time;

предположим, что существуют объявления для Minutes и Next_Time. Небольшие относительные задержки могут быть полезны для интерактивного использования, в то время как задержка до наступления момента может быть использована для программирования периодических событий. Время можно измерять часами реального времени (они гарантируют некоторую точность), либо локальными часами, подверженными таким фактором, как переход на летнее время. В Аде также учитываются временные зоны и високосные секунды.

В стандарт Ада 2005 были добавлены несколько таймеров, при срабатывании которых вызывается защищенная процедура (обработчик). Есть три типа таймеров. Первый измеряет время ЦПУ, использованное конкретной задачей. Другой измеряет общий бюджет группы задач. Третий основан на часах реального времени. Установка обработчика выполняется по принципу процедуры Set_Handler.

Проиллюстрируем это на забавном примере варки яиц. Мы объявим защищенные объект Egg:

protected Egg is
   procedure Boil(For_Time: in Time_Span);
private
   procedure Is_Done(Event: in out Timing_Event);
   Egg_Time: Timing_Event;
end Egg;

protected body Egg is

   procedure Boil(For_Time: in Time_Span) is
   begin
      Put_Egg_In_Water;
      Set_Handler(Egg_Done, For_Time, Is_Done'Access);
   end Boil;

   procedure Is_Done(Event: in out Timing_Event) is
   begin
      Ring_The_Pinger;
   end Is_Done;

end Egg;

Пользователь напишет так:

Egg.Boil(Minutes (10)); -- лучше сварить вкрутую

-- читаем пока яйцо варится

и будильник зазвенит, когда яйцо будет готово.

Планирование задается директивой компилятору Task_Dispatching_Policy( политика). В Аде 95 определена политика FIFO_Within_Priorities, а в Аде 2005, в Приложении Систем Реального Времени — еще несколько. С помощью этой директивы можно назначить политику всем задачам или задачам, имеющим приоритет из заданного диапазона. Перечислим существующие политики:

  • FIFO_Within_Priorities — В пределах каждого уровня приоритета с этой политикой задачи исполняются по принципу первый‐пришел‐первый‐вышел. Задачи с более высоким приоритетом могут вытеснять задачи с меньшим приоритетом.
  • Non_Preemtive_FIFO_Within_Priotities — В пределах каждого уровня приоритета с этой политикой задачи исполняются, пока не окончат выполнение, будут заблокированы или выполнят инструкцию задержки (delay). Задача с более высоким приоритетом не может вытеснить их. Эта политика широко используется в приложениях с повышенными требованиями к безопасности.
  • Round_Robin_Within_Priotities — В пределах каждого уровня приоритета с этой политикой задачам выделяются кванты времени заданной продолжительности. Эта традиционная политика применяется с первых дней появления параллельных программ.
  • EDF_Across_Priotities — EDF это сокращение Earliest Deadline First. Общая идея следующая. На заданном диапазоне уровней приоритета каждая задача имеет крайний срок исполнения (deadline). Исполняется та, у которой это значение меньше. Это новая политика. Для ее использования разработан специальный математический аппарат.

Ада позволяет устанавливать и динамически изменять приоритеты задач и так называемые граничные приоритеты защищенных объектов. Это позволяет избежать проблемы инверсии приоритетов, как описано в [9].

Стандарт Ада 2012 ввел множество полезных усовершенствований, касающихся времени и планирования. Большинство из них не касаются темы этого буклета, но мы затронем здесь один из вопросов, теперь отраженный в стандарте — поддержка многопроцессорных/многоядерных платформ. Эта проблема включает следующие возможности:

  • Пакет System.Multiprocessors, где определена функция, возвращающая количество ЦПУ.
  • Новый аспект, позволяющий назначить задачу данному ЦПУ.
  • Дочерний пакет System.Multiprocessors.Dispatching_Domains позволяет выделить диапазон процессоров, как отдельный «домен диспетчеризации», а затем назначать задачи на исполнение этим доменом либо с помощью аспектов, либо при помощи вызова подпрограммы. После этого задача будет исполняться любым ЦПУ из заданного диапазона.
  • Определение директивы компилятору Volatile поменяли. Теперь она гарантирует корректный порядок операций чтения и записи вместо требования указанной переменной находиться в памяти, как было раньше.