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

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

Подстановка процесса превращает поток команды в путь к файлу

Синтаксис компактен до предела. Конструкция <(command) запускает команду в скобках, перехватывает её вывод и подставляет на своё место путь к специальному файлу, из которого этот вывод можно прочитать. Снаружи всё выглядит так, будто на этом месте стоит обычное имя файла.

diff <(sort file1.txt) <(sort file2.txt)

Эта строка делает ровно то, ради чего раньше городили временные файлы: сортирует оба файла и сравнивает результаты. Никаких промежуточных файлов, никакой ручной уборки, никаких коллизий имён. Обе команды sort запускаются одновременно, а diff читает их вывод параллельно, как два настоящих файла.

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

echo <(true)
# /dev/fd/63

Под капотом bash создаёт именованный канал, технически называемый FIFO, либо файловый дескриптор в каталоге /dev/fd/. Команда из скобок выполняется, её вывод перенаправляется в этот канал, а путь к каналу - что-то вроде /dev/fd/63 - подставляется в исходную строку. Потребляющая команда получает этот путь и читает из него так, будто открыла обычный файл. На системах, где дескрипторы /dev/fd/ недоступны, bash откатывается к настоящему временному файлу, но прячет это от пользователя.

Различие с обычным каналом объясняет, зачем механизм вообще нужен

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

# Так работать не будет: diff ждёт два файла, а не строки
diff "$(cat file1)" "$(cat file2)"   # ОШИБКА: подставятся строки

# А так всё корректно: каждая подстановка выглядит как файл
diff <(cat file1) <(cat file2)

Подстановка команды через $( ) тут не спасает, потому что она вставляет в строку содержимое вывода как текст, а diff примет этот текст за имя файла и не найдёт такого файла. Подстановка процесса решает задачу принципиально иначе - она даёт путь, по которому лежит поток, а не сам текст. В этом и кроется граница между двумя похожими на вид конструкциями.

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

Сравнение удалённого и локального состояния раскрывает практическую силу

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

# Сравнить локальный конфиг с конфигом на удалённом сервере
diff <(cat /etc/nginx/nginx.conf) <(ssh web01 cat /etc/nginx/nginx.conf)

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

# Поймать расхождение между фактическим и ожидаемым набором пакетов
if ! diff <(sort установленные.txt) <(sort эталон.txt) > /dev/null 2>&1; then
    echo "Обнаружен дрейф:"
    diff <(sort установленные.txt) <(sort эталон.txt)
fi

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

# Найти строки, присутствующие только в одном из выводов
comm <(ls -l каталог1) <(ls -l каталог2)

Решение проблемы переменной, теряющей значение после цикла

У подстановки процесса есть второе применение, не связанное со сравнением напрямую, но решающее старую боль bash. Когда вывод команды читается циклом через обычный канал, цикл выполняется в подоболочке, и все переменные, изменённые внутри, после завершения цикла бесследно исчезают. Это сбивает с толку всех, кто столкнулся впервые.

# Ловушка: переменная обнулится после цикла
count=0
cat файл.txt | while read строка; do
    (( count++ ))
done
echo $count    # 0 - изменения пропали в подоболочке

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

# Решение: переменная переживёт цикл
count=0
while read строка; do
    (( count++ ))
done < <(cat файл.txt)
echo $count    # реальное число строк

Здесь стоит обратить внимание на пробел между < и <(. Он обязателен. Первый знак - это перенаправление ввода, вторая конструкция - подстановка процесса, и слипшись вместе они дадут синтаксическую ошибку. Этот пробел - один из самых частых источников недоумения у новичков.

Граница применимости проходит по нескольким острым углам

Изящество механизма не отменяет его ограничений, и понимание этих границ отделяет надёжный скрипт от того, что капризничает в самый неподходящий момент.

Первое и главное: подстановка процесса - расширение bash, а не часть стандарта POSIX. В оболочке /bin/sh, на которую в Ubuntu и многих других системах указывает dash, её попросту нет. Скрипт, начинающийся с #!/bin/sh, но использующий <(command), упадёт с синтаксической ошибкой. Заголовок скрипта обязан быть #!/bin/bash, если механизм задействован.

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

Третий капкан тоньше и опаснее, потому что код выглядит правильным. Путь вида /dev/fd/63 нельзя сохранить в переменную, чтобы воспользоваться им позже. Подставленный путь живёт ровно столько, сколько выполняется команда, в которой он появился. Как только команда завершилась, канал закрывается, а сохранённый путь указывает в пустоту.

# Так делать нельзя: путь устареет к моменту использования
fd=<(generate_data)
process_later "$fd"   # путь уже мёртв, канал закрыт

Есть и нюанс с буферизацией. Когда подстановка используется не для чтения, а для записи через форму >(command), между записывающей и принимающей сторонами стоит буфер канала. Данные могут задержаться в нём и появиться на выходе позже, чем ожидается, что путает при отладке потоковой обработки или логирования через tee. Размер буфера канала ограничен, и при попытке протолкнуть через него очень большой объём без активного читателя на другом конце запись может заблокироваться до тех пор, пока буфер не освободится.

Когда стоит тянуться к этому инструменту

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

Тянуться к ней не стоит, когда поток нужно прочитать несколько раз или вернуться к его началу - тут честный временный файл надёжнее. И она бесполезна в скриптах на чистом POSIX-shell, где её просто нет.

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