воскресенье, 25 июня 2017 г.

Unix для Perl-программистов: каналы и процессы

Перевод: Unix for Perl programmers: pipes and processes
Автор: Аарон Крейн (Aaron Crane)

Примечания переводчика:
Эту статью я начал переводить в конце 2013 года, когда писал на работе программу на Perl, в которой мне было нужно обрабатывать данные в нескольких параллельных процессах. Я поискал готовые модули на CPAN, но ничего подходящего тогда почему-то не нашёл, из-за чего решил писать свой собственный модуль. Эта статья пригодилась, чтобы уточнить некоторые моменты, поэтому я и решил её перевести. Когда дописал модуль, начал переводить эту статью. Через некоторое время закрутился и не мог найти времени, после чего и вовсе забыл про незавершённый перевод.

Вспомнил о неоконченном переводе, когда нашёл на CPAN модуль, который оказался аккуратнее моего модуля, а делал в точности то же самое. Это модуль Parallel::DataPipe, написанный Александром Харченко. К сожалению, и на этот раз ситуация повторилась и я опять надолго забыл про перевод.

И вот уже та программа полностью переделана на Python и модуль multiprocessing, а статья всё оставалась не переведённой. Если работа не завершена, значит время было потрачено впустую. Поэтому решил довести дело до конца - перевести остаток статьи, вычитать перевод, согласовать терминологию, оформить и выложить.
Это сопроводительный документ к выступлению на YAPC::EU 2009.

Одна из общеизвестных сильных сторон Perl заключается в том, что он скрывает избыточную сложность, делая простые вещи простыми. В контексте Unix-программирования это означает, что, например, можно легко захватить вывод команды, выполненной в shell:
my $ps_output = `ps`;
Эта строчка запустит команду ps, захватит весь её вывод и сохранит его в переменную $ps_output. Потрясающе просто.

Однако, не всегда можно обойтись стандартными средствами Perl. Например, что если понадобится запустить несколько процессов параллельно? Что если потребуется одновременно канал для ввода в процесс и канал для захвата его вывода?

К счастью, назначение Perl заключается и в том, чтобы сделать сложное возможным: он предоставляет свободный доступ к низкоуровневому API Unix. Поэтому, если вы знаете, что средств Unix достаточно для достижения ваших целей, Perl сможет вам помочь.

Итак, статья вот-вот начнётся. Она покажет всю подноготную каналов, процессов и сигналов с точки зрения Perl-программиста, поэтому если вам когда-нибудь потребуется что-то из названного, вы поймёте, что нужно делать.

CPAN

Одна из классических добродетелей хорошего программиста - это лень. В частности, Perl-программисту важно знать, как (и когда) стоит воспользоваться чужими наработками из CPAN. Не секрет, что на CPAN уже имеются реализации многих описанных здесь приёмов. Но поскольку моя цель - объяснить что происходит за кулисами Unix, я поступлю необычно - просто пропущу этап поиска модулей на CPAN.

Как запустить программу?

Начнём с простого вопроса, ответ на который сформирует основы для понимания работы процессов в Unix: как запустить программу и дождаться её завершения?

Приятно, что в Perl есть простой встроенный способ для ответа на этот вопрос, по крайней мере он просто выглядит. Если нужно запустить программу с именем update_web_server, можно просто воспользоваться именем этой программы, как аргументом для функции system, встроенной в Perl:
system 'update_web_server';
Но тут же возникает следующий вопрос: как узнать, что программа выполнилась успешно? В Unix каждый процесс завершает работу, возвращая статус завершения - целое число, доступное вызвавшему процессу. По устоявшейся традиции, если процесс завершил работу со статусом 0, то это значит, что он успешно выполнил то, что требовалось. Соответственно - ненулевой статус свидетельствует об ошибке.

(Многие люди удивляются, почему ноль означает успех, а не ноль означает ошибку, потому что во многих языках программирования ноль считается ложью, а не ноль - истиной. Причина в том, что у большинства программ существует только один вариант успешного завершения, но множество неуспешных вариантов. И некоторые программы этим пользуются. Например, grep(1) возвращает 0 - если было найдено совпадение, 1 - если совпадение не найдено и 2 - если произошла ошибка, например - ошибка открытия файла.)

Естественно, в Perl имеется простой способ узнать статус завершения запущенной команды: переменная $? содержит статус завершения последней выполненной команды. (Важно, что функция system возвращает это же значение.) Однако, $? - это не просто статус завершения: если процесс завершился из-за того, что операционная система отправила неожиданный сигнал, этот сигнал также кодируется в $?. Подробное описание можно найти в perlvar, а сейчас достаточно знать, что $? равен нулю в случае успешного завершения процесса.

Соглашение о нуле в случае успеха приводит к тому, что им неудобно пользоваться для обнаружения ошибок. Проще всего это сделать так:
system('update_web_server') == 0
    or die "Не удалось обновить веб-серверы\n";
