Управление задачами

Задачи и защищенные объекты позволяют реализовать параллельное исполнение в Аде. В следующих разделах эти концепции объясняются более подробно.

Задачи

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

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

Простая задача

Задачи объявляются с использованием ключевого слова task. Реализация задачи определяется в теле задачи (task body). Например:

with Ada.Text_IO; use Ada.Text_IO; procedure Show_Simple_Task is task T; task body T is begin Put_Line ("In task T"); end T; begin Put_Line ("In main"); end Show_Simple_Task;

Здесь мы объявляем и реализуем задачу T. Как только запускается основное приложение, задача T запускается автоматически - нет необходимости вручную запускать эту задачу. Запустив приложение выше, мы видим, что выполняются оба вызова Put_Line.

Обратите внимание, что:

  • Основное приложение само по себе является задачей (основной задачей).

    • В этом примере подпрограмма Show_Simple_Task является основной задачей приложения.

  • Задача T - это подзадача.

    • Каждая подзадача имеет задачу-родителя.

    • Поэтому основная задача - это также задача-родитель задачи T.

  • Количество задач не ограничено одной: мы могли бы включить задачу T2 в приведенный выше пример.

    • Эта задача также запускается автоматически и выполняется одновременно как с задачей T, так и с основной задачей. Например:

    with Ada.Text_IO; use Ada.Text_IO; procedure Show_Simple_Tasks is task T; task T2; task body T is begin Put_Line ("In task T"); end T; task body T2 is begin Put_Line ("In task T2"); end T2; begin Put_Line ("In main"); end Show_Simple_Tasks;

Простая синхронизация

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

with Ada.Text_IO; use Ada.Text_IO; procedure Show_Simple_Sync is task T; task body T is begin for I in 1 .. 10 loop Put_Line ("hello"); end loop; end T; begin null; -- Will wait here until all tasks -- have terminated end Show_Simple_Sync;

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

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

package Simple_Sync_Pkg is task T; end Simple_Sync_Pkg;

Это соответствующее тело пакета:

with Ada.Text_IO; use Ada.Text_IO; package body Simple_Sync_Pkg is task body T is begin for I in 1 .. 10 loop Put_Line ("hello"); end loop; end T; end Simple_Sync_Pkg;

Поскольку пакет указан в with основной процедуры, задача T, определенная в пакете, является частью основной задачи. Например:

with Simple_Sync_Pkg; procedure Test_Simple_Sync_Pkg is begin null; -- Will wait here until all tasks -- have terminated end Test_Simple_Sync_Pkg;

Опять же, как только основная задача достигает своего конца, она синхронизируется с задачей T из Simple_Sync_Pkg перед завершением.

Оператор задержки

Мы можем ввести задержку, используя ключевое слово delay. Это переводит задачу в спящий режим на время, указанное (в секундах) в операторе delay. Например:

with Ada.Text_IO; use Ada.Text_IO; procedure Show_Delay is task T; task body T is begin for I in 1 .. 5 loop Put_Line ("hello from task T"); delay 1.0; -- ^ Wait 1.0 seconds end loop; end T; begin delay 1.5; Put_Line ("hello from main"); end Show_Delay;

В этом примере мы заставляем задачу T ждать одну секунду после каждого вывода сообщения "hello". Кроме того, основная задача ожидает 1,5 секунды перед выводом своего сообщения "hello".

Синхронизация: рандеву

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

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

  • вызываемая задача ожидает в этот момент (в операторе принятия accept) и готова принять вызов соответствующей входа из вызывающей задачи;

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

Эта синхронизация между задачами называется рандеву. Давайте посмотрим на пример:

with Ada.Text_IO; use Ada.Text_IO; procedure Show_Rendezvous is task T is entry Start; end T; task body T is begin accept Start; -- ^ Waiting for somebody -- to call the entry Put_Line ("In T"); end T; begin Put_Line ("In Main"); -- Calling T's entry: T.Start; end Show_Rendezvous;

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

Вход может использоваться для выполнения чего-то большего, чем простая синхронизация задач: он также может выполнять несколько операторов в течение времени синхронизации обеих задач. Мы делаем это с помощью блока do ... end. Для предыдущего примера мы бы просто написали accept Start do <операторы>; end;. Мы используем эту конструкцию в следующем примере.

