Тема 05

Потоки ввода-вывода, перенаправления и конвейеры

Потоки ввода-вывода, перенаправления и конвейеры

Предыдущие темы показали, как устроена командная строка: тема 1 объяснила, что такое командная оболочка и как устроена команда, тема 2 — как навигировать по файловой системе и работать с файлами, тема 3 — как просматривать и редактировать текстовые файлы. Во всех примерах вывод команд появлялся на экране, а команды читали данные либо из аргументов, либо из файлов, названных явно. Между тем главная идея UNIX состоит в том, что программы обмениваются данными через стандартные потоки, а не через временные файлы или специальные интерфейсы. Именно поэтому одна и та же программа sort может сортировать список файлов, вывод ls, содержимое журнала или результат другой программы, не зная, откуда пришли данные.

Тема раскрывает этот механизм в три шага. Сначала описываются стандартные потоки ввода, вывода и диагностики и файловые дескрипторы, которыми процесс на самом деле пользуется. Затем рассматриваются перенаправления: как направить вывод в файл, как разделить обычный вывод и сообщения об ошибках, почему порядок записи перенаправлений имеет значение и как подать данные на вход из файла, из текста в тексте команды или из /dev/null. Наконец, изучаются конвейеры — цепочки команд, соединённых каналами, — и управление последовательностью команд по коду возврата. Тема завершается разбором одной из самых коварных ошибок новичков: xargs и имена файлов с пробелами.

Команды темы показаны с реальным выводом, полученным в учебной среде Ubuntu. Там, где вывод зависит от прав доступа или состава системы, он помечен как пример. Регулярные выражения, sed и awk здесь не рассматриваются: им посвящена тема 6, а скрипты на Bash — темы 13–15.

Стандартные потоки и файловые дескрипторы

Три стандартных потока

Почти каждая программа, запущенная из командной оболочки, получает три потока данных. Первый — стандартный ввод (англ. standard input, stdin), из которого программа читает входные данные. Второй — стандартный вывод (англ. standard output, stdout), куда программа пишет результат. Третий — стандартный поток диагностики (англ. standard error, stderr), куда программа пишет сообщения об ошибках и предупреждения. Разделение на два выходных потока — не прихоть: результат работы и сообщение о проблеме имеют разные адресаты, и их нужно уметь разводить по разным местам. Традиция разделения описана в классических руководствах по UNIX 2 и в более поздних работах по философии системы 3.

По умолчанию все три потока связаны с терминалом. Клавиатура подключена к стандартному вводу, экран — к стандартному выводу и к стандартной диагностике. Поэтому команда ls с несуществующим каталогом пишет в терминал и результат, и сообщение об ошибке, и пользователь не видит различия между ними:

ls /nonexistent_dir /etc/hostname
ls: cannot access '/nonexistent_dir': No such file or directory
/etc/hostname

Строка с сообщением cannot access попала в поток диагностики, а строка /etc/hostname — в стандартный вывод. В терминале это выглядит одинаково, но дальше мы увидим, что разница становится существенной, как только вывод перенаправлен.

Номера потоков — это числа 0, 1 и 2. Стандартный ввод имеет номер 0, стандартный вывод — 1, стандартная диагностика — 2. Эти числа называются файловыми дескрипторами (англ. file descriptor). Дескриптор — это целое число, которым процесс обозначает открытый ресурс: файл, канал, терминал или сетевое соединение. Процесс не знает, куда ведёт дескриптор; он просто пишет в номер 1 или читает из номера 0, а ядро направляет данные туда, куда указывает запись в таблице дескрипторов процесса. Интерфейс open, read, write и close в системных вызовах построен именно так 8. Подробно о процессах и их дескрипторах будет рассказано в теме 7; сейчас достаточно понимать, что каждый процесс имеет собственную таблицу дескрипторов, которая наследуется дочерними процессами при запуске.

Таблица дескрипторов

Представим, что процесс — это программа с таблицей из трёх строк:

ДескрипторИмяПо умолчанию указывает наПрограмма использует его для
0stdinклавиатуру (терминал)чтения входных данных
1stdoutэкран (терминал)основного результата
2stderrэкран (терминал)сообщений об ошибках

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

Визуально это выглядит так: процесс ls слева, его дескрипторы — стрелками, а объекты, на которые они указывают, — справа.

Дескрипторы 0, 1 и 2 процесса ls по умолчанию и после перенаправления >f 2>&1
Дескрипторы процесса до и после перенаправления: номера потоков не меняются, меняется только цель, на которую они указывают

Верхняя часть рисунка показывает исходное состояние: ввод приходит с клавиатуры, оба выходных потока уходят на экран. Нижняя часть соответствует команде ls ... >f 2>&1, которая будет разобрана ниже: и результат, и сообщения об ошибках попадают в один файл, а клавиатура по-прежнему служит источником ввода.