Следующий вопрос заключается в том, как передать программе аргументы. В некоторых случаях лучше воспользоваться наиболее очевидным подходом - просто вставить аргументы в вызов system:
system 'update_web_server --all';
Однако, в более сложных случаях этот подход может не подойти. Когда функция system вызывается с одним строковым аргументом, функция трактует этот аргумент как команду, которую нужно выполнить при помощи /bin/sh - оболочки операционной системы. Но что будет, если часть строки поступит из источника, не заслуживающего доверия?
my $host = $ARGV[0];
system "update_web_server --host=$host";
Всё выглядит довольно очевидно, но что если $ARGV[0] - это x; rm -rf /? Поскольку при указании единственного аргумента функция system вызывает оболочку для запуска программы, оболочка сначала запустит update_web_server с аргументом --host=x, а затем приступит к выполнению rm -rf /. Ой!

К счастью, в Perl есть выход: если передать функции system список аргументов, а не одну строку с командой, функция трактует список как имя программы и её аргументы, и запустит программу напрямую, без вызова оболочки:
system 'update_web_server', "--host=$host";

Как запустить программу в фоновом режиме?

Представим, что нужно запустить программу, работа которой займёт много времени, но при этом нужно продолжить работу основной программы. Можно воспользоваться средствами оболочки - добавить амперсанд к команде, чтобы запустить её в фоновом режиме:
system 'update_web_server --all &';
Однако, как было написано в предыдущем разделе, это небезопасно, если команда должна обработать данные из недоверенного источника. Можно обработать данные, применив приёмы экранирования данных перед их встраиванием в команду. Но существует и другой подход: можно воспользоваться низкоуровневыми средствами Unix - разветвлением процесса при помощи системного вызова fork.

С низкоуровневой точки зрения разветвление - это способ создания нового процесса в Unix. Любопытно, что разветвление не подразумевает запуск программы. Напротив, каждый созданный процесс является практически идентичным клоном - ответвлением от породившего процесса.

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

(Термины "родитель" и "потомок" для создавшего и созданного процессов распространены среди большинства Unix-хакеров и даже встречается в описании некоторых системных вызовов, например - getppid(2). Иногда даже употребляются термины "прародитель", "внук", "брат" в их очевидном смысле.)

Как выглядит разветвление с точки зрения Perl? Во-первых, родитель вызывает встроенную подпрограмму fork, которая является тонкой обёрткой вокруг системного вызова fork(2):
my $pid = fork;
В родителе этот вызов возвращает идентификатор процесса нового потомка (или неопределённое значение, если по некоторой причине вызов fork завершился ошибкой). Однако, в созданном потомке код продолжает работать точно так же, за исключением того, что вызов fork в нём возвращает ноль. Поясним происходящее на примере следующего кода:
my $pid = fork;
die "Не удалось выполнить fork: $!\n" if !defined $pid;
if ($pid == 0) {
    # В потомке
}
else {
    # В родителе. Потомок имеет идентификатор процесса $pid
}
По крайней мере этот пример характерен для программ на C, но для Perl он может оказаться излишним. Остаток этого документа будет опираться на функцию fork_child, определённую на Perl следующим образом:
sub fork_child {
    my ($child_process_code) = @_;

    my $pid = fork;
    die "Не удалось выполнить fork: $!\n" if !defined $pid;

    return $pid if $pid != 0;

    # Теперь мы в потомке
    $child_process_code->();
    exit;
}
Эта функция получает один аргумент - ссылку на некий код, который должен быть выполнен в потомке. Функция возвращает идентификатор потомка, который выполняет код. (Или, если вызов fork завершился неудачно, он просто бросает исключение.) Новый потомок завершает работу сразу после выполнения кода по ссылке.

Итак, вернёмся к вопросу - как запустить программу в фоновом режиме? Всё, что нужно сделать - это предотвратить выполнение того же кода, что и в потомке и вместо этого запустить нужную программу. Поскольку родитель продолжает работать, этого будет достаточно для достижения цели.

Perl позволяет достичь этого при помощи встроенной подпрограммы exec, которая похожа на system, за исключением того, что она заменяет запущенный в настоящее время код программой с указанным именем. exec - это Perl-интерфейс к системному вызову execve(2) (или к одному из его аналогов в языке C, например execv(3)). Легко представить, что exec - это system, приводящий к немедленному завершению работы и такое представление поможет начать им пользоваться. Но стоит сказать, что это не совсем правда. Например, exec не меняет идентификатор процесса.

Собрав эти части вместе, получим код для запуска программы в фоновом режиме, который будет выглядеть примерно так:
fork_child(sub {
    exec 'update_web_server', "--host=$host"
        or die "Не удалось выполнить update_web_server: $!\n";
});

Как можно получить статус завершения фоновой программы?

