Параллельные оболочки с xargs: Используем все процессорные ядра в UNIX и Windows +27



▍ Введение


Одним из наиболее неприятных моментов в работе с оболочкой UNIX является невозможность легко запланировать несколько параллельных задач, которые бы полностью использовали ядра процессора, представленные в большинстве современных системах. В данной статье в качестве примера рассматривается сжатие файлов, но проблема также возникает и при выполнении многих задач с интенсивными вычислениями, таких как: обработка изображений/аудио/медиа, подбор паролей и анализ хэшей, резервное копирование, а также извлечение, преобразование и загрузка баз данных. Вполне понятно, что ждать, когда gzip * выполнится на одном ядре процессора, довольно утомительно, в то время как большая часть вычислительной мощности машины простаивает.

Это можно объяснить несовершенством первого десятилетия Research UNIX, который не разрабатывался на машинах с SMP. Оболочка Bourne не вышла из 7-го издания с каким-либо собственным синтаксисом или средствами управления для последовательного управления потреблением ресурсов фоновыми процессами.

Для реализации некоторых из этих функций были бессистемно разработаны соответствующие утилиты. GNU-версия xargs способна осуществлять некоторый примитивный контроль над распределением фоновых процессов, что довольно подробно обсуждается в документации. Хотя расширения GNU для xargs распространились на многие другие реализации (в частности, BusyBox, включая выпуск для Microsoft Windows, пример ниже), они не соответствуют POSIX.2 и, скорее всего, не найдут применения в коммерческих UNIX системах.

Бывалые пользователи xargs помнят его как полезный инструмент для каталогов, содержащих слишком много файлов, чтобы можно было использовать echo * или другие шаблоны поиска; в такой ситуации xargs вызывается для многократной пакетной обработки групп файлов одной командой. По мере развития xargs за пределами POSIX, он приобрёл новую актуальность, которую будет полезно изучить.

▍ Почему POSIX.2 настолько плох?


Для ясного понимания отсутствия согласованного планирования задач в UNIX необходимо немного углубиться в историю развития этих утилит.

Оболочка, как определено в POSIX.2, имеет примитивные функции управления системными заданиями. Эта функциональность возникла из одного источника — csh, написанного Биллом Джоем и впервые распространённого в 1978 году, и с тех пор не получила значительного развития, даже после того, как управление заданиями было поглощено оболочкой Korn. Ниже приведён пример управления заданиями [c]sh, реализованный в bash, которым оболочки POSIX.2 по-прежнему ограничены. В этом сеансе ^Z и ^C означают комбинацию клавиш Control.

$ xz -9e users00.dat
^Z
[1]+  Stopped                 xz -9e users00.dat

$ bg
[1]+ xz -9e users00.dat &

$ xz -9e users01.dat
^Z
[2]+  Stopped                 xz -9e users01.dat

$ xz -9e users02.dat
^Z
[3]+  Stopped                 xz -9e users02.dat

$ jobs
[1]   Running                 xz -9e users00.dat &
[2]-  Stopped                 xz -9e users01.dat
[3]+  Stopped                 xz -9e users02.dat

$ bg 3
[3]+ xz -9e users02.dat &

$ jobs
[1]   Running                 xz -9e users00.dat &
[2]+  Stopped                 xz -9e users01.dat
[3]-  Running                 xz -9e users02.dat &

$ fg 2
xz -9e users01.dat
^C

$ jobs
[1]-  Running                 xz -9e users00.dat &
[3]+  Running                 xz -9e users02.dat &

В приведённом выше примере были запущены три команды сжатия, вторая отменена, а остальные перенесены в фоновый режим.

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

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

Хотя SMP впервые появилась в компьютерных системах, продаваемых на рынке в 1962 году, и прочно закрепилась с выпуском IBM System/370, который появился в том же году, что и рождение UNIX, такие мощные машины не были доступны разработчикам в «условиях бедности» того времени, которое известно как Research UNIX. Системы с такими возможностями не получат широкого распространения ещё много лет.

"[Система] UNIX не поддерживала многопроцессорность… Процессор IBM 3033AP отвечал этому требованию, обладая примерно в 15 раз большей вычислительной мощностью, чем один процессор PDP-11/70."

Похоже, что первой платформой UNIX с поддержкой SMP был Sperry/UNIVAC 1100, внутренний порт AT&T, начатый в 1977 году. Этот порт, как и более поздние усилия IBM на System/370, оба были построены на компонентах ОС, предоставленных поставщиками (EXEC 8 и TSS), и, похоже, не полагались на общую SMP, реализованную в ядре 7-й редакции.