Что видит процесс

Увидеть таблицу дескрипторов можно непосредственно. В Linux каждый процесс представлен каталогом /proc/<PID>/fd, в котором для каждого открытого дескриптора есть символическая ссылка на объект, с которым он связан. Подробно устройство /proc рассматривается в теме 7; здесь важно лишь то, что это обычный каталог, который можно просмотреть. Запустим ls так, чтобы его стандартный ввод был файлом /etc/hostname, а оба выходных потока — файлом fdl.txt:

bash -c 'ls -l /proc/self/fd' </etc/hostname >fdl.txt 2>&1
cat fdl.txt
total 0
lr-x------ 1 student student 64 Oct 12 14:18 0 -> /etc/hostname
l-wx------ 1 student student 64 Oct 12 14:18 1 -> /tmp/t5/fdl.txt
l-wx------ 1 student student 64 Oct 12 14:18 2 -> /tmp/t5/fdl.txt
lr-x------ 1 student student 64 Oct 12 14:18 3 -> /proc/24809/fd

Дескриптор 0 указывает на /etc/hostname, дескрипторы 1 и 2 — на один и тот же файл fdl.txt. Дескриптор 3 появился потому, что ls открыл собственный каталог для чтения списка; его смысл здесь не важен. Заметим, что в обычном терминале вместо /tmp/t5/fdl.txt было бы имя устройства вида /dev/pts/0: так ядро называет псевдотерминал, через который работает окно эмулятора. Этот пример также показывает, что вывод вида 2 -> ... может означать одно и то же, что и 1 -> ..., — различие между потоками существует только в том смысле, куда они направлены.

Перенаправления

Вывод в файл и дописывание

Оператор > направляет стандартный вывод команды в файл. Если файла нет, он создаётся; если есть — его содержимое полностью заменяется. Оператор >> дописывает в конец существующего файла. Оператор < подаёт содержимое файла на стандартный ввод.

echo 'первая строка' > notes.txt
echo 'вторая строка' >> notes.txt
cat notes.txt
первая строка
вторая строка

Вторая команда использовала >>, поэтому первая строка сохранилась. Если бы вместо этого была записана команда echo 'вторая строка' > notes.txt, в файле осталась бы только вторая строка. Ошибка перезаписи файла — одна из самых частых у новичков: оболочка открывает файл на запись ещё до запуска программы, и при операторе > содержимое уничтожается мгновенно, даже если команда потом завершается с ошибкой.

Сравним ввод из файла и явную передачу имени файла в программу:

wc -l < notes.txt
wc -l notes.txt
2
2 notes.txt

Оба варианта дают одно число, но выводы различаются. В первом случае wc не знает имени файла: для него стандартный ввод — безымянный поток, и wc печатает только число. Во втором случае wc открывает файл сам и добавляет имя в вывод. Разница важна при написании скриптов, где нужно получить чистое значение без сопроводительного текста.

Раздельный вывод stdout и stderr

Оператор 2> направляет в файл поток диагностики, а не стандартный вывод. Поэтому можно разделить результат и сообщения об ошибках. Рассмотрим команду find, которая обходит дерево каталогов и печатает найденные пути. Обычный пользователь не может читать некоторые каталоги системы, и find выводит для них сообщение об ошибке.

find /etc/hostname /root >found.txt 2>errs.txt
echo "код возврата: $?"
echo "--- stdout (found.txt):"
cat found.txt
echo "--- stderr (errs.txt):"
cat errs.txt
код возврата: 1
--- stdout (found.txt):
/etc/hostname
/root
--- stderr (errs.txt):
find: ‘/root’: Permission denied

Каталог /root недоступен обычному пользователю; сообщение Permission denied ушло в errs.txt, а найденные пути — в found.txt. Код возврата 1 показывает, что find встретил ошибку, хотя результат всё равно был записан. Прежде чем продолжить, отметим, что сообщение об отказе в доступе — тема прав доступа, которой посвящена тема 4; здесь важно лишь то, что это сообщение относится к диагностике и не смешивается с результатом.

Поток диагностики часто нужно скрыть полностью, если он заведомо шумный и не несёт полезной информации. Для этого служит 2>/dev/null:

find /etc/hostname /root 2>/dev/null
echo "код возврата: $?"
/etc/hostname
/root
код возврата: 1

Сообщение Permission denied исчезло, а результат остался. Код возврата по-прежнему 1: скрытие сообщения не отменяет ошибку. Это важный момент: команда, которая «молчит», может быть неуспешной, и скрипт, проверяющий только вывод, пропустит проблему. Проверка результата по коду возврата рассматривается в последнем разделе.