Одна из проблем приведённого выше кода заключается в том, что он не позволяет определить, с каким статусом завершилась фоновая программа (а в конце концов она завершится). Чтобы получить его, нужно будет дождаться завершения фоновой программы. Его позволяет получить функция wait, встроенная в Perl:
my $pid = fork_child(sub {
    exec 'update_web_server', "--host=$host"
        or die "Не удалось выполнить update_web_server: $!\n";
});

do_complicated_calculations(); # Выполнение сложных вычислений

waitpid $pid, 0;
waitpid, похожа на системный вызов waitpid(2) и является его обёрткой. Она приостанавливает текущий процесс до тех пор, пока не завершится потомок, идентификатор которого указан в первом аргументе, и возвращает идентификатор потомка. Perl помещает статус завершения потомка в переменной $?, как и при завершении функции system. Вы также можете указать идентификатор процесса -1, чтобы дождаться завершения любого из потомков.

Ожидание потомка иногда называют его жатвой (reaping).

Отметим, что с точки зрения ядра почти нет разницы между интерактивным и фоновым процессом. Единственное важное различие заключается в том, что родитель продолжает свою работу, перед тем как приступит к ожиданию потомков.

Что случится, если не читать статус завершения потомков?

Если вы породили фоновый процесс и не собираетесь ожидать его завершения, ядро оставит запись о процессе (и статус его завершения) на неопределённое время, потому что будет считать, что вы ещё можете затребовать его в будущем. Процессы, которые завершились, но статус завершения которых не был прочитан, называются процессами-зомби. Если у процесса на момент завершения ещё остаются процессы-зомби, эти процессы усыновляются: ядро меняет идентификатор их родителя на 1 (на большинстве Unix-систем - это init(8)), одна из задач которого заключается в чтении статуса завершения таких процессов-зомби.

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

Поскольку в данном случае потомки становятся зомби, единственный способ избежать их создания - удостовериться, что потомков нет. Как вариант - можно защитить свою программу, порождая внуков текущего процесса, выполняя второй системный вызов fork:
sub run_and_forget {
    my ($command, @arguments) = @_;

    my $child_pid = fork_child(sub {
        # Здесь - потомок
        fork_child(sub {
            # А тут - процесс-внук
            exec $command, @arguments
                or die "Не удалось выполнить $program: $!\n";
        });

    });

waitpid $child_pid, 0;
}
Исходный процесс порождает потомка и ожидает, когда тот завершит работу. Потомок немедленно порождает внука и завершает работу (так что исходный процесс может прочитать статус его завершения). Теперь родитель внуков более не существует, поэтому ядро передаёт их на усыновление процессу с идентификатором 1. Статус завершения потомка сразу считывается, а init(8) получает на усыновление внуков (поскольку у них больше нет родителя). Таким образом не остаётся процессов-зомби, занимающих ресурсы.

Как запустить несколько программ параллельно?

Учитывая возможность запуска программы в фоновом режиме, параллельный запуск нескольких программ довольно очевиден: всё, что нужно - это запустить их все в фоновом режиме и дождаться, когда они все завершат работу. Неплохо также запомнить идентификаторы порождённых процессов (чтобы, например, выводить сообщения о произошедших ошибках).
my %host_for_pid;
for my $host (hosts_to_update()) {
    my $pid = fork_child(sub {
        exec 'update_web_server', "--host=$host"
            or die "Не удалось выполнить update_web_server: $!\n";
        });
    $host_for_pid{$pid} = $host;
}

while (keys %host_for_pid) {
    my $pid = waitpid -1, 0;
    warn "Не удалось обновить узел $host_for_pid{$pid}\n"
        if $? != 0;
    delete $host_for_pid{$pid};
}

Как запустить программу в другом каталоге?

Раздельность fork и exec в Unix поначалу поражает людей излишней сложностью при запуске программ. Но в этом есть значительная выгода: потомок может изменить собственное окружение необходимым образом перед выполнением требуемой программы.

Приведём простой пример, в котором новая программа будет запущена в другом текущем каталоге. (Вообще, можно достичь того же результата поменяв текущий каталог в родителе перед запуском потомка, а затем вернуться в исходный каталог; но на практике такой подход чреват ошибками, поскольку исходный каталог в это время может быть переименован.)
my $pid = fork_child(sub {
chdir $dir
    or die "Не удалось перейти в каталог $dir: $!\n";
exec 'tar', '-cf', $tar_file, '.'
    or die "Не удалось выполнить tar: $!\n";
});

waitpid $pid, 0;

Как перехватить вывод программы?

Во многих случаях лучший способ перехватить вывод программы - воспользоваться встроенными возможностями Perl: косыми кавычками или довольно сложным открытием через оболочку. Например, так:
open my $ps, 'ps |' or die "Не удалось открыть на чтение команду ps: $!\n";
# Теперь можно читать из $ps, как из обычного файла Perl
Или без использования оболочки для вызова команды, что безопаснее если аргументы команды содержат данные из недоверенных источников:
open my $ps, '-|', 'ps', $pid or die "Не удалось открыть на чтение команду ps: $!\n";

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