«Любая конфигурация, поставляемая Sperry, включая многопроцессорные, может запускать систему UNIX».

Поскольку csh не мог быть написан на многопроцессорной машине, а прошедшие годы до появления UNIX System V в целом не ввели SMP, контроль заданий оболочки также не имеет представления о нескольких процессорах и не был разработан для их использования.

Такое отсутствие прогресса было закреплено в POSIX.2 из-за войн UNIX, где эти стандарты были выпущены в качестве защитной меры консорциумом во главе с IBM, HP и DEC (среди прочих), навсегда заблокировав возможности UNIX System V в индустрии. Для многих инновации за пределами POSIX оказались недопустимыми.

Когда POSIX.2 был утверждён, все основные игроки реализовали SMP, но не было мотива расширить стандартную оболочку POSIX.2 за пределы System V. Это привело к тому, что серверы x86 NUMA и встроенные big.LITTLE были одинаково слабо представлены в любой строго соответствующей POSIX-имплементации.

Причина, по которой параллельный вывод процессов gzip остаётся нетривиальной задачей, кроется в кодифицированном защитном маркетинге.

▍ GNU xargs


В связи с отсутствием современного управления заданиями в оболочке POSIX.2, можно воспользоваться одним хаком, который предоставляет расширенные возможности в GNU xargs. Другие решения включают GNU parallel и pdsh, которые здесь не представлены.

Классическая утилита xargs объединяет стандартный ввод и позиционные параметры для форка команд. Простым примером xargs может быть перечисление нескольких номеров inode:

$ echo /etc/passwd /etc/group | xargs stat -c '%i %n'
525008 /etc/passwd
525256 /etc/group

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

$ uname -a
HP-UX localhost B.10.20 A 9000/800 862741461 two-user license

$ cd /directory/with/lots/of/files
$ chmod 644 *
sh: There is not enough memory available now.

$ ls | xargs chmod 644
$ echo *
sh: There is not enough memory available now.

$ ksh
$ what /usr/bin/ksh | grep Version
        Version 11/16/88

$ echo *
ksh: no space

$ /usr/dt/bin/dtksh
$ echo ${.sh.version}
Version M-12/28/93d

$ echo *
Pid 1954 received a SIGSEGV for stack growth failure.
Possible causes: insufficient memory or swap space,
or stack size exceeded maxssiz.
Memory fault

$ /usr/old/bin/sh
$ ls *
/usr/bin/ls: arg list too long

$ ls * *
no stack space

Удачи найти это в руководстве…

Существует проблема с POSIX xargs, которая заключается в том, что он плохо справляется с пробелами или новыми строками в файлах на стандартном вводе. Единственный универсально запрещённый символ в имени файла UNIX — это прямая косая черта (/). Расширение GNU, аргумент -0, устанавливает разделитель файлов на NUL, или нулевой байт, что значительно упрощает обработку файлов и значительно повышает безопасность. GNU find имеет переключатели для использования этой возможности в конвейере (pipeline). В действительности, xargs, в котором нет -0, просто не стоит использовать.

Второе крупное расширение GNU позволяет выполнять параллельную обработку с помощью аргумента -P #. Сам по себе он не запускает параллельную обработку, но в сочетании с опцией -L 1 все входные файлы будут запускаться отдельно с целевой программой, выполняя только отведённое количество слотов процесса.

Перед запуском нашего первого параллельного скрипта проверьте программу, которая сообщает о количестве ядер процессора, видимых Linux:

$ nproc
4

Это число может не совпадать с числом физических ядер, а также SMT/гиперпотоков, которые могут быть реализованы в нескольких ядрах. Некоторые команды плохо работают при выполнении в потоках, реализованных на одном ядре.

Теперь представим параллельный скрипт сжатия, достаточно гибкий для генерации нескольких форматов файлов. Он совместим с POSIX и работает под управлением Debian DASH и оболочки BusyBox.

$ cat ~/ppack_lz
#!/bin/sh
PARALLEL="$(nproc --ignore=1)"
EXT="${0##*_}"
case "$EXT" in
     bz2) CMD='bzip2 -9'                              ;;
     gz)  CMD='gzip -9'                               ;;
     lz)  CMD='lzip -9'                               ;;
     xz)  CMD='xz -9e'                                ;;
     zst) CMD='zstd --rm --single-thread --ultra -22' ;;
esac
if [ -z "$1" ]
then echo "Specify files to pack into ${EXT} files."
else for x
     do printf '%s\0' "$x"
     done | nice xargs -0 -L 1 -P "$PARALLEL" $CMD