Обрабатывающий цикл

Задача может исполнять оператор принятия входа не ограниченое число раз. Мы могли бы даже создать бесконечный цикл в задаче и принимать вызовы одного и того же входа снова и снова. Однако бесконечный цикл препятствует завершению задачи-родителя и заблокирует ее, когда она достигает своего конца. Поэтому цикл, содержащий оператор принятия accept в теле задачи, обычно используется в сочетании с оператором select ... or terminate (выбрать или завершить). Говоря упрощенно, этот оператор позволяет родительской задаче автоматически завершать выполнение подзадачи по достижении своего конца. Например:

with Ada.Text_IO; use Ada.Text_IO; procedure Show_Rendezvous_Loop is task T is entry Reset; entry Increment; end T; task body T is Cnt : Integer := 0; begin loop select accept Reset do Cnt := 0; end Reset; Put_Line ("Reset"); or accept Increment do Cnt := Cnt + 1; end Increment; Put_Line ("In T's loop (" & Integer'Image (Cnt) & ")"); or terminate; end select; end loop; end T; begin Put_Line ("In Main"); for I in 1 .. 4 loop -- Calling T's entry multiple times T.Increment; end loop; T.Reset; for I in 1 .. 4 loop -- Calling T's entry multiple times T.Increment; end loop; end Show_Rendezvous_Loop;

В этом примере тело задачи содержит бесконечный цикл, который принимает вызовы входов Reset и Increment. Нам стоит отметить следуещее:

  • Блок accept E do ... end используется для увеличения счетчика.

    • До тех пор, пока задача T выполняет блок do ... end, основная задача ожидает завершения блока.

  • Основная задача выполняет вызов входа Increment несколько раз в цикле от 1 .. 4. Она также вызывает вход Reset перед вторым циклом.

    • Поскольку задача T содержит бесконечный цикл, она всегда принимает вызовы входов Reset и Increment.

    • Когда основная задача достигает конца, она проверяет статус задачи T. Несмотря на то, что задача T может принимать новые вызовы входов Reset или Increment, главная задача может завершить задачу T введу наличия or terminate ветви оператора select.

Циклические задачи

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

while True loop
   delay 1.0;
   --    ^ Wait 1.0 seconds
   Computational_Intensive_App;
end loop;

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

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

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

with Ada.Real_Time; use Ada.Real_Time; package Delay_Aux_Pkg is function Get_Start_Time return Time with Inline; procedure Show_Elapsed_Time with Inline; procedure Computational_Intensive_App; private Start_Time : Time := Clock; function Get_Start_Time return Time is (Start_Time); end Delay_Aux_Pkg;
with Ada.Text_IO; use Ada.Text_IO; package body Delay_Aux_Pkg is procedure Show_Elapsed_Time is Now_Time : Time; Elapsed_Time : Time_Span; begin Now_Time := Clock; Elapsed_Time := Now_Time - Start_Time; Put_Line ("Elapsed time " & Duration'Image (To_Duration (Elapsed_Time)) & " seconds"); end Show_Elapsed_Time; procedure Computational_Intensive_App is begin delay 0.5; end Computational_Intensive_App; end Delay_Aux_Pkg;

Используя этот вспомогательный пакет, теперь мы готовы написать наше приложение с дрейфом по времени:

with Ada.Text_IO; use Ada.Text_IO; with Ada.Real_Time; use Ada.Real_Time; with Delay_Aux_Pkg; procedure Show_Time_Task is package Aux renames Delay_Aux_Pkg; task T; task body T is Cnt : Integer := 1; begin for I in 1 .. 5 loop delay 1.0; Aux.Show_Elapsed_Time; Aux.Computational_Intensive_App; Put_Line ("Cycle # " & Integer'Image (Cnt)); Cnt := Cnt + 1; end loop; Put_Line ("Finished time-drifting loop"); end T; begin null; end Show_Time_Task;

Запустив приложение, мы видим, что у нас уже есть разница во времени примерно в четыре секунды после трех итераций цикла из-за дрейфа, вызванного Computational_Intensive_Applications. Однако, используя оператор delay until, мы можем избежать этого дрейфа и получить регулярный интервал ровно итерации в одну секунду:

with Ada.Text_IO; use Ada.Text_IO; with Ada.Real_Time; use Ada.Real_Time; with Delay_Aux_Pkg; procedure Show_Time_Task is package Aux renames Delay_Aux_Pkg; task T; task body T is Cycle : constant Time_Span := Milliseconds (1000); Next : Time := Aux.Get_Start_Time + Cycle; Cnt : Integer := 1; begin for I in 1 .. 5 loop delay until Next; Aux.Show_Elapsed_Time; Aux.Computational_Intensive_App; -- Calculate next execution time -- using a cycle of one second Next := Next + Cycle; Put_Line ("Cycle # " & Integer'Image (Cnt)); Cnt := Cnt + 1; end loop; Put_Line ("Finished cycling"); end T; begin null; end Show_Time_Task;

Теперь, как мы можем видеть, запустив приложение, оператор delay until гарантирует, что Computational_Intensive_App не нарушает регулярный интервал в одну секунду между итерациями.

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

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

Защищенные объекты инкапсулируют данные и обеспечивают доступ к этим данным с помощью защищенных операций, которые могут быть подпрограммами или защищенными входами. Использование защищенных объектов гарантирует, что данные не будут повреждены из-за «состояния гонки» или другого одновременного доступа.

Важное замечание.

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

Простой объект

Вы объявляете защищенный объект с помощью ключевого слова protected. Синтаксис аналогичен тому, который используется для пакетов: вы можете объявлять операции (например, процедуры и функции) в видимом разделе, а данные - в личном разделе. Соответствующая реализация операций включена в тело защищенного объекта (protected body). Например:

with Ada.Text_IO; use Ada.Text_IO; procedure Show_Protected_Objects is protected Obj is -- Operations go here (only subprograms) procedure Set (V : Integer); function Get return Integer; private -- Data goes here Local : Integer := 0; end Obj; protected body Obj is -- procedures can modify the data procedure Set (V : Integer) is begin Local := V; end Set; -- functions cannot modify the data function Get return Integer is begin return Local; end Get; end Obj; begin Obj.Set (5); Put_Line ("Number is: " & Integer'Image (Obj.Get)); end Show_Protected_Objects;

В этом примере мы определяем две операции для Obj: Set и Get. Реализация этих операций находится в теле объекта Obj. Синтаксис, используемый для записи этих операций, такой же, как и для обычных процедур и функций. Реализация защищенных объектов проста - мы просто читаем и переписываем значение Local в этих подпрограммах. Для вызова этих операций в основном приложении мы используем точечную нотацию, например, Obj.Get.

Входы

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

В предыдущем примере использовались процедуры и функции для определения операций с защищенными объектами. Однако при этом можно считать защищенную информацию (через Obj.Get) до того, как она будет установлена (через Obj.Set). Чтобы код имел детерминированное поведение, мы указали значение по умолчанию (0). Если, вместо этого, переписать функцию Obj.Get как вход, мы сможем установить барьер, гарантирующий, что ни одна задача не сможет прочитать информацию до того, как она будет записана.

В следующем примере реализован барьер для операции Obj.Get. Он также содержит две параллельно исполняющиеся подпрограммы (основная задача и задача T), которые пытаются получить доступ к защищаемому объекту.

with Ada.Text_IO; use Ada.Text_IO; procedure Show_Protected_Objects_Entries is protected Obj is procedure Set (V : Integer); entry Get (V : out Integer); private Local : Integer; Is_Set : Boolean := False; end Obj; protected body Obj is procedure Set (V : Integer) is begin Local := V; Is_Set := True; end Set; entry Get (V : out Integer) when Is_Set is -- Entry is blocked until the -- condition is true. The barrier -- is evaluated at call of entries -- and at exits of procedures and -- entries. The calling task sleeps -- until the barrier is released. begin V := Local; Is_Set := False; end Get; end Obj; N : Integer := 0; task T; task body T is begin Put_Line ("Task T will delay for 4 seconds..."); delay 4.0; Put_Line ("Task T will set Obj..."); Obj.Set (5); Put_Line ("Task T has just set Obj..."); end T; begin Put_Line ("Main application will get Obj..."); Obj.Get (N); Put_Line ("Main application has just retrieved Obj..."); Put_Line ("Number is: " & Integer'Image (N)); end Show_Protected_Objects_Entries;

Как видим, запустив пример, основное приложение ждет, пока не произойдет запись в защищенный объект (по вызову Obj.Set в задаче T), прежде чем прочитать информацию (через Obj.Get). Поскольку в задаче T добавлена 4-секундная задержка, основное приложение также задерживается на 4 секунды. Только после этой задержки задача T записывает данные в объект и снимает барьер в Obj.Get, чтобы главное приложение могло затем возобновить обработку (после извлечения информации из защищенного объекта).

Задачные и защищенные типы

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

Задачные типы

Задачный типй - это обобщение задачи. Его объявление аналогично единичным задачам: вы заменяете task на task type. Разница между единичными задачами и задачными заключается в том, что задачные типы не создают фактические задачи и не запускают их автоматически. Вместо этого требуется объявление задачи. Именно так работают обычные переменные и типы: объекты создаются только с помощью определений переменных, но не определений типов.

Чтобы проиллюстрировать это, мы повторим наш первый пример:

with Ada.Text_IO; use Ada.Text_IO; procedure Show_Simple_Task is task T; task body T is begin Put_Line ("In task T"); end T; begin Put_Line ("In main"); end Show_Simple_Task;

Теперь мы переписываем его, заменив задачу task T задачным типом task type TT. Объявляем задачу (A_Task) на основе задачного типа TT после её определения:

with Ada.Text_IO; use Ada.Text_IO; procedure Show_Simple_Task_Type is task type TT; task body TT is begin Put_Line ("In task type TT"); end TT; A_Task : TT; begin Put_Line ("In main"); end Show_Simple_Task_Type;

Мы можем расширить этот пример и создать массив задач. Поскольку мы используем тот же синтаксис, что и для объявлений переменных, мы используем аналогичный синтаксис для задачного типа: array (<>) of Task_Type. Кроме того, мы можем передавать информацию отдельным задачам, определив начальный вход Start. Вот обновленный пример:

with Ada.Text_IO; use Ada.Text_IO; procedure Show_Task_Type_Array is task type TT is entry Start (N : Integer); end TT; task body TT is Task_N : Integer; begin accept Start (N : Integer) do Task_N := N; end Start; Put_Line ("In task T: " & Integer'Image (Task_N)); end TT; My_Tasks : array (1 .. 5) of TT; begin Put_Line ("In main"); for I in My_Tasks'Range loop My_Tasks (I).Start (I); end loop; end Show_Task_Type_Array;

В этом примере мы объявляем пять задач в массиве My_Tasks. Мы передаем индекс в массиве отдельной задаче вызвав вход (Start). После синхронизации между отдельными подзадачами и основной задачей каждая подзадача одновременно вызывает Put_Line.

Защищенные типы

Защищенный тип - это обобщение защищенного объекта. Объявление аналогично объявлению для защищенных объектов: вы заменяете protected на protected type. Как и в случае задачных типов, защищенные типы требуют объявления объекта для создания реальных объектов. Опять же, это похоже на объявления переменных и позволяет создавать массивы (или другие составные объекты) из защищенных объектов.

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

with Ada.Text_IO; use Ada.Text_IO; procedure Show_Protected_Object_Type is protected type Obj_Type is procedure Set (V : Integer); function Get return Integer; private Local : Integer := 0; end Obj_Type; protected body Obj_Type is procedure Set (V : Integer) is begin Local := V; end Set; function Get return Integer is begin return Local; end Get; end Obj_Type; Obj : Obj_Type; begin Obj.Set (5); Put_Line ("Number is: " & Integer'Image (Obj.Get)); end Show_Protected_Object_Type;

В этом примере вместо непосредственного определения объекта Obj мы сначала определяем защищенный тип Obj_Type а затем объявляем Obj как объект этого защищенного типа. Обратите внимание, что основное приложение не изменилось: мы по-прежнему используем Obj.Set и Obj.Get для доступа к защищенному объекту, как в исходном примере.