Для перехвата вывода команды используется канал: однонаправленное соединение для простого поточно-ориентированного взаимодействия между процессами. Канал поддерживается ядром системы и выглядит для приложений как пара файловых дескрипторов. (Файловые дескрипторы Unix - это целые числа. Они являются низкоуровневыми эквивалентами файлов из Perl; на самом деле файл Perl можно рассматривать как простую обёртку вокруг файлового дескриптора с опциональной буферизацией.) Один из файловых дескрипторов предназначен для записи в канал, а другой - для чтения из канала; всё записанное в канал со стороны записи в конечном счёте появится на стороне для чтения.

Возможность создать канал хорошо сочетается с исключительно важной особенностью системного вызова fork, упомянутой ранее: потомки наследуют все открытые файлы своего родителя. Захват вывода процесса реализуется следующим образом:
  1. Родитель создаёт канал и получает пару новых файловых дескрипторов (обёрнутых в файлы Perl, если вы используете его),
  2. Затем порождает потомка, который наследует файловые дескрипторы канала,
  3. Потомок закрывает канал со стороны для чтения (поскольку он будет лишь писать данные),
  4. Потомок отправляет данные, записывая их в канал со стороны записи, возможно даже выполняя новую программу,
  5. Родитель закрывает канал со стороны для записи (поскольку ему нужно лишь читать данные),
  6. Родитель читает из канала, блокируясь до тех пор, пока не появятся данные от потомка,
  7. Когда операция чтения сообщает о конце файла, родитель считывает статус завершения потомка.
Здесь не отражена одна тонкость. В Unix принято, что по умолчанию программы пишут на свой стандартный вывод с файловым дескриптором 1. Но в подавляющем большинстве случаев у только что созданного канала конец, используемый для записи, не будет иметь файловый дескриптор, равный 1 (потому что это означало бы, что к моменту создания канала не был открыт ни стандартный ввод, ни стандартный вывод).

Поэтому нужен этап 3а, на котором потомок должен связать файловый дескриптор 1 с концом канала, используемым для записи. Проще всего это сделать при помощи системного вызова dup2(2), который связывает выбранный вами файловый дескриптор с другим файловым дескриптором, так что они оба становятся связаны с одним и тем же файлом. Когда потомок удвоит конец канала для записи, он может закрыть исходный файловый дескриптор (для порядка, особенно в том случае, когда потомок собирается выполнить другую программу).

К несчастью, Perl не предоставляет прямого доступа к dup2, но его можно импортировать из стандартного модуля POSIX. (Системный вызов dup очень похож на dup2, но воспользоваться им в наших целях сложнее. В Perl нет встроенной функции dup, но её свойствами обладает функция open.) Первый аргумент dup2 - номер файлового дескриптора, который нужно клонировать (и который можно получить из файла Perl при помощи встроенной функции fileno), а второй аргумент - номер файлового дескриптора, который должен стать клоном первого.

Совместив вместе код дублирования с примером, использующим ps, получим следующее:
use POSIX qw<dup2>;

# 1. Создаём оба конца канала
pipe my ($readable, $writable)
    or die "Не удалось создать канал: $!\n";

# 2. Создаём потомка
my $pid = fork_child(sub {
    # 3. Потомок закрывает конец канала, пригодный для чтения
    close $readable or die "Потомок не смог закрыть канал: $!\n";
    # 3a. Подключаем стандартный вывод к концу канала, пригодному для записи
    dup2(fileno $writable, 1)
        or die "Потомок не смог перенаправить стандартный вывод в канал: $!\n";
    close $writable or die "Потомок не смог закрыть канал: $!\n";
    # 4. Выполняем процесс, выводящий данные на стандартный вывод
    exec 'ps' or die "Не удалось выполнить ps: $!\n";
});

# 5. Родитель закрывает сторону канала, пригодную для записи
close $writable or die "Не удалось закрыть канал: $!\n";

# 6. Родитель читает из другого конца канала, блокируясь в ожидании данных
while (<$readable>) {
    print "Вывод ps: $_";
}
close $readable or die "Не удалось закрыть канал: $!\n";
# 7. Вывода больше не ожидается, считываем статус завершения потомка
waitpid $pid, 0;
die "ps завершился с ошибкой\n" if $? != 0;
Шаг 5 особенно важен, что не кажется очевидным. Когда родитель читает из канала, он блокируется в ожидании данных. Он не распознает события окончания файла, пока не будут закрыты все файловые дескрипторы канала, связанные с концом для записи (потому что пока они не закрыты, могут появиться новые данные). Поэтому если шаг 5 был бы пропущен, то когда потомок завершил бы работу (и закрыл бы свою копию дескриптора файла для записи), родитель по-прежнему был бы заблокирован в ожидании, когда будет закрыт его собственный дескриптор канала для записи.

