Выпуск 21. Ноябрь 2014

GUI-приложения на Perl с помощью wxWidgets | Содержание | Обзор CPAN за октябрь 2014 г.

Еще немного об асинхронном программировании на Anyevent

В журнале уже были статьи, посвященные асинхронному программированию на Perl. Статьи, бесспорно, весьма достойные и интересные, однако, к сожалению, они отвечают на вопрос “как?”, тогда как для лучшего понимания материала они должны отвечать на еще один вопрос “зачем?”.

У меня много спрашивают касательно AnyEvent, причем очень часто спрашивают такие вещи, которые, в принципе, на AnyEvent написать нельзя.

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

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

Итак, поехали.

Историческая справка

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

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

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

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

Лирическое отступление

Синхронное программирование

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

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

for (my $i = 0; $i <= 10; $i++) {
    print "OMFG!!111\n";
}

То вовсе не обязательно, но вероятно, что все итерации цикла будут выполнены в рамках одной задачи. А переключение происходит настолько быстро, что зрительно это увидеть вообще нереально. Если мы напишем приложение, которое будет просто ждать пять секунд, а потом печатать “Hello world”, например, вот так:

sleep 5;
print "Hello world!\n";

То мы просто ничего не делаем пять секунд, а строчка “Hello world” будет напечатана только после того, как эти самые пять секунд пройдут.

Асинхронное программирование

Самым важным и основой основ асинхронного программирование является понятие блокировки. Для того, чтобы объяснить, что такое блокировка, рассмотрим простейший пример приложения, которое получает при помощи GET-запроса данные с узла http://pragmaticperl.com и немного теории:

#!/usr/bin/env perl
use strict;
use warnings;
use LWP::UserAgent;

my $url = "http://pragmaticperl.com/";
my $content = get_content();

sub get_content {
    my $url = shift;
    my $ua = LWP::UserAgent->new();

    my $response = $ua->get($url);
    return $response->content();
}

И еще рассмотрим такой пример:

#!/usr/bin/env perl
use strict;
use warnings;

print hello_world();

sub hello_world {
    return "Hello world!\n";
}

Итак, у нас есть два приложения и две функции. Это get_content и hello_world. Между ними есть очень важное отличие: результат работы функции get_content может быть разным, сервер упал, или интернета нет, например. Т.е. при вызове с одинаковыми параметрами несколько раз функция может вернуть разный результат. Функция же hello_world всегда возвращает “Hello world”. Такие функции имеют определенные названия. Итак, get_content — функция с побочными эффектами.

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

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

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

Вообще, асинхронное выполнение можно организовать несколькими способами:

  • Многопоточность
  • Многопроцессность
  • Событийность

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

Все мы знаем функцию map. map — функция высшего порядка. Функцией высшего порядка называется такая функция, которая принимает в качестве аргументов или возвращает в качестве результата другую функцию. В Perl функцию высшего порядка написать элементарно. Например:

mySub(sub {
    print "Hello world!\n";
});

sub mySub {
    my $sub = shift;
    $sub->();
}

В этом примере mySub — функция высшего порядка, т.к. принимает функцию в качестве первого и единственного аргумента. Кстати, PSGI-приложение тоже является функцией высшего порядка, т.к.

return sub {
    ...
}

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

callback, он же обратный вызов, это действие, которое выполняется по возникновению какого-либо события. Например, запросить данные с google.com, а по получению данных вывести их на стандартный вывод.

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

Событийная машина работает следующим образом. У нее есть несколько стадий работы:

  1. Запуск событийной машины.
  2. Регистрация событий, наблюдателей.
  3. Блокировка.
  4. Выполнение.

Если хотя-бы одного элемента нет — ничего не будет. И вот теперь я могу объяснить, что такое блокировка и почему это важно.

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

Суть асинхронного программирования. Суть асинхронного программирования в максимальном использовани имеющихся ресурсов и уменьшении времени простоя в целом, а, как следствие, суммарное время выполнения уменьшается.

