Задачи (параллельное программирование). Часть 1

Ну вот мы добрались и до задач. Хотел уложиться в одной части, но не получилось. Итак, задачи. Постараюсь изложить попроще.

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

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

task My_First_Task; --анонимная задача
task type My_Second_Task; --тип задачи
task type My_Third_Task(n : Integer); --задача будет принимать параметры (дискриминант)

После объявления задачи нужно создать её тело, т.е расписать, что должна делать задача:

task My_First_Task;
task body My_First_Task is
begin
    loop --бесконечный цикл
        Put_Line("Hello, World!");
    end loop;
end My_First_Task;
...
task type My_Third_Task(n : Integer);
task body My_Third_Task is
    cnt : Integer := n;
begin
    for i in 1..cnt loop
        Put(Item => i, Width => 3);
    end loop;
end My_Third_Task;

Как видите, объявление задачи ничем не отличается от объявления типа и/или подпрограммы. Рассмотрим работающий пример. В нём одновременно будут работать 3 задачи: основная процедура example и две задачи. Для наглядности будем использовать оператор задержки delay. Каждая задача выполняет свою работу: первая выводит цепочку цифр, вторая - сообщение. Основная процедура ожидает нажатия клавиши и выводит её на экран:

with Ada.Text_IO; use Ada.Text_IO;
with Ada.Integer_Text_IO; use Ada.Integer_Text_IO;
 
procedure example is
    ch : Character := 'a';
 
    --Объявление первой задачи. В этом месте стартует первая задача
    task My_First_Task;
    task body My_First_Task is
    begin
        loop --бесконечный цикл
            for i in 1..10 loop
                Put(Item => i, Width => 3);
            end loop;
            New_Line;
            delay 0.31; --задержка 0.31 секунды
        end loop;
    end My_First_Task;
 
    --дискриминант должен быть либо ссылкой, либо иметь дискретный тип. Используем ссылку
    task type My_Second_Task(str : access constant String);
    task body My_Second_Task is
    begin
        loop --бесконечный цикл
            Put_Line(str.all);
            delay 0.9; --задержка 0.9 секунды
        end loop;
    end My_Second_Task;
 
    str : aliased constant String := "Hello, World!";
    pstr : access constant String := str'Access; --анонимная ссылка на строку
    --объявление переменной типа задачи. В этом месте стартует вторая задача:
    mst : My_Second_Task(pstr);
 
begin
    --Основная процедура. Ожидает нажатия клавиши.
    while ch /= 'x' loop
        Get_Immediate(ch);
        Put(ch);
    end loop;
end example;

Если после запуска этой программы посмотреть монитор ресурсов, то можно увидеть, что она выполняется в три потока: один поток - процедура example и два потока - задачи:

Все три потока выполняются независимо друг от друга. Ну а результат выполнения выглядит примерно так:

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

Таким образом можно запустить множество задач. Однако если у Вас один процессор и Вы создаёте две или более задач, то это будет просто эмулирование многопоточности. Чудес не бывает 🙂

В примере мы использовали оператор delay. Есть ещё один способ его использования: delay until:

delay until Time_Of(2150, 1, 1, 0.0) --задержка до 0 часов 0 минут 1 января 2150 года.

Тип Time описан в пакетах Ada.Calendar и Ada.Real_Time (при разработке систем реального времени лучше использовать последний пакет).

Для взаимодействия задач друг с другом в Аде создан механизм Рандеву (фр. rendez-vous — встреча, свидание). Рассмотрим аналогию: пусть у нас есть два человека - парень и девушка. Они договорились встретиться (рандеву), и парень с цветами (информация для обмена) пришёл на свидание немного раньше девушки (первая задача пытается обменяться информацией со второй задачей, но вторая задача не готова принять информацию от первой). Парню (задаче один) ничего не остаётся как ожидать прихода девушки (ожидать готовности второй задачи к обмену информацией. Ещё раз повторюсь, что задачи независимы друг от друга и их нельзя вызвать как подпрограмму в любое время). И только тогда, когда девушка придёт (т.е. вторая задача будет находится на том этапе выполнения (будут выполняться те инструкции), когда она может обработать запрос первой задачи), парень подарит ей цветы (состоится рандеву и обмен информацией).

Для того, чтобы механизм рандеву работал, в задаче (в данном случае во второй задаче) создаётся как минимум один вход, на котором она готова ожидать обращения к ней от другой задачи (возвращаясь к нашей аналогии - для того, чтобы взять цветы, у девушки должна быть как минимум одна рука - один вход 🙂 ).

Вход объявляется аналогично процедуре (включая режимы доступа in, out, in out), только вместо слова procedure используется слово entry:

--Тип задачи
task type My_First_Task is
    --Задача будет иметь одну точку входа Input
    entry Input(str : Unbounded_String; num : Integer);
end My_First_Task;

В теле задачи для описания инструкций входа используется оператор приёма accept. Он означает, что задача будет приостановлена и будет находится в режиме ожидания поступления запроса. С помощью accept указываются условия наступления рандеву. Рассмотрим пример:

with Ada.Strings.Unbounded; use Ada.Strings.Unbounded;
with Ada.Text_IO; use Ada.Text_IO;
--with MyTask;
 