Теперь можно отметить выгоды, которые приобретает Unix от разделения вызова программ на этапы fork и exec: потомок может выполнить произвольную подготовительную работу между двумя этапами.

Как отправлять данные в программу?

Как и в случае с захватом вывода, лучше всего воспользоваться стандартной для Perl возможностью "открыть команду":
open my $lpr, '| lpr'
    or die "Не удалось вывести данные в lpr: $!\n";
open my $lpr, '|-', 'lpr', '-H', $server
    or die "Не удалось вывести данные в lpr: $!\n";
И снова стоит обратить внимание на то, что происходит внутри. Как и ожидалось, происходит строго обратное захвату вывода программы. При этом потомок дублирует сторону канала для чтения, замещая ей стандартный ввод команды (с файловым дескриптором 0):
pipe my ($readable, $writable)
    or die "Не удалось создать канал: $!\n";

my $pid = fork_child(sub {
    close $writable or die "Потомок не смог закрыть канал: $!\n";
    dup2(fileno $readable, 0)
        or die "Потомок не смог перенаправить вывод канала на стандартный ввод: $!\n";
    close $readable or die "Потомок не смог закрыть канал: $!\n";
    exec 'lpr' or die "Не удалось выполнить lpr: $!\n";

});

close $readable or die "Не удалось закрыть канал: $!\n";
while (my $line = get_next_printer_line()) {
    print $writable $line;
}
close $writable or die "Не удалось закрыть канал: $!\n";
waitpid $pid, 0;
die "lpr завершился с ошибкой\n" if $? != 0;
Важно знать, что чтение из канала приводит к блокировке до тех пор, пока не появятся данные. Но и запись в канал может привести к блокировке, если с другой стороны канала его никто не читает. Данные, записанные в канал, ядро помещает в буфер до тех пор, пока кто-нибудь не попытается прочитать эти данные. Но размер буфера ограничен. Как только буфер заполнится, процесс, пытающийся писать в канал, будет приостановлен до тех пор, пока другой процесс не прочитает данные из буфера.

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

Как отправлять данные в процесс и захватывать его вывод?

Можно ли совместить в одном процессе захват вывода процесса и отправку на стандартный ввод этого процесса? Короткий ответ - да, но об этом стоит рассказать подробнее.

Для двунаправленного перенаправления в процесс-потомок нужны два канала, один - для отправки и второй - для захвата (поскольку каждый из них является однонаправленным каналом связи). Учитывая это, код кажется простым: нужно создать оба канала в родителе, выполнить ветвление, а в потомке присоединить каналы к стандартному вводу и стандартному выводу, прежде чем запустить нужную программу:
pipe my ($send_readable, $send_writable)
    or die "Не удалось открыть канал для отправки: $!\n";

pipe my ($capture_readable, $capture_writable)
    or die "Не удалось открыть канал для захвата: $!\n";

my $pid = fork_child(sub {
    close $send_writable or die "Не удалось закрыть канал: $!\n";
    close $capture_readable or die "Не удалось закрыть канал: $!\n";
    dup2(fileno $send_readable, 0)
        or die "Потомок не смог соединить канал со стандартным вводом: $!\n";
    dup2(fileno $capture_writable, 1)
        or die "Потомок не смог соединить канал со стандартным выводом: $!\n";
    close $send_readable or die "Не удалось закрыть канал: $!\n";
    close $capture_writable or die "Не удалось закрыть канал: $!\n";
    exec 'sort' or die "Не удалось запустить sort: $!\n";
});

close $send_readable or die "Не удалось закрыть канал: $!\n";
close $capture_writable or die "Не удалось закрыть канал: $!\n";
print {$send_writable} get_data();
close $send_writable or die "Не удалось закрыть канал: $!\n";
while (my $line = <$capture_readable>) {
    print $line;
}
waitpid $pid, 0;
die "sort завершился неудачно\n" if $? != 0;
Конкретно этот пример написан аккуратно, так что будет правильно работать вне зависимости от того, что вернёт get_data. Но если, скажем, просто заменить программу, запускаемую в потомке, могут возникнуть серьёзные проблемы.

Этот код пишет всю информацию на вход sort перед тем, как начать что-то читать на выходе из sort'а. Но sort поглощает все данные. И только когда родитель закрывает пригодный для записи конец канала для отправки данных, sort понимает, что данные закончились и приступает к сортировке. Затем родитель начинает чтение из канала для захвата данных. Если данных много, то на этом месте он даже может заблокироваться в ожидании данных, потому что для сортировки большого объёма данных нужно много времени. В конце концов sort заканчивает самую трудную часть работы и начинает выдавать результат, а родитель - читать отсортированные данные.