fi

Несколько примечаний к этому примеру:

  • Скрипт настроен на использование всех процессоров, о которых сообщает nproc, кроме одного. В зависимости от загрузки машины, возможно, лучше установить это значение вручную.
  • Скрипт определяет тип сжатия по последним символам после подчёркивания (_) в имени файла скрипта. Если скрипт назван foo_bz2, то он будет выполнять обработку bzip2 вместо lzip, выбранного выше с помощью ppack_lz.
  • Файлы для сжатия, указанные в качестве аргументов скрипта, будут выдаваться циклом for на стандартный вывод, разделённый NUL, для планирования xargs.

Чтобы наблюдать этот сценарий в действии, полезно иметь (практически POSIX-совместимую) функцию оболочки для поиска вывода команды ps:

psearch () {
  local xx_a xx_b xx_COLUMNS IFS=\|
  [ -z "$COLUMNS" ] && xx_COLUMNS=80 || xx_COLUMNS="$COLUMNS"
  ps -e -o user:7,pid:5,ppid:5,start,bsdtime,%cpu,%mem,args |
  while read xx_a
  do if [ -z "$xx_b" ]
     then printf '%s\n' "${xx_b:=$xx_a}"
     else for xx_b
          do case "$xx_a" in
               *"$xx_b"*)
                 printf '%s\n' "$(expr substr "$xx_a" 1 "$xx_COLUMNS")" ;;
             esac
          done
     fi
  done
}

С готовым монитором мы можем запустить этот скрипт для нескольких WAV-файлов на четырёхъядерном процессоре:

$ ~/ppack_lz *.wav

В другом терминале виден xargs, который планирует выполнение этих команд:

$ psearch lzip
USER      PID  PPID  STARTED   TIME %CPU %MEM COMMAND
cfisher 29995 29992 16:01:49   0:00  0.0  0.0 xargs -0 -L 1 -P 3 lzip -9
cfisher 30007 29995 16:02:10   0:27  100  2.8 lzip -9 track01.cdda.wav
cfisher 30046 29995 16:02:31   0:05 97.5  1.4 lzip -9 track02.cdda.wav
cfisher 30049 29995 16:02:33   0:04  108  1.2 lzip -9 track03.cdda.wav

Как указано в документации по параллелизму xargs, отправка SIGUSER1 и SIGUSER2 будет противоположно добавлять и уменьшать количество параллельных процессов, запланированных xargs. Добавление вступает в силу немедленно, в то время как уменьшение будет требовать завершения существующих процессов.

Приведённая выше форма команды xargs является ограничивающей, поскольку порядок заданных аргументов и параметр, предоставляемый xargs, не могут быть изменены. Более тонкий вариант, обеспечивающий большую гибкость сценариев, можно задать с помощью опции POSIX -I, но для этого требуется «мета-скрипт», который генерируется во время выполнения.

$ cat ~/parallel-pack_gz
#!/bin/sh
PARALLEL="$(nproc --ignore=1)"
S="$(mktemp -t PARALLEL-XXXXXX)"
trap 'rm -f "$S"' EXIT
EXT="${0##*_}"
case "$EXT" in
     7z)  printf '#!/bin/sh \n exec 7za a -bso0 -bsp0 --mx=9 "${1}.7z" "$1"' ;;
     bz2) printf '#!/bin/sh \n exec bzip2 -9 "$1"'                           ;;
     gz)  printf '#!/bin/sh \n exec gzip -9 "$1"'                            ;;
     lz)  printf '#!/bin/sh \n exec lzip -9 "$1"'                            ;;
     xz)  printf '#!/bin/sh \n exec xz -9e "$1"'                             ;;
     zst) printf '#!/bin/sh\nexec zstd --rm --single-thread --ultra -22 "$1"';;
esac > "$S"
chmod 500 "$S"
if [ -z "$1" ]
then echo "Specify files to pack into ${EXT} files."
else for x
     do printf '%s\0' "$x"
     done | nice xargs -0 -P "$PARALLEL" -Ifname "$S" fname
fi

Выше был добавлен вызов 7za, который содержится в пакете p7zip, доступном на многих платформах (пользователи Red Hat могут найти его в EPEL). Использование 7-zip сопровождается несколькими предостережениями, поскольку сама программа является многопоточной (использует от 1,25 до 1,5 ядер) и требования к памяти повышаются, поэтому количество параллельных процессов должно быть уменьшено. Кроме того, 7-zip имеет возможность добавлять существующий архив (как Info-ZIP, который он призван заменить); не планируйте несколько процессов 7-zip для добавления в один и тот же целевой файл. Возможности шифрования в 7-zip могут представлять особый интерес во избежание нарушений правил безопасности при работе с резервными носителями.

