Буферы и окна: подробности о тайне ssh и цикла чтения while

в 10:02, , рубрики: linux, ruvds_перевод, Блог компании RUVDS.com, системное администрирование

Если вы когда-нибудь пробовали воспользоваться в цикле чтения while командой ssh, или, точно так же, командами ffmpeg или mplayer, это значит, что вы сталкивались с неожиданным поведением такого цикла: он, после первой итерации, таинственным образом прекращал работать!

Буферы и окна: подробности о тайне ssh и цикла чтения while - 1

Решение этой проблемы, в случае с 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

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js