Проблема в том, что канал использует буфер постоянного размера. Представим, что вместо sort(1) в примере выше запускается программа-фильтр вроде tr(1). Важное отличие между sort и tr заключается в том, что для выполнения своей задачи sort должен прочитать свой ввод целиком, прежде чем он сможет сгенерировать вывод (потому что последние строки могут попасть в начало), в то время как tr может легко генерировать вывод по мере поступления данных.

Родитель в этом примере попытается что-то записать, прежде чем начать читать. Как только начнётся запись, потомок с tr прочитает со своего конца канала данные и сгенерирует вывод, который должен быть прочитан через канал для захвата данных. Запись в канал для захвата данных рано или поздно вызовет проблему: канал заполнится, ядро заблокирует процесс tr, прежде чем он сможет записать следующую порцию данных, до тех пор, пока они не будут прочитаны с другого конца канала. Но единственный процесс, который может прочитать данные - это родитель, а он всё ещё ждёт когда освободится канал для записи данных. Вот так:
  • Никто не читает из канала для захвата данных,
  • Поэтому потомок tr не может записать данные в этот канал,
  • Поэтому никто больше не читает данные из канала для отправки данных,
  • Поэтому родитель остаётся заблокированным, потому что по-прежнему пытается писать в канал для отправки данных,
  • … но пока он не отправит все данные, он не станет читать из канала для захвата данных!
Это классический пример взаимоблокировки: два процесса заблокированы в ожидании когда другой освободит общий ресурс. И есть только один выход из этого состояния - прервать один или оба процесса сигналом.

Есть способы безопасной работы с двойными каналами. Первый - это использование программ, похожих на sort, которые считывают весь ввод прежде чем сгенерировать вывод. Второй - если вы полностью уверены в том, что активность в двух каналах будет чередоваться таким образом, что взаимоблокировка никогда не произойдёт. Например, если соблюдать осторожность, можно использовать пару каналов для работы с dc(1), калькулятором произвольной точности с постфиксной нотацией. Это возможно поскольку вы можете предсказать, какой вывод будет сгенерирован на отправленную команду; если вы отправите такую команду, вы можете прервать запись в канал отправки и прочитать правильное количество строк из канала для захвата вывода.

Иногда проблему можно обойти, породив два процесса, а не один. Один будет выполнять требуемую программу; а другой будет клоном родителя, который будет заниматься только генерацией данных, подаваемых на вход программы-фильтра:
pipe my ($send_readable, $send_writable)
    or die "Не удалось создать канал для отправки: $!\n";
pipe my ($capture_readable, $capture_writable)
    or die "Не удалось создать канал для захвата: $!\n";

my $writer_pid = fork_child(sub {
    close $capture_readable or die "Не удалось закрыть канал: $!\n";
    close $capture_writable or die "Не удалось закрыть канал: $!\n";
    close $send_readable or die "Не удалось закрыть канал: $!\n";
    print {$send_writable} get_data();
    close $send_writable or die "Не удалось закрыть канал: $!\n";
});

my $filter_pid = fork_child(sub {
    close $send_writable or die "Не удалось закрыть канал: $!\n";
    close $capture_readable or die "Не удалось закрыть канал: $!\n";
    dup2(fileno $send_readable, 0)
        or die "Потомок не смог соединить стандартный ввод с каналом: $!\n";
    dup2(fileno $capture_writable, 1)
        or die "Потомок не смог соединить стандартный вывод с каналом: $!\n";
    close $send_readable or die "Не удалось закрыть канал: $!\n";
    close $capture_writable or die "Не удалось закрыть канал: $!\n";
    exec 'tr', 'a-z', 'A-Z'
        or die "Не удалось выполнить tr: $!\n";
});

close $send_readable or die "Не удалось закрыть канал: $!\n";
close $send_writable or die "Не удалось закрыть канал: $!\n";
close $capture_writable or die "Не удалось закрыть канал: $!\n";
print while <$capture_readable>;

while (1) {
    my $pid = waitpid -1, 0;
    last if $pid == -1;
    next if $? == 0;
    my $process = $pid == $writer_pid ? 'писатель'
                : $pid == $filter_pid ? 'фильтр'
                : 'другой';
    warn "Процесс $process завершился неудачно\n";
}
Однако на практике это решение может оказаться неуклюжим, потому что процесс, порождающий вывод, не догадывается о связи с родителем. В частности, код процесса, порождающего вывод, не сможет изменить переменные родителя.

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

Ваше приложение должно принимать решение, нужно ли использовать временный файл для ввода или вывода, оценив ожидаемый объём данных, передаваемых в каждом из направлений. В следующем примере используется временный файл для отправки данных в фильтр:
use File::Temp qw<tempfile>;

my $temp_fh = tempfile();
print {$temp_fh} get_data();
seek $temp_fh, 0, 0 or die "Не удалось вернуться в начало временного файла: $!\n";