Оператор &> направляет оба потока в один файл, а именно так же, как записано >f 2>&1. Он доступен в Bash, но не в классическом sh; поэтому в переносимых скриптах предпочтительна явная запись >f 2>&1 4.

Порядок перенаправлений

Перенаправления в командной строке обрабатываются слева направо, и каждое из них меняет таблицу дескрипторов в момент своей обработки 5. Это правило объясняет результат, который многих удивляет. Рассмотрим две записи, которые на первый взгляд отличаются только порядком слов.

Первая запись — >f 2>&1: сначала стандартный вывод направляется в файл, затем стандартная диагностика направляется туда же, куда сейчас указывает дескриптор 1.

ls /nonexistent_dir /etc/hostname >f1.txt 2>&1
echo "код возврата: $?"
echo "--- f1.txt:"
cat f1.txt
код возврата: 2
--- f1.txt:
ls: cannot access '/nonexistent_dir': No such file or directory
/etc/hostname

Оба потока попали в файл, на экране ничего не осталось. Разберём порядок пошагово.

  1. В начале дескриптор 1 указывает на экран, дескриптор 2 — тоже на экран.
  2. Перенаправление >f1.txt меняет дескриптор 1: теперь он указывает на f1.txt. Дескриптор 2 не изменился и по-прежнему указывает на экран.
  3. Перенаправление 2>&1 означает «сделать дескриптор 2 копией дескриптора 1». Дескриптор 1 сейчас указывает на f1.txt, значит, и дескриптор 2 начинает указывать на f1.txt.

Вторая запись — 2>&1 >f2.txt, то есть порядок изменён.

ls /nonexistent_dir /etc/hostname 2>&1 >f2.txt
echo "код возврата: $?"
echo "--- f2.txt:"
cat f2.txt
ls: cannot access '/nonexistent_dir': No such file or directory
код возврата: 2
--- f2.txt:
/etc/hostname

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

  1. В начале оба дескриптора указывают на экран.
  2. Перенаправление 2>&1 делает дескриптор 2 копией дескриптора 1. Дескриптор 1 пока указывает на экран, значит, и дескриптор 2 указывает на экран.
  3. Перенаправление >f2.txt меняет только дескриптор 1: теперь он указывает на f2.txt. Дескриптор 2 уже скопирован и остался на экране; копирование повторно не происходит.

Эта пара примеров показывает общее правило: 2>&1 означает не «объединить потоки», а «скопировать текущую цель дескриптора 1». Всё, что стоит справа от >f после 2>&1, на поток диагностики не влияет. Запись 2>&1 >f поэтому почти всегда ошибочна, если нужно записать всё в файл. Правильная запись — >f 2>&1.

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

Пошаговое изменение дескрипторов 1 и 2 для записей >f 2>&1 и 2>&1 >f
Порядок перенаправлений: каждое перенаправление меняет таблицу дескрипторов в момент своей обработки

Заметим, что ошибка в порядке перенаправлений не выдаёт предупреждения. Команда выполняется, код возврата тот же, и отличие видно только при внимательном чтении вывода. Поэтому полезно проверять незнакомые записи на простом примере, как это сделано выше, прежде чем использовать их в важной работе.

Есть и противоположная ловушка: перенаправление можно записать так, что сообщение об ошибке уйдёт не туда, куда ожидалось, из-за того же правила. Рассмотрим запись echo "x" >&2 2>se.txt. Здесь >&2 сначала делает дескриптор 1 копией дескриптора 2 (то есть экрана), и только потом 2>se.txt перенаправляет дескриптор 2 в файл. Дескриптор 1 остаётся на экране, поэтому текст выводится в терминал, а файл пуст. Правильный порядок в этом случае — echo "x" 2>se.txt >&2, хотя на практике для этого существует более понятная запись: echo "x" >&2, без второго перенаправления.

/dev/null

Устройство /dev/null — это специальный файл, который принимает любые данные и отбрасывает их, а при чтении сразу возвращает конец файла 8. Оно удобно, когда поток нужно просто убрать. Проверим размер этого файла и его содержимое:

ls -l /dev/null
cat /dev/null
echo "байт: $(wc -c </dev/null)"
crw-rw-rw- 1 root root 1, 3 Oct 12 12:36 /dev/null
байт: 0

Первый символ c в правах означает символьное устройство; размер в ls -l не выводится, а вместо него печатаются номера устройства 1, 3. Команда cat /dev/null ничего не выводит, а wc -c сообщает, что в нём нулевое число байт. Использовать /dev/null имеет смысл в трёх случаях: чтобы отбросить шумный поток диагностики (2>/dev/null), чтобы подавить вывод команды, результат которой не нужен (>/dev/null), и чтобы явно дать программе пустой ввод (</dev/null). Последний приём полезен для программ, которые без него могут ждать ввода с клавиатуры в фоновом режиме.