Хотя название этой статьи, «параллельные оболочки» — технически верное (в вышеупомянутом использовании), приведённый выше exec стирает оболочки мгновенно и является более эффективным использованием таблицы процессов.

С помощью этого гибкого скрипта мы проводим стресс-тест с pigz, многопоточным gzip, против 80 файлов размером 2 гигабайта (которые в данном случае являются файлами данных базы данных Oracle, содержащими блоки таблиц и индексов в случайном порядке). Базовым сервером является (старый) HP DL380 Gen8 с 8 доступными процессорными ядрами:

$ lscpu | grep name
Model name:            Intel(R) Xeon(R) CPU E5-2609 0 @ 2.40GHz
# time pigz -9v users*
users00.dat to users00.dat.gz
users01.dat to users01.dat.gz
users02.dat to users02.dat.gz
...
users77.dat to users77.dat.gz
users78.dat to users78.dat.gz
users79.dat to users79.dat.gz
real   45m51.904s
user   335m15.939s
sys    2m11.146s

Во время выполнения pigz утилита top сообщает о следующей загрузке процессора процесса:

PID USER PR NI   VIRT  RES SHR S  %CPU %MEM    TIME+ COMMAND
11162 root 20  0 617616 6864 772 S 714.2  0.0 17:58.21 pigz -9v users01.dat...

По сравнению с этим (идеальным) бенчмарком, сценарий xargs был немного быстрее, даже работая под приоритетом nice для CPU и с PARALLEL, установленным на 8 на том же хосте:

$ time ~/parallel-pack_gz users*
real   44m42.107s
user   341m18.650s
sys    2m47.379s

Во время выполнения xargs-orchestrated параллельно gzip в верхнем отчёте были перечислены все однопоточные процессы, запланированные на отдельных процессорах (обратите внимание на уровень приоритета 30, уменьшенный до nice, по сравнению с 20 для pigz):

PID USER PR NI VIRT RES SHR S  %CPU %MEM   TIME+ COMMAND
14624 root 30 10 4624 828 424 R 100.0  0.0 0:09.85 gzip -9 users00.dat
14625 root 30 10 4624 832 424 R 100.0  0.0 0:09.86 gzip -9 users01.dat
...
14630 root 30 10 4624 832 424 R  99.3  0.0 0:09.76 gzip -9 users06.dat
14631 root 30 10 4624 824 424 R  98.0  0.0 0:09.69 gzip -9 users07.dat

В этом идеальном варианте количество файлов равномерно делилось на количество процессоров, что помогло параллельному xargs победить pigz; добавление ещё одного файла привело бы к проигрышу xargs в этой гонке.

Существуют также параллельные версии bzip2 (pbzip2), lzip (plzip), xz (pixz), а утилита zstd обычно является многопоточной и использует все ядра процессора, но это значение по умолчанию было отключено выше. Многопоточные версии могут демонстрировать отличные от xargs характеристики производительности. Для 7za, xargs является очевидным методом утилизации всей вычислительной мощности системы.

Существенной проблемой ввода-вывода при параллельном планировании xargs на вращающихся носителях является фрагментация. Хотя на твердотельных накопителях это не является фактором, на обычных носителях это проблема, которую следует регулярно решать, если это возможно, что можно наблюдать на примере этого результата, сопоставленного по номеру инода (inod):

# ls -li users46.dat.lz
2684590096 -rw-r--r--. 1 oracle dba 174653599 Jan 28 13:30 users46.dat.lz
# xfs_fsr -v
...
ino=2684590096
extents before:52 after:1 DONE ino=2684590096
...

Эта фрагментация в файловой системе XFS (родной для Red Hat и производных) очевидна. С ней следует регулярно бороться в тех файловых системах, где существуют инструменты для её устранения (например, e4defrag, btrfs defrag). На файловой системе ZFS, где не существует инструментов для устранения фрагментации, к параллельной обработке следует подходить с большой осторожностью и только для тех наборов данных, которые находятся в пулах с достаточным свободным пространством.

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

$ cat unpack
#!/bin/sh
for x
do echo "$x"
   EXT="${x##*.}"
   case "$EXT" in
        bz2) bzip2 -cd "$x" ;;
        gz)  gzip  -cd "$x" ;;
        lz)  lzip  -cd "$x" ;;
        xz)  xz    -cd "$x" ;;
        zst) zstd  -cd "$x" ;;
   esac > "$(basename "$x" ".${EXT}")"