pipe my ($readable, $writable)
    or die "Не удалось создать канал: $!\n";

my $pid = fork_child(sub {
    close $readable or die "Потомок не смог закрыть сторону канала для чтения: $!\n";
    dup2(fileno $temp_fh, 0)
        or die "Потомок не смог присоединить стандартный ввод ко временному файлу: $!\n";
    dup2(fileno $writable, 1)
        or die "Потомок не смог присоединить стандартный вывод к каналу: $!\n";
    exec 'tr', 'a-z', 'A-Z'
        or die "Не удалось выполнить tr: $!\n";
});

close $writable or die "Не удалось закрыть канал: $!\n";
print while <$readable>;

waitpid $pid, 0;
die "tr завершился неудачно\n" if $? != 0;
Здесь используется функция tempfile из модуля File::Temp, которая по умолчанию создаёт временный файл и немедленно удаляет его. Тут используется следующая особенность Unix: когда файл удалён по имени, его данные остаются нетронутыми до тех пор, пока не останется ни одного процесса, открывшего этот файл. В данном случае потомок унаследует соответствующий файловый дескриптор, так что данные во временном файле не удалятся до тех пор, пока оба процесса не закроют файл.

Отметим, что родитель должен явным образом установить указатель в начало файла после записи в него. Когда файл открывается, все файловые дескрипторы работают с одной и той же структурой, описывающей текущее состояние файла (вне зависимости от того, созданы ли дескрипторы при помощи операции dup или при разветвлении процесса). Поэтому если пропустить перемотку в начало файла, порождённый процесс начнёт читать файл с того места, на котором закончилась последняя операция ввода-вывода, то есть - позади недавно записанных данных.

Как захватить вывод программы в поток диагностики?

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

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

Захват стандартного вывода и потока диагностики легко объединить: для этого нужно подсоединить в потомке и stdout и stderr к каналу ввода-вывода:
pipe my ($readable, $writable)
    or die "Не удалось создать канал ввода-вывода: $!\n";

my $pid = fork_child(sub {
    close $readable or die "Потомку не удалось закрыть канал ввода-вывода со стороны для чтения: $!\n";
    dup2(fileno $writable, 1)
        or die "Потомку не удалось подсоединить стандартный вывод к каналу ввода-вывода: $!\n";
    dup2(fileno $writable, 2)
        or die "Потомку не удалось подсоединить поток диагностики к каналу ввода-вывода: $!\n";
    close $writable or die "Потомку не удалось закрыть канал ввода-вывода со стороны для записи: $!\n";
    exec $program, @args or die "Не удалось запустить $program: $!\n";
});

close $writable or die "Не удалось закрыть канал ввода-вывода: $!\n";
print while <$readable>;
close $readable or die "Не удалось закрыть канал ввода-вывода: $!\n";

waitpid $pid, 0;
die "Программа $program завершилась неудачно\n" if $? != 0;
С другой стороны, захват двух отдельных потоков значительно сложнее: обычный подход (с использованием одного канала для стандартного вывода и другого для потока диагностики) чреват рисками взаимоблокировки. Проблема в том, что родитель должен определить, из какого из двух каналов нужно читать в данный момент: если попытаться прочитать из одного, когда в нём нет данных, программа заблокируется; в то время как потомок не сможет записать данные в другой канал, пока он заблокирован.

Один из подходов справиться с этим - читать из заданного канала только когда ядро посчитает, что канал готов для чтения. В Perl это обычно означает использование стандартного модуля IO::Select или, возможно, встроенной функции select с четырьмя аргументами, обёрткой над которой и является этот модуль. Идея заключается в том, что вместо простого чтения данных из дескриптора файла, сначала вызывается select со всеми интересующими нас файловыми дескрипторами. Произойдёт ожидание, пока хотя бы один из этих файловых дескрипторов не перейдёт в состояние готовности, после чего будет возвращён список готовых дескрипторов. После этого можно читать из всех них, повторяя цикл до тех пор, пока все файловые дескрипторы не достигнут конца файла.

Большой минус при использовании select заключается в том, что больше нельзя использовать обычный оператор Perl <> для чтения из файла. Вместо этого нужно использовать встроенную функцию sysread, потому что <> буферизует ввод, что не сочетается с необходимостью чтения из файловых дескрипторов лишь в тех случаях, когда они к этому готовы.

Зная это, во многих обстоятельствах проще бывает воспользоваться временным файлом для одного из дескрипторов - например, для потока диагностики, предполагая, что сообщений об ошибках не будет настолько много, что возникнет проблема нехватки места на диске.
my $temp_stderr = tempfile();

pipe my ($readable, $writable)
    or die "Не удалось создать канал: $!\n";

