Управление задачами
Задачи и защищенные объекты позволяют реализовать параллельное исполнение в Аде. В следующих разделах эти концепции объясняются более подробно.
Задачи
Задачу можно рассматривать как приложение, которое выполняется одновременно с основным приложением. В других языках программирования задачи могут назваться потоками, а управление задачами можно назвать многопоточностью.
Задачи могут синхронизироваться с основным приложением, но также могут обрабатывать информацию полностью независимо от основного приложения. Здесь мы покажем, как это достигается.
Простая задача
Задачи объявляются с использованием ключевого слова task
. Реализация
задачи определяется в теле задачи (task body
). Например:
Здесь мы объявляем и реализуем задачу 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;
Простая синхронизация
Как мы сейчас видели, как только запускается основная задача, автоматически запускаются и ее подзадачи. Основная задача продолжает свою работу до тех пор, пока ей есть что делать. Однако после этого она не сразу завершится. Вместо этого задача ожидает завершения выполнения своих подзадач, прежде чем позволит себе завершиться. Другими словами, этот процесс ожидания обеспечивает синхронизацию между основной задачей и ее подзадачами. После этой синхронизации основная задача завершится. Например:
Тот же механизм используется для других подпрограмм, содержащих подзадачи: задача-родитель подпрограммы будет ждать завершения своих подзадач. Таким образом, этот механизм не ограничивается основным приложением, а также применяется к любой подпрограмме, вызываемой основным приложением или его подпрограммами.
Синхронизация также происходит, если мы вынесем задачу в отдельный
пакет. В приведенном ниже примере мы объявляем задачу T
в пакете
Simple_Sync_Pkg
.
Это соответствующее тело пакета:
Поскольку пакет указан в with
основной процедуры, задача
T
, определенная в пакете, является частью основной задачи. Например:
Опять же, как только основная задача достигает своего конца, она
синхронизируется с задачей T
из Simple_Sync_Pkg
перед
завершением.
Оператор задержки
Мы можем ввести задержку, используя ключевое слово delay
. Это переводит
задачу в спящий режим на время, указанное (в секундах) в операторе
delay. Например:
В этом примере мы заставляем задачу T
ждать одну секунду после каждого
вывода сообщения "hello". Кроме того, основная задача ожидает 1,5
секунды перед выводом своего сообщения "hello".
Синхронизация: рандеву
Единственный тип синхронизации, который мы видели до сих пор, - это
тот, что происходит автоматически в конце основной задачи. Вы
также можете определить пользовательские точки синхронизации (входы задач),
используя ключевое слово entry
. Вход задачи можно рассматривать
как особый вид подпрограммы, вызов которой выполняется другой задачей и
имеет синтаксис аналогичный синтаксу вызова процедуры.
При написании тела задачи вы определяете места, где задача будет
принимать входы, используя ключевое слово accept
. Задача выполняется
до тех пор, пока не достигнет оператора accept
, а затем ожидает
синхронизации с вызывающей задачей. А именно:
вызываемая задача ожидает в этот момент (в операторе принятия
accept
) и готова принять вызов соответствующей входа из вызывающей задачи;вызывающая задача вызывает вход задачи способом, аналогичным вызову процедуры, чтобы синхронизации с вызываемой задачей.
Эта синхронизация между задачами называется рандеву. Давайте посмотрим на пример:
В этом примере мы объявляем вход Start
задачи T
. В теле задачи
мы реализуем этот вход с помощью оператора принятия accept Start
.
Когда задача T
достигает этой точки, она ожидает основную задачу.
Эта синхронизация происходит в операторе T.Start
. После
завершения синхронизации основная задача и задача T
снова выполняются
одновременно, пока они не синхронизируются в последний раз, когда
основная задача завершается.
Вход может использоваться для выполнения чего-то большего, чем простая
синхронизация задач: он также может выполнять несколько операторов в
течение времени синхронизации обеих задач. Мы делаем это с помощью
блока do ... end
. Для предыдущего примера мы бы просто написали
accept Start do <операторы>; end;
. Мы используем эту конструкцию
в следующем примере.
Обрабатывающий цикл
Задача может исполнять оператор принятия входа не ограниченое число раз. Мы
могли бы даже создать бесконечный цикл в задаче и принимать вызовы
одного и того же входа снова и снова. Однако бесконечный цикл
препятствует завершению задачи-родителя и заблокирует ее,
когда она достигает своего конца. Поэтому цикл,
содержащий оператор принятия accept
в теле задачи, обычно используется в
сочетании с оператором select ... or terminate
(выбрать или завершить).
Говоря упрощенно, этот оператор позволяет родительской задаче
автоматически завершать выполнение подзадачи по достижении своего конца.
Например:
В этом примере тело задачи содержит бесконечный цикл, который
принимает вызовы входов 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
, которая
эмулирыется с помощью простой задержки. Вот полный текст пакета:
Используя этот вспомогательный пакет, теперь мы готовы написать наше приложение с дрейфом по времени:
Запустив приложение, мы видим, что у нас уже есть разница во времени
примерно в четыре секунды после трех итераций цикла из-за дрейфа,
вызванного Computational_Intensive_Applications
. Однако, используя
оператор delay until
, мы можем избежать
этого дрейфа и получить регулярный интервал ровно итерации в одну
секунду:
Теперь, как мы можем видеть, запустив приложение, оператор delay until
гарантирует, что Computational_Intensive_App
не нарушает регулярный
интервал в одну секунду между итерациями.
Защищенные объекты
Когда несколько задач получают доступ к общим данным, может произойти повреждение этих данных. Например, данные могут оказаться несогласованными, если одна задача перезаписывает части информации, которые в то же время считываются другой задачей. Чтобы избежать подобных проблем и обеспечить скоординированный доступ к информации, мы используем защищенные объекты.
Защищенные объекты инкапсулируют данные и обеспечивают доступ к этим данным с помощью защищенных операций, которые могут быть подпрограммами или защищенными входами. Использование защищенных объектов гарантирует, что данные не будут повреждены из-за «состояния гонки» или другого одновременного доступа.
Важное замечание.
Защищенные объекты могут быть реализованы с помощью задач Ада. Фактически это был единственный возможный способ их реализации в Аде 83 (первый вариант стандарта языка Ада). Однако использование защищённых объектов гораздо проще, чем использование аналогичных механизмов, реализованных с использованием лишь задач. Поэтому предпочтительно использовать защищенные объекты, когда ваша основная цель - только защита данных.
Простой объект
Вы объявляете защищенный объект с помощью ключевого слова protected
.
Синтаксис аналогичен тому, который используется для пакетов: вы можете
объявлять операции (например, процедуры и функции) в видимом разделе, а
данные - в личном разделе. Соответствующая реализация операций
включена в тело защищенного объекта (protected body
). Например:
В этом примере мы определяем две операции для Obj
: Set
и
Get
. Реализация этих операций находится в теле объекта Obj
.
Синтаксис, используемый для записи этих операций, такой же, как и для
обычных процедур и функций. Реализация защищенных объектов проста - мы
просто читаем и переписываем значение Local
в этих
подпрограммах. Для вызова этих операций в основном приложении мы используем
точечную нотацию, например, Obj.Get
.
Входы
В дополнение к защищенным процедурам и функциям вы также можете
определить защищенные входы. Сделайте это, используя ключевое
слово entry
. Защищенные входы позволяют вам определить барьеры с
помощью ключевого слова when
. Барьеры - это условия, которые должны
быть выполнены до того, как вход сможет начать выполнять свой
код - мы говорим о снятии барьера при выполнении условия.
В предыдущем примере использовались процедуры и функции для
определения операций с защищенными объектами. Однако при этом можно
считать защищенную информацию (через Obj.Get
) до того, как она будет
установлена (через Obj.Set
). Чтобы код имел детерминированное поведение,
мы указали значение по умолчанию (0). Если, вместо этого, переписать функцию
Obj.Get
как вход, мы сможем установить барьер, гарантирующий,
что ни одна задача не сможет прочитать информацию до того, как она будет
записана.
В следующем примере реализован барьер для операции Obj.Get
. Он также
содержит две параллельно исполняющиеся подпрограммы (основная задача и задача
T
), которые пытаются получить доступ к защищаемому объекту.
Как видим, запустив пример, основное приложение ждет, пока не произойдет
запись в защищенный объект (по вызову Obj.Set
в задаче T
),
прежде чем прочитать информацию (через Obj.Get
). Поскольку в задаче
T
добавлена 4-секундная задержка, основное приложение также
задерживается на 4 секунды. Только после этой задержки задача T
записывает данные в объект и снимает барьер в Obj.Get
, чтобы главное
приложение могло затем возобновить обработку (после извлечения информации
из защищенного объекта).
Задачные и защищенные типы
В предыдущих примерах мы определили единичные задачи и защищенные объекты. Однако мы можем описывать задачи и защищенные объекты, используя определения типов. Это позволяет нам, например, создавать несколько задач на основе только одного типа задач.
Задачные типы
Задачный типй - это обобщение задачи. Его объявление аналогично единичным
задачам: вы заменяете task
на task type
. Разница между единичными
задачами и задачными заключается в том, что задачные типы не создают
фактические задачи и не запускают их автоматически.
Вместо этого требуется объявление
задачи. Именно так работают обычные переменные и типы: объекты
создаются только с помощью определений переменных, но не определений
типов.
Чтобы проиллюстрировать это, мы повторим наш первый пример:
Теперь мы переписываем его, заменив задачу task T
задачным типом
task type TT
. Объявляем задачу (A_Task
) на основе задачного типа
TT
после её определения:
Мы можем расширить этот пример и создать массив задач. Поскольку мы
используем тот же синтаксис, что и для объявлений переменных, мы
используем аналогичный синтаксис для задачного типа:
array (<>) of Task_Type
. Кроме того, мы
можем передавать информацию отдельным задачам, определив начальный
вход Start
. Вот обновленный пример:
В этом примере мы объявляем пять задач в массиве My_Tasks
.
Мы передаем индекс в массиве отдельной задаче вызвав вход (Start
).
После синхронизации между отдельными подзадачами и основной задачей каждая
подзадача одновременно вызывает Put_Line
.
Защищенные типы
Защищенный тип - это обобщение защищенного объекта. Объявление
аналогично объявлению для защищенных объектов: вы заменяете protected
на protected type
. Как и в случае задачных типов,
защищенные типы требуют объявления объекта для создания
реальных объектов. Опять же, это похоже на объявления переменных и
позволяет создавать массивы (или другие составные объекты) из защищенных
объектов.
Мы можем повторно использовать предыдущий пример и переписать его, чтобы использовать защищенный тип:
В этом примере вместо непосредственного определения объекта Obj
мы
сначала определяем защищенный тип Obj_Type
а затем объявляем
Obj
как объект этого защищенного типа. Обратите внимание, что
основное приложение не изменилось: мы по-прежнему используем Obj.Set
и Obj.Get
для доступа к защищенному объекту, как в исходном примере.