6. Параллельные вычисления в Perl 6

Вчера мы видели, как создаются параллельные потоки с помощью слова start. Сегодня мы чуть подробнее остановимся на этом.

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

my $promise = start {
    sleep 2;
    say 'Done';
}

say 'Waiting...';
await $promise;

Эта программа создает промис $promise и ждет его выполнения. На печати появляется следующее:

$ perl6 start-1.pl 
Waiting...
Done

Если убрать строку с await, то программа завершится прежде чем завершит работу блок кода из промиса, поэтому Done напечатано не будет.

Разумеется, возможно создать более одного промиса, и все они будут выполняться параллельно.

my @promises;
for 1..10 -> $n {
    push @promises, start {
        say "Done $n";
    }
}

say 'Waiting...';
await @promises;

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

$ perl6 start-2.pl 
Done 1
Done 2
Done 3
Done 4
Done 5
Done 6
Waiting...
Done 7
Done 8
Done 9
Done 10

Вместо того, чтобы сохранять промисы в массиве (это нужно, чтобы было что передать await), удобно воспользоваться вот таким приемом с gather и take:

await gather for 1..10 -> $n {
    take start {
        say "Done $n";
    }
}

say 'Waiting...';

Еще проще конструкция с do:

await do for 1..10 -> $n {
    start {
        say "Done $n";
    }
}

say 'Waiting...';

Синтаксически, слово start — это префиксный оператор, который делает то же самое что и вызов одноименного метода класса Promise. Первую программу можно было бы переписать так:

my $promise = Promise.start({
    sleep 2;
    say 'Done';
});

say 'Waiting...';
await $promise;

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

5. Атомарные операции в Perl 6

Внимание: код в этом посте требует Rakudo версии не менее 2017.09.

Как-нибудь мы более подробно поговорим о параллельном программировании в Perl 6, но пока заглянем вперед и создадим простой счетчик, который будет увеличиваться десятью параллельными потоками:

my $c = 0;

await do for 1..10 {
    start {
        $c++ for 1 .. 1_000_000
    }
}

say $c;

Выполнив программу несколько раз, вы удивитесь, насколько разные и насколько неверные результаты она печатает:

$ perl6 atomic-1.pl 
3141187
$ perl6 atomic-1.pl 
3211980
$ perl6 atomic-1.pl 
3174944
$ perl6 atomic-1.pl 
3271573

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

Решение — использование атомарных (atomic) операций. Синтаксис Perl 6 дополнен символом Atom Symbol (U+0x269B) ⚛ (почему-то он именно такого цвета). Вместо $c++ надо написать $c⚛++.

my atomicint $c = 0;

await do for 1..10 {
    start {
        $c⚛++ for 1 .. 1_000_000
    }
}

say $c;

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

$ perl6 atomic-2.pl 
10000000

Именно то, что и требовалось.

Обратите внимание еще на один момент: переменная объявлена как переменная типа atomicint. Это синоним int — машинного целого числа (в отличие от Int, что является классом).

Обычные переменные не могут учавствовать в атомарных операциях; это пресекается компилятором:

$ perl6 -e'my $c; $c⚛++'
Expected a modifiable native int argument for '$target'
  in block  at -e line 1

Атомарность поддерживают еще несколько операторов: префиксные и постфиксные ++ и --, += и -=, а также атомарные операции присваивания = и чтения: ⚛.

Использовать символ ⚛ вовсе необязательно. Существуют альтернативные функции, которые можно вызывать вместо операторов:

my atomicint $c = 1;

my $x = ⚛$c;  $x = atomic-fetch($c);
$c ⚛= $x;     atomic-assign($c, $x);
$c⚛++;        atomic-fetch-inc($c);
$c⚛--;        atomic-fetch-dec($c);
++⚛$c;        atomic-inc-fetch($c);
--⚛$c;        atomic-dec-fetch($c);
$c ⚛+= $x;    atomic-fetch-add($c,$x);

say $x; # 1
say $c; # 3

4. Установка компилятора Perl 6

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

Любой компилятор, который проходит стандартный набор тестов, может называть себя компилятором Perl 6. Это одна из основных идей, заложенных при создании языка.

Сегодняшняя реальность такова, что в нашем распоряжении есть только один пригодный для работы инструмент: Rakudo Perl 6. Некоторые из разработчиков предпочитают даже называть весь язык не просто Perl 6, а Rakudo или Rakudo Perl 6. Традиционный компилятор Perl 5 при этом иногда называют Pumpkin(g) Perl 5.

Итак, Ракудо и ничего больше. Но и про него надо знать важную вещь: есть компилятор Rakudo, а есть дистрибутив Rakudo Star.

Сложный путь

Собственно, в компилятор входит сам компилятор и ничего больше. Дистрибутив находится на гитхабе: github.com/rakudo/rakudo. Этот вариант подойдет тем, кто предпочитает собирать все вручную. Но потребуется установить еще два компонента: Not Quite Perl 6 (NQP) — упрощенную версию Perl 6 — и виртуальную машину MoarVM.

Установка дополнительных компонентов не вызывает сложностей, так как сам Ракудо может об этом позаботиться:

perl Configure.pl --gen-moar --gen-nqp --backends=moar