my $pid = fork_child(sub {
    close $readable or die "Потомок закрыл пригодный для чтения канал: $!\n";
    dup2(fileno $writable, 1)
        or die "Потомку не удалось подсоединить стандартный вывод к каналу ввода-вывода: $!\n";
    dup2(fileno $temp_stderr, 2)
        or die "Потомку не удалось подсоединить поток диагностики ко временному файлу: $!\n";
    exec $program, @arguments
        or die "Не удалось запустить $program: $!\n";
});

close $writable or die "Не удалось закрыть канал ввода-вывода: $!\n";

# Обработка данных со стандартного вывода потомка
print "stdout: $_" while <$readable>;

waitpid $pid, 0;
die "Программа $program завершилась неудачно\n" if $? != 0;

# Теперь потомок завершился, поэтому данных в $temp_stderr больше не будет
seek $temp_stderr, 0, 0;

# Обработка данных с потока диагностики потомка
print "stderr: $_" while <$temp_stderr>;

Как можно передать несколько открытых файлов в процесс-потомок?

Предположим, что вы хотите запустить потомка, стандартный ввод, стандартный вывод и поток диагностики которого подключены туда же, куда подключен родитель. Но родитель также получит доступ к другим открытым файлам, в том числе ко временным файлам. Звучит довольно просто. Возможно, потомок должен получить дескриптор файла в качестве аргумента, тогда он сможет узнать, где найти дополнительный файл; если записать это на Perl, то с помощью open можно создать файл Perl, соответствующий дескриптору файла:
my $file_descriptor = $ARGV[0];
open my $temp_fh, "<&=$file_descriptor"
    or die "Не удалось открыть дескриптор $file_descriptor: $!\n";

Затем родителю нужно открыть временный файл и просто выполнить fork/exec для порождения потомка, верно?
my $temp_fh = tempfile();
fork_child(sub {
    exec 'use_tempfile', fileno $temp_fh
        or die "Не удалось запустить use_tempfile: $!\n";
});
К сожалению, это не работает: вы просто получите сообщение "Не удалось открыть дескриптор 3: Плохой файловый дескриптор" или что-то подобное. Причина в том, что File::Temp явным образом создаёт временные файлы недоступными для потомков. (Это происходит потому, что по соображениям безопасности эта настройка в File::Temp используется по умолчанию, но всё-таки это было неожиданным.)

Это делается настройкой флагов дескриптора файла, к которым надо добавить флаг FD_CLOEXEC — он сообщает ядру о необходимости закрыть дескриптор файла, если будет запущена новая программа. Таким образом, потомок, выполнивший вызов fork, изначально имеет доступ ко временному файлу, но дескриптор этого файла будет закрыт в потомке при успешном выполнении exec. (FD_CLOEXEC означает “file descriptor close on exec” - "файловый дескриптор закрыть при exec".)

Тем временем родитель ничего не выполнял, поэтому его версия файлового дескриптора не меняется.

Итак, чтобы передать открытый файл в процесс-потомок, нужно явным образом удалить флаг FD_CLOEXEC у дескриптора временного файла. Для безопасности нужно сначала получить текущие флаги файлового дескриптора, убрать из них флаг FD_CLOEXEC и только потом установить новое значение флагов файлового дескриптора:
use POSIX qw<F_SETFD F_GETFD FD_CLOEXEC>;

my $flags = fcntl $temp_fh, F_GETFD, 0;
$flags &= ~FD_CLOEXEC;
fcntl $temp_fh, F_SETFD, $flags;
Это можно сделать либо в потомке перед выполнением новой программы, либо в родителе; в частности, последний способ может оказаться полезным, если нужно передать один и тот же открытый файл нескольким разным потомкам.

Возникает вопрос, как же работали все предыдущие примеры, хотя они этого не делали? Ответ заключается в том, что флаги вроде FD_CLOEXEC привязаны к определённым номерам дескрипторов файлов. Код выше делал примерно следующее:
my $temp_fh = tempfile();

fork_child(sub {
    dup2(fileno $temp_fh, 1);
    exec $program, @arguments;
});
В этом случае дескриптор файла, скрывающийся в файле, возвращаемом tempfile(), закрывался при выполнении программы $program. Но к этому моменту он уже был склонирован как файловый дескриптор 1, а FD_CLOEXEC применяется только к fileno $temp_fh, но не к клону.

2 комментария:

MrCricket комментирует...

Неправильное объяснение насчет внуков, см http://world.std.com/~swmcd/steven/tech/daemon.html

morbo комментирует...

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

Сам, когда писал программу на Си, написал такую вот функцию daemon:
https://stupin.su/git/stupin/parled12/src/master/daemon/daemon.c#L80

Моей вины в неполноте информации нет, т.к. я просто переводил текст на русский язык. Стоило, правда, вставить в перевод собственные комментарии и обратить внимание на этот момент.

В целом, благодарю за то, что обратили внимание на этот тонкий момент.