Подавление вывода не подавляет код возврата: команда по-прежнему возвращает результат, который можно проверить. Этим и воспользуемся в разделе о коде возврата.

Ввод из файла, here-document и here-string

Для подачи многострочного текста на стандартный ввод удобна конструкция «здесь-документ» (англ. here-document). Она записывается как <<МЕТКА: все строки до строки, состоящей только из метки, передаются программе как содержимое стандартного ввода 4.

cat <<EOF
Каталог: $HOME
Пользователь: $(whoami)
EOF
Каталог: /home/student
Пользователь: student

Переменные и подстановки команд в здесь-документе раскрываются, как в обычной строке в двойных кавычках. Если метку записать в кавычках, <<'EOF', подстановки отключаются, и текст передаётся как есть:

cat <<'EOF'
Каталог: $HOME
EOF
Каталог: $HOME

Для короткой строки удобнее здесь-строка (англ. here-string): <<< передаёт строку на стандартный ввод и не требует метки.

wc -w <<< "раз два три"
3

Здесь-строка добавляет в конец перевод строки, поэтому wc -l для неё вернёт 1, но wc -w считает только слова. Эту разницу полезно учитывать, если результат потом сравнивается с другим значением.

Конвейеры

Каналы и процессы

Конвейер (англ. pipeline) — это последовательность команд, соединённых символом |. Оператор | направляет стандартный вывод левой команды на стандартный ввод правой. Это и есть канал (англ. pipe) 1: буфер, который ядро создаёт между двумя процессами 8. Важно понимать, что каждая команда конвейера — отдельный процесс, и все процессы работают одновременно. Данные не накапливаются в файле: правая команда начинает читать, как только левая записала первые байты.

Рассмотрим простой конвейер, который подсчитывает, сколько раз встречается каждый элемент списка:

printf 'b\na\nb\nc\nb\na\n' | sort | uniq -c | sort -rn
      3 b
      2 a
      1 c

Здесь три программы: printf пишет шесть строк, sort сортирует их, uniq -c подсчитывает повторы подряд идущих строк, а последний sort -rn сортирует результат по числу повторов по убыванию. Ни одна из программ не знает о существовании других: sort читает со стандартного ввода и пишет на стандартный вывод, uniq делает то же. Философия UNIX состоит именно в том, чтобы строить сложные обработки из маленьких программ, каждая из которых делает одно дело и работает с текстом 2, 3. Эта идея известна как правило модульности и композиции, и конвейер — её самое наглядное воплощение.

Конвейер может включать любое количество команд. Для сравнения приведём конвейер, который выводит первые строки файла /etc/passwd, извлекая из каждой строки только первое поле:

cut -d: -f1 /etc/passwd | head -n 3
root
daemon
bin

Программа cut выбирает поля, разделённые двоеточием, а head -n 3 печатает первые три строки и затем завершается. Такое раннее завершение правой команды тоже имеет смысл: левая команда в этом случае получит сигнал о закрытии канала и прекратит работу. Подробнее о сигналах будет сказано в теме 7.

На рисунке показана структура конвейера из трёх процессов для подсчёта имён файлов в каталоге проекта. Стрелки «|» — это каналы, пунктирные стрелки — поток диагностики, который не попадает в канал.

Конвейер из трёх процессов: find, sort и uniq -c соединены каналами; стандартная диагностика каждого процесса идёт на экран
Конвейер: стандартный вывод соединяется с вводом следующей команды, стандартная диагностика остаётся на экране

Из рисунка видно, что поток ошибок не проходит по каналам. Ошибка из find не попадёт в sort, и sort успешно обработает оставшиеся строки. Это свойство конвейера часто приводит к неожиданным результатам, и к нему мы вернёмся при обсуждении кода возврата.

Команда find и конвейер на практике

Поток имён файлов из find удобно подавать в конвейер. Пусть каталог project содержит файлы разных типов. Команда find project -type f -printf '%f\n' выводит только имена файлов без путей. Выведем расширения файлов и посчитаем их. Для выделения расширения инвертируем строку, отрежем первое поле до точки и инвертируем обратно:

find project -type f -printf '%f\n' | rev | cut -d. -f1 | rev | sort | uniq -c | sort -rn
      4 txt
      3 csv
      2 log
      2 jpg
      1 sh
      1 png
      1 README