done

Наконец, эта техника может быть использована в порте BusyBox для Windows и, вероятно, в других (POSIX) реализациях оболочки на платформе Win32/64, поддерживающих GNU xargs. В оболочке BusyBox не реализован nice (удалите его из скрипта), также в ней не предусмотрен nproc (установите PARALLEL вручную). BusyBox полностью реализует только gzip и bzip2 (апплет xz существует, но не реализует числовую настройку качества). Что касается изменений bzip2, вот демонстрация на моём ноутбуке, тест с копией всех файлов Cygwin .DLL:

C:\Temp>busybox64 sh
C:/Temp $ time sh parallel-pack_bz2 dtest/*.dll
real    0m 58.70s
user    0m 0.00s
sys     0m 0.06s
C:/Temp $ exit
C:\Temp>dir dtest
Volume in drive C is OSDisk
Volume Serial Number is E44B-22EC
Directory of C:\Temp\dtest
02/02/2021  11:10 AM    <DIR>          .
02/02/2021  11:10 AM    <DIR>          ..
02/02/2021  11:09 AM            40,957 cygaa-1.dll.bz2
02/02/2021  11:09 AM           263,248 cygakonadi-calendar-4.dll.bz2
02/02/2021  11:09 AM           289,716 cygakonadi-contact-4.dll.bz2
. . .
02/02/2021  11:10 AM           658,119 libtcl8.6.dll.bz2
02/02/2021  11:10 AM           489,135 libtk8.6.dll.bz2
02/02/2021  11:09 AM             5,942 Xdummy.dll.bz2
            1044 File(s)    338,341,460 bytes
               2 Dir(s)  133,704,908,800 bytes free

▍ Заключение


IBM в процессе переноса UNIX на System/370 написала:

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

В то же время мы испытываем ностальгию по непонятному нам управлению заданиями (в операционных системах System/370).

Хотя Linux, возможно, не дотягивает до уровня PDP-11, он в значительной степени разделяет это свойство с 7-м изданием, работая при этом на машинах с невообразимой скоростью с точки зрения 1970-х годов. Однако POSIX.2 требует, чтобы мы оставались в 1970-х годах с рядом наших инструментов, что, вероятно, толкает пользователей к менее экспансивным конкурентам с лучшим (в плане работы с задачами) инструментарием.

Я начал своё знакомство с UNIX SMP на Encore Multimax в университете в начале 90-х годов, и мне трудно представить, что даже пользовательское пространство этой машины будет ограничено неразумными требованиями POSIX.2. Принятие, даже сейчас, тех же ограничений для современных SMP конструкций является, в некоторой степени, проклятием.

POSIX во многих сферах считается незыблемым стандартом. То, что SELinux и systemd в малой степени превзошли его, даёт некоторую надежду на то, что мы сможем преодолеть ограничения, наложенные на нас предыдущим поколением. Возможно, очевидным решением было бы использование systemd в качестве новой системы планирования заданий. Хотя можно утверждать, что портативность (portability) преобладает над функциональностью, инновации также должны в конечном счёте преобладать над традициями. Портативность — полезное стремление, но возможности и эффективность также не лишены ценности.

Участие ядра в улучшенной системе планирования заданий не является строго необходимым условием. Базовая реализация в пространстве пользователя, добавленная к POSIX, скорее всего, будет встречена с большим удовольствием сообществом пользователей (и, надеюсь, будет лучше, чем использование SIGUSR1/2 для корректировки во время выполнения). Стандарт POSIX не позволяет этого, но пришло время оставить прошлое позади.

Быть вынужденным пользоваться непонятной утилитой для параллельного написания сценариев из-за ранней бедности UNIX — неразумная позиция. Обновлённый стандарт POSIX.2 для функциональной оболочки и различных пользовательских утилит давно назрел.

А пока этого не произошло, благодарите FSF за подобный подход.




Комментарии (2):

  1. ajijiadduh
    /#24477686

    1) гуглоперевод. спасибо. сами мы бы статью конечно не смогли ни перевести, ни запихать в гугл при надобности

    2) parallel ?

  2. polar_yogi
    /#24482336

    Удачи найти это в руководстве…

    HP-UX 10.20 - вышел в 1996 году.
    ksh 88 года и dtshell 93 года.
    Правильнее было бы написать - удачи найти руководство.