Если вы когда-нибудь пробовали воспользоваться в цикле чтения while
командой ssh
, или, точно так же, командами ffmpeg
или mplayer
, это значит, что вы сталкивались с неожиданным поведением такого цикла: он, после первой итерации, таинственным образом прекращал работать!
Решение этой проблемы, в случае с ssh
, заключается в использовании конструкций ssh
-n
или ssh < /dev/null
, на которые мгновенно «ругается» ShellCheck (тут можно посмотреть на результаты анализа кода с этими конструкциями). На этом можно было бы и остановиться, ведь проблема решена, но мы этого делать не будем. Лучше — детально разберём причины этой проблемы.
Обратите внимание на то, что все числа, представленные в этом материале, весьма вероятно, зависят от используемых инструментов и от платформы, на которой запускается код. В частности, они применимы к GNU coreutils 8.26, Linux 4.9 и OpenSSH 7.4, в том виде, в котором всё это присутствовало в Debian, в июле 2017 года. Если вы используете другую платформу, или — всего лишь значительно более новые версии этих инструментов, и хотите воспроизвести мои эксперименты, вам может понадобиться, учтя мои рассуждения, соответствующим образом поменять числовые данные.
В любом случае, для того чтобы, в первую очередь, продемонстрировать эту проблему, рассмотрим цикл чтения while
, который выполняет команду для каждой строки файла:
while IFS= read -r host
do
echo ssh "$host" uptime
done < hostlist.txt
Команда работает отлично, перебирая все строки файла:
ssh localhost uptime
ssh 10.0.0.4 uptime
ssh 10.0.0.7 uptime
Но если мы уберём echo
и просто выполним команду ssh
— цикл остановится после первой итерации, не выводя никаких предупреждений и не сообщая нам ни о каких ошибках:
16:12:41 up 21 days, 4:24, 12 users, load average: 0.00, 0.00, 0.00
Даже команда uptime
, сама по себе, работает хорошо, но конструкция ssh localhost uptime
остановится после первой итерации цикла, несмотря на то, что речь идёт о выполнении одной и той же команды на одной и той же машине.
Конечно, использование одного из описанных выше вариантов исправления команды (ssh -n
или ssh < /dev/null
) решает проблему и даёт нам ожидаемый результат:
16:14:11 up 21 days, 4:24, 12 users, load average: 0.00, 0.00, 0.00
16:14:11 up 14 days, 6:59, 15 users, load average: 0.00, 0.00, 0.00
01:14:13 up 73 days, 13:17, 8 users, load average: 0.08, 0.15, 0.11
Правда, если нам нужно было лишь решить проблему, то мы могли бы последовать совету ShellCheck, ссылка на который есть в начале материала. Но мы продолжим разбираться в происходящем.
Похожим образом ведут себя и ffmpeg
при преобразовании медиафайлов, и mplayer
при их проигрывании. Но тут всё выглядит даже хуже: цикл не только останавливается после первой итерации, он может даже остановиться посреди этой самой итерации!
А вот все остальные команды работают в подобной ситуации нормально — даже другие конвертеры, проигрыватели и команды, основанные на ssh
, вроде sox
, vlc
и scp
. Почему же некоторые команды дают сбой?
Корень проблемы кроется в том, что когда мы пользуемся конвейером или перенаправлением в цикле чтения while
, мы не просто перенаправляем данные в read
. Перенаправление действует и во всём теле цикла. Всё — и в блоке условия, и в теле цикла, будет использовать один и тот же файловый дескриптор в роли стандартного ввода.
Взгляните на этот цикл:
while IFS= read -r line
do
cat > rest
done < file.txt
Команда read
, при её первом вызове, успешно считывает строку, после чего запускается первая итерация цикла. Затем команда cat
выполняет чтение данных из того же источника входных данных, из того места, в котором остановилась команда read
. Команда cat
читает данные до достижения EOF
и завершает работу, после чего работа цикла продолжается. Команда read
снова пытается прочитать данные из того же источника и натыкается на EOF
. Работа цикла завершается. Это приводит к тому, что цикл выполняется лишь один раз.
Но, правда, наш вопрос пока остаётся неотвеченным: почему три вышеупомянутые команды «опустошают» stdin
?
С командами ffmpeg
и mplayer
разобраться в этом плане довольно легко. И та и другая принимает клавиатурные команды из stdin
.
Когда ffmpeg
занимается кодированием видео, можно нажать клавишу +
для того чтобы в ходе работы выводилось бы больше данных, или c
для того чтобы вводить команды для применения фильтров. Когда mplayer
проигрывает видеофайл, можно воспользоваться клавишей Пробел
для того чтобы поставить воспроизведение на паузу, а клавиша m
позволяет отключить звук. Команды, обрабатывая эти данные, поступающие с клавиатуры, берут из stdin
всё, что могут.
Обе эти утилиты используют одну и ту же клавиатурную команду, завершающую их работу. А именно, они мгновенно завершаются, если прочтут клавиатурную команду q
.
А при чём тут ssh
? Не должна ли эта утилита лишь отражать поведение удалённой команды? Если команда uptime
ничего не прочитала — почему что-то должна прочитать команда ssh localhost uptime
?
В модели процессов Unix нет хорошего способа обнаружения того, что процесс нуждается во входных данных. Вместо использования какого-то такого механизма, утилита ssh
вынуждена заблаговременно считывать данные, передавать их по сети, а sshd
предлагает эти данные процессу, пользуясь конвейером. Если процессу эти данные не нужны, нет способа вернуть данные в файловый дескриптор, из которого они поступили.
«Игрушечный» вариант той же проблемы можно получить с помощью конструкции cat | uptime
. Выходные данные в этом случае будут такими же, как и при использовании ssh localhost uptime
:
16:25:51 up 21 days, 4:34, 12 users, load average: 0.16, 0.03, 0.01
В данном случае cat
будет читать данные из stdin
и писать их в конвейер до тех пор, пока буфер конвейера не заполнится, после чего конвейер будет заблокирован до тех пор, пока что-то не прочтёт эти данные. Используя strace
можно увидеть, что команда GNU cat
из coreutils 8.26 использует буфер размером 128 КиБ, что больше, чем текущий буфер конвейера в Linux, размер которого составляет 64 КиБ. В результате размер данных, потерю которых мы можем ожидать, равен размеру буфера в 128 Киб.
Это означает, что цикл, на самом деле, не останавливается. Он продолжает работать в том случае, если, после чтения 128 КиБ остались ещё какие-то данные. Попробуем следующее:
{
echo first
for ((i=0; i < 16384; i++)); do echo "garbage"; done
echo "second"
} > file
while IFS= read -r line
do
echo "Read $line"
cat | uptime > /dev/null
done < file
Тут мы пишем в файл 16386 строк. Сначала — строку first
, потом — 16384 строки garbage
, а потом — second
. Строка garbage
+ символ перевода строки — это 8 байтов, в результате 16384 строки дадут в точности 128 КиБ. То, что данные сохранены в файл, предотвращает состояние гонок, которое может возникнуть между производителем и потребителем данных.
Вот что мы получим:
Read first
Read second
Если добавить в файл одну дополнительную строку garbage
— мы увидим именно её. Если в файле будет на одну строку меньше — тогда строка second
исчезнет. Другим словами — между итерациями цикла будет потеряно 128 КиБ данных.
Команде ssh
приходится делать то же самое, но и не только это: нужно читать входные данные, шифровать их и передавать по сети. А sshd
, находящийся на противоположном конце соединения, получает их, дешифрует и передаёт в конвейер. Обе стороны работают асинхронно, в дуплексном режиме, одна из сторон может в любое время закрыть соединение.
Если мы используем конструкцию ssh localhost uptime
, это значит, что мы спешим узнать о том, какой объём данных мы можем передать до того, как sshd
уведомит нас о том, что работа команды уже завершилась. Чем быстрее компьютер, и чем больше время, необходимое на передачу данных по сетевому соединению и на получение ответа, тем больше данных мы можем записать. Для того чтобы этого избежать и обеспечить получение детерминированных результатов, мы с этого момента будем пользоваться, вместо uptime
, конструкцией sleep 5
.
Вот один из способов измерения объёма записанных нами данных:
$ tee >(wc -c >&2) < /dev/zero | { sleep 5; }
65536
Конечно, эта конструкция, показывая, сколько данных она записала, не показывает напрямую то, какой объём данных прочитала команда sleep
: 65536 байт — это размер буфера конвейера Linux.
Этот подход, к тому же, нельзя назвать обычным методом для получения точных результатов измерений, так как он полностью зависит от выравнивания буферов. Если ничто не осуществляет чтение данных из конвейера — в него можно успешно записать два блока по 32768 байтов, но лишь один блок размером 32769 байтов.
К счастью, команда GNU tee
в настоящее время использует буфер размером 8192 байта, поэтому, при условии проведения 8 успешных операций чтения с полным заполнением буфера, она идеально заполнит буфер конвейера размером в 65536 байтов. Команда strace
, кроме того, позволила узнать о том, что ssh
(OpenSSH 7.4) использует буфер размера 16384 байта, что в точности в 2 раза больше, чем буфер tee
, и в 4 раза меньше буфера конвейера. В результате все эти буферы хорошо выравниваются и при работе с ними можно проводить точные подсчёты.
Проведём наш эксперимент с ssh
:
$ tee >(wc -c >&2) < /dev/zero | ssh localhost sleep 5
2228224
Как уже было сказано, мы корректируем полученный результат на размер буфера конвейера, поэтому можно предположить, что команда ssh
прочитала 2162688 байт. Можно, если надо, проверить это самостоятельно, воспользовавшись strace
. Но почему — именно 2162688?
Дело в том, что sshd
, в свою очередь, должен передать эти данные sleep
через конвейер, из которого чтение данных не выполняется. Это — ещё 65536 байтов. Теперь у нас осталось 2097152 байта. Почему — именно столько?
Это число, на самом деле, представляет собой стандартный размер окна транспортного уровня OpenSSH для неинтерактивных каналов!
Вот — извлечение из файла channels.h, входящего в состав исходного кода OpenSSH, где речь идёт о стандартном размере окна/пакета:
/* default window/packet sizes for tcp/x11-fwd-channel */
#define CHAN_SES_PACKET_DEFAULT (32*1024)
#define CHAN_SES_WINDOW_DEFAULT (64*CHAN_SES_PACKET_DEFAULT)
Посчитаем: 64*32*1024 = 2097152.
Если переориентировать предыдущий пример на использование ssh anyhost sleep 5
и записать в файл строку garbage
(64*32*1024+65536)/8 = 270336 раз, то мы сможем снова «обыграть» буфер, и сделать так, чтобы наш итератор прочитал бы именно те строки, которые нам интересны:
{
echo first
for ((i=0; i < $(( (64*32*1024 + 65536) / 8)); i++)); do echo "garbage"; done
echo "second"
} > file
while IFS= read -r line
do
echo "Read $line"
ssh localhost sleep 5
done < file
Выполнение этого кода приведёт к выводу того же результата:
Read first
Read second
Эксперимент это, правда, совершенно бесполезный, но весьма остроумный.
Если вы сталкиваетесь со странным поведением Linux и находите решение проблемы, не до конца понимая её причин, довольствуетесь ли вы этим решением, или полностью разбираетесь с причинами проблемы?
Автор: ru_vds