Конвейер подсчитывает файлы по расширению. Файл README без точки в имени попал в отдельную строку, потому что для него cut вернул всё имя целиком; такой вывод стоит иметь в виду при интерпретации. Имена файлов с пробелами в этом конвейере работают корректно, потому что конвейер передаёт строки целиком. Проблемы начинаются тогда, когда список имён передаётся через аргументы командной строки, и именно эта ситуация разобрана ниже.

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

tee: разветвление потока

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

echo 'hello tee' | tee tee1.txt | tr a-z A-Z
cat tee1.txt
HELLO TEE
hello tee

Строка hello tee записана в файл tee1.txt и одновременно передана программе tr, которая перевела её в верхний регистр. Опция -a дописывает в файл, а не перезаписывает его, как и у оператора >>:

echo more | tee -a tee1.txt >/dev/null
cat tee1.txt
hello tee
more

Обратим внимание на >/dev/null: вывод tee направлен в пустоту, потому что нужна только запись в файл. Без этого перенаправления строка more появилась бы на экране дважды — один раз от tee, второй раз как сохранённая.

tee часто используют вместе с диагностикой. Записи вида команда 2>&1 | tee журнал.txt сохраняют и результат, и ошибки в журнал, одновременно показывая их на экране. Здесь порядок важен: 2>&1 стоит до | и потому копирует дескриптор 1, который в этот момент указывает на канал. Ошибки попадают в канал, а из канала — в tee.

Проверим это на примере. Ошибка ls попадёт в канал, и wc -l её посчитает:

ls /nonexistent_p /etc/hostname 2>&1 | wc -l
ls /nonexistent_p /etc/hostname | wc -l
2
ls: cannot access '/nonexistent_p': No such file or directory
1

В первом случае wc получил две строки, потому что ошибка прошла через канал. Во втором случае сообщение ушло на экран напрямую, а в канал попала только строка /etc/hostname. Это та же логика, что и при перенаправлении в файл: 2>&1 копирует текущую цель дескриптора 1, которой здесь является канал.

Почему вывод иногда задерживается

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

python3 -c 'import time; print("первая"); time.sleep(3); print("вторая")' | while IFS= read -r l; do echo "$(date +%T) $l"; done
13:26:03 первая
13:26:03 вторая

Обе строки получили одно и то же время — момент завершения программы, через три секунды после запуска. Python накопил вывод в буфере и передал его одним куском уже после завершения. Если добавить опцию -u, которая отключает буферизацию для стандартного вывода, строки придут с разницей в три секунды:

python3 -u -c 'import time; print("первая"); time.sleep(3); print("вторая")' | while IFS= read -r l; do echo "$(date +%T) $l"; done
13:26:00 первая
13:26:03 вторая

Этот эффект важен при наблюдении за журналами и длительными процессами. Многие утилиты (например, grep) при выводе в канал также буферизуют результат, и для интерактивного наблюдения им нужны специальные опции, такие как --line-buffered у grep. Подробности о буферизации выходят за рамки темы; достаточно помнить, что отсутствие вывода в конвейере не всегда означает, что программа ничего не делает.

xargs и имена с пробелами

Конвейер передаёт данные через поток, но многие программы принимают имена файлов как аргументы командной строки. Программа xargs читает стандартный ввод и вызывает другую команду, добавляя прочитанное как аргументы 8. Это удобно, но таит ловушку, которую полезно разобрать на примере.

Новичок часто пишет так: нужно скопировать все текстовые файлы из проекта в резервный каталог, и он переносит имена через конвейер в xargs. Пусть каталог project содержит файлы, в именах которых есть пробелы. Посмотрим, что делает такая команда:

mkdir -p backup-bad
find project -name '*.txt' | xargs cp -t backup-bad
echo "код: $?"
ls -a backup-bad
cp: cannot stat 'project/.заметки': No such file or directory
cp: cannot stat 'к': No such file or directory
cp: cannot stat 'проекту.txt': No such file or directory
cp: cannot stat 'project/Отчёты/Итоги': No such file or directory
cp: cannot stat 'за': No such file or directory
cp: cannot stat 'квартал.txt': No such file or directory
cp: cannot stat 'project/Отчёты/Отчёт': No such file or directory
cp: cannot stat 'март.txt': No such file or directory
cp: cannot stat 'project/Отчёты/Отчёт': No such file or directory
cp: cannot stat 'апрель.txt': No such file or directory
код: 123
.
..

Ни один файл не скопирован. xargs по умолчанию разделяет входной поток по пробелам и переводам строк, поэтому имя Отчёт март.txt превращается в два отдельных аргумента: Отчёт и март.txt. Команда cp ищет оба и не находит ни одного. Заметим, что find выдал правильные имена; сломалось именно разделение на слова в xargs. Ошибка сообщает о несуществующих файлах, но не о том, что проблема в разбиении имён, и новичок может долго искать причину.