Идем в бар

Например, возьмем в качестве примера два простых бара. В первом баре все синхронно, а во втором, соответственно, асинхронно. В первом баре бармен может наливать по одному стакану пива за раз. Не важно, что есть свободные краны, не важно, что от бармена требуется поставить стакан, открыть кран и ждать. Это синхронный подход. Асинхронный подход — бармен ставит стакан, открывает кран, а пока стакан наливается, бармен ставит рядом, к другому крану, еще один стакан. И открывает чипсы, например. При синхронном подходе бармен будет делать все эти действия последовательно. И если каждое действие будет занимать 9 секунд, то в первом случае бармен потратит 27 секунд, как минимум, для того, чтобы выполнить все необходимые манипуляции. В асинхронном баре же эти действия займут, как минимум, 9 секунд, т.к. бармен не ждет, а что-то делает в фоне. Соответственно, мы выигрываем во времени, но проигрываем по нагрузке. За все надо платить, в асинхронном баре бармен больше устает, но у него и зарплата больше, которая зависит от количества налитых стаканов пива и открытых чипсов. Как-то так, да.

Негатив

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

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

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

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

Резюмирую

Асинхронное решение вам подходит, если:

  • приложение активно взаимодействует с внешним миром;
  • приложение производит мало вычислительной работы;
  • приложение постоянно работает с какими-либо БД;
  • приложение чего-то ждет;
  • приложение — HTTP-сервер.

Асинхронное решение вам не подходит, если:

  • много матана у вас в приложении;
  • лениво разбираться с асинхронными решениями;
  • мало ресурсов.

Несколько советов:

  • Если не надо блокироваться — не блокируйся.
  • Если не знаешь, а надо ли здесь колбэк, оно не надо.
  • Три действия по 30 мс каждое лучше, чем одно по 90 мс.
  • Сломаться может где-угодно. Программирование на побочных эффектах требует перестраховываться везде.
  • Если думаешь, нужна проверка или нет, оно же не сломается — проверка нужна, оно сломается.
  • Порядок выполнения колбэков, если они не вложенные, определяет событийная машина, и она лучше программиста понимает, что надо делать.
  • Не полагайся на порядок выполнения колбэков в приложении, он может всегда измениться.

Событийно-ориентированное программирование на Perl. AnyEvent.

Про AnyEvent уже ранее писалось в рамках данного журнала, потому на данном этапе я буду краток. AnyEvent — фреймворк, универсальный интерфейс для построения асинхронных приложений. Грубо говоря, он представляет собой DSL для написания асинхронных приложений, абстрагируясь от событийной машины и цикла событий. Т.е. AnyEvent позволяет с легкостью заменить один цикл событий другим, а приложение, если оно не использовало специфические возможности цикла событий, запустится без изменений кода. AnyEvent — DBI of event loop programming. Более подробно теорию по AnyEvent мы рассмотрим в следующих статьях. Сейчас же займемся практикой.

Немного кода

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

#!/usr/bin/env perl
use strict;
use warnings;
use LWP::UserAgent;

my $urls = ['http://justnoxx.name/one.html', 'http://justnoxx.name/two.html'];
my $ua = LWP::UserAgent->new();

for my $url (@$urls) {
    print "GET request: $url\n";
    my $content = $ua->get($url)->content();
    print "Content: $content\n";
}
print "Done.\n";

Вывод данной программы будет иметь вид:

GET request: http://justnoxx.name/one.html
Content: one

GET request: http://justnoxx.name/two.html
Content: two

Done.

Совершенно очевидно и, к тому же, логично, что порядок работы программы не изменится. Допустим, если первый запрос будет занимать 10 с, а второй 1 с, то суммарно время работы скрипта будет примерно равно 11 секундам. Если второй запрос будет занимать 10 секунд, а первый 1, то суммарное время будет равно примерно 11 секундам. От перестановки слагаемых сумма не меняется. В данном случае асинхронное решение нам подходит идеально. Т. к:

  • Мы работаем с внешним миром.
  • У нас мало вычислений.
  • Приложение много простаивает.

