Пишем простой асинхронный парсер
Многие разработчики типового говнософта, ориентированного на работу с вебом, зачастую используют потоки для того, чтобы получить выигрыш в скорости. Данный подход, конечно, обладает своими плюсами, но все же не является оптимальным, например, с точки зрения потребляемых ресурсов системы (особенно когда речь идет о потребителях, любящих ставить сразу “тыщу потоков”).
Альтернативным и общеизвестным способом ускорения работы софта является асинхронная модель, то есть модель, при которой все вызовы методов являются неблокирующими. В данной статье я рассмотрю простой пример, который будет использовать асинхронные веб-запросы.
В качестве примера будет написан парсер идентификаторов приложений с Android Market, который пригодится в готовящейся статье, посвященной добычи трафика с маркета. Для простоты будем использовать модуль AnyEvent, он упрощает реализацию асинхронной событийной модели. Итак, приступим.
Для начала прагмы, необходимые инклюды и переменные:
use strict; use warnings; use AnyEvent::HTTP; use Fcntl qw/:flock/; #Список категорий, которые скрипт будет обрабатывать my @cat_list = qw/ARCADE BRAIN CARDS CASUAL GAME_WALLPAPER RACING SPORTS_GAMES GAME_WIDGETS/; #Лимит страниц для каждой категории my $page_limit = 15; #Файл для сохранения результата my $res_file = 'result1.txt'; #Autoflush $| = 1; #Для последующего определения времени работы скрипта my $time = time;
В целом код довольно небольшой, поэтому я не буду излишне фрагментировать его, а изложу основную часть одним куском и прокомментирую.
#Перебираем категории в массиве for my $cat_name(@cat_list) { #Создаем переменную, необходимую для последующей блокировки дальнейшего выполнения скрипта #(чтобы выполнять запросы пачками, по 15) #http://search.cpan.org/~mlehmann/AnyEvent-6.14/lib/AnyEvent.pm#CONDITION_VARIABLES my $cv = AnyEvent->condvar; #Ставим в очередь гет-запросы for(my ($i, $j) = (0, 0); $i < $page_limit; $i++) { print $cat_name, ' - ', $i, $/; #Определяем параметры гет-запроса http_get ( 'https://market.android.com/details?id=apps_topselling_paid&cat='.$cat_name.'&start='.($i * 25).'&num=25', headers => { 'User-Agent' => 'Mozilla/5.0 (Windows; U; Windows NT 5.1; en-US; rv:1.9.2)' }, timeout => 5, #Колбэк, который вызывается после выполнения запроса sub { my ($data, $headers) = @_; #Если страница не существует, то android market возвращает 404 #Проверяем этот момент if($headers->{Status} =~ /^200/) { #Сохраняем в файл идентификаторы приложений open F, '>>', $res_file or warn $!; flock F, LOCK_EX; print F (join $/, $data =~ /data-docid="(.+?)"/g), $/; flock F, LOCK_UN; close F; } #Если все страницы были обработаны, if(++$j == $page_limit) { #Снимаем блокировку, чтобы поставить в очередь следующую пачку запросов $cv->send; } } ); } #Блокируем переменную, ждем пока все запросы будут обработаны #(как-бы ждем "сигнала", когда будет вызван метод ->send) $cv->recv; } #Выводим время работы скрипта print 'Time elapsed: ', time - $time, $/;
Теперь сравним вышеприведенный скрипт со скриптом, который выполняет ту же самую работу, но опирается на потоки. Вот код этого скрипта:
use strict; use warnings; use threads; use threads::shared; use IO::Socket::SSL; use Fcntl qw/:flock/; my $thr_cnt = 15; my @cat_list : shared = qw/ARCADE BRAIN CARDS CASUAL GAME_WALLPAPER RACING SPORTS_GAMES GAME_WIDGETS/; my $page_limit = 15; my $res_file = 'result2.txt'; $| = 1; my @trl = (); my $w_lock : shared; my $time = time; $trl[$_] = threads->create(\&main) for 0..$thr_cnt - 1; $_->join for @trl; print 'Time elapsed: ', time - $time, $/; sub main { while(1) { my $cat_name = shift @cat_list or last; for(my $i = 0; $i < $page_limit; $i++) { print $cat_name, ' - ', $i, $/; my $socket = IO::Socket::SSL->new ( PeerAddr => 'market.android.com', PeerPort => 443, PeerProto => 'tcp', TimeOut => 5 ); if($socket) { my $req = "GET /details?id=apps_topselling_paid&cat=$cat_name&start=".($i * 25)."&num=25 HTTP/1.0\r\n". "Host: market.android.com\r\n". "User-Agent: Mozilla/5.0 (Windows; U; Windows NT 5.1; en-US; rv:1.9.2)\r\n". "Connection: Close\r\n\r\n"; print $socket $req; my $data; while(read $socket, my $buffer, 128) { $data .= $buffer; } close $socket; if($data =~ /200 OK/) { lock $w_lock; open F, '>>', $res_file or warn $!; flock F, LOCK_EX; print F (join $/, $data =~ /data-docid="(.+?)"/g), $/; flock F, LOCK_UN; close F; } } } } }
Скорость работы скриптов примерно одинакова, и там и там идет одновременное выполнение 15 веб-запросов, однако, если обратить внимание на потребляемую память и нагрузку на процессор, то мы увидим явное преимущество у первого скрипта. У меня количество потребляемой памяти для асинхронного варианта составляло ~ 12 мегабайт, а в случае с потоками оно увеличилось до ~ 53 мегабайт (хотя прожорливость perl + pthreads под win* – известная печалька).
Но даже если абстрагироваться от языка программирования и использовать WinAPI для работы с потоками, то можно увидеть, что при большом количестве потоков (конечно, ведь чем больше потоков, тем круче и быстрее (с) дефолт логика) нагрузка на процессор вырастет несоизмеримо по сравнению с асинхронной моделью.
В общем, асинхронная модель зачастую является более предпочтительной, но иногда требует нетривиального планирования алгоритма работы программы, например, когда есть необходимость совершать множество зависящих друг от друга запросов и менять логику поведения в зависимости от результатов, полученных на предыдущих стадиях.