Такая же ошибка возникает в циклах for f in $(find ...) (разбор ловушки есть в справочнике Wooledge 6): подстановка команды разбивается по пробелам так же, как аргументы xargs. Её разбор и правильная запись относятся к темам 13 и 14, но у ошибки одна природа, и её лучше запомнить вместе.

Решение состоит в том, чтобы передавать имена, разделённые нулевым байтом, а не пробелом или переводом строки. Имя файла в Linux не может содержать нулевой байт, поэтому такой разделитель однозначен. find записывает его опцией -print0, а xargs читает опцией -0 8:

mkdir -p backup-good
find project -name '*.txt' -print0 | xargs -0 cp -t backup-good
ls backup-good
Итоги за квартал.txt
Отчёт апрель.txt
Отчёт март.txt

Все три видимых файла скопированы, хотя их имена содержат пробелы. Скрытый файл .заметки к проекту.txt тоже попал в резервную копию, потому что его имя совпадает с шаблоном *.txt. Обычный ls его не показывает: имена, начинающиеся с точки, скрыты. Опция -a выводит их вместе со служебными . и ..:

ls -a backup-good
.
..
.заметки к проекту.txt
Итоги за квартал.txt
Отчёт апрель.txt
Отчёт март.txt

Этот пример полезен тем, что вывод ls без -a может создать ложное впечатление, будто файл потерян.

Если имена не нужно передавать через поток, xargs можно обойтись без -0, выбрав одну из двух альтернатив. Опция -I заменяет каждую строку на шаблон и не разбивает её по пробелам, а find -exec ... {} + вызывает команду напрямую, без участия оболочки.

find project -name '*.txt' -exec cp -t backup-good {} +

Эта запись не использует конвейер вовсе, поэтому проблемы разбиения здесь не возникает. Правило для практики такое: если имена файлов передаются через поток, всегда используйте пару -print0 и -0; если возможно, предпочитайте -exec или шаблоны оболочки с кавычками.

Отметим ещё одну особенность xargs. Если входной поток пуст, xargs всё равно запустит команду один раз без аргументов:

printf '' | xargs echo 'без -r:'
printf '' | xargs -r echo 'с -r:'
без -r:

Опция -r (или --no-run-if-empty) запрещает такой запуск. Без неё команда вроде rm может выполниться с пустым списком и выдать ошибку или, что хуже, сработать неожиданно. Поэтому в скриптах, которые передают имена через xargs, полезно указывать -r.

Параллельный запуск: xargs -P

До сих пор xargs запускал команды строго по очереди: следующий запуск начинается только после завершения предыдущего. Если обработка одного файла занимает заметное время (сжатие, перекодирование изображения, загрузка по сети), а ядер у процессора несколько, такая очередь расходует время впустую. Опция -P N (англ. max-procs) разрешает xargs держать одновременно до N запущенных процессов: как только один завершается, xargs запускает следующий 1.

Рассмотрим пример. Каталог jobs содержит восемь файлов с пробелами в именах, а сценарий work.sh имитирует долгую обработку одного файла: ждёт секунду и печатает имя.

cat work.sh
#!/usr/bin/env bash
# Имитация долгой обработки одного файла
sleep 1
echo "обработан: $1"

Сравним последовательный и параллельный запуск с помощью встроенной команды time, которая сообщает, сколько времени заняла команда:

time (find jobs -name '*.txt' -print0 | xargs -0 -n 1 ./work.sh >/dev/null)
time (find jobs -name '*.txt' -print0 | xargs -0 -n 1 -P 4 ./work.sh >/dev/null)
real	0m8.085s
user	0m0.023s
sys	0m0.033s

real	0m2.021s
user	0m0.014s
sys	0m0.039s

Строка real — реальное (настенное) время. Восемь файлов по секунде последовательно дают восемь секунд, а четыре одновременных процесса справляются за две. Заметим, что параллельный запуск сохраняет пару -print0 и -0: опция -P меняет только порядок запуска, но не способ разбиения имён.

Важна и опция -n 1, которая ограничивает число аргументов одного запуска. Без неё xargs по умолчанию собирает все имена в один вызов команды, и распараллеливать оказывается нечего:

find jobs -name '*.txt' -print0 | xargs -0 -P 4 ./work.sh
обработан: jobs/задача 6.txt

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