Асинхронное приложение будет выглядеть примерно так:

#!/usr/bin/env perl
use strict;
use warnings;

# Подключаем AE
use AnyEvent;
# Асинхронный юзер-агент
use AnyEvent::HTTP;

my $urls = ['http://justnoxx.name/one.html', 'http://justnoxx.name/two.html'];

# $cv - переменная состояния
my $cv = AnyEvent->condvar();
# количество запросов
my $count = 0;

for my $url (@$urls) {
    # вывод нам поможет понять, как именно, а что более важно, в каком порядке, выполняется приложение
    print "New event: GET => $url\n";
    # это очень важная строка. my $guard; $guard будет пояснено далее в тексте статьи
    my $guard; $guard = http_get(
        $url, sub {
            # см. предыдущий коммент
            undef $guard;
            my ($content, $headers) = @_; 
            print "Content of $url: $content\n";
            $count++;
            # если количество успешных запросов равно размеру списка URLов, отправляем данные, на уровень выше.
            $cv->send("Done.\n") if $count == scalar @$urls;
        },  
    );  
}

# блокировка и ожидание. Собственной персоной.
my $result = $cv->recv();

print "$result\n";

А вывод вот так:

New event: GET => http://justnoxx.name/one.html
New event: GET => http://justnoxx.name/two.html
Content of http://justnoxx.name/two.html: two

Content of http://justnoxx.name/one.html: one

Done.

А теперь самое важное. Отличия, как и, самое главное, почему это работает.

Я выше писал, что приложение сначала набирает задания в очередь (http_get), а потом начинает выполнять. Это можно увидеть в выводе. Сначала задачи ставятся в очередь, а затем выполняются. Обратите внимание на то, что: GET => http://justnoxx.name/one.html был поставлен в очередь раньше, но первым выполнился GET => http://justnoxx.name/two.html.

$cv — переменная состояния. Если вызывать у нее метод recv — никакие новые события в очередь поступать не будут, т.к. цикл событый решит, и решит весьма справедливо, что дальнейшая работа без данных невозможна. recv ожидает данных, которые можно отправить при помощи метода send этой же переменной. В данном приложении, как только данные получены, блокировка снимается и приложение завершается. Однако, есть тут подводный камень. В одной области видимости может существовать только одна переменная состояния. Иначе цикл событий начинает конкретно паниковать, не понимая, что ему делать. Еще. Нельзя вызывать метод recv без send, или без событий. Вызов в скрипте ->recv, если это единственный вызов во всем скрипте вообще, приведет к массе ошибок и/или загрузке процессора на 100%, зависит от цикла событий.

my $guard; $guard = ... undef $guard;

Подобный прием не редкость. Он используется для того, чтобы сборщик мусора не утилизировал наш callback (увеличиваем счетчик ссылок на 1). Если мы убереи undef $guard, ничего работать не будет. Иногда говорят что мы “замыкаем переменную”. Кстати, я писал про замыкания ранее. Мы можем сделать иначе — объявить в глобальной области видимости переменную-массив и потом при помощи push поместить туда $guard, и тогда наш callback не будет утилизирован.

Затем мы выполняем send-метод, который отправляет на уровень выше результат (“Done.” в нашем случае), который принимает ->recv. Затем программа завершается.

И наконец, если мы уберем $cv->recv();, ничего работать не будет, т.к. нет блокировки. В принципе, для начала достаточно. В следующей статье я остановлюсь более подробно на этих моментах.

В следующей статье цикла мы рассмотрим теорию по AnyEvent, понятие относительной асинхронности, таймеры и концепцию наблюдателей.

Я надеюсь, что данная статья была интересна. До новых встреч.

Дмитрий Шаматрин


GUI-приложения на Perl с помощью wxWidgets | Содержание | Обзор CPAN за октябрь 2014 г.
Нас уже 1393. Больше подписчиков — лучше выпуски!

Комментарии к статье