Дублировать stdout в канал для передачи в другую команду с использованием именованного канала в функции POSIX shell скрипта

Вопрос или проблема

mkfifo foo
printf %s\\n bar | tee foo &
tr -s '[:lower:]' '[:upper:]' <foo
wait
rm foo

Это рабочий скрипт POSIX shell, реализующий то, что я хочу сделать:

  • printf %s\\n bar символизирует внешнюю программу, создающую stdout
  • tr -s '[:lower:]' '[:upper:]' символизирует другую команду, которая должна получить stdout и сделать с ним что-то
  • tee дублирует stdout в именованный канал foo

И вывод получается ожидаемым:

bar
BAR

Теперь я бы хотел упростить код, чтобы он выглядел как external_program | my_function. Что-то вроде:

f() (
  mkfifo foo
  tee foo &
  tr -s '[:lower:]' '[:upper:]' <foo
  wait
  rm foo
)
printf %s\\n bar | f

Но теперь вывода нет вовсе.

Суть проблемы в том, что POSIX имеет (обычно не полезное) требование для sh: асинхронные команды должны иметь перенаправленный stdin на /dev/null, если нет явного перенаправления stdin (впрочем, технически, это неявное перенаправление на /dev/null происходит перед явными перенаправлениями, если они есть).

Посмотрите, например, на Linux-системах:

$ sh -c 'realpath /dev/stdin & wait'
/dev/null

Общее решение для асинхронной команды, чтобы stdin оставался нетронутым, заключается в следующем:

{ cmd <&3 3<&- & } 3<&0

Где исходный stdin доступен на fd 3 в дополнение к 0 в группе команд с 3<&0, и внутри группы команд cmd stdin, который был снова открыт на /dev/null из-за & перенаправляется обратно на исходный stdin через этот fd 3 (который мы затем закрываем, так как он больше не нужен).

В:

f() (
  mkfifo foo
  tee foo &
  tr -s '[:lower:]' '[:upper:]' <foo
  rm foo
)
printf %s\\n bar | f

stdin tee будет /dev/null, а не концом канала чтения от printf. Изменение на:

f() (
  mkfifo foo
  { tee foo <&3 3<&- & } 3<&0
  tr -s '[:lower:]' '[:upper:]' <foo
  rm foo
  wait
)
printf '%s\n' bar | f

решит проблему, но как вы обнаружили, так же поможет

f() (
  mkfifo foo
  tr -s '[:lower:]' '[:upper:]' <foo &
  tee foo
  rm foo
)
printf %s\\n bar | f

Тогда tee не запускается асинхронно, поэтому его stdin не перенаправляется на /dev/null, а stdin tr перенаправляется явно, так что не имеет значения, что он был перенаправлен на /dev/null заранее.

Также нам не нужно wait, так как tee — это процесс, выполняющийся синхронно (и поэтому ожидаемый implicitly shell) и обычно не завершается перед tr, так как ожидает eof на своем stdin (а stdout tr, который является противоположным концом этого канала, закрывается только после выхода).

Возможно, вы все-таки захотите дождаться завершения tr, чтобы получить код его выхода:

f() (
  ret=0
  mkfifo foo || exit
  tr -s '[:lower:]' '[:upper:]' <foo &
  tee foo || ret=$?
  rm -f foo
  wait "$!" || ret=$?
  exit "$ret"
)
printf '%s\n' bar | f

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

f() (
  ret=0
  mkfifo foo || exit
  { tee foo <&3 3<&- & } 3<&0
  {
    # на данный момент, foo уже будет открыт здесь в режиме только для чтения
    # на stdin, что может произойти только в том случае,
    # если tee уже открыл его в режиме только для записи.
    rm -f foo
    tr -s '[:lower:]' '[:upper:]' || ret=$?
  } < foo
  wait "$!" || ret=$?
  exit "$ret"
)
printf '%s\n' bar | f

Похоже, что проблема, как и предполагалось, заключается в попытке передать external_program в отсоединенный tee.

В оригинальном скрипте было:

printf %s\\n bar | tee foo &

Что не отсоединяет только tee, а всю последовательность pipe целиком. Изначально я сделал такую же конструкцию pipe, которая разрешается немедленно, то есть и sleep и пустая операция : отсоединяются вместе:

$ time sh -c 'sleep 10 | : &'

real    0m0.001s
user    0m0.000s
sys     0m0.001s

Однако, при отсоединении tee внутри функции, отсоединяется только tee, а не внешняя программа в начале pipe. Повторяя пример со sleep, мы можем видеть, что отсоединяется только пустая операция :, но sleep — нет, и скрипт занимает все 10 секунд:

$ time sh -c 'sleep 10 | { : & }'

real    0m10.002s
user    0m0.002s
sys     0m0.000s

Решение заключается в том, чтобы не отсоединять tee, а другую команду:

f() (
  mkfifo foo
  tr -s '[:lower:]' '[:upper:]' <foo &
  tee foo
  wait
  rm foo
)
printf %s\\n bar | f

Теперь tee может получать stdout из pipe, записывать в FIFO, и выход функции снова будет:

bar
BAR

Ответ или решение

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

Теория

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

Когда команда запускается с &, она выполняется асинхронно в фоновом режиме. Недостатком является автоматическая переадресация стандартного ввода на /dev/null, что может вызывать проблему с передачей данных через канал, поскольку команда не получает нужного ввода для обработки.

Пример

Ваша первоначальная реализация:

mkfifo foo
printf %s\\n bar | tee foo &
tr -s '[:lower:]' '[:upper:]' <foo
wait
rm foo

работает, потому что printf передает данные в tee, которая дублирует выходные данные как на стандартный вывод, так и в именованный канал foo. Команда tr затем считывает из foo и преобразует текст в верхний регистр. Однако, когда вы переносите эту логику в функцию:

f() (
  mkfifo foo
  tee foo &
  tr -s '[:lower:]' '[:upper:]' <foo
  wait
  rm foo
)
printf %s\\n bar | f

Вы сталкиваетесь с проблемой из-за вышеописанного поведения &, так как tee теряет стандартный ввод.

Применение

Чтобы исправить это, одним из решений является использование техники перенаправления файлового дескриптора. Можно переопределить stdin для tee, чтобы он читал из стандартного ввода, как описывается далее:

f() (
  mkfifo foo
  { tee foo <&3 3<&- & } 3<&0
  tr -s '[:lower:]' '[:upper:]' <foo
  rm foo
  wait
)
printf '%s\n' bar | f

Здесь используются перенаправления файловых дескрипторов, чтобы гарантировать, что tee будет работать с правильным входным потоком.

Другое решение заключается в изменении порядка выполнения команд:

f() (
  mkfifo foo
  tr -s '[:lower:]' '[:upper:]' <foo &
  tee foo
  wait
  rm foo
)
printf %s\\n bar | f

Так, tee больше не запускается в фоне, а выполняется синхронно, что сохраняет его stdin от перемещения на /dev/null.

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

Эти примеры иллюстрируют, как можно манипулировать потоками ввода-вывода, перенаправлениями и обеспечивать взаимодействие между параллельными процессами, что и является ключевым аспектом при работе с UNIX-подобными системами.

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

Оцените материал
Добавить комментарий

Капча загружается...