У параллельного запуска есть три следствия, которые нужно помнить.

  • Порядок вывода не определён. Процессы пишут в один и тот же стандартный вывод, и строки появляются в порядке завершения, а не запуска. При длинном выводе строки разных процессов могут даже перемешаться. Если порядок важен, каждый процесс должен писать в свой файл, а результаты объединяются после завершения.
  • Число процессов стоит ограничивать. Разумное значение — число ядер, которое печатает команда nproc: xargs -P "$(nproc)". Значение -P 0 снимает ограничение, и сотня одновременных загрузок может перегрузить машину или удалённый сервер.
  • Код возврата сводный. Если хотя бы один запуск завершился с ошибкой, xargs возвращает код 123, а не код упавшей команды; какой именно файл не обработан, по коду узнать нельзя. Коды возврата подробно рассматриваются в следующем разделе.
printf '%s\n' 1 2 3 | xargs -n 1 -P 3 sh -c 'test "$0" != 2'
echo "код: $?"
код: 123

Здесь из трёх запусков ошибкой завершился один (для аргумента 2), и xargs сообщил об этом общим кодом 123. Для более сложных задач (сохранение порядка вывода, журнал заданий, повтор неудачных запусков) существует отдельная программа GNU parallel, но для типичной задачи «обработать много файлов на всех ядрах» достаточно xargs -0 -n 1 -P.

Код возврата и управление последовательностью

Код возврата и $?

Каждая команда завершается с кодом возврата (англ. exit status) — числом от 0 до 255. По соглашению UNIX код 0 означает успех, любое другое значение — ошибку, а конкретные значения зависят от программы 8. Последний код возврата доступен в специальной переменной $?. Она обновляется после каждой команды, поэтому её нужно прочитать сразу, иначе следующая команда перезапишет значение.

ls /etc/hostname
echo "код: $?"
ls /nonexistent_dir 2>/dev/null
echo "код: $?"
bash -c 'exit 3'
echo "код: $?"
/etc/hostname
код: 0
код: 2
код: 3

ls вернул 2, когда каталог не найден. Команда exit 3 в дочерней оболочке вернула 3, и это значение прочитано в переменной $?. Заметим, что 2>/dev/null не влияет на код: он скрыл сообщение, но ls всё равно завершился с ошибкой.

Для проверки кода возврата существуют встроенные команды true и false, которые всегда возвращают 0 и 1 соответственно:

false
echo "код: $?"
true
echo "код: $?"
код: 1
код: 0

Эти команды удобны в примерах и тестах логических операторов.

Операторы &&, || и ;

Последовательность команд можно разделить тремя способами. Оператор ; выполняет команды одну за другой независимо от результата. Оператор && выполняет правую команду только тогда, когда левая завершилась с кодом 0. Оператор || выполняет правую команду только тогда, когда левая завершилась с ненулевым кодом.

Сравним два случая: переход в каталог, которого нет, и последующий вывод.

cd ~/lab-05/project && echo "в каталоге: $PWD"
cd /nonexistent_zz && echo "не будет напечатано"
echo "текущий каталог: $PWD"
в каталоге: /home/student/lab-05/project
bash: cd: /nonexistent_zz: No such file or directory
текущий каталог: /home/student/lab-05/project

Первая команда успешна, поэтому echo выполнился. Во второй cd завершился с ошибкой, и echo после && пропущен. Третья команда стоит после ;, поэтому выполнилась в любом случае. Её вывод показывает, что каталог остался прежним: неудачный cd не менял текущий каталог.

Оператор || удобен для обработки неудачи:

grep -q root_no_such_user_x /etc/passwd || echo "совпадений нет"
grep -q root /etc/passwd || echo "совпадений нет"
совпадений нет

Флаг -q подавляет вывод grep и оставляет только код возврата. Во втором случае grep нашёл root, вернул 0, и echo не выполнился. Сочетание && и || позволяет записать простую логику в одну строку, но в длинных цепочках читаемость падает. Тогда лучше использовать if, который будет разобран в теме 14.

Отметим тонкость: a && b || c выполняется слева направо и означает не «если a и b, иначе c», а «если a выполнено и b выполнено, либо a не выполнено, то c». Если b завершилась с ошибкой, выполнится c, даже если a успешна. Для надёжных скриптов такие цепочки стоит разбивать явно.

Группировка { } и ( )

Иногда нужно применить перенаправление или условие к нескольким командам сразу. Для этого служат фигурные скобки { } и круглые скобки ( ). Различие между ними существенно.

Фигурные скобки группируют команды в текущей оболочке. Пробелы после открывающей и перед закрывающей скобкой обязательны, а последняя команда внутри завершается точкой с запятой:

{ echo первая; echo вторая; } > grp.txt
cat grp.txt
первая
вторая

Перенаправление относится ко всей группе: оба echo записали в файл. Скобки { } не создают новый процесс, поэтому переменные, изменённые внутри, сохраняются:

x=1
{ x=3; }
echo "x=$x"
x=3