Компилятор обновляется примерно раз в месяц.

Простой путь

Если вы не планируете изучать внутренности Rakudo, поставьте Rakudo Star. Помимо NQP и MoarVM, он содержит набор стандартных модулей и утилиту для установки новых модулей.

Для Windows и Mac OS существуют готовые установщики, которые все сделают сами. Для Линуксов опубликованы инструкции по сборке. Все свежие ссылки находятся на странице rakudo.org/how-to-get-rakudo.

Релиз Rakudo Star случается обычно раз в квартал, поэтому не забывайте его время от времени обновлять.

Если все получилось, компилятор сможет напечатать свою версию:

$ perl6 -v
This is Rakudo version 2017.12-88-g8fd776f built
on MoarVM version 2017.12-1-g912f967
implementing Perl 6.c.

Помимо версии компилятора и виртуальной машины, здесь видна и версия самого перла: 6.c.

3. Мультифункции для рекурсии в Perl 6

Perl 6 — мультипарадигменный язык. Это означает, что на нем можно писать в разных стилях, как минимум в процедурном, в ООП- и в функциональном.

Сегодня мы посмотрим, как можно организовать рекурсию для простейшей задачи — печати чисел от 1 до 10.

Сразу оговоримся, что простейшее решение этой задачи такое:

.say for 1..10;

Но на этом примере попробуем понять, как задействовать механизмы Perl 6, чтобы сделать рекурсивный код.

Самое примитивное — вызвать функцию из самой себя и в нужный момент остановиться; здесь нет ничего нового:

gen-number(1, 10);

sub gen-number($current, $maximum) {
    say $current;
    gen-number($current + 1, $maximum) if $current < $maximum;
}

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

my $current = 1;
my $maximum = 10;

while $current <= $maximum {
    say $current;
    $current++; # $current = $current + 1
}

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

gen-number(1, 10);

multi sub gen-number($current, $maximum) {
    say $current;
    gen-number($current + 1, $maximum);
}

multi sub gen-number($current, $maximum where {$current == $maximum}) {
    say $current;
}

Функция gen-number теперь объявлена мультифункцией. Второй вариант ограничивает свои аргументы: они должны быть равны, чего требует условие со словом where. Поэтому в первых девяти случаях будет вызван первый вариант, и только когда $current сравняется с $maximum, сработает второй вариант функции, которая лишь печатает значение, но не продолжает рекурсию.

2. Что такое мультифункции в Perl 6

В Perl 6 есть ключевое слово multi, которое создает так называемые мультифункции (multi-subs). Это ни что иное как множественная диспетчеризация (multiple dispatch), встроенная в язык.

В отличие от обычных функций, которые объявляются со словом sub, мультифункции требуют двух слов: multi sub. Имя функции при этом остается одним и тем же для всего набора мультифункций. В зависимости от выполненных условий, компилятор выбирает один из вариантов функции.

Число аргументов

Рассмотрим несколько примеров. Первый пример — вычисление расстояния от начала координат до точки на прямой, на плоскости или в трехмерном пространстве.

multi sub dist($x) {
    return $x;
}

multi sub dist($x, $y) {
    return sqrt($x ** 2 + $y ** 2);
}

multi sub dist($x, $y, $z) {
    return sqrt($x ** 2 + $y ** 2 + $z ** 2);
}

say dist(2);        # 2
say dist(3, 4);     # 5
say dist(8, 9, 12); # 17

В этом примере функции различаются исключительно числом аргументов. При вызове, Perl 6 однозначно определяет, какой из вариантов следует вызвать.

Типы аргументов

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

multi sub mirror(Int $i) {
    -$i
}

multi sub mirror(Str $s) {
    $s.flip
}

say mirror(42);   # -42
say mirror('42'); # 24

Здесь функция возвращает должна вернуть «зеркальный» вариант своего аргумента, чтобы это ни значило. Точный смысл вы в праве установить самостоятельно для каждого типа аргумента. Для целых чисел (Int $i) это будет противоположное число, а для строки (Str $s) — строка, записанная в обратном порядке.

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

class TrafficLight {
    has Str $.value;
}

multi sub mirror(TrafficLight $obj) {
    $obj.value eq 'red' ?? 'green' !! 'red'
}

say mirror(TrafficLight.new(value => 'green')); # red

Ограничение на значение аргументов

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

multi sub set-password($pwd) {
    say "Password '$pwd' is OK"
}

multi sub set-password($pwd where {$pwd.chars <= 5}) {
    say "Password '$pwd' is too short!"
}

set-password('He11oW0rld!'); # OK
set-password('qwert');       # too short!

Во втором варианте функции значение аргумента уточняется с помощью ключевого слова where:

multi sub set-password($pwd where {$pwd.chars <= 5}) { . . . }

Эта функция будет вызвана только в том случае, если выполняется условие $pwd.chars <= 5, то есть когда строка недостаточно длинна.

Обратите внимание, что для первого варианта функции никаких ограничений нет, тем не менее, Perl 6 принимает совершенно правильно решение, опираясь на весь комплект функций, и не активирует этот вариант для короткой строки.

На сегодня все. Завтра будем использовать мультифункции для создания рекурсии.