procedure main is
 
    --Тип задачи
    task type My_First_Task is
        --Задача будет иметь только одну точку входа Input
        entry Input(str : Unbounded_String; num : Integer);
    end My_First_Task;
 
    --тело задачи
    task body My_First_Task is
        s : Unbounded_String;
        n : Integer;
    begin
        --accept означает, что в этой точке задача будет приостановлена
        --до тех пор, пока не получит запрос к точке входа Input
        accept Input(str : Unbounded_String; num : Integer) do
            --Задача получает на входе две переменные. Их нужно скопировать в локальные
            --Вот здесь и происходит рандеву
            s := str;
            n := Num;
        end Input;
 
        for i in 1..n loop
            --Вывод переданного на вход задачи сообщения и этапа выполнения задачи
            Put_Line(To_String(s) & Integer'Image(i)); 
            delay 0.5;
        end loop;
 
    end My_First_Task;
 
    --Создаём две задачи
    mft_1 : My_First_Task;
    mft_2 : My_First_Task;
begin
    --Здесь обе задачи начнут своё выполнение, но будут приостановлены в точках access
    --Для запуска задач нужно послать им запрос Input (он в задачах единственный):
    mft_2.Input(To_Unbounded_String("Задача 2. Этап"), 5);
    mft_1.Input(To_Unbounded_String("Задача 1. Этап"), 10);
end main;

Ну или если кому-то проще с вышеописанной аналогией про парня и девушку:

with Ada.Strings.Unbounded; use Ada.Strings.Unbounded;
with Ada.Strings.Unbounded.Text_IO; use Ada.Strings.Unbounded.Text_IO;
 
procedure main is
 
    --задача "Девушка"
    task type girl is
        --вход - взять цветы
        entry GetFlowers(str : Unbounded_String);
    end girl;
 
    task body girl is
        flowers : Unbounded_String := To_Unbounded_String("нет цветов");
    begin
        --после вывода этой строки задача будет приостановлена
        Put_Line("У меня " & flowers);
 
        --Рандеву - взять цветы
        accept GetFlowers(str : Unbounded_String) do
            flowers := str;
        end GetFlowers;
 
        Put_Line("Теперь у меня есть " & flowers);
    end girl;
 
    gl : girl; --создать задачу
 
    --Задача "Парень"
    task boy;
    task body boy is
    begin
        --Подарить цветы (вход задачи gl)
        gl.GetFlowers(To_Unbounded_String("ромашки"));
        delay 1.0;
    end boy;
begin
    null;
end main;

Задача может иметь несколько входов. Тогда для избежания блокировки в каждой инструкции accept в Аде используется инструкция отбора select.

Пусть у нас есть задача с несколькими входами:

task My_Task is
    entry Input_One(Параметры); --Параметров может и не быть
    entry Input_Two(Параметры);
    entry Input_Three(Параметры);
    ...
    entry Input_X(Параметры);
end My_Task;

Тогда для возможности выбора (ожидания) более одного рандеву следует использовать такую конструкцию:

task body My_Task is
begin
    loop
        select
            accept Input_One(Параметры) do
                ...
            end Input_One;
        or
            accept Input_Two(Параметры) do
                ...
            end Input_Two;
        or
            accept Input_Three(Параметры) do
                ...
            end Input_Three;
        or
            ...
        or
            accept Input_X(Параметры) do
                ...
            end Input_X;
        or
            terminate; --прекращение задачи
        end select;
    end loop;
end My_Task;

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

task body My_Task is
begin
    loop
        select
            accept Input_One(Параметры) do
                ...
            end Input_One;
        or
            ...
        or
            accept Input_X(Параметры) do
                ...
            end Input_X;
        or
            terminate; --прекращение задачи
        end select;
    end loop;
end My_Task;

Но чаще всего, как мне кажется, в процессе ожидания рандеву задача должна выполнять какие-то действия (какую-то свою работу). Для этого можно в конструкции select создать раздел else:

task body My_Task is
begin
    loop
        select
            accept Input_One(Параметры) do
                ...
            end Input_One;
        or
            ...
        or
            accept Input_X(Параметры) do
                ...
            end Input_X;
        else
            ... --Какие-то действия
        end select;
    end loop;
end My_Task;

Также в конструкции select можно установить интервал времени, в течение которого будут осуществляться попытки установить рандеву:

task body My_Task is
begin
    loop
        select
            accept Input_One(Параметры) do
                ...
            end Input_One;
        or
            ...
        or
            accept Input_X(Параметры) do
                ...
            end Input_X;
        or
            delay 3.0; --Попытки установления рандеву в течение 3 секунд
            ... --Какие-то действия, если рандеву не состоялось
        end select;
    end loop;
end My_Task;

Внутри одного блока select может быть только одна из инструкций: terminate, delay или else. А может и не быть ни одной, эти инструкции не являются обязательными.

Можно организовать дополнительную проверку каких-то условий перед принятием рандеву:

select
    when <Условие> =>
        accept Input_One(Параметры) do
            ...
        end Input_One;
or
    ...
end select;

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

select
    My_Task.Input_One(...);
else
    Put("Невозможно установить рандеву");
end select;

То есть, использование select аналогично как для принимающей задачи, так и для вызывающей.