Круглые скобки запускают группу в дочерней оболочке (подоболочке). Изменения переменных, рабочего каталога и другого состояния в ней не переживают завершения группы:

pwd
(cd /etc && pwd)
pwd
x=1
(x=2)
echo "x=$x"
/home/student
/etc
/home/student
x=1

Вторая команда pwd показала, что cd внутри скобок не изменил каталог оболочки. Переменная x в скобках получила значение 2, но вне их осталась 1. Эта разница важна при написании скриптов: например, cd в круглых скобках — стандартный приём для временного перехода в каталог без последующего возврата.

Так же устроены и конвейеры: каждая команда конвейера выполняется в отдельной подоболочке. Поэтому переменная, изменённая в правой части конвейера, не видна после него. Эта ловушка подробно разбирается в теме 14, но её причина та же, что и здесь.

Код возврата конвейера

Код возврата конвейера — это код последней команды. Остальные команды могут завершиться с ошибкой, и это не отразится на результате. Проверим:

ls /nonexistent_q 2>/dev/null | cat
echo "код: $?"
ls /nonexistent_q 2>/dev/null | wc -l
echo "коды всех команд: ${PIPESTATUS[*]}"
код: 0
0
коды всех команд: 2 0

ls завершился с ошибкой, но конвейер вернул 0, потому что последней командой был cat. Переменная PIPESTATUS содержит коды всех команд последнего конвейера, и здесь видно, что ls вернул 2. Запись ${PIPESTATUS[*]} содержит квадратные скобки внутри оболочки; их подробный разбор относится к теме 14.

Замечание о set -o pipefail

Чтобы конвейер возвращал ошибку, если завершилась с ошибкой любая его команда, в Bash существует опция set -o pipefail. С ней код конвейера равен коду последней ненулевой команды. Эта опция и её сочетание с set -e рассматриваются в теме 13 как первые правила надёжности, а подробный разбор ловушек — в теме 15. Здесь достаточно отметить, что без неё конвейер может «выглядеть успешным» при ошибке в начале.

Итоги темы

Потоки ввода-вывода и перенаправления образуют основу работы в командной строке. Каждый процесс имеет три стандартных потока с номерами 0, 1 и 2, а его поведение определяется тем, на что указывают эти номера в таблице дескрипторов. Перенаправление меняет цель дескриптора, но не его номер, и потому одна и та же программа может писать в экран, в файл или в канал. Правило обработки слева направо объясняет, почему >f 2>&1 и 2>&1 >f дают разные результаты, а /dev/null, здесь-документы и здесь-строки позволяют точно управлять вводом и выводом.

Конвейер соединяет несколько процессов каналами, и данные идут потоком, без промежуточных файлов. Этот принцип позволяет строить сложную обработку из простых программ. Однако при передаче имён файлов через xargs пробелы и другие разделители требуют особого внимания: правильная пара find -print0 и xargs -0 или запись -exec решают проблему, а её отсутствие приводит к молчаливой потере данных. Опция xargs -P запускает команды параллельно; её используют вместе с -n, ограничивают число процессов значением nproc и помнят, что порядок вывода не определён, а код возврата 123 лишь сообщает, что хотя бы один запуск завершился ошибкой. Код возврата и операторы &&, || и ; позволяют управлять последовательностью команд, но требуют понимания того, какая команда и в каком контексте вернула результат.

Тема подготавливает работу с инструментами обработки текста: grep, sed и awk построены на тех же потоках, и без уверенного понимания перенаправлений их конвейеры трудно читать и отлаживать. Следующая тема будет посвящена именно этим инструментам и регулярным выражениям.

Литература

  1. Shotts W. The Linux Command Line: A Complete Introduction. 2nd ed. — No Starch Press, 2019. Открытая версия: https://linuxcommand.org/tlcl.php
  2. Kernighan B. W., Pike R. The UNIX Programming Environment. — Prentice Hall, 1984.
  3. Raymond E. S. The Art of UNIX Programming. — Addison-Wesley, 2003. Открытая версия: http://www.catb.org/esr/writings/taoup/
  4. Free Software Foundation. GNU Bash Reference Manual. — https://www.gnu.org/software/bash/manual/
  5. The Open Group. The Open Group Base Specifications Issue 7 (IEEE Std 1003.1-2017), Shell Command Language. — https://pubs.opengroup.org/onlinepubs/9699919799/utilities/V3_chap02.html
  6. Wooledge G. Bash FAQ и Bash Pitfalls. — https://mywiki.wooledge.org/BashPitfalls
  7. Free Software Foundation. GNU Coreutils Manual. — https://www.gnu.org/software/coreutils/manual/
  8. The Linux man-pages project. — https://man7.org/linux/man-pages/