АЛЕКСЕЙ МИЧУРИН
Нити в Perl
Нити, называемые ещё «легковесными процессами» и управляющими потоками, – это части кода, принадлежащие одному процессу, разделяющие общее адресное пространство, но способные выполняться параллельно и асинхронно, что позволяет разделять вычисления между отдельными процессорами в многопроцессорных системах или продолжать вычисления, пока другие части программы ожидают определённых событий. В настоящей статье рассказывается об организации нитей в Perl, даётся оценка этой, пока достаточно новой и развивающейся, технологии.
threads vs fork
Все знают о системном вызове fork, который создаёт точную копию процесса. При этом новый процесс получает своё собственное адресное пространство и начинает существовать независимо от родителя. Такое ветвление процессов – широко распространённая практика в многозадачных средах, способных выполнять сразу несколько потоков команд. Но ветвление – не единственный способ воспользоваться преимуществами многопотоковости.
Другой подход – создание нитей (threads). При обычном fork-подходе одно отдельное адресное пространство обрабатывается одним потоком команд. Но согласитесь, что нет никаких принципиальных ограничений на количество потоков команд, работающих в одном адресном пространстве. Такие потоки и принято называть нитями.
Нити позволяют операционной системе выполнять программу сразу на нескольких процессорах или продолжать выполнение одной нити, пока другая ожидает ввода/вывода, сетевого соединения или других событий. Подобные возможности доступны и при fork-ветвлении, но нити дают некоторое дополнительное преимущество перед ветвлением. Во-первых, при переключении между нитями системе не приходится менять контекст задачи. Во-вторых, передача данных между нитями происходит гораздо проще, чем между отдельными процессами, благодаря общей памяти. Хотя, конечно, именно «благодаря» этим преимуществам, программирование нитей требует повышенной аккуратности.
Но на недостатках нитей я подробно остановлюсь в конце статьи, когда мы поближе познакомимся с тонкостями их функционирования.
В этой статье я рассказываю о средствах, имеющихся в Perl, для создания нитей. Конечно, мне не удастся охватить все возможности и тонкости программирования лёгких процессов, но я постараюсь детально остановиться на базовых вопросах и по крайней мере упомянуть средства, расширяющие возможности программиста.
Поддерживаются ли нити вашим Perl?
Немного истории. Механизм создания нитей появился ещё в Perl версии 5.005, но первая его реализация обладала массой изъянов. Поэтому в Perl 5.6 появился новый код, названный ithreads, а в версии 5.8 был добавлен интерфейсный модуль, позволяющий создавать нити.
Документация настоятельно советует отказываться от старого подхода к созданию и управлению нитями и как можно быстрее переходить на технологию ithreads, т.к. в Perl 5.10 (работа над которым уже идёт) старый подход, скорее всего, не будет поддерживаться. Поэтому я не буду касаться старой технологии управления нитями.
Итак, речь пойдёт о самой последней версии Perl 5.8.
Но это ещё не всё. Чтобы интерпретатор поддерживал нити, он должен быть собран с соответствующими опциями. Интерпретаторы, поставляемые с разными системами, могут отличаться. Так, например, Perl 5.8, поставляемый с FreeBSD 5.3, не поддерживает нити, и его следует перекомпилировать. Perl, входящий в дистрибутив SuSE Linux 9.1, напротив, собран с поддержкой нитей, и для их использования не требуется никаких дополнительных усилий.
Как узнать, способен ли ваш интерпретатор Perl управлять нитями? Посмотреть его настройки командой «Perl -V». Если вы хотите увидеть все характеристики Perl на вашем веб-сервере, то можете выполнить на нём, скажем, вот такой CGI-сценарий.
#!/usr/bin/perl
use Config;
print "Content-Type: text/plain\n\n";
print join "\n",
map {$_.' => '.
(defined $Config{$_}?
($Config{$_} eq ''?
'[EMPTY STRING]':
"'$Config{$_}'"):
'[UNDEFINED]')}
sort keys %Config;
Во всей полученной информации нас будут интересовать две переменные: use5005threads и useithreads. Если обе они не определены, то ваш интерпретатор не может создавать нити. Если определена только первая, значит, ваш интерпретатор поддерживает только старый механизм создания нитей. Если определена только вторая – вы имеете то, что надо, – поддерживается новый аппарат управления нитями. Наконец, если определены обе – это странно; скорее всего, Perl был сконфигурирован некорректно, и ничего работать не будет из-за несовместимости старой и новой технологии.
Итак, у вас должен быть Perl версии 5.8, а переменные use5005threads и useithreads должны иметь значения «неопределённое» и define соответственно. Если это так, продолжим изучение механизма ithread.
Создание нитей
Интерфейс управления нитями реализован в модуле threads. Для создания нити используется метод create, который требует один обязательный аргумент – ссылку на функцию, за которым может следовать список аргументов. Метод возвращает объект-дескриптор нити. Простейший пример:
sub thread_function { print "Ok! " }
$thread = threads->create(&thread_function);
$thread->join;
В первой строке мы создали нехитрую функцию, во второй – создали дескриптор, в третьей – вызвали нашу функцию.
Можно использовать анонимные функции. В нашем примере первые две строки можно объединить в конструкцию:
$thread = threads->create(sub { print "Ok! " });
Кроме метода create существует и метод new, являющийся просто псевдонимом первого. Поэтому допустим и другой синтаксис:
$thread = new threads(sub { print "Ok! " });
Все три действия можно, при желании, записать и одной строкой:
threads->create(sub { print "Ok! " })->join;
Для запуска нити предусмотрено два метода.
Первый мы уже видели, это join. Второй – detach.
Метод join запускает новую нить, при этом выполнение текущей нити приостанавливается до завершения дочерней, результат работы которой возвращается методом join в запускающую нить. Приведу простой пример:
#!/usr/bin/perl -w
use strict;
use threads;
sub f {
my ($a)=@_;
print "WAIT $a\n";
sleep 1;
print "DONE $a\n";
return "RESULT $a\n";
}
my $a = threads->create(\&f, 'A');
my $b = threads->create(\&f, 'B');
print $a->join;
print $b->join;
Как вы видите, наша функция &f получает один аргумент, печатает фразу «WAIT »+аргумент, ждёт одну секунду, печатает «DONE »+аргумент и возвращает результат – строку «RESULT »+аргумент.
Мы создаём две нити. Дескриптор $a соответствует нити, выполняющей функцию &f с параметром «A», а $b – «B».
Далее мы запускаем обе нити.
Как вы думаете, сколько времени потребуется на выполнение программы? Правильно, чуть больше одной секунды, т.к. оба ожидания будут идти практически одновременно! В результате мы получим следующий вывод:
WAIT A
WAIT B
DONE A
RESULT A
DONE B
RESULT B
|
То есть произошёл запуск нити A, она напечатала первую строку и «заснула» на секунду. Не дожидаясь пробуждения A, началось выполнение нити B (строка «WAIT B»). Пока B спала, проснулась A и вывела результат на печать. Далее проснулась B. Когда все нити отработали, программа завершилась.
Давайте чуть усложним наш код:
#!/usr/bin/perl -w
use strict;
use threads;
sub f {
my ($a, $t)=@_;
print "WAIT $a\n";
sleep $t;
print "DONE $a\n";
return "RESULT $a\n";
}
my $kida = threads->create(\&f, 'A', 2);
my $kidb = threads->create(\&f, 'B', 1);
print $kida->join;
print $kidb->join;
Теперь нить A выполняется две секунды, а нить B – по-прежнему одну.
Результат будет таков:
WAIT A
WAIT B
DONE B
DONE A
RESULT A
RESULT B
|
Вы видите, что нить В отработала быстрее, но первый оператор print не выполнялся, пока нить A не завершила работу. Не мог выполниться и второй оператор print, хотя результат уже был готов.
Метод detach работает иначе. Запущенная нить становится полностью автономна, а запускающий код продолжает выполняться, не дожидаясь её завершения. Поэтому метод detach не возвращает результат работы; последний просто теряется.
Несколько слов о других возможностях модуля threads
Модуль thread содержит ещё несколько полезных функций.
Название метода threads->self говорит само за себя, он возвращает дескриптор текущей нити.
Метод threads->tid возвращает более удобный целочисленный идентификатор текущей нити. Каждая нить получает свой уникальный идентификатор. Основная программа является нитью номер ноль.
Метод threads->object($tid) возвращает дескриптор, соответствующий идентификатору, или undef, если нити с заданным $tid не существует.
Метод threads->yield (теоретически) сообщает операционной системе, что текущая нить может уступить остаток кванта времени, выделенного ей, другому процессу или нити. Эта функция может быть весьма полезна, но фактически она реализована далеко не во всех операционных системах и зачастую просто не производит никаких действий.
И наконец, метод threads->list возвращает список объектов, которые уже созданы методом create (или new), но пока не были запущены ни методом join, ни detach.
Доступ к глобальным переменным
При беглом взгляде на следующий пример может показаться, что глобальные переменные доступны для нитей. Это не совсем так. Чтобы прояснить ситуацию, предлагаю запустить следующий код:
#!/usr/bin/perl -w
use strict;
use threads;
my $a;
sub f { my $aa=$a; $a=7; return "DONE (a=$aa)\n" }
$a=0;
my $kida = threads->create(\&f);
$a=1;
my $kidb = threads->create(\&f);
$a=2;
print "(a=$a)\n";
print $kida->join;
$a=3;
print "(a=$a)\n";
print $kidb->join;
Несмотря на использование ключа -w и модуля strict, ни ошибок, ни предупреждений этот пример не вызовет. То есть глобальная переменная $a доступна в функции &f. Но давайте посмотрим, что же выдаст эта программа:
(a=2)
DONE (a=0)
(a=3)
DONE (a=1)
|
Удивлены? Ничего странного, функция &f, выполняемая в нити, видит то значение глобальной переменной, которое имелось на момент создания нити (вызов create).
Дело в том, что при создании нити методом create или new происходит копирование всех глобальных переменных в локальный контекст нити, с которым она и работает. Поэтому изменение значения $a в коде нити (в функции &f) никак не влияет на значение $a, которое доступно из основной программы или из других нитей.
Именно поэтому нити рекомендуется создавать как можно раньше, пока программа не успела аккумулировать большой объём данных; не следует перегружать код глобальными переменными. Иначе создание нити, вернее копирование данных (очень скоро мы убедимся, что это не совсем копирование «один к одному»), может занять десятки (!) секунд и привести к существенному перерасходу памяти.
Справедливости ради надо сказать, что я не замечал за нитями утечек памяти. Копия глобальных переменных уничтожается сразу после завершения работы нити.
Создание копии данных имеет ещё одно побочное действие – изменение значений указателей. Взгляните на следующий пример:
#!/usr/bin/perl -w
use strict;
use threads;
my $a;
my $b=\$a;
sub f { print "in thread b=$b\n" }
my $kid = threads->create(\&f);
print "in main b=$b\n";
$kid->join;
В результате его работы мы получим нечто подобное:
in thread b=SCALAR(0x8237bb8)
in main b=SCALAR(0x81691e8)
|
Как видите, мы создали глобальный указатель $b на глобальную переменную $a. В основной программе и внутри нити указатель имеет разные значения. В первом случае он хранит адрес переменной $a, во втором – адрес копии $a, созданной в момент создания нити. Именно это я имел в виду, когда говорил, что копирование глобальных переменных – это не совсем копирование. Значение ссылки будет изменяться даже при передаче её в качестве аргумента. Таким образом, Perl не позволяет обмануть его и закрывает все возможные лазейки для доступа к одним и тем же глобальным переменным из разных нитей.
Для разделения данных существует специальный набор инструментов.
Разделение данных
Модуль thread::share предоставляет все необходимые средства для создания разделяемых переменных и корректной работы с ними. Он может использоваться только в сочетании с модулем threads. В противном случае все методы модуля thread::share не выполняют никаких действий.
Разделяемые переменные создаются функцией share, аргументом которой может быть скаляр, массив, хэш или указатель на любой из этих типов данных. Функция делает аргумент разделяемым и возвращает ссылку на него.
Например:
# создаём разделяемый скаляр
my $a; share($a);
# создаём разделяемый массив
my @a; share(@a);
# создаём разделяемый хэш
my %a; share(%a);
# указатель на разделяемый массив
my $a=&share([]);
# указатель на разделяемый хэш (!)
my $a=&share();
Share имеет несколько специфических особенностей.
Во-первых, в последних двух командах этого примера следует обязательно использовать полное имя функции &share с явным указанием типа.
Во-вторых, разыменование ссылок осуществляется только на один уровень. То есть share($a) эквивалентно share($a), но не эквивалентно share($a).
В-третьих, обобществление массивов и хэшей приводит к обобществлению всех их элементов и ключей.
В-четвёртых, и это очень существенно, разделяемой переменной можно присваивать только простые значения и ссылки на другие разделяемые переменные. Таким образом, нить по-прежнему не может получить доступ к простым глобальным переменным даже через указатель на таковые, сохранённый в разделяемой переменной.
Вот иллюстрация:
my $a; share($a);
my $b;
$a=$b;
Последняя строка приведёт к ошибке: «Invalid value for shared».
Ошибки не возникнет, если мы чуть изменим код, сделав $b разделяемой:
my $a; share($a);
my $b; share($b);
$a=$b;
Последнее, что следует сказать о создании разделяемых переменных, это то, что их можно создавать на этапе компиляции программы, указывая атрибут shared:
my $a : shared = 1;
Проблемы, возникающие при работе с разделяемыми переменными
Потенциальные трудности, связанные с разделяемыми переменными, проще проиллюстрировать примером:
#!/usr/bin/perl -w
use strict;
use threads;
use threads::shared;
my $a : shared = 0;
sub f { my $c=$a; $c++; $a=$c; }
my $p=threads->create(\&f);
my $q=threads->create(\&f);
$p->join;
$q->join;
print "$a\n";
Как видите, каждый вызов нити должен увеличивать $a на единицу. Мы вызываем нить дважды, но увидим ли мы в результате двойку? Совсем не обязательно! Обе нити выполняются параллельно, и может сложиться такая ситуация, что локальные переменные $c будут инициализированы до того момента, когда хотя бы одна из нитей изменит значение общей переменной $a. Тогда обе $c будут равны нулю, после инкремента – единице, и $a получит значение один. Это произойдёт почти наверняка, если чуть модифицировать функцию &f:
sub f { my $c=$a; sleep 1; $c++; $a=$c; }
Более того, даже простой инкремент нельзя считать атомарной (неделимой) операцией. И даже если мы изменим &f следующим образом:
sub f { $a++ }
Результат по-прежнему будет зависеть от того, как операционная система распределит процессорное время между разными нитями. То есть фактически он будет непредсказуем.
Блокировка переменных
Для разрешения подобных проблем модуль threads::shared предлагает различные средства блокировки. Наиболее часто используется функция lock. Эта функция блокирует переменную в пределах её области видимости. То есть в пределах блока, ограниченного фигурными скобками.
Если переменная заблокирована одной нитью, то вызов lock для этой же переменной в другой нити вызовет приостановку выполнения этой нити до тех пор, пока первая нить не снимет блокировку.
Корректно функцию &f в нашем примере можно переписать так:
sub f {
lock($a);
$a++;
}
Если необходимо изменить две переменные, то совсем не обязательно блокировать обе на всё время выполнения функции. Достаточно разнести действия с каждой из переменных в отдельные блоки и выполнять блокировку в каждом из них только одной переменной (конечно, если алгоритм допускает подобное разделение).
Пример:
sub f {
{ lock($a); $a++ }
{ lock($b); $b++ } # забл. только $b
}
Но при работе с несколькими разделяемыми переменными следует соблюдать определённую технику безопасности, чтобы избежать ситуации, называемой в англоязычных источниках deadlock (что я бы перевёл, как блокировка намертво).
Вот пример такой ситуации:
#!/usr/bin/perl -w
use strict;
use threads;
use threads::shared;
my $a : shared;
my $b : shared;
my $p=threads->create( sub { lock($a); sleep 1; lock($b) } );
my $q=threads->create( sub { lock($b); sleep 1; lock($a) } );
$p->join;
$q->join;
Эта программа, скорее всего, зависнет «намертво». Причина проста: обе нити выполняются практически одновременно. Каждая блокирует одну из двух переменных (первый вызов lock), а потом обе ждут, когда будет снята блокировка другой переменной (второй вызов lock). Ни одна не может уступить, и весь конгломерат «зависает» в нескончаемом ожидании.
Надёжной стратегией, позволяющей избежать таких ситуаций, является строгое соблюдение порядка установки блокировок. Допустим, всегда блокировать $a раньше, чем $b; и никогда иначе.
У блокировки есть несколько важных свойств.
Во-первых, важно понимать, что lock действует аналогично системной функции flock, обеспечивающей блокировку файлов. Она не ограничивает использование переменной, а только блокирует выполнение других вызовов lock. Это похоже на светофор, который лишь указывает на возможность или невозможность движения, но не способен остановить нарушителя.
Во-вторых, повторный вызов lock для той же переменной в той же области видимости не выполняет никаких действий:
{ lock($x);
lock($x);
}
В этом примере второй вызов lock не выполнит никаких действий. Программа продолжит работу.
В-третьих, при вызове других функций заблокированные переменные остаются таковыми же:
sub a {
$x++; # $x осталась заблокирована
}
sub b {
lock($x);
a();
}
Другие возможности модуля use threads::shared
Модуль содержит ещё несколько очень полезных инструментов, связанных с блокировкой. Очень коротко расскажу про них. За более подробной информацией обращайтесь к руководству perldoc threads::shared.
Функция cond_wait служит для временного снятия блокировки с переменной. Допустим, вы заблокировали переменную, но хотите временно предоставить к ней доступ другим нитям. Тогда вы вызываете cond_wait. Блокировка с переменной снимается, и выполнение cond_wait приостанавливается. Теперь ваша нить ждёт, пока другая нить проделает необходимые манипуляции с переменной. Когда работа с переменной завершена, нить (выполнявшая действия) должна сообщить о том, что переменная ей больше не нужна, вызвав функцию cond_signal для этой же переменной. Тогда cond_wait снова блокирует переменную и завершается, позволяя первой нити продолжить работу.
Допустим вызов функции cond_wait с двумя параметрами. Тогда она снимает блокировку со второго и ждёт, когда поступит сигнал для первого аргумента.
Функция cond_timedwait выполняет аналогичные действия, но позволяет задать тайм-аут. Она также снимает блокировку и ждёт сигнала или наступления тайм-аута. Если наступил тайм-аут, cond_timedwait возвращает ложь, если был получен сигнал – истину.
Функцию cond_timedwait можно вызывать и с тремя аргументами, тогда она действует аналогично cond_wait с двумя аргументами, но отслеживает ещё и тайм-аут.
Для отправки сигналов существует две функции: cond_ signal и cond_broadcast. Обе получают один аргумент – переменную и посылают сигнал для cond_wait. Разница состоит только в том, что если сразу несколько нитей ожидают сигнала, то cond_signal посылает сигнал только одной из них (причём неизвестно, какой), а cond_broadcast посылает сигнал всем.
Использование системы сигналов (как и многие вопросы, затрагиваемые в этой статье) вполне заслуживает отдельной книги. Неаккуратность может привести к зависаниям и другим неприятным последствиям. Я не буду здесь подробно останавливаться на вопросах низкоуровневой синхронизации параллельных процессов и позволю себе перейти к рассмотрению более высокоуровневых и менее прихотливых средств.
Семафоры
Классическим средством синхронизации являются семафоры. Для нитей они реализованы в модуле Thread::Semaphore, который предоставляет всего три функции: new – создать семафор, down – опустить семафор (в железнодорожном понимании – закрыть проезд), up – поднять семафор.
Проще всего представить семафор как счётчик. Функция up увеличивает счётчик на единицу. Функция down уменьшает счётчик на единицу, и если счётчик становится равен нулю (или меньше), то down останавливается и ждёт, когда семафор поднимется. Так семафоры сигнализируют, занят ресурс или свободен, и позволяют ехать по рельсам только одному паровозу (для всех других семафор закрыт).
Помните наш пример, демонстрирующий, какие проблемы возникают, если не блокировать разделяемые переменные? Тогда мы спасли ситуацию, использовав функцию lock, но выйти из положения можно было и обратившись к аппарату семафоров.
Вот пример безопасного кода, не использующего функцию lock; обходящего возможные проблемы только средствами семафоров:
use threads;
use Thread::Semaphore;
my $s = new Thread::Semaphore;
my $a : shared = 0;
sub f {
$s->down;
$a++;
$s->up;
}
my $p=threads->create(\&f);
my $q=threads->create(\&f);
$p->join;
$q->join;
print "$a\n";
Подобную защиту можно было реализовать на основе cond_wait/cond_signal, но эта пара функций связана с блокировкой переменных, а семафоры заслуживают особого внимания, так как они гораздо универсальней. С этой универсальностью связано их следующее замечательное свойство.
Все три метода – new, up и down – можно вызывать с аргументом. Это должно быть целое число, которое new интерпретирует как начальное значение счётчика, а up и down – как величину, на которую следует изменить счётчик.
На первый взгляд кажется, что семафор, созданный методом new с аргументом 3, – сломанный семафор. Но если присмотреться, то оказывается, что он позволяет ехать одновременно не более чем трём паровозам. Это очень полезно, когда речь идёт не о переменных, хранящихся в памяти, а о ресурсах, допускающих одновременное коллективное использование, но требующих определённой экономии. Примером такого ресурса может быть сетевой канал. Вы можете ограничить количество нитей, работающих с каналом, но ограничить его не единицей, а любым числом! В некоторых случаях это делает семафоры гораздо привлекательнее, чем блокировки.
Для тех, у кого ещё осталось недопонимание, приведу пример:
use threads;
use Thread::Semaphore;
$|=1;
my $s = Thread::Semaphore->new(2);
my $a : shared = 0;
sub f {
my ($name, $time, $greed)=@_;
print "$name: Пытаюсь опустить семафор, захватив $greed шт. ресурсов\n";
$s->down($greed);
print "$name: Семафор опущен, работаю с $greed шт. ресурсов $time с.\n";
sleep $time;
print "$name: Поднимаю семафор\n";
$s->up($greed);
{lock($a); $a++}
}
foreach (qw/A B C D E F/) {
threads->create(\&f, $_, 1+int(rand(3)), 1+int(rand(2)))->detach;
}
for (my $i=0; $a<6; $i++) {
print "Идёт секунда $i\n";
sleep 1;
}
Здесь мы создаём семафор, позволяющий сразу двум нитям использовать одновременно некий воображаемый ресурс. Потом мы создаём шесть нитей, задавая им случайные аргументы. Каждая оккупирует ресурс на несколько секунд и в разном объёме: некоторым необходима одна единица ресурса, некоторым – две.
После запуска нитей запускается цикл – таймер, ожидающий завершения всех нитей.
Чтобы Perl выдавал сообщения незамедлительно, нам пришлось отключить буферизацию $|=1.
Вот какой вывод мы получим (естественно, он будет получаться всегда немного разный, оттого что мы используем для инициализации случайные числа):
A: Пытаюсь опустить семафор, захватив 2 шт. ресурсов
A: Семафор опущен, работаю с 2 шт. ресурсов 1 с.
B: Пытаюсь опустить семафор, захватив 1 шт. ресурсов
C: Пытаюсь опустить семафор, захватив 1 шт. ресурсов
D: Пытаюсь опустить семафор, захватив 2 шт. ресурсов
E: Пытаюсь опустить семафор, захватив 2 шт. ресурсов
F: Пытаюсь опустить семафор, захватив 2 шт. ресурсов
Идёт секунда 0
A: Поднимаю семафор
B: Семафор опущен, работаю с 1 шт. ресурсов 1 с.
C: Семафор опущен, работаю с 1 шт. ресурсов 2 с.
Идёт секунда 1
B: Поднимаю семафор
Идёт секунда 2
C: Поднимаю семафор
D: Семафор опущен, работаю с 2 шт. ресурсов 3 с.
Идёт секунда 3
Идёт секунда 4
Идёт секунда 5
D: Поднимаю семафор
E: Семафор опущен, работаю с 2 шт. ресурсов 1 с.
Идёт секунда 6
E: Поднимаю семафор
F: Семафор опущен, работаю с 2 шт. ресурсов 3 с.
Идёт секунда 7
Идёт секунда 8
Идёт секунда 9
F: Поднимаю семафор
|
Как видите, мы достигли поставленной цели: наш набор нитей использует одновременно не более чем две единицы ресурса.
Обратите внимание, что мы могли захватывать и освобождать ресурсы постепенно даже в пределах одной нити.
Заметьте также, что подобная практика может привести к зависанию одной из нитей, если аргумент down больше аргумента new. Получается, что нить требует больше ресурсов, чем дозволено использовать вообще. Такая нить будет вечно ждать благоприятных условий.
Очереди
Очереди позволяют передавать данные между нитями, не заботясь ни о синхронизации, ни о блокировке. Модуль Thread::Queue предоставляет полный набор инструментов для работы с очередями: новая очередь создаётся методом new, метод enqueue помещает данные в очередь (аргумент – скаляр или список), метод dequeue извлекает данные из очереди.
my $q=Thread::Queue->new;
$q->enqueue("text");
$q->enqueue(1, 2, 3);
my $var=$q->dequeue;
Причём, если очередь пуста, то метод dequeue ждёт, пока в очереди не появятся данные.
Приведу простой пример использования очередей:
#!/usr/bin/perl -w
use strict;
use threads;
use Thread::Queue;
my $q=Thread::Queue->new;
sub f {
$q->enqueue("I'm going sleep");
sleep 1;
$q->enqueue("I'm waiking up");
$q->enqueue(undef);
}
my $p=threads->create(\&f);
$p->detach;
while (my $t=$q->dequeue) {
print "He say: '$t'\n";
}
Здесь нить асинхронно помещает данные в очередь, а основная программа (нить номер ноль) «прослушивает» эту очередь.
Я бы хотел обратить ваше внимание на несколько аспектов.
Во-первых, нам не только не пришлось заботиться о блокировке переменных и передаче/приёме сигналов, но и вообще не понадобилось подключать модуль threads::share и обращаться к разделяемым переменным – мы вполне обошлись глобальными. Это не значит, что все сложности, возникающие при работе с глобальными переменными, описанные выше, исчезли. Это значит только то, что модуль Thread::Queue обходит их сам.
Во-вторых, так или иначе, но при подобном обмене данных следует соблюдать определённый протокол. В нашем случае появление в очереди ложного значения сигнализирует процессу-получателю об окончании передачи. Если бы мы не поместили в очередь заключительное undef, наша программа просто зависла бы в ожидании новых данных в очереди.
В документации perldoc perlthrtut есть очень интересный пример. Программа ищет простые числа; всё написано на нитях и очередях. Предложенный там код выгодно отличается от моих примитивных примеров, приводимых в этой статье. Для компактности и максимальной наглядности я в своих примерах создаю только столько нитей, сколько необходимо для демонстрации той или иной возможности, передавая функциям аргументы-константы. Пример в perlthrtut порождает нити в том количестве, какое необходимо, динамически разветвляя процесс вычислений (более ста штук, если ничего не менять). Я бы с удовольствием рассмотрел здесь подобный пример, но боюсь, что эта задача не сможет уложиться в рамки журнальной статьи. Тем не менее приведённых здесь фактов более чем достаточно, чтобы понять, как работает пример из perlthrtut. К тому же он снабжён краткими, но исчерпывающими комментариями (на английском языке). Рекомендую взглянуть на него всем, кто заинтересовался.
Обеспечение совместимости и переносимости кода
Как вы уже могли убедиться, не все реализации (сборки) интерпретатора Perl поддерживают нити. В программе, требующей работы с нитями, уместно предусмотреть хотя бы элементарную проверку. Например, такую:
...
use Config;
...
die "Я работаю только с нитями "
unless ($Config{"useithreads"});
...
Удачной идеей будет изолировать весь threads-зависимый код в отдельный модуль. А полной переносимости можно достичь, если создать модуль-дублёр, выполняющий те же функции, но не требующий поддержки нитей. Тогда можно подключать тот или другой модуль, в зависимости от конкретной ситуации:
use Config;
BEGIN {
if ($Config{"useithreads"}) {
require my_threads_dep;
import my_threads_dep;
} else {
require my_threads_indep;
import my_threads_indep;
}
}
Функция import не является встроенной функцией Perl. Эту функцию традиционно содержит модуль. Возможно, для подключения вашего модуля будет достаточно оператора require.
И снова threads vs fork. Отличие нитей от ветвления
Теперь, когда мы уже знакомы с особенностями нитей, давайте подведём некоторые итоги: чем лёгкие процессы отличаются от обычных дочерних процессов. Мы уже много говорили о преимуществах, давайте просуммируем и недостатки.
Основное обстоятельство, накладывающее серьёзные ограничения на производительность нитей, то, что каждая из них получает копию всех данных, доступных родителю. Впрочем, дочерние процессы, порождённые с помощью fork, тоже получают копию данных родителя. Создание такой копии приводит не только к излишнему расходу памяти, но и к существенным затратам процессорного времени. Мы уже видели, что при «копировании» Perl выполняет ряд дополнительных действий, например, корректирует ссылки.
К счастью, вы можете свести эти затраты практически к нулю, ограничив количество глобальных переменных или полностью отказавшись от таковых. Это, как известно, вообще хороший стиль программирования.
Следует заметить, что разделяемые переменные требуют немного больше памяти и работают чуть медленнее обычных.
Следующее ограничивающее обстоятельство напрямую следует из того, что все нити принадлежат одному процессу, а стало быть, могут изменять контекст выполнения процесса, и эти изменения будут касаться всех нитей.
Поэтому в нитях следует избегать команд, влияющих на контекст процесса, таких как chdir (смена текущего рабочего каталога), chroot (смена корневого каталога), umask (смена маски атрибутов файлов), а также команд, изменяющих идентификатор пользователя и группы, и прочих подобных действий.
Небезопасными вызовами являются exit и другие, приводящие к завершению программы. Такой вызов может сделать любая нить, но при этом завершится программа и все нити будут аварийно остановлены. Если Perl приходится останавливать сразу несколько нитей, то он выдаёт предупреждение.
Некорректно в нитях могут работать и функции rand и srand, функции работы с временем и даже с сетевыми интерфейсами, так как эти функции могут быть связаны с глобальным окружением процесса. Если вы хотите использовать эти функции, то в первую очередь обратитесь к документации на вашу систему.
Также неудачной идеей является сочетание fork- и threads-подходов. В разных операционных системах реализации fork- и thread-механизмов могут очень сильно отличаться. Совместное использование этих двух подходов может привести к непредсказуемым результатам. Самый простой вопрос: должен ли процесс, порождённый вызовом fork, наследовать все нити родителя, или он станет копией только одной вызывающей нити? Ответ на этот вопрос различен для разных операционных систем.
По тем же причинам не следует использовать сигналы (системный вызов kill) для синхронизации нитей.
При работе с файлами следует соблюдать обычные в таких случаях меры предосторожности. Блокировать дескрипторы (системный вызов flock), своевременно сбрасывать буферы.
Одним словом, нити гораздо более капризны, чем дочерние процессы. Технологию создания нитей нельзя считать столь же зрелой и стандартизованной, как технологию порождения дочерних процессов. И прежде чем вы начнёте использовать нити в больших проектах, обязательно ознакомьтесь со страницами документации perldoc threads, threads::shared, Thread::Queue, Thread::Semaphore, perlthrtut (в которой дано несколько дополнительных ссылок) и документацией на вашу операционную систему. Не помешает и потестировать критичные узлы отдельно, прежде чем вносить окончательные изменения.
Техника использования легковесных процессов, как вы видите, ещё очень молода, и использовать её следует со всей возможной осторожностью. Но с увеличением доли многопроцессорных машин она, безусловно, будет совершенствоваться, развиваться и стандартизироваться. А при аккуратном использовании она позволяет уже сейчас качественно усовершенствовать ваши программы, значительно